# Technical Documentation: apostrophecms/apostrophe

> ℹ️ **Provenance:** Multi-Tier Ingestion · [CodeWiki Reference](https://codewiki.google/github.com/apostrophecms/apostrophe)

### Overview (`README.md`)

<div align="center">
  <a href="https://github.com/apostrophecms/apostrophe">
    <img src="logo.svg" alt="ApostropheCMS logo" width="80" height="80">
  </a>

  <h1>ApostropheCMS</h1>

  <p>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20" />
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/apostrophe/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639" />
    </a>
  </p>

  <p>
    <strong>Full-stack CMS for developers and content teams</strong><br />
    Build websites with in-context editing and headless flexibility using Node.js and MongoDB.
    <br />
    <a href="https://docs.apostrophecms.org/"><strong>Documentation »</strong></a>
    <br />
    <br />
    <a href="http://demo.apostrophecms.com">Demo</a>
    ·
    <a href="https://roadmap.apostrophecms.com/roadmap">Roadmap</a>
    ·
    <a href="https://github.com/apostrophecms/apostrophe/issues/new?assignees=&labels=bug,3.0&template=bug_report.md&title=">Report Bug</a>
  </p>
</div>

## About

ApostropheCMS is a full-stack content management system built with Node.js and MongoDB. Content creators can edit directly on live pages without switching between admin interfaces, while developers can build with modern JavaScript or use it headlessly with any frontend framework.

### Key Features

- **🎯 In-Context Editing** - Content creators edit directly on the live page, seeing changes instantly
- **⚡ Headless-Ready** - Use any frontend framework while keeping the powerful admin experience
- **🛠️ Developer-First** - Built with Node.js and MongoDB for full-stack JavaScript development
- **📈 Scales Beautifully** - From small sites to enterprise applications handling millions of pages
- **🔐 Enterprise Features** - Advanced permissions, workflow management, automated translations, and more

## System Requirements

| Requirement | Version | Installation Notes |
|-------------|---------|-------------------|
| **Node.js** | 20.x+ | Use [NVM](https://github.com/nvm-sh/nvm) for version management |
| **MongoDB** | 6.0+ | [MongoDB Atlas](https://www.mongodb.com/atlas) (cloud) or local install |
| **npm** | 10.x+ | Included with Node.js |

See our [setup guides](https://docs.apostrophecms.org/guide/development-setup.html) for installation instructions.

## Quick Start

Get ApostropheCMS running locally in minutes:

```bash
# Option 1: Install CLI globally (recommended for multiple projects)
npm install @apostrophecms/cli
apos create my-website
cd my-website
npm run dev

# Option 2: Use npx for one-time project creation
npx @apostrophecms/cli create my-website
cd my-website
npm run dev
```

Your new ApostropheCMS site will be available at `http://localhost:3000` with a powerful admin interface at `/login`.

### Prefer to Go Headless?

**Get started with Astro integration** - the easiest way to build headless sites while keeping visual editing:

- **[Apollo Starter Kit (Astro)](https://apostrophecms.com/starter-kits/apollo-starter-kit-for-astro-cms)** - Production-ready foundation with beautiful design system and rich content features
- **[Essentials Starter Kit (Astro)](https://github.com/apostrophecms/starter-kit-astro-essentials)** - Minimal, clean foundation for building custom designs from scratch

Both starter kits provide headless CMS power with in-context editing, letting content creators edit directly on the live site while you build with modern frontend tools. Our Astro integration handles all the content fetching automatically—no REST API calls to write.

**Desire a different frontend framework?** Use our REST APIs with React, Vue, Svelte, or any other framework:

- **[REST API Documentation](https://docs.apostrophecms.org/reference/api/pieces.html)** - Complete API reference
- **[Headless CMS Guide](https://docs.apostrophecms.org/guide/headless-cms.html)** - Integration walkthrough for any framework

### Hosting & Deployment

Choose [ApostropheCMS hosting](https://apostrophecms.com/hosting) for turnkey solutions with optimized performance and dedicated support, or deploy to [any platform where Node.js runs](https://docs.apostrophecms.org/guide/hosting.html).

## Built With Modern Tech

- **[Node.js](https://nodejs.org/)** - JavaScript runtime for server-side development
- **[MongoDB](https://www.mongodb.com/)** - Flexible document database for content storage
- **ESM Modules** - Native ES6 module support for modern JavaScript
- **Vite** - Lightning-fast build tool and development server
- **Modern JavaScript** - ES6+, async/await, and contemporary development patterns

## Community & Support

**Join other developers and content creators using ApostropheCMS:**

- **[Discord](https://discord.com/invite/XkbRNq7)** - Get help, share projects, and connect with other users
- **[GitHub Discussions](https://github.com/apostrophecms/apostrophe/discussions)** - Feature requests, technical discussions, and community support
- **[Documentation](https://docs.apostrophecms.org/)** - Comprehensive guides, tutorials, and API references

## Contributing

We welcome contributions from the community! Whether you're fixing bugs, adding features, or improving documentation, your help makes ApostropheCMS better for everyone.

- **[Contribution Guide](https://github.com/apostrophecms/apostrophe/blob/main/CONTRIBUTING.md)** - How to contribute code, documentation, and feedback
- **[Good First Issues](https://github.com/apostrophecms/apostrophe/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)** - Perfect starting points for new contributors


## Pro Features

**For teams and organizations requiring additional features:**

- **🔐 Advanced User Management** - Granular permissions, user groups, and access controls
- **🌍 Automated Translation** - AI-powered translation with DeepL, Google Translate, and Azure
- **📊 Analytics & SEO** - Built-in SEO optimization and content analytics
- **⚡ Performance Optimization** - Advanced caching, CDN integration, and performance monitoring
- **🏢 Multisite Management** - Manage multiple sites from a single dashboard with shared resources
- **💼 Professional Support** - Dedicated support, training, and consultation services

[Explore all the pro extensions](https://apostrophecms.com/extensions?autocomplete=&license=assembly&license=pro) and [sign up](https://app.apostrophecms.com/login) for a Pro license in our self-service Apostrophe Workspaces, or [contact us](https://apostrophecms.com/contact-us) to learn about licensing and support options.

## License

ApostropheCMS is open source software licensed under the [MIT License](https://github.com/apostrophecms/apostrophe/blob/main/LICENSE.md). This means you're free to use, modify, and distribute it for both personal and commercial projects.

---

<div align="center">
  <p>
    <strong>Ready to build something amazing?</strong><br>
    <a href="https://docs.apostrophecms.org/">Get started with our documentation</a> or <a href="https://apostrophecms.com/contact-us">talk to our team</a>
  </p>
  <p>
    <em>Built with ❤️ by the <a href="https://apostrophecms.com">ApostropheCMS team</a></em>
  </p>
</div>

---

### Overview (`.changeset/README.md`)

# Changesets

Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)

We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

---

### Overview (`packages/ai-helper/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80" />

  <h1>AI Helper</h1>

  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20" />
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/ai-helper/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639" />
    </a>
  </p>
</div>

**Bring AI-powered content generation directly into your ApostropheCMS editing workflow.** Generate compelling text and stunning images from simple prompts, all without leaving the CMS. Choose your preferred AI provider (OpenAI, Anthropic Claude, or Google Gemini) or connect to enterprise AI services.

## ⚠️ Beta / Experimental Module 

**This module is experimental and will remain in beta status.** It provides early access to AI-powered content generation features in ApostropheCMS, but you should expect:

- **Functional but evolving**: Core features work well and are production-ready, but the UI/UX may not have the polish of finished ApostropheCMS modules
- **Active development**: Features and APIs may change as we refine the AI integration experience
- **Future migration**: Much of this functionality will eventually move into ApostropheCMS core as part of a comprehensive AI coordination system

**Who should use this module:**
- Developers comfortable with experimental features who want early access to AI capabilities
- Teams willing to provide feedback to help shape ApostropheCMS AI features
- Projects that can adapt to potential UI/API changes in future updates

**Stability note:** While the module is marked as beta, it is used in production environments. The "experimental" designation reflects ongoing UI refinement rather than fundamental instability.

## Why AI Helper? 

- **⚡ Accelerate Content Creation**: Generate high-quality text and images in seconds, not hours
- **🎯 Stay in Your Workflow**: AI generation built directly into the rich text editor and media manager
- **🔌 Your Choice of AI Provider**: Use OpenAI, Anthropic Claude, or Google Gemini - switch anytime

### **Table of Contents**
- [Installation](#installation)
- [Configuration](#configuration)
  - [Enable AI Text Generation](#enable-ai-text-generation)
  - [(Optional) Customize System Prompt](#optional-customize-system-prompt)
  - [(Optional) Configure Security Headers](#optional-configure-security-headers)
  - [Mock Mode for Testing](#mock-mode-for-testing)
- [Usage](#usage)
  - [Generating Images](#generating-images)
  - [Generating Text](#generating-text)
- [Provider Configuration Options](#provider-configuration-options)
  - [OpenAI Provider](#openai-provider)
  - [Anthropic Provider](#anthropic-provider)
  - [Gemini Provider](#gemini-provider)
- [Mix and Match Providers](#mix-and-match-providers)
- [Bundled Providers](#bundled-providers)
- [Provider Metadata](#provider-metadata)
- [Custom AI Providers](#custom-ai-providers)
  - [Provider Architecture](#provider-architecture)
  - [Provider Registration](#provider-registration)
  - [Custom Provider Contract](#custom-provider-contract)
  - [Error Handling](#error-handling)
  - [Metadata](#metadata)
  - [Implementation Guidance](#implementation-guidance)
- [API Endpoints](#api-endpoints)
  - [List Registered Providers](#list-registered-providers)
- [💎 Ready for Enterprise AI Features?](#-ready-for-enterprise-ai-features)
  - [🚀 Pro AI Features](#-pro-ai-features)
- [Need Help?](#need-help)
- [License](#license)


## Installation
To install the module, use the command line to run this command in an Apostrophe project's root directory:

```bash
npm install @apostrophecms/ai-helper
```

All providers (OpenAI, Anthropic, Gemini) are bundled with the main package - no separate installation needed.

## Configuration

Configure the AI helper module in your `app.js` file:

```javascript
import apostrophe from 'apostrophe';

apostrophe({
  root: import.meta,
  shortName: 'my-project',
  modules: {
    '@apostrophecms/ai-helper': {
      options: {
        // Choose which providers to use
        textProvider: 'openai',  // 'openai', 'anthropic', or 'gemini'
        imageProvider: 'openai',  // 'openai' or 'gemini'

        // Configure your text provider
        textProviderOptions: {
          apiKey: process.env.APOS_OPENAI_KEY,  // or export APOS_OPENAI_KEY
          textModel: 'gpt-5.1',
          textMaxTokens: 1000
        },

        // Configure your image provider
        imageProviderOptions: {
          apiKey: process.env.APOS_OPENAI_KEY,  // or export APOS_OPENAI_KEY
          imageModel: 'gpt-image-1-mini',
          imageSize: '1024x1024',
          imageQuality: 'medium',
          imageCount: 1
        }
      }
    }
  }
});
```
> **Note:** Providers are selected at application startup via `app.js` configuration.
> Switching providers at runtime via an admin UI is not currently supported.


**API Keys:**
Each provider requires its own API key. You can set them via:
- Environment variables: `APOS_OPENAI_KEY`, `APOS_ANTHROPIC_KEY`, `APOS_GEMINI_KEY`
- Configuration options: `textProviderOptions.apiKey` or `imageProviderOptions.apiKey`

> Note:
> You only need to provide keys for the services you will be using.

### Enable AI Text Generation

Add the AI option to your rich text widget toolbar in any area configuration:

```javascript
// modules/article/index.js
export default {
  extend: '@apostrophecms/piece-type',
  fields: {
    add: {
      main: {
        type: 'area',
        options: {
          widgets: {
            '@apostrophecms/rich-text': {
              toolbar: [ 'styles', 'bold', 'italic', 'bulletList', 'orderedList' ],
              insert: [ 'ai' ],  // Enable AI text generation
              styles: [
                { name: 'Heading', tag: 'h2' },
                { name: 'Subheading', tag: 'h3' },
                { name: 'Paragraph', tag: 'p' }
              ]
            }
          }
        }
      }
    }
  }
};
```

### (Optional) Customize System Prompt

>**Default System Prompt (with guardrails):**
>```
>You are a helpful text-generation assistant for CMS content. You generate text in Markdown format based on the given prompt. Do not include any meta-commentary, explanations, or offers to create additional versions. Output the content directly without preamble or postamble.
>```

Customize how the AI generates text by modifying the system prompt. This can be configured **globally** (affects all rich text widgets) or **per-area** (specific areas only). Note that any prompt passed to a widget in an area will override the prompt set globally.

**Precedence order:**
1. Per-area prompt
2. Global prompt
3. Default guardrails

**Global Configuration (in app.js):**
```javascript
import apostrophe from 'apostrophe';

apostrophe({
  root: import.meta,
  modules: {
    '@apostrophecms/rich-text-widget': {
      options: {
        customSystemPrompt: 'Write in a professional, technical tone suitable for enterprise documentation.',
        appendSystemPrompt: true  // Append to default guardrails instead of replacing
      }
    }
  }
});
```

**Per-Area Configuration:**
```javascript
// modules/article/index.js
export default {
  extend: '@apostrophecms/piece-type',
  fields: {
    add: {
      main: {
        type: 'area',
        options: {
          widgets: {
            '@apostrophecms/rich-text': {
              toolbar: [ 'styles', 'bold', 'italic', 'bulletList', 'orderedList' ],
              insert: [ 'ai' ],
              // Customize AI behavior for this specific area
              customSystemPrompt: 'Write in a casual, conversational tone for blog content.',
              appendSystemPrompt: true
            }
          }
        }
      }
    }
  }
};
```

**Options:**
- `customSystemPrompt` - Your custom instructions for the AI
- `appendSystemPrompt` - When `true`, appends your prompt to the default guardrails. When `false`, replaces them entirely.

> **Tip:** Set `appendSystemPrompt: true` to keep the default guardrails (Markdown formatting, no meta-commentary) while adding your brand voice or style requirements. Per-area configuration overrides global settings.

### (Optional) Configure Security Headers

If using the `@apostrophecms/security-headers` module, add AI image sources:

```javascript
// modules/@apostrophecms/security-headers/index.js
export default {
  options: {
    policies: {
      ai: {
        'img-src': '*.blob.core.windows.net'  // OpenAI image URLs
      }
    }
  }
};
```
**Note:** This is an example only. The exact `img-src` value depends on the AI provider and region you are using.

### Mock Mode for Testing

For offline development and testing without API calls or costs:

```bash
export APOS_AI_HELPER_MOCK=true
```

When enabled:
- Text generation returns pre-defined sample content
- Image generation returns placeholder images
- No API keys required
- No external network calls made
- Instant responses for rapid iteration

> **Note:** Mock mode metadata may not match actual provider responses exactly.

## Usage

### Generating Images

1. Add an image widget to your page
2. Click "Browse" to open the media manager
3. Click the 🤖 robot button in the upper right
4. Enter a description and click "Generate"
5. Review the generated image(s) (1 is the default)
6. Click "Select" to import to media library, "Variants" for variations, or "Delete"

**Note:** Generated images expire after 1 hour if not imported to your media library.

### Generating Text

> **Note:** Generated content may include headings, bullet lists, bold, italic, and other formatting. Make sure your rich text widget toolbar includes the formatting options you want to preserve (e.g., `toolbar: [ 'styles', 'bold', 'italic', 'bulletList', 'orderedList' ]`). Unsupported formatting will be stripped when inserted.

1. In a rich text widget, press `/` at the start of a line
2. Choose "Generate Text"
3. Enter your prompt (be specific!)
4. Click "Generate"

**Prompt Tips:**
- Specify a word count: "Write 200 words about sustainable packaging"
- Request specific formats: "Write a bulleted list of 5 benefits of..."
- Ask for structure: "Write an article with 3 sections about cloud migration"
- Include tone: "Write a friendly, conversational guide to..."

## Provider Configuration Options

Each provider supports specific configuration parameters through `textProviderOptions` and `imageProviderOptions`:

### OpenAI Provider
```javascript
'@apostrophecms/ai-helper': {
  options: {
    textProvider: 'openai',
    imageProvider: 'openai',

    textProviderOptions: {
      apiKey: process.env.APOS_OPENAI_KEY,
      textModel: 'gpt-5.1', //  Text model to use
      textMaxTokens: 1000, //  Max tokens for text generation
      textRetries: 3 //  Times to reattempt generation on timeout
    },

    imageProviderOptions: {
      apiKey: process.env.APOS_OPENAI_KEY,
      imageModel: 'gpt-image-1-mini',  // Image model to use
      imageSize: '1024x1024',  // Image dimensions
      imageQuality: 'medium',  // 'low', 'medium', or 'high'
      imageCount: 1  //  Number of images to generate
    }
  }
}
```

### Anthropic Provider
```javascript
'@apostrophecms/ai-helper': {
  options: {
    textProvider: 'anthropic',

    textProviderOptions: {
      apiKey: process.env.APOS_ANTHROPIC_KEY,
      textModel: 'claude-sonnet-4-20250514',  // Claude model to use
      textMaxTokens: 1000,  // Max tokens for text generation
      textRetries: 3  // Times to reattempt generation on timeout
    }
  }
}
```

**Note:** Anthropic does not support image generation. Use OpenAI or Gemini for images.

### Gemini Provider
```javascript
'@apostrophecms/ai-helper': {
  options: {
    textProvider: 'gemini',
    imageProvider: 'gemini',

    textProviderOptions: {
      apiKey: process.env.APOS_GEMINI_KEY,
      textModel: 'gemini-2.5-flash-lite',  // Text model to use
      textMaxTokens: 1000,  // Max tokens for text generation
      textRetries: 3  // Times to reattempt generation on timeout
    },

    imageProviderOptions: {
      apiKey: process.env.APOS_GEMINI_KEY,
      imageModel: 'gemini-2.5-flash-image',   // Image model to use
      imageAspectRatio: '1:1',                // '1:1', '16:9', or '9:16'
      imageCount: 1                           // Number of images to generate
    }
  }
}
```

**Note:** Gemini uses `imageAspectRatio` instead of `imageSize`.

> The values shown are default values used for all generations. Future versions may support per-request options through the UI.

## Mix and Match Providers

Use different providers for text and images to optimize for cost, quality, or capability:

```javascript
import apostrophe from 'apostrophe';

apostrophe({
  root: import.meta,
  modules: {
    '@apostrophecms/ai-helper': {
      options: {
        textProvider: 'anthropic',  // Use Claude for text generation
        imageProvider: 'gemini',  // Use Gemini for image generation

        textProviderOptions: {
          apiKey: process.env.APOS_ANTHROPIC_KEY,
          textModel: 'claude-sonnet-4-20250514'
        },

        imageProviderOptions: {
          apiKey: process.env.APOS_GEMINI_KEY,
          imageModel: 'gemini-2.5-flash-image',
          imageAspectRatio: '16:9'
        }
      }
    }
  }
});
```

**Note:** Provider switching is currently configured in `app.js`. A future admin UI may allow switching providers without code changes.

## Bundled Providers

All providers are included with `@apostrophecms/ai-helper` - no separate installation needed. They are implemented as lightweight factory functions and configured through `textProviderOptions` and `imageProviderOptions`.

**OpenAI** (`textProvider: 'openai'` / `imageProvider: 'openai'`)
- Text generation (GPT models)
- Image generation (GPT Image models)
- Image variations
- Uses `imageSize` parameter (e.g., '1024x1024')

**Anthropic** (`textProvider: 'anthropic'`)
- Text generation (Claude models)
- No image generation support

**Gemini** (`textProvider: 'gemini'` / `imageProvider: 'gemini'`)
- Text generation (Gemini models)
- Image generation (Gemini Image models)
- Image variations
- Uses `imageAspectRatio` parameter (e.g., '1:1', '16:9', '9:16')

## Provider Metadata

All providers return metadata with each generation, including:
- Model used
- Token counts and usage statistics
- Provider-specific details (stop reasons, timestamps, etc.)

**For text generation**, metadata is returned alongside the generated content in the API response. This allows you to capture usage information if needed for custom auditing or analytics at the project level.

**For image generation**, metadata is stored temporarily in the `aposAiHelperImages` collection as `providerMetadata` on each generated image. This data persists for 1 hour (until cleanup) or until the image is imported to your media library.

The module does not automatically log or persist this metadata beyond what's needed for operation. If you need detailed auditing, cost tracking, or analytics, you can extend the relevant modules to capture and process this data according to your requirements.

## Custom AI Providers

In addition to the bundled OpenAI, Anthropic, and Gemini providers, `@apostrophecms/ai-helper` supports **custom AI providers** for organizations that need to integrate enterprise platforms or internal AI services
(e.g. AWS Bedrock, Azure OpenAI, self-hosted models).

### Provider Architecture

The AI Helper uses a two-phase provider system:

1. **Registration** - Providers register their factory function and metadata (capabilities, label)
2. **Activation** - The core module instantiates only the providers actually configured for use

This means you can register multiple providers without the overhead of instantiating unused services.

### Provider Registration

Custom providers register a **factory function** along with metadata. The factory function
will be called by the core module during activation if the provider is configured for use.

Providers can be implemented either:

- As a **dedicated ApostropheCMS module**, or
- As a **factory function** registered during another module's initialization

Both patterns must register a factory function - the AI Helper core handles instantiation.

---

### Custom Provider Contract

Custom providers must implement a well-defined contract so the AI Helper can normalize behavior across different AI services.

> **Streaming responses are not currently supported.**
> Providers must return complete results for text and image generation.
> Partial or incremental responses will be ignored.

---

#### Provider Registration API

Providers register using `registerProvider(name, factoryInfo)`:

| Parameter | Type | Description |
|-----------|------|-------------|
| `name` | `string` | Unique provider identifier (e.g. `"bedrock"`) |
| `factoryInfo.factory` | `function` | Factory function `(apos, textOpts, imageOpts) => provider` |
| `factoryInfo.label` | `string` | Human-readable name for admin UI |
| `factoryInfo.capabilities` | `object` | Declares supported features |

Example:
```javascript
aiHelper.registerProvider('bedrock', {
  factory: bedrockFactory,
  label: 'AWS Bedrock',
  capabilities: {
    text: true,
    image: false,
    imageVariation: false
  }
});
```

The factory function will be called with:
- `apos` - ApostropheCMS instance
- `textOpts` - Options from `textProviderOptions`
- `imageOpts` - Options from `imageProviderOptions`

If a custom provider registers using the same name as a bundled provider, it will override the bundled implementation.

---

#### Required Provider Properties

The provider object **returned by the factory** must include:

| Property | Type | Description |
|--------|------|-------------|
| `name` | `string` | Provider identifier (should match registration name) |
| `label` | `string` | Human-readable name |
| `capabilities` | `object` | Supported features |
| `validate` | `function` | Configuration validation method |

Plus methods for each declared capability (see below).

---

#### Required Methods

Providers must implement methods that correspond to their declared capabilities.

---

##### `validate()`

* Called during provider activation (after factory instantiation)
* Must throw an error if required configuration is missing
* Should be a no-op when `APOS_AI_HELPER_MOCK` is enabled

Used to:

* Verify API keys or credentials
* Validate required options
* Fail fast on misconfiguration

---

##### `generateText(req, prompt, options)`

**Required if `capabilities.text === true`**

* Generates text content from a prompt
* Must return the full generated result

Expected return shape:
```ts
{
  content: string,       // Markdown or HTML
  metadata?: object      // Usage, model, provider-specific data
}
```

---

##### `generateImage(req, prompt, options)`

**Required if `capabilities.image === true`**

* Generates one or more images from a prompt
* Must return complete image data

Expected return shape:
```ts
[
  {
    type: 'url' | 'base64',
    data: string,
    metadata?: object
  }
]
```

---

##### `generateImageVariation(req, image, prompt, options)`

**Required if `capabilities.imageVariation === true`**

* Generates a variant of an existing image based on an additional prompt or default

Expected return shape is the same as `generateImage`.

---

### Error Handling

* Providers should throw standard JavaScript `Error` objects
* The AI Helper will normalize errors into user-facing messages
* Provider-specific error details may be included in metadata when available

---

### Metadata

Providers may return provider-specific metadata, such as:

* Model name
* Token usage
* Stop reasons
* Timestamps or request IDs

Metadata is:

* Returned with text generation responses
* Temporarily stored for image generation (until import or cleanup)

---

### Implementation Guidance

Detailed implementation examples, testing guidance, and edge-case handling
are intentionally excluded from this README.

For full implementation details, see:

* **`provider-creator.md`** in this repository

This file can also be added to AI-assisted development tools
(e.g. Claude Projects, Cursor workspaces) to help generate
provider implementations safely and consistently.

## API Endpoints

### List Registered Providers

Query which AI providers are available for registration (whether currently active or not):
```javascript
const aiHelper = apos.modules['@apostrophecms/ai-helper'];
const providers = aiHelper.listRegisteredProviders();

console.log(providers);
// [
//   {
//     name: 'openai',
//     label: 'OpenAI',
//     capabilities: { text: true, image: true, imageVariation: true }
//   },
//   {
//     name: 'anthropic',
//     label: 'Anthropic (Claude)',
//     capabilities: { text: true, image: false, imageVariation: false }
//   },
//   {
//     name: 'gemini',
//     label: 'Google Gemini',
//     capabilities: { text: true, image: true, imageVariation: true }
//   }
// ]
```

This is useful for debugging provider registration, building admin UIs, or when creating custom providers that need to check what's already available.

## 💎 Ready for Enterprise AI Features?

**Love AI Helper but need advanced capabilities?** Upgrade to **ApostropheCMS Pro** to unlock AI-powered features that transform your content workflow:

### 🚀 Pro AI Features
- **🌍 AI-Powered Translation** - Automatically translate content into 100+ languages using DeepL, Google Translate, or Azure
- **🔍 AI SEO Assistant** - Generate optimized meta descriptions, titles, and content recommendations

**[Contact our team](https://apostrophecms.com/contact-us)** to learn about Pro licensing and enterprise AI capabilities.

---

## Need Help?

- **Documentation**: [ApostropheCMS Documentation](https://apostrophecms.com/docs/)
- **Community Support**: [Join our Discord](https://discord.com/invite/HwntQpADJr)
- **Professional Support**: [Contact us](https://apostrophecms.com/contact-us) for dedicated support packages
- **Issues & Feedback**: [GitHub Issues](https://github.com/apostrophecms/apostrophe/issues) - Your feedback helps shape future AI features!

## License

MIT

---

<div>
  <p>Made with ❤️ by the <a href="https://apostrophecms.com">ApostropheCMS</a> team. <strong>Found this useful? <a href="https://github.com/apostrophecms/ai-helper">Give us a star on GitHub!</a> ⭐</strong>
  </p>
</div>

---

### Overview (`packages/anchors/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80" />

  <h1>Apostrophe Anchors</h1>

  <p>
    <a aria-label="Apostrophe logo" href="https://docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20" />
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/apostrophe/blob/main/packages/anchors/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639" />
    </a>
  </p>
</div>

**Enable deep linking and smooth navigation** with automatic anchor generation for all ApostropheCMS widgets. Perfect for landing pages, documentation sites, and content-heavy applications where users need to link directly to specific sections.

## Why Apostrophe Anchors?

- **🎯 Improve User Experience**: Let visitors bookmark and share links to specific content sections
- **📈 Boost SEO**: Search engines can index and link directly to your content sections
- **⚡ Zero Configuration**: Works automatically with all widget types out of the box
- **🛠️ Developer Friendly**: Customize anchor attributes and disable for specific widgets as needed
- **🔗 Marketing Ready**: Perfect for campaign landing pages where you need to link to specific sections

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```bash
npm install @apostrophecms/anchors
```

## Usage

Configure the anchor modules in the `app.js` file:

```javascript
import apostrophe from 'apostrophe';

apostrophe ({
  root: import.meta,
  shortName: 'my-project',
  modules: {
    '@apostrophecms/anchors': {},
  }
});
```

When the modules are active, all widget types will have a new "HTML Anchor Value" field. When an editor populates that field with a slug-style string, the widget HTML markup will be wrapped with a new `div` with an attribute (an `id` by default) set to the anchor value. This attribute can be targeted by anchor links, e.g., `href="#anchor-id-value"`.

The only Apostrophe core widget type with this feature disabled is the rich text widget, `@apostrophecms/rich-text-widget`.

The "HTML Anchor Value" field is a "slug" type field, which means it is limited to lowercase characters and dashes for consistent usage.

## Configuration Options

### `anchorAttribute`

By default, the anchor attribute is an `id`. This makes it easy to link to the widget with a hash `href` matching the anchor value as described above. **Developers have the option to use another anchor attribute** if they prefer to target the HTML element with custom JavaScript instead.

To do this, include an `anchorAttribute` option on the individual widget type. You can also add this option on the root `@apostrophecms/widget-type` module or the `@apostrophecms/anchors-widget-type` module to set this for all widget types.

```javascript
// modules/my-banner-widget/index.js
export default {
  options: {
    anchorAttribute: 'data-anchor'
  }
};
```

In this example the wrapping `div` element would look like this:

```html
<div data-anchor="anchor-id-value">
  
</div>
```

### `anchors: false`

Developers can choose to disable this module's features for any widget type by setting an `anchors: false` option on the widget type.

```javascript
// modules/my-banner-widget/index.js
export default {
  options: {
    anchors: false
  }
};
```

This is automatically set for the rich text widget. That can be reversed by setting `anchors: true` on `@apostrophecms/rich-text-widget`.

### `anchorDefault`

To help content editors populate anchor values automatically, set the `anchorDefault` option to the name of an existing field on a widget type. The "HTML Anchor Value" field will populate automatically based on the value of the named field [using the `following` field option mechanism](https://docs.apostrophecms.org/reference/field-types/slug.html#optional).

```javascript
// modules/my-banner-widget/index.js
export default {
  options: {
    anchorDefault: 'heading'
  },
  fields: {
    add: {
      heading: {
        type: 'string',
        label: 'Heading'
      }
    }
  }
};
```

## Use Cases

**Landing Pages**: Create marketing pages where different sections can be linked to directly from emails, ads, or social media.

**Documentation**: Allow readers to bookmark and share links to specific sections of your documentation.

**Long-form Content**: Help readers navigate lengthy articles or reports by providing direct links to sections.

**Campaign Pages**: Enable marketing campaigns to link directly to specific product features or testimonials.

---

<div>
  <p>Made with ❤️ by the <a href="https://apostrophecms.com">ApostropheCMS</a> team. <strong>Found this useful? <a href="https://github.com/apostrophecms/apostrophe">Give us a star on GitHub!</a> ⭐</strong>
  </p>
</div>

---

### Overview (`packages/apostrophe-astro/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>@apostrophecms/apostrophe-astro</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://apostrophecms.com/docs/">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
  </p>
</div>

- [Astro integration for ApostropheCMS](#astro-integration-for-apostrophecms)
  - [About Astro](#about-astro)
  - [Bringing ApostropheCMS and Astro together](#bringing-apostrophecms-and-astro-together)
  - [Installation](#installation)
  - [Security](#security)
  - [Configuration (Astro)](#configuration-astro)
  - [Options](#options)
    - [`aposHost` (mandatory)](#aposhost-mandatory)
    - [`widgetsMapping` (mandatory)](#widgetsmapping-mandatory)
    - [`templatesMapping` (mandatory)](#templatesmapping-mandatory)
    - [`onBeforeWidgetRender` (optional)](#onbeforewidgetrender-optional)
    - [`viewTransitionWorkaround` (optional)](#viewtransitionworkaround-optional)
    - [`includeResponseHeaders`](#includeresponseheaders)
    - [`excludeRequestHeaders`](#excluderequestheaders)
    - [`forwardHeaders` (deprecated)](#forwardheaders-deprecated)
    - [`proxyRoutes` (optional)](#proxyroutes-optional)
    - [`aposPrefix` (optional)](#aposprefix-optional)
    - [`staticBuild` (optional)](#staticbuild-optional)
  - [Mapping Apostrophe templates to Astro components](#mapping-apostrophe-templates-to-astro-components)
    - [How Apostrophe template names work](#how-apostrophe-template-names-work)
    - [Special template names](#special-template-names)
  - [Mapping Apostrophe widgets to Astro components](#mapping-apostrophe-widgets-to-astro-components)
  - [Creating the `[...slug.astro]` component and fetching Apostrophe data](#creating-the-slugastro-component-and-fetching-apostrophe-data)
    - [Understanding `AposLayout`](#understanding-aposlayout)
    - [Understanding `AposTemplate`](#understanding-apostemplate)
  - [Creating Astro page components](#creating-astro-page-components)
    - [Understanding the `AposArea` component](#understanding-the-aposarea-component)
    - [Understanding the `AposField` component](#understanding-the-aposfield-component)
      - [Telling Apostrophe which fields you render](#telling-apostrophe-which-fields-you-render)
      - [Why the opt in](#why-the-opt-in)
      - [Props](#props)
  - [Creating Astro widget components](#creating-astro-widget-components)
    - [Placeholders are important in widgets that use them](#placeholders-are-important-in-widgets-that-use-them)
    - [Remember, relationship properties might not be populated](#remember-relationship-properties-might-not-be-populated)
  - [Accessing image and URLs](#accessing-image-and-urls)
  - [What to change in your Apostrophe project](#what-to-change-in-your-apostrophe-project)
  - [Starting up your combined project](#starting-up-your-combined-project)
  - [Logging in](#logging-in)
  - [Redirections](#redirections)
  - [404 Not Found](#404-not-found)
  - [Reserved routes](#reserved-routes)
  - [What about widget players?](#what-about-widget-players)
  - [`aposSetQueryParameter`: working with query parameters](#apossetqueryparameter-working-with-query-parameters)
  - [Other helper functions](#other-helper-functions)
  - [What about Vue, React, SvelteJS, etc.?](#what-about-vue-react-sveltejs-etc)
  - [A note on production use](#a-note-on-production-use)
  - [Static builds](#static-builds)
  - [Output and logging](#output-and-logging)
  - [Debugging](#debugging)
    - [Widget Render Hook](#widget-render-hook)
      - [1. Configure the Integration](#1-configure-the-integration)
      - [2. Create the Hook File](#2-create-the-hook-file)
      - [Notes](#notes)
  - [Enabling the `render-area` option to ApostropheCMS REST APIs](#enabling-the-render-area-option-to-apostrophecms-rest-apis)
  - [Enabling the `@apostrophecms/layout-widget` in an existing project](#enabling-the-apostrophecmslayout-widget-in-an-existing-project)
    - [Backend updates](#backend-updates)
    - [Frontend updates](#frontend-updates)
  - [Conclusion](#conclusion)
  - [Acknowledgements](#acknowledgements)

# Astro integration for ApostropheCMS

This module integrates ApostropheCMS into your [Astro](https://astro.build/) application.

## About Astro

Astro provides a "universal bridge" to run modern frontend frameworks like React, Vue,
and SvelteJS on the server side, as well as a straightforward, JSX-like template
language of its own to meld everything together.

## Bringing ApostropheCMS and Astro together

The intent of this integration is to let Apostrophe manage content, handle routing of URLs and fetch content,
and let Astro take the responsibility for the rendering of pages
and any associated logic using your framework(s) of choice like React, Vue.js,
Svelte, etc. (see the [Astro integrations page](https://docs.astro.build/en/guides/integrations-guide/) for more).

**This module also brings the ApostropheCMS Admin UI in your Astro application**, so you can manage your site exactly as if you were in a "normal" Apostrophe instance.

When you use this module, you will have **two** projects:

1. An Astro project. This is where you write your templates and frontend code.

2. An Apostrophe project. This is where you define your page types, widget types
and other content types with their schemas and other customizations.

This kind of dual-project CMS integration is typical for Astro.

The best way to keep everything consistent is to build these in `frontend` and `backend` subdirectories of the same git repository.

To get you started quickly, we recommend one of our official Astro starter kits:

* [apostrophecms/starter-kit-astro-essentials](https://github.com/apostrophecms/starter-kit-astro-essentials) is best for a clean start with as little extra code as possible.
* [apostrophecms/astro-public-demo](https://github.com/apostrophecms/astro-public-demo) is a full-fledged project with a blog, custom widgets, and other nice touches. It can also be loaded with the same data as found in our online [public demo](https://demo.apostrophecms.com/?utm_source=github&utm_medium=readme&utm_campaign=apostrophe-astro)

> 💡 These combined Astro + Apostrophe projects can be launched by using npm create apostrophe@latest and selecting either "Barebones" or "Demo" from the available options.

You can also adapt your own existing ApostropheCMS project as explained below.

> Note that this module, `@apostrophecms/apostrophe-astro`, is meant to be installed as a dependency of your *Astro project*,
> not your Apostrophe project.

This module is primarily designed for use with Astro's `output: 'server'` setting (SSR mode), so that you can edit your content directly on the page. It also supports `output: 'static'` for publishing a fully static build of your site — see [Static builds](#static-builds) below. Content editing always requires a running Apostrophe backend, so a static build is a publishing target, not a replacement for the SSR development workflow.

## Installation

If you are not starting from one of the projects above, you will need to install this
module into your Astro project. Install this module in your
**Astro project**, not your ApostropheCMS project:

```shell
cd my-astro-project
npm install @apostrophecms/apostrophe-astro
```

*Astro 5.x, 6.x and 7.x are supported (`astro >=5.0.0 <8.0.0`). Node.js 22.19 or newer is required.*

> If you are upgrading from an earlier release of this integration, see the [upgrade guide](https://apostrophecms.com/docs/guide/migration/upgrading-apostrophe-astro.html) for
> the Astro v6/v7 notes, the deprecated `lib/` import paths, and the removal of the private Vite virtual modules.

## Security

You **must** set the `APOS_EXTERNAL_FRONT_KEY` environment variable to a secret
value when running your Astro project, and also set the same variable to the same value when running your Apostrophe application.
This ensures that other sites on the web cannot fetch excessive amounts of
information from ApostropheCMS without your permission.

## Configuration (Astro)

Since this is an Astro integration, you will need to add it to your Astro project's `astro.config.mjs` file.
Here is a working `astro.config.mjs` file for a project with an Apostrophe CMS backend.

```js
import { defineConfig } from 'astro/config';
import apostrophe from '@apostrophecms/apostrophe-astro';

// For production. You can use other adapters that support
// `output: 'server'`
import node from '@astrojs/node';

export default defineConfig({
  output: 'server',
  adapter: node({
    mode: 'standalone'
  }),
  integrations: [
    apostrophe({
      aposHost: 'http://localhost:3000',
      widgetsMapping: './src/widgets',
      templatesMapping: './src/templates',
      onBeforeWidgetRender: './src/hooks/before-widget-render.js', // Optional
      viewTransitionWorkaround: false,
      includeResponseHeaders: [
        'content-security-policy',
        'strict-transport-security',
        'x-frame-options',
        'referrer-policy',
        'cache-control'
      ],
      excludeRequestHeaders: [
        // For single-site setups or hosting on multiple servers, block the host header
        'host'
      ],
      proxyRoutes: [
        // Custom URLs that should be proxied to Apostrophe.
        // Note that all of `/api/v1` is already proxied, and literal
        // content files declared by Apostrophe modules (robots.txt,
        // sitemap.xml, llms.txt, ...) are routed automatically, so
        // this is usually unnecessary
      ]
    })
  ]
});
```

> Earlier versions of this document asked you to add a `vite.ssr.noExternal` entry for
> `@apostrophecms/apostrophe-astro` to your own config. The integration now applies that itself
> (for both the SSR and prerender Vite environments), so you no longer need it. Leaving an existing
> entry in place is harmless.

## Options

### `aposHost` (mandatory)

This option is the base URL of your Apostrophe instance. It must contain the
port number if testing locally and/or communicating directly with another instance
on the same server in a small production deployment. This option can be overriden
at runtime with the `APOS_HOST` environment variable.

During development it defaults automatically to: `http://localhost:3000`

### `widgetsMapping` (mandatory)

The file in your project that contains the mapping between Apostrophe widget types and your Astro components (see below).

### `templatesMapping` (mandatory)

The file in your project that contains the mapping between Apostrophe templates and your Astro templates (see below).

### `onBeforeWidgetRender` (optional)

Path to a hook function that runs before rendering widgets in edit mode. See [Widget Render Hook](#widget-render-hook) for details.

### `viewTransitionWorkaround` (optional)

If set to `true`, Apostrophe will refresh its admin UI JavaScript on
every page transition, to ensure compatibility with Astro
[view transitions](https://docs.astro.build/en/guides/view-transitions/).
If you are not using this feature of Astro, you can omit this flag to
improve performance for editors. Ordinary website visitors are
not impacted in any case. We are seeking an alternative solution to
eliminate this option.

### `includeResponseHeaders`

An array of HTTP headers that you want to include from Apostrophe to the final response sent to the browser - useful if you want to use an Apostrophe module like `@apostrophecms/security-headers` and want to keep those headers as configured in Apostrophe and to preserve Apostrophe's caching headers.

At the present time, Astro is not compatible with the `nonce` property of `content-security-policy` `script-src` value. So this is automatically removed with that integration. The rest of the CSP header remains unchanged.

### `excludeRequestHeaders`

An array of HTTP headers that you want to prevent from being forwarded from the browser to Apostrophe. This is particularly useful in single-site setups where you want to block the `host` header to allow Astro and Apostrophe to run on different hostnames.

By default, all headers are forwarded except those specified in this array.

Do not exclude `host` in a multisite project. Multisite selects the site by the `host` header, so pages and literal content files would no longer reach the right site.

### `forwardHeaders` (deprecated)

This option has been replaced by `includeResponseHeaders` which provides clearer naming for its purpose. If both options are provided, `includeResponseHeaders` takes precedence. `forwardHeaders` will be removed in a future version.

### `proxyRoutes` (optional)

An array of additional Astro route patterns that should be proxied straight through to Apostrophe rather than
rendered as Astro pages. All of `/api/v1` is proxied already (see [Reserved routes](#reserved-routes)), and since
v1.14.0 literal content files declared by your Apostrophe modules — `robots.txt`, `sitemap.xml`, `llms.txt` and
friends — are routed automatically, so this option is usually unnecessary. It remains available for any other URL
you want Apostrophe to answer directly.

This option has no effect in a static build, where there is no server to proxy through.

### `aposPrefix` (optional)

A URL path prefix matching the `prefix` option of your Apostrophe backend, for example `'/my-repo'`. When omitted it
is inferred from Astro's own `base` config, with any trailing slashes removed. This option can be overridden at
runtime with the `APOS_PREFIX` environment variable, which takes precedence over both the option and `base`.

### `staticBuild` (optional)

Options controlling a static build, used when your Astro config sets `output: 'static'`. They are ignored in SSR mode.
Every one of them can be overridden by an environment variable, so that a CI or deployment pipeline can change the
behaviour without editing code — the environment variable always wins.

| Option | Default | Environment variable | Purpose |
|---|---|---|---|
| `attachments` | `true` | `APOS_SKIP_ATTACHMENTS` (`1` disables, `0` enables) | Copy attachments into the build output. |
| `attachmentSizes` | all sizes | `APOS_ATTACHMENT_SIZES` (comma-separated, e.g. `max,full`) | Restrict output to specific image sizes. |
| `attachmentSkipSizes` | `['original']` | `APOS_ATTACHMENT_SKIP_SIZES` (comma-separated) | Image sizes to exclude. |
| `attachmentFilter` | `'all'` | `APOS_ATTACHMENT_FILTER` | `'all'` writes both uploadfs attachments and pretty URL files; `'prettyOnly'` skips the uploadfs attachments, which is what you want when those are served by a CDN. Pretty URL files are always written because they are backend-served. No effect when `attachments` is `false`. |
| `attachmentScope` | `'used'` | `APOS_ATTACHMENT_SCOPE` | `'used'` limits output to attachments referenced by the pages actually built; `'all'` includes every attachment in the database. |

```js
apostrophe({
  aposHost: 'http://localhost:3000',
  widgetsMapping: './src/widgets',
  templatesMapping: './src/templates',
  staticBuild: {
    attachmentFilter: 'prettyOnly',
    attachmentSizes: [ 'max', 'full' ]
  }
})
```

A static build still talks to Apostrophe at build time: `APOS_HOST` (or `aposHost`) and `APOS_EXTERNAL_FRONT_KEY`
must both be set, or the attachment and literal-content steps are skipped silently.

## Mapping Apostrophe templates to Astro components

Since the front end of our project is entirely Astro, we'll need to create Astro components corresponding to each
template that Apostrophe would normally render with JSX or Nunjucks.

Create your template mapping in `src/templates/index.js` file.
As shown above, this file path must then be added to your `astro.config.mjs` file,
in the `templatesMapping` option of the `apostrophe` integration.

```js
// src/templates/index.js
import HomePage from './HomePage.astro';
import DefaultPage from './DefaultPage.astro';
import BlogIndexPage from './BlogIndexPage.astro';
import BlogShowPage from './BlogShowPage.astro';
import NotFoundPage from './NotFoundPage.astro';

const templateComponents = {
  '@apostrophecms/home-page': HomePage,
  'default-page': DefaultPage,
  '@apostrophecms/blog-page:index': BlogIndexPage,
  '@apostrophecms/blog-page:show': BlogShowPage,
  '@apostrophecms/page:notFound': NotFoundPage
};

export default templateComponents;
```

### How Apostrophe template names work

For ordinary page templates, like the home page or a typical "default" page type
in an Apostrophe project, you can just specify the Apostrophe module name.

For special templates like `notFound`, and for modules that serve more than one
template, you'll need to specify the complete name. For instance, Apostrophe's
`@apostrophecms/blog` module contains an `@apostrophecms/blog-page` page type
that renders an `index` template when viewing the main page of the blog, and
a `show` template when viewing a single blog post (a "permalink" page).

If you don't specify the template name, `:page` is assumed, which is just right
for ordinary page types.

For the "404 Not Found" page, use `@apostrophecms/page:notFound`, which is
the standard name for this template in ApostropheCMS.

### Special template names

The integration comes with two additional special template names that can be mapped to Astro templates.
You should not add a module name to these special names:

* `apos-fetch-error`: served when Apostrophe generates a 500-class error. The integration will set Astro's response status to 500.
* `apos-no-template`: served when there is no mapping corresponding to the Apostrophe page type for this page.

See below for an example Astro template for the `@apostrophe-cms/home-page` type. But first,
let's look at widgets.

## Mapping Apostrophe widgets to Astro components

Similar to Astro page components, Astro widget components replace Apostrophe's usual
widget rendering.

Create your template mapping in a file in your application, for example in a
`src/widgets/index.js` file. This file path must then be added to your `astro.config.mjs` file,
in the `widgetsMapping` option of the `apostrophe` integration, as seen above.

```js
// src/widgets/index.js

import RichTextWidget from './RichTextWidget.astro';
import ImageWidget from './ImageWidget.astro';
import VideoWidget from './VideoWidget.astro';
import LayoutWidget from '@apostrophecms/apostrophe-astro/widgets/LayoutWidget.astro';
import LayoutColumnWidget from '@apostrophecms/apostrophe-astro/widgets/LayoutColumnWidget.astro';

const widgetComponents = {
  // Standard widgets, but we must provide our own Astro components for them
  '@apostrophecms/rich-text': RichTextWidget,
  '@apostrophecms/image': ImageWidget,
  '@apostrophecms/video': VideoWidget,
  '@apostrophecms/layout': LayoutWidget,
  '@apostrophecms/layout-column': LayoutColumnWidget
};

export default widgetComponents;
```

> Note that even basic widget types like `@apostrophecms/image` do need an Astro
template in your project. This integration does not currently ship with built-in
Astro templates for all of the common Apostrophe widgets. However, all of the starter kits referenced in this document include all the necessary code for the most common core widgets.

Note that the Apostrophe widget name (on the left) is the name of your widget module **without**
the `-widget` part.

> [!TIP]
> The `@apostrophecms/layout-widget` needs some extra configuration and addition to areas in your ApostropheCMS project. You can read more in the [documentation](https://apostrophecms.com/docs/guide/core-widgets.html#layout-widget).

The naming of your Astro widget templates is up to you. The above convention is just
a suggestion.

## Creating the `[...slug.astro]` component and fetching Apostrophe data

Since Apostrophe is responsible for managing URLs to content, including creating new content and pages
on the fly, you will only need one top-level Astro page component: the `[...slug].astro` route.

The integration comes with an `aposPageFetch` method that can be used to automatically
fetch the relevant data for the current URL.

Your `[...slug].astro` component should look like this:

```js
---
import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js';
import AposLayout from '@apostrophecms/apostrophe-astro/components/layouts/AposLayout.astro';
import AposTemplate from '@apostrophecms/apostrophe-astro/components/AposTemplate.astro';

const aposData = await aposPageFetch(Astro.request);
const bodyClass = `myclass`;

if (aposData.redirect) {
  return Astro.redirect(aposData.url, aposData.status);
}
if (aposData.notFound) {
  Astro.response.status = 404;
}
---
<AposLayout title={aposData.page?.title} {aposData} {bodyClass}>
    <Fragment slot="standardHead">
      <meta name="description" content={aposData.page?.seoDescription} />
      <meta name="viewport" content="width=device-width, initial-scale=1" />
      <meta charset="UTF-8" />
    </Fragment>
    <AposTemplate {aposData} slot="main" />
</AposLayout>
```

Thanks to the `aposPageFetch` call, the `aposData` object will then contain all of
the information normally provided by `data` in an ApostropheCMS Nunjucks template.
This includes, but is not limited to:

* `page`: the page document for the current URL, if any
* `piece`: the piece document when on a "show page" for a piece page type
* `pieces`: an array of pieces when on an "index page" for a piece page type
* `user`: information about the currently logged-in user
* `global`: the ApostropheCMS global document e.g. global settings, editable global
headers and footers, etc.
* `query`: the `req.query` object, giving access to query parameters in the URL.

Any other data that your custom Apostrophe code attaches to `req.data` is also
available here.

### Understanding `AposLayout`

This integration comes with a full managed global layout, replacing the `outerLayout.html`
used in Nunjucks page templates.

In your `[...slug].astro` file, use the `AposLayout` component built into this
integration to leverage the global layout.

To override any aspect of the global layout, take advantage of the following Astro slots,
which are closely related to what ApostropheCMS offers in Nunjucks:

* `startHead`: slot in the very beginning of the `<head>`
* `standardHead`: slot in the middle of `<head>`, just after `<title>`
* `extraHead`: still in the HTML `<head>`, at the very end
* `startBody`: at the very beginning of the `<body>` - this is not part of the refresh zone in edit mode
* `beforeMain`: at the very beginning of the main body zone - part of the refresh zone in edit mode
* `main`: the inner part of the main body zone - part of the refresh zone in edit mode
* `afterMain`: at the very end of the main body zone - part of the refresh zone in edit mode
* `endBody`: at the very end of the `<body>` - this is not part of the refresh zone in edit mode

In addition, the `AposLayout` component expects four props:

* `aposData`: the data fetched from Apostrophe
* `title`: this will go in the `<title>` HTML tag
* `lang` which will be set in the `<html>` `lang` attribute
* `bodyClass`: this will be added in the `class` attribute of the `<body>` element

This layout component will automatically manage the switch between support for
the editing UI if a user is logged in and a simpler "Run Layout" for all other
page requests.

### Understanding `AposTemplate`

The role of `AposTemplate` is to automatically find the right Astro component
to render based on the template mapping you created earlier. It accepts one
prop, the full `aposData` object.

## Creating Astro page components

Next we'll look at how to write Astro page components, such as the
`src/templates/HomePage.astro` file mentioned above.

> We do not recommend placing these in `src/pages` because their names are not
> routes and Astro should not try to compile them as routes. Place them in
> `src/templates` instead. `src/pages` should only contain the `[...slug.astro]` file.

As an example, let's take a look at a simple home page template:

```js
---
// src/templates/HomePage.astro
import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
const { page } = Astro.props.aposData;
const { main } = page;
---

<section>
  <h1>{ page.title }</h>
  <AposArea area={main} />
</section>
```

Notice that we receive the `page` object from Apostrophe, which gives us
access to `page.title`. This is similar to `data.page` in a Nunjucks template.

### Understanding the `AposArea` component

This component allows Astro to render Apostrophe areas, and provides a
standard Apostrophe editing experience when doing so. Astro will automatically
call our widget components once content exists in the area. All we have to do is
pass on the area object, in this case the `main` schema field of `page`.

Note that we can also pass area objects that are schema fields of widgets.
This allows for nested widgets, such as multiple-column widgets often used
for page layout.

Note that additional props can be passed to the `AposArea` component and will be made
accessible to widget components.

### Understanding the `AposField` component

In the template above, `{ page.title }` displays the title but cannot be
edited on the page: to change it the user has to open the editor modal. The
`AposField` component displays a schema field *and* lets the user edit it
right where it appears, the way an area is edited in place. It is the Astro
equivalent of Apostrophe's `{% field %}` tag.

```js
---
// src/templates/HomePage.astro
import AposArea from '@apostrophecms/apostrophe-astro/components/AposArea.astro';
import AposField from '@apostrophecms/apostrophe-astro/components/AposField.astro';
const { page } = Astro.props.aposData;
---

<section>
  <AposField doc={page} name="title" tag="h1" />
  <AposArea area={page.main} />
</section>
```

For areas, keep using `AposArea`: an area is editable in place already, and
its own component knows all about widgets.

#### Telling Apostrophe which fields you render

Apostrophe sends your data before your templates run, so it cannot tell which
fields you display in place. You have to say so, or the field will be
displayed but not editable. There are two ways, and they do the same thing:

```js
// backend/modules/article/index.js
export default {
  extend: '@apostrophecms/piece-type',
  options: {
    // For fields you did not declare here, like the inherited `title`
    wysiwygFields: [ 'title' ]
  },
  fields: {
    add: {
      subhead: {
        type: 'string',
        label: 'Subhead',
        // For fields you did declare
        wysiwyg: true
      },
      body: {
        type: 'richText',
        label: 'Body',
        wysiwyg: true
      }
    }
  }
};
```

Use the `wysiwygFields` option for fields that come from a base type, such as
`title`. Adding `title` again just to set `wysiwyg: true` would replace the
whole inherited definition, `required` and all. A name in `wysiwygFields` that
is not in the schema is an error at startup, so a typo says so rather than
leaving a field mysteriously uneditable.

Only field types with an in-place editor can be opted in. Today those are
`string` and `richText`; setting `wysiwyg: true` on any other type does
nothing. Fields inside arrays and objects work the same way — put `wysiwyg:
true` on the subfield.

While developing, `AposField` writes a note to the terminal when it displays a
field Apostrophe sent no editing information for, naming the field and the
option to set.

#### Why the opt in

Editing a field in place costs a few hundred bytes of the page's JSON, and a
real page has dozens of fields no template renders — every SEO field, every
Open Graph field, every slug, on the page, its ancestors, its children and any
pieces alongside them. On a demo site, annotating all of them added 70% to the
response. Opting in keeps the cost to the fields you actually use, and an
anonymous visitor, who cannot edit anything, is only sent what it takes to
display them.

Each field is named by its id rather than carrying its own copy of its
definition, so a page with fifty fields of the same type does not repeat that
definition fifty times: the schema it already carries is looked up in the
browser.

#### Props

| Prop | Description |
| --- | --- |
| `doc` | The document, widget, array item or object the field belongs to. Required. |
| `name` | The name of the field in that object's schema. Required. |
| `tag` | The HTML tag to render. Defaults to what the field type asks for: `div` for rich text, `span` for a single line string, so a string sits on the line the template put it on. |
| `class` | Added to the classes the field type asks for, never replacing them. |
| `style`, `attrs` | Passed through to the tag. `attrs` is an object of additional attributes. |
| `edit` | `false` never offers editing, even to a user who could. |

Any other props are passed to the editor as its options, exactly as the `with`
clause of the `{% field %}` tag does.

Fields of the *context* document, the one the page is about, are editable.
Fields of any other document are displayed and not edited, just as areas are:
edit them on their own page. Documents your templates fetched straight from
the REST API carry this information only when the request asked for
`render-areas`; without it their fields are displayed but never editable.
Fields of `global` are displayed and not edited.

## Creating Astro widget components

Earlier we created a mapping from Apostrophe widget names to Astro components.
Let's take a look at how to implement these.

You Astro widget will receive a `widget` property, in addition to any custom props
you passed to the `AposArea` component. This `widget` property contains the
the schema fields of your Apostrophe widget.  

As an example, here is a simple Astro component to render `@apostrophecms/image` widgets:

```js
---
const { widget } = Astro.props;
const placeholder = widget?.aposPlaceholder;
const src = placeholder ?
  '/images/image-widget-placeholder.jpg' :
  widget?._image[0]?.attachment?._urls['full'];
---
<style>
  .img-widget {
    width: 100%;
  }
</style>
<img class="img-widget" {src} />
```

### Placeholders are important in widgets that use them

Why are we checking for `aposPlaceholder`? Apostrophe's `@apostrophecms/image`
widget displays a placeholder image until the user clicks the edit pencil to
select their image of choice. When rendered by Astro, Apostrophe still expects
this to be the case. So we need to provide our own placeholder rendering.

In this case, a suitably named file must exist in `public/images` in our Astro project.

### Remember, relationship properties might not be populated

It is always possible that the image associated with an image widget has
been archived. The `?.` syntax is a simple way to avoid a 500 error
in such a situation. You may wish to add a more sophisticated fallback.

## Accessing image and URLs

Properties like `.attachment._urls['full']` exist on all image pieces,
while properties like `.attachment._url` exist on non-image attachments
such as PDFs. For more information, see
the [attachment field format](https://apostrophecms.com/docs/reference/api/field-formats.html#attachment).

## What to change in your Apostrophe project

Nothing! Well, almost.

* Your project must be using Apostrophe 4.x.
* You'll need to `npm update` your project to the latest version of `apostrophe`.
* You'll need to set the `APOS_EXTERNAL_FRONT_KEY` environment variable to a secret
value of your choosing when running Apostrophe.
* Make sure you set that **same value** when running your Astro project.
* To avoid developer confusion, we recommend changing any page templates in your
Apostrophe project to provide a link to your Astro frontend site and
remove all other output. Everyone, editors included, should go straight to Astro.

## Starting up your combined project

To start your Astro project, follow the usual practice:

```bash
cd my-astro-project
npm install
export APOS_EXTERNAL_FRONT_KEY=your-secret-goes-here
npm run dev
```

In an adjacent terminal, start your Apostrophe project:

```bash
cd my-apostrophe-project
npm install
export APOS_EXTERNAL_FRONT_KEY=your-secret-goes-here
npm run dev
```

For convenience, Astro generally defaults to port `4321`, while
Apostrophe defaults to port `3000`.

## Logging in

Once your integration is complete, you will be able to reach the login page in
the usual way at `http://localhost:4321/login`. Astro proxies this route directly
to Apostrophe. Therefore any additional extensions you have added such as
Apostrophe's hCaptcha and TOTP modules will work as expected.

## Redirections

When Apostrophe sends a response as a redirection, you will receive a specially
formatted `aposData` object containing `redirect: true`, a `url` property for the url
to redirect to, and a `status` for the redirection HTTP status code. This is handled
in the earlier example, repeated here for convenience:

```js
const aposData = await aposPageFetch(Astro.request)
// Redirect
if (aposData.redirect) {
  return Astro.redirect(aposData.url, aposData.status);
}
```

## 404 Not Found

Much like the redirect case, when Apostrophe determines that the page was not
found, `aposData.notFound` will be set to true. The example `[...slug].astro`
file provided above includes logic to set Astro's status code to 404 in this
situation.

## Reserved routes

As this integration proxies certain Apostrophe endpoints, there are some routes that are taken by those endpoints:

* `/apos-frontend/[...slug]` for serving Apostrophe assets
* `/uploads/[...slug]` for serving Apostrophe uploaded assets
* `/api/v1/[...slug]` and `/[locale]/api/v1/[...slug]` for Apostrophe API endpoints
* `/login` and `/[locale]/login` for the login page

In addition, literal content files declared by your Apostrophe modules — `robots.txt`, `sitemap.xml`, `llms.txt`
and any others — are recognised at request time by a middleware and sent to the raw proxy instead of the page
renderer. Since v1.14.0 you no longer need to list these individually in `proxyRoutes`.

In a multisite project each site has its own list of literal content files, recognised by the request's `host`. This requires most recent versions of `apostrophe`, multisite and this integration. With an older `apostrophe` or multisite, all sites share the list of the first site that is requested.

These routes exist in SSR mode only. A static build injects none of them.

As all Apostrophe API endpoints are proxied, you can expose new api routes as usual in your Apostrophe modules, and be able to request them through your Astro application.
Those proxies are forwarding all of the original request headers, such as cookies, so that Apostrophe login works normally.

## What about widget players?

ApostropheCMS is very unopinionated on the front end, but it does include one
important front end feature: widget players. These provide a way for developers
to provide special behavior to widgets, calling each widget's player exactly
once at page load and when new widgets are inserted or replaced with new values.
Users appreciate this and expect interactive widget features to work normally
without a page refresh, even if the widget was just added to the page.

In Astro, web components are a recommended strategy to achieve the same thing.
Defining and using a web component in an Astro widget component has much
the same effect as defining a widget player in a standalone Apostrophe project.

Here is a simple outline of such a web component. For a complete example of
the same widget, check out the source code of `VideoWidget.astro` in our
[Astro Essentials Starter Kit](https://github.com/apostrophecms/starter-kit-astro-essentials/blob/main/frontend/src/widgets/VideoWidget.astro) project.

```js
---
// src/widgets/VideoWidget.astro
const { widget } = Astro.props;
const placeholder = widget?.aposPlaceholder ? 'true' : '';
const url = widget?.video?.url;
---
<style>
  video-widget {
    width: 100%;
  }
</style>
<video-widget
  url={placeholder ? 'https://youtu.be/Q5UX9yexEyM' : url }
>
</video-widget>
<script>
  class VideoWidget extends HTMLElement {
    constructor() {
      super();
      this.init();
    }
    async init() {
      const videoUrl = this.getAttribute('url');
      // Your logic here!
      //
      // Fetch details about the video URL,
      // create an iframe to embed it, append it
      // to the component's HTML element with this.append(),
      // etc.
    }
  }
  customElements.define('video-widget', VideoWidget);
</script>
```

> Note that Astro script tags aren't really plain vanilla HTML script tags.
> They are efficiently compiled, support TypeScript and are only executed
> once even if the component appears may times on the page. Defining a
> web component allows us to leverage that code more than once by using
> the newly defined element as often as we wish.

## `aposSetQueryParameter`: working with query parameters

One last thing: query parameters. Sometimes we want to create pagination
links with page numbers, add filters to a URL's query string, and so on.
But, working with query parameters coming from Apostrophe can
be a little bit tricky because there are often special query parameters
present during editing that should not be part of a visible URL.

As a convenience, Apostrophe provides `aposSetQueryParameter` to abstract
all that away.

Here is how the `BlogIndexPage.astro` component of the
[Starter Kit Astro Essentials](https://github.com/apostrophecms/starter-kit-astro-essentials/blob/main/frontend/src/templates/BlogIndexPage.astro) project generates
links to each page of blog posts:

```js
---
import { aposSetQueryParameter as setParameter } from '@apostrophecms/apostrophe-astro/helpers/universal';

const {
  page,
  pieces,
  currentPage,
  totalPages
} = Astro.props.aposData;

const pages = [];
for (let i = 1; (i <= totalPages); i++) {
  pages.push({
    number: i,
    current: i === currentPage,
    url: setParameter(Astro.url, 'page', i)
  });
}
---

<section class="bp-content">
  <h1>{ page.title }</h1>

  <h2>Blog Posts</h2>

  {pieces.map(piece => (
    <h3>
      <a href={ piece._url }>{ piece.title }</a>
    </h3>
  ))}

  {pages.map(page => (
    <a
      class={page.current ? 'current' : ''}
      href={page.url}>{page.number}
    </a>
  ))}
</section>
```

Imported here as `setParameter`, `aposSetQueryParameter` allows
us to do two things:

1. Take a URL and return a new URL with a certain query parameter set
to a new value.
2. Remove a query parameter completely by passing the empty string as
a value, or by passing `null` or `undefined`.

While you can get the same result by manipulating `Astro.url` yourself,
you'll be able to avoid the confusing presence of query parameters
like `aposMode` by using this convenient feature.

## Other helper functions

`aposSetQueryParameter` is one of a number of helpers this package exports. They are split into two entry points
according to where they can safely run.

`@apostrophecms/apostrophe-astro/helpers/universal` — pure functions, usable in both server code and browser
scripts:

* `aposSetQueryParameter`, `buildPageUrl`, `getFilterBaseUrl` — URL and query string construction
* `slugify` — the same slug rules Apostrophe uses
* `stylesAttributes`, `stylesElements` — render the styles system's output for a document or widget
* `getAttachmentUrl`, `getAttachmentSrcset`, `getFocalPoint`, `getWidth`, `getHeight` — attachment and image helpers

`@apostrophecms/apostrophe-astro/helpers/server` — server only, for Astro frontmatter, endpoints and prerendering.
These depend on the generated integration config and on Node internals, so do not import them from client-side
scripts:

* `aposFetch` — an authenticated fetch against your Apostrophe backend
* `getAposHost`, `isStaticBuild` — resolved integration state
* `getAllStaticPaths`, `getAllUrlMetadata`, `getLocales` — see [Static builds](#static-builds)

Importing these from their old `lib/` paths still works but is deprecated; see the [upgrade guide](https://apostrophecms.com/docs/guide/migration/upgrading-apostrophe-astro.html) for
the mapping.

## What about Vue, React, SvelteJS, etc.?

While not shown directly in the examples above, **Astro can import components
written in any of these frameworks.** Just use `astro add` to install
the appropriate integration, then `import` your components freely in your
`.astro` files. For complete documentation and examples, see the
[`@astrojs/react` integration](https://docs.astro.build/en/guides/integrations-guide/react/).

In this way, Astro acts as a **universal bridge** to essentially all modern
frontend frameworks.

## A note on production use

For production use, any Astro hosting adapter that supports `mode: 'server'` should
be acceptable. In particular, our [Starter Kit Astro Essentials](https://github.com/apostrophecms/starter-kit-astro-essentials) project comes pre-configured
for the `node` adapter, and includes `npm run build` and `npm run serve`
support to take advantage of that. In `server` mode there is not a great
deal of difference between these and `npm run dev`, but there is less
overhead and less information exposed to the public, so we recommend following
this best practice.

## Static builds

Setting `output: 'static'` in your Astro config switches the integration into static mode. Instead of proxying
requests to Apostrophe at runtime, it fetches everything at build time and writes a plain directory of files you
can host anywhere.

In static mode the integration does not inject the proxy routes at all — there is no server to proxy through — so
`/login` and the admin UI are not available in the published output. Editing continues to happen against your SSR
development site; the static build is what you publish.

Your `[...slug].astro` supplies the list of pages through Astro's `getStaticPaths`:

```js
---
import { getAllStaticPaths } from '@apostrophecms/apostrophe-astro/helpers/server';

export async function getStaticPaths() {
  return getAllStaticPaths({
    aposHost: process.env.APOS_HOST,
    aposExternalFrontKey: process.env.APOS_EXTERNAL_FRONT_KEY
  });
}
---
```

`getAllStaticPaths` fetches the supported locales from Apostrophe, collects URL metadata for each non-private
locale, and returns a flat array of `{ params, props }` entries ready for Astro. Two lower-level helpers are
exported alongside it from the same entry point: `getLocales`, and `getAllUrlMetadata` for a single locale.

If a component needs to behave differently depending on the mode, import `isStaticBuild` from
`@apostrophecms/apostrophe-astro/helpers/server`.

After Astro finishes writing pages, the integration's `astro:build:done` hook writes two more things into the
output directory: literal content declared by your Apostrophe modules (`robots.txt`, `sitemap.xml`, `llms.txt`
and so on) and the attachment files your pages reference. What gets copied is controlled by the
[`staticBuild`](#staticbuild-optional) option and its environment variables. Both steps need `APOS_HOST` and
`APOS_EXTERNAL_FRONT_KEY` to be set at build time and are skipped silently if either is missing.

> [!TIP]
> If your project sets `security.allowedDomains` in `astro.config.mjs`, restrict it to SSR. Astro v6 and v7 read
> request headers during prerendering, which produces a spurious warning for every page built. See
> the [upgrade guide](https://apostrophecms.com/docs/guide/migration/upgrading-apostrophe-astro.html) for the details.

## Output and logging

Everything this integration prints is Astro's output, not Apostrophe's: it runs in the Astro process, so it never travels through the Apostrophe log pipeline and is not affected by the `log` option of your Apostrophe project. Warnings and errors are written to the console with a consistent `[apostrophe-astro]` prefix and are colored only when the terminal accepts color, honoring `NO_COLOR`, `FORCE_COLOR` and `--no-color` like Astro itself.

## Debugging

In most cases, Astro prints helpful error messages directly in the browser
when in a development environment.

The integration writes its resolved configuration to real files under
`node_modules/.apostrophe-astro-config/` (`config.js` and `doctypes.js`) during `astro:config:setup`, and
registers Vite aliases for them. Static builds additionally cache data in `node_modules/.apostrophe-astro-static/`.

If you see an import of `apostrophe-astro-config/config` or `apostrophe-astro-config/doctypes` fail to resolve,
those generated files are missing or stale — most often after switching branches or removing `node_modules`
partially. Reinstall or clear the two directories above and restart the dev server so `astro:config:setup` runs again.

> Earlier versions of this package used private Vite virtual modules named `virtual:apostrophe-config` and
> `virtual:apostrophe-doctypes`. They have been removed. If your project imported either of them directly, see
> the [upgrade guide](https://apostrophecms.com/docs/guide/migration/upgrading-apostrophe-astro.html).

### Widget Render Hook

When using patterns that require per-request initialization (like `AsyncLocalStorage` for request-scoped state), normal page rendering works fine because the page component can run setup code before rendering widgets. However, in edit mode, the widget render endpoint renders widgets directly without any extension point for initialization.

The `onBeforeWidgetRender` hook solves this by providing a function that runs before each widget renders in edit mode.

**Setup**

Using this hook requires two steps: configuring the path in your integration options and creating the hook file.

#### 1. Configure the Integration

Add the `onBeforeWidgetRender` option to your `astro.config.mjs`:
```javascript
// astro.config.mjs
export default defineConfig({
  integrations: [
    apostrophe({
      widgetsMapping: './src/widgets',
      templatesMapping: './src/templates',
      onBeforeWidgetRender: './src/hooks/before-widget-render.js'
    })
  ]
});
```

#### 2. Create the Hook File
```javascript
// src/hooks/before-widget-render.js
/**
 * Runs before rendering each widget in edit mode
 * @param {Object} context.widget - The widget being rendered
 * @param {Object} context.props - Props including global data
 * @param {Object} context.Astro - Astro global object
 */
export default function({ widget, props, Astro }) {
  // Your initialization logic
  console.log('Rendering widget:', widget.type);
}
```
#### Notes

- The hook **only runs in edit mode** (when rendering via `/api/v1/@apostrophecms/area/render-widget`)
- The hook can be synchronous or asynchronous (return a Promise)
- Errors in the hook are logged but don't prevent widget rendering
- The hook receives the same `props` that would be passed to the widget component

## Enabling the `render-area` option to ApostropheCMS REST APIs

In order to enable section template library previews, and also unlock the `?render-area=1` and `?render-area=inline` query parameters to ApostropheCMS REST APIs in general, you'll need to add the following route:

```markup
---
// Place this file in: src/pages/api/apos-external-front/render-area.astro

import AposRenderAreaForApi from '@apostrophecms/apostrophe-astro/components/AposRenderAreaForApi.astro';
---
<AposRenderAreaForApi />
```

This file provides a "bridge" between ApostropheCMS and Astro, allowing ApostropheCMS to "call back" to the Astro project to render the content for a particular area.

Our recently updated starter kits already include this file.

## Enabling the `@apostrophecms/layout-widget` in an existing project
If you are using any of our starter kits, or you are following the integration steps outlined above, you will have the core layout-widget installed. For existing projects you will have a few steps to activate it.

### Backend updates
1. Add `@apostrophecms/layout` to any areas where you will want to add the widget.

By default, the layout widget columns will include the core rich-text, image, and video widgets. If you want any additional widget types, you will have to follow several additional steps:

1. Create a `backend/modules/@apostrophecms/layout-column-widget/index.js` file.
2. Add the following code:
```javascript
export default {
  fields(self, options) {
    return {
      add: {
        content: {
          type: 'area',
          label: 'Main Content',
          options: {
            widgets: {
              // add any project-specific content widgets
              // nesting layout widgets can lead to poor performance
              // or rendering issues
              '@apostrophecms/rich-text': {},
              '@apostrophecms/image': {},
              '@apostrophecms/video': {}
            }
          }
        }
      }
    };
  }
};
```

This file extends the default column widget to define which content widgets editors can add inside each column. Avoid nesting layout widgets inside other layouts to prevent excessive DOM complexity and performance issues.

> [!TIP]
> You can read more about configuring and using the layout-widget in the [documentation](https://apostrophecms.com/docs/guide/core-widgets.html#layout-widget).

### Frontend updates
The `@apostrophecms/apostrophe-astro` package contains templates for the layout widget and column, but like the other widgets, they have to be mapped to the corresponding Apostrophe widgets.

1. Open the `frontend/src/widgets/index.js` file.
2. Import the `layout` and `layout-column` widgets
  ``` javascript
    import LayoutWidget from '@apostrophecms/apostrophe-astro/widgets/LayoutWidget.astro';
    import LayoutColumnWidget from '@apostrophecms/apostrophe-astro/widgets/LayoutColumnWidget.astro';
  ```
3. Map the components in the `widgetComponents` object
  ``` javascript
    export const widgetComponents = {
    ...widgetComponents,
    '@apostrophecms/layout': LayoutWidget,
    '@apostrophecms/layout-column': LayoutColumnWidget
  };
  ```
Once you’ve added these mappings, restart your Apostrophe server and refresh the editor. The layout widget should now appear as an option in any area that includes @apostrophecms/layout.

## Conclusion

This module provides a new way to use ApostropheCMS: as a back end
for modern front end development in Astro. But more than that, it
provides a future-proof bridge to many different front-end frameworks.

Also important, Apostrophe fully maintains the on-page, in-context editing
experience when integrated with Astro, going beyond "side-by-side"
editing experiences to achieve integration close enough that we often
have to look at the address bar to know whether we are looking at
Astro or Apostrophe.

That being said, this integration is also new, and we encourage you
to share your feedback.

## Acknowledgements

Development of this module began with Stéphane Maccari and Clément Ravier of
Michelin. We are grateful for their generous support of ApostropheCMS.

---

### Overview (`packages/apostrophe/README.md`)

<div align="center">
  <a href="https://github.com/apostrophecms/apostrophe">
    <img src="https://static.apostrophecms.com/apostrophecms/apostrophe/logo.svg" alt="ApostropheCMS logo" width="80" height="80">
  </a>

  <h1>ApostropheCMS</h1>

  <p>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20" />
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/apostrophe/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639" />
    </a>
  </p>

  <p>
    <strong>Full-stack CMS for developers and content teams</strong><br />
    Build websites with in-context editing and headless flexibility using Node.js and MongoDB.
    <br />
    <a href="https://docs.apostrophecms.org/"><strong>Documentation »</strong></a>
    <br />
    <br />
    <a href="http://demo.apostrophecms.com">Demo</a>
    ·
    <a href="https://roadmap.apostrophecms.com/roadmap">Roadmap</a>
    ·
    <a href="https://github.com/apostrophecms/apostrophe/issues/new?assignees=&labels=bug,3.0&template=bug_report.md&title=">Report Bug</a>
  </p>
</div>

## About

ApostropheCMS is a full-stack content management system built with Node.js and MongoDB. Content creators can edit directly on live pages without switching between admin interfaces, while developers can build with modern JavaScript or use it headlessly with any frontend framework.

### Key Features

- **🎯 In-Context Editing** - Content creators edit directly on the live page, seeing changes instantly
- **⚡ Headless-Ready** - Use any frontend framework while keeping the powerful admin experience
- **🛠️ Developer-First** - Built with Node.js and MongoDB for full-stack JavaScript development
- **📈 Scales Beautifully** - From small sites to enterprise applications handling millions of pages
- **🔐 Enterprise Features** - Advanced permissions, workflow management, automated translations, and more

## System Requirements

| Requirement | Version | Installation Notes |
|-------------|---------|-------------------|
| **Node.js** | 20.x+ | Use [NVM](https://github.com/nvm-sh/nvm) for version management |
| **MongoDB** | 6.0+ | [MongoDB Atlas](https://www.mongodb.com/atlas) (cloud) or local install |
| **npm** | 10.x+ | Included with Node.js |

See our [setup guides](https://docs.apostrophecms.org/guide/development-setup.html) for installation instructions.

## Quick Start

Get ApostropheCMS running locally in minutes:

```bash
# Option 1: Install CLI globally (recommended for multiple projects)
npm install @apostrophecms/cli
apos create my-website
cd my-website
npm run dev

# Option 2: Use npx for one-time project creation
npx @apostrophecms/cli create my-website
cd my-website
npm run dev
```

Your new ApostropheCMS site will be available at `http://localhost:3000` with a powerful admin interface at `/login`.

### Prefer to Go Headless?

**Get started with Astro integration** - the easiest way to build headless sites while keeping visual editing:

- **[Apollo Starter Kit (Astro)](https://apostrophecms.com/starter-kits/apollo-starter-kit-for-astro-cms)** - Production-ready foundation with beautiful design system and rich content features
- **[Essentials Starter Kit (Astro)](git clone https://github.com/apostrophecms/starter-kit-astro-essentials)** - Minimal, clean foundation for building custom designs from scratch

Both starter kits provide headless CMS power with in-context editing, letting content creators edit directly on the live site while you build with modern frontend tools. Our Astro integration handles all the content fetching automatically—no REST API calls to write.

**Desire a different frontend framework?** Use our REST APIs with React, Vue, Svelte, or any other framework:

- **[REST API Documentation](https://docs.apostrophecms.org/reference/api/pieces.html)** - Complete API reference
- **[Headless CMS Guide](https://docs.apostrophecms.org/guide/headless-cms.html)** - Integration walkthrough for any framework

### Hosting & Deployment

Choose [ApostropheCMS hosting](https://apostrophecms.com/hosting) for turnkey solutions with optimized performance and dedicated support, or deploy to [any platform where Node.js runs](https://docs.apostrophecms.org/guide/hosting.html).

## Built With Modern Tech

- **[Node.js](https://nodejs.org/)** - JavaScript runtime for server-side development
- **[MongoDB](https://www.mongodb.com/)** - Flexible document database for content storage
- **ESM Modules** - Native ES6 module support for modern JavaScript
- **Vite** - Lightning-fast build tool and development server
- **Modern JavaScript** - ES6+, async/await, and contemporary development patterns

## Community & Support

**Join other developers and content creators using ApostropheCMS:**

- **[Discord](https://discord.com/invite/XkbRNq7)** - Get help, share projects, and connect with other users
- **[GitHub Discussions](https://github.com/apostrophecms/apostrophe/discussions)** - Feature requests, technical discussions, and community support
- **[Documentation](https://docs.apostrophecms.org/)** - Comprehensive guides, tutorials, and API references

## Contributing

We welcome contributions from the community! Whether you're fixing bugs, adding features, or improving documentation, your help makes ApostropheCMS better for everyone.

- **[Contribution Guide](https://github.com/apostrophecms/apostrophe/blob/main/CONTRIBUTING.md)** - How to contribute code, documentation, and feedback
- **[Good First Issues](https://github.com/apostrophecms/apostrophe/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22)** - Perfect starting points for new contributors


## Pro Features

**For teams and organizations requiring additional features:**

- **🔐 Advanced User Management** - Granular permissions, user groups, and access controls
- **🌍 Automated Translation** - AI-powered translation with DeepL, Google Translate, and Azure
- **📊 Analytics & SEO** - Built-in SEO optimization and content analytics
- **⚡ Performance Optimization** - Advanced caching, CDN integration, and performance monitoring
- **🏢 Multisite Management** - Manage multiple sites from a single dashboard with shared resources
- **💼 Professional Support** - Dedicated support, training, and consultation services

[Explore all the pro extensions](https://apostrophecms.com/extensions?autocomplete=&license=assembly&license=pro) and [sign up](https://app.apostrophecms.com/login) for a Pro license in our self-service Apostrophe Workspaces, or [contact us](https://apostrophecms.com/contact-us) to learn about licensing and support options.

## License

ApostropheCMS is open source software licensed under the [MIT License](https://github.com/apostrophecms/apostrophe/blob/main/LICENSE.md). This means you're free to use, modify, and distribute it for both personal and commercial projects.

---

<div align="center">
  <p>
    <strong>Ready to build something amazing?</strong><br>
    <a href="https://docs.apostrophecms.org/">Get started with our documentation</a> or <a href="https://apostrophecms.com/contact-us">talk to our team</a>
  </p>
  <p>
    <em>Built with ❤️ by the <a href="https://apostrophecms.com">ApostropheCMS team</a></em>
  </p>
</div>

---

### Overview (`packages/apostrophecms-openapi/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>ApostropheCMS OpenAPI Specification</h1>

  <p>
    <a aria-label="Apostrophe logo" href="https://docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/apostrophecms-openapi/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

The official OpenAPI 3.1 specification for the ApostropheCMS REST API.  Explore endpoints interactively, mock the API for rapid prototyping, or generate type-safe SDKs in your preferred language.

---

## What This Is (And Isn't)

This repository contains the **core ApostropheCMS OpenAPI specification** - the base REST API that every Apostrophe project inherits. It documents standard endpoints for pages, pieces, assets, users, and workflow management.

**Think of it as the foundation, not the complete building.** Your project likely has custom piece types (like products, events, or blog posts) and project-specific routes that aren't included here.

> ⚠️ **Important**: This repository contains the API specification, not ApostropheCMS itself. For the CMS, visit the [main ApostropheCMS repository](https://github.com/apostrophecms/apostrophe).

### For Your Own Project

To generate a specification that includes **both** core and your custom modules:

1. Install [@apostrophecms/openapi-generator](https://apostrophecms.com/extensions/openapi-generator) in your Apostrophe project
2. Run the generator from your project directory  
3. Use the generated specification for SDK creation and documentation specific to your application

### For Exploring Core ApostropheCMS

Use this repository to:

- **Explore the API** - Browse all core endpoints in interactive documentation
- **Design API contracts** - Use as a foundation for planning new projects and features
- **Mock for prototyping** - Build frontend apps before your backend is ready
- **Generate SDKs** - Create client libraries in TypeScript, Python, PHP, and more
- **Learn conventions** - Understand ApostropheCMS API patterns and best practices

---

## Getting Started

### Explore the API Interactively

View the complete API documentation with Swagger UI:

```bash
# Install dependencies
npm install

# Open interactive documentation
npm run docs:open
```

This opens a browser interface where you can browse endpoints, view schemas, and test API calls.

> **Note**: You will have to have an ApostropheCMS project running before testing the endpoints.

### Authenticate for Testing

You can authenticate in Swagger UI using either an API key or bearer token:

#### Option 1: API Key (Recommended)

The simplest method - requires one-time setup in your project:

1. **Add an API key** to your ApostropheCMS project in `modules/@apostrophecms/express/index.js`:

```javascript
export default {
  options: {
    apiKeys: {
      myTestKey: {
        role: 'admin'
      }
    }
  }
};
```

2. **In Swagger UI**: Click "Authorize" → scroll to "ApiKeyAuth" → enter `myTestKey`

3. **Test away**: Execute requests directly from the documentation

#### Option 2: Bearer Token

No project configuration needed - use your existing login credentials:

1. **Generate a token**: In Swagger UI, find the `POST /@apostrophecms/login/login` endpoint and execute it with:

```json
{
  "username": "your-username",
  "password": "your-password"
}
```

2. **Copy the token**: From the response, copy **only the token value** (not the full JSON). 
   - Example: if response is `{"token": "abc123xyz"}`, copy only `abc123xyz`

3. **Authorize**: Click "Authorize" → scroll to "BearerAuth" → paste the token value

The token will be automatically sent as `Authorization: Bearer {your-token}` with each request.

---

## API-First Development

Use this specification to design and prototype before writing code - perfect for parallel frontend/backend development.

### Design Your API Contract

When starting a new project or feature:

1. **Start with the core spec** as your foundation
2. **Manually add your custom endpoints** following the same patterns used in the core spec
3. **Share the spec** with your team as the contract between frontend and backend
4. **Develop in parallel** - frontend uses mocks, backend implements to match the spec

### Mock the API

Create a fully functional mock server without any backend code:

```bash
# Install Prism globally
npm install @stoplight/prism-cli

# Mock the core API
prism mock apostrophecms-openapi.yaml

# Or mock your project-specific spec
prism mock my-project-openapi.yaml

# Mock server runs at http://localhost:4010
```

The mock server returns realistic example responses, letting frontend developers build and test their applications before the backend is ready.

**Use cases:**
- Prototype new features without backend changes
- Frontend development while backend is in progress  
- Demo UIs to stakeholders before implementation
- Test frontend error handling and edge cases

> **Note:** The [@apostrophecms/openapi-generator](https://apostrophecms.com/extensions/openapi-generator) generates documentation for *existing* ApostropheCMS projects - it documents what you've already built. For true API-first design, you'd manually extend this core spec before implementation.

---

## Generate an SDK

Create a client library in your preferred language (requires Java runtime):

```bash
# TypeScript/JavaScript
npm run generate:typescript

# Python
npm run generate:python

# PHP
npm run generate:php
```

The generated SDK will be in the `examples/` folder, complete with documentation and usage examples.

---

## Using Generated SDKs

After generating an SDK, you'll find complete documentation in the generated folder including a README with examples for every endpoint.

### Quick TypeScript Example

```bash
# Build the SDK
cd examples/typescript
npm install && npm run build

# Install in your project
npm install /path/to/examples/typescript
```

Basic usage:

```typescript
import { Configuration, PagesApi } from 'apostrophecms-client';

const config = new Configuration({
  basePath: 'http://localhost:3000/api/v1',
  apiKey: process.env.APOSTROPHE_API_KEY
});

const pages = new PagesApi(config);

// Get page tree
const tree = await pages.pageGet();
console.log(tree.data);

// Create a page
const newPage = await pages.pagePost({
  title: 'Welcome',
  type: 'default-page',
  slug: '/welcome'
});
```

**See the generated `examples/typescript/README.md` for complete documentation**, including authentication options, error handling, and examples for all endpoints.

---

## What's Included

### Specifications

- **`apostrophecms-openapi.yaml`** - Core ApostropheCMS REST API specification
- **`examples/apostrophecms-piece-examples.yaml`** - Sample piece types for learning

### Generated Examples

- **`examples/typescript/`** - Pre-generated TypeScript SDK with full documentation
- Includes comprehensive README with examples for every endpoint

### Scripts

| Command | Purpose |
|---------|---------|
| `npm run docs:open` | Open core API documentation |
| `npm run example-docs:open` | Open example piece documentation |
| `npm run validate` | Validate OpenAPI specification |
| `npm run lint` | Lint specification with Spectral |
| `npm test` | Run validation and linting |
| `npm run generate:typescript` | Generate TypeScript SDK |
| `npm run generate:python` | Generate Python SDK |
| `npm run generate:php` | Generate PHP SDK |

---

## Validation

Ensure specification quality:

```bash
npm run validate  # Check OpenAPI structure
npm run lint      # Lint with Spectral
npm test          # Run both checks
```

The specification follows OpenAPI 3.1 standards and uses [Spectral](https://stoplight.io/open-source/spectral/) for linting.

---

## SDK Generation Details

This repository uses [OpenAPI Generator](https://openapi-generator.tech/) to create client libraries. 

### Other Languages

```bash
# Python
npm run generate:python

# PHP
npm run generate:php

# See full list of supported languages:
# https://openapi-generator.tech/docs/generators/
```

Each generated SDK includes:
- Complete API client with type definitions
- README with usage examples
- Documentation for all endpoints
- Authentication configuration helpers

Check the generated SDK's README for language-specific setup and usage instructions.

---

## Contributing

We welcome contributions! To contribute:

1. Fork the repository and create a feature branch
2. Make changes to the OpenAPI specification
3. Run `npm test` to validate your changes
4. Submit a pull request with a clear description

Please ensure your changes:
- Follow OpenAPI 3.1 standards
- Include appropriate examples
- Pass validation and linting
- Update documentation as needed

---

## Resources

### ApostropheCMS Documentation
- [Main Documentation](https://docs.apostrophecms.org/)
- [REST API Guide](https://docs.apostrophecms.org/guide/rest-api.html)
- [Headless CMS Guide](https://docs.apostrophecms.org/guide/headless.html)
- [Main Repository](https://github.com/apostrophecms/apostrophe)

### Support & Community
- [Discord Community](https://discord.com/invite/HwntQpADJr) - Get help from other developers
- [GitHub Issues](https://github.com/apostrophecms/apostrophecms-openapi/issues) - Report bugs or request features
- [Professional Support](https://apostrophecms.com/contact-us) - Enterprise support and consulting

---

## Versioning

- **Specification Version**: Follows semantic versioning (current: `1.0.0`)
- **API Compatibility**: The `x-apostrophe.cmsVersion` field indicates compatible ApostropheCMS versions
- **Breaking Changes**: Major version updates indicate breaking API changes

---

<div align="center">
  <p>Made with ❤️ by the <a href="https://apostrophecms.com">ApostropheCMS</a> team.</p>
  <p><strong>Found this useful? <a href="https://github.com/apostrophecms/apostrophecms-openapi">Give us a star!</a> ⭐</strong></p>
</div>

---

### Overview (`packages/blog/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Apostrophe Blog</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/blog/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

**Add blog functionality to ApostropheCMS sites** with article management, date-based filtering, and multiple blog support. Provides both the blog piece type and page templates to get started quickly.

## Features

- **📝 Blog Article Management** - Complete CRUD interface for blog posts with publication dates
- **🗓️ Date-based Filtering** - Built-in year/month/day query filters for archives and navigation
- **🎨 Fully Customizable** — Override templates, add fields, and style it to match your brand
- **🔗 Multiple Blog Types** - Extend to create different blog categories (news, updates, etc.)
- **⏰ Publication Control** - Articles only appear when published date is in the past

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```bash
npm install @apostrophecms/blog
```

## Usage

Configure the blog modules in your `app.js` file:

```javascript
import apostrophe from 'apostrophe';

export default apostrophe({
  root: import.meta,
  shortName: 'my-project',
  bundles: [ '@apostrophecms/blog' ],
  modules: {
    '@apostrophecms/blog': {},
    '@apostrophecms/blog-page': {}
  }
});
```

### Enable the page type

Add the blog page type to your page configuration:

```javascript
// modules/@apostrophecms/page/index.js
export default {
  options: {
    types: [
      {
        name: '@apostrophecms/home-page',
        label: 'Home'
      },
      {
        name: '@apostrophecms/blog-page',
        label: 'Blog'
      }
    ]
  }
};
```

## Customizing Templates

The included templates (`index.html`, `show.html`, `filters.html`) are starting points that demonstrate the available data. Override them in your project to implement your own styling and layout:

```
modules/
├── @apostrophecms/
│   └── blog-page/
│       └── views/
│           ├── index.html      # Blog listing page
│           ├── show.html       # Individual blog post
│           └── filters.html    # Date filtering controls
```

## Date-based Filtering

The blog includes built-in query filters for creating archive navigation and date-based URLs:

| Filter | Format | Example URL |
|--------|--------|-------------|
| `year` | `YYYY` | `/blog?year=2024` |
| `month` | `YYYY-MM` | `/blog?month=2024-03` |
| `day` | `YYYY-MM-DD` | `/blog?day=2024-03-15` |

### Publication Control

Blog posts use the `publishedAt` field to control visibility. Only articles with publication dates in the past appear on the public site. Editors see all articles in the admin interface.

> **Note:** This doesn't automatically publish draft changes on the publication date. For scheduled publishing of draft content, consider the [@apostrophecms/scheduled-publishing](https://apostrophecms.com/extensions/scheduled-publishing) module.

## Multiple Blog Types

Sometimes a website needs multiple, distinct types of blog posts. If the blog posts types can be managed together, it might be easiest to [add a new field](https://docs.apostrophecms.org/guide/content-schema.html#using-existing-field-groups) and [query builder](https://docs.apostrophecms.org/reference/module-api/module-overview.html#queries-self-query) to customize blog views. But if the blog posts types should be managed completely separately, it may be better to create separate piece types for each.

### Creating a Custom Blog Type

```javascript
// modules/news-blog/index.js - for news articles
export default {
  extend: '@apostrophecms/blog',
  options: {
    label: 'News Article',
    pluralLabel: 'News Articles'
  },
  fields: {
    add: {
      priority: {
        type: 'select',
        choices: [
          { label: 'Standard', value: 'standard' },
          { label: 'Breaking', value: 'breaking' },
          { label: 'Featured', value: 'featured' }
        ]
      },
      source: {
        type: 'string',
        label: 'News Source',
        help: 'Original source of this news item'
      }
    },
    group: {
      basics: { fields: ['priority', 'source'] }
    }
  }
};
```

Every blog piece type needs a corresponding page type that extends `@apostrophecms/blog-page`:

```javascript
// modules/news-blog-page/index.js - page type for news articles
export default {
  extend: '@apostrophecms/blog-page'
};
```

### Custom Templates for Blog Types

Each blog type can have its own templates. Create them in the corresponding page module:

```
modules/
├── news-blog-page/
│   └── views/
│       ├── index.html      # News listing page
│       ├── show.html       # Individual news article
│       └── filters.html    # Custom filters for news
└── @apostrophecms/
    └── blog-page/
        └── views/
            ├── index.html  # Default blog listing
            └── show.html   # Default blog post
```

This allows you to:
- Style news articles differently from regular blog posts
- Add custom filtering options specific to news content
- Display different fields or layouts for each blog type
- Create distinct navigation and user experiences

This approach works well when blog types have different:
- **Content structures** - News articles vs. technical tutorials vs. company announcements
- **Editorial workflows** - Different teams managing different content types
- **Display requirements** - Unique styling, filtering, or organization needs
- **URL patterns** - `/blog/`, `/news/`, `/updates/` with distinct navigation

## Field Reference

The blog piece type includes these fields by default:

- **Title** (`title`) - Article headline
- **Slug** (`slug`) - URL-friendly identifier  
- **Publication Date** (`publishedAt`) - Controls public visibility
- **Content** (`body`) - Rich text content area
- **Meta Description** (`metaDescription`) - SEO description
- **Tags** (`tags`) - Taxonomy for categorization

---
*Built with ❤️ by the ApostropheCMS team.*
---
**Found this useful?** [Give us a star on GitHub](https://github.com/apostrophecms/blog) ⭐

---

### Overview (`packages/boring/README.md`)

# Boring

<a href="https://apostrophecms.com/"><img src="https://raw.github.com/apostrophecms/boring/master/logos/logo-box-madefor.png" align="right" alt="Made for ApostropheCMS"/></a>

A command line argument parser without pirates

## What you get with Boring

Input:

```
node app jump sideways --foo --bar=whee --super-cool=totally
```

Response:

```javascript
{
  _: [ "jump", "sideways"],
  foo: true,
  bar: "whee",
  "super-cool": "totally"
}
```

Notice that parameters without `--`, if any, go into the `_` array. Parameters with `--` become properties in their own right.

## How you get it

```javascript
const argv = require('boring')({});
```

The options object is optional.

## Options

### Passthrough

It is a common convention to never treat any arguments that appear after a `--` placeholder (by itself) as named options, even if they start with `--`.

Instead, the remainder are treated as positional arguments, no matter what.

To get this behavior with Boring, pass the `end: true` option:

```javascript
const argv = require('boring')({
  end: true
});
console.log(argv);
```

Now, when you run this command:

```bash
node app hello --pretty -- --boring
```

You will get:

```javascript
{
  _: [ 'hello', '--boring' ],
  pretty: true
}
```

## What you don't get with boring

### Single hyphens: nope

There is no support for old-fashioned "single-hyphen" options, like:

```
-x 50
```

Or:

```
-h
```

You can't tell which are boolean and which take arguments unless a specification is passed in. And that's not boring enough for us.

### Usage messages, strictness, etc.: nope

These are very simple to implement, and if you're like us, you'd rather do it yourself.

## Philosophy

We have nothing against full-featured, pirate-themed option parsers, which are very nice if you're into that sort of thing. We just find ourselves walking the plank when our options don't follow the pattern of what's easy to validate with piracy.

This simple module is too dumb to break.

## About ApostropheCMS

Boring was created for use in ApostropheCMS, an open-source content management system built on node.js. If you like Boring you should definitely [check out apostrophecms.org](http://apostrophecms.org).

## Support

Feel free to open issues on [github](http://github.com/apostrophecms/boring).

<a href="http://apostrophecms.com/"><img src="https://raw.github.com/apostrophecms/boring/master/logos/logo-box-builtby.png" /></a>

---

### Overview (`packages/broadband/README.md`)

# broadband
=========
[](https://circleci.com/gh/apostrophecms/broadband/tree/master)

<a href="https://apostrophecms.org/"><img src="https://raw.githubusercontent.com/boutell/broadband/master/logos/logo-box-madefor.png" align="right" /></a>

Given a MongoDB query cursor, process the results in parallel, up to the specified limit.

```javascript
var broadband = require('broadband');
var cursor = mongoCollection.find({});

return broadband(cursor, 8, function(doc, callback) {
  // Up to 8 of these will be invoked simultaneously
  // Do something with doc, then...
  return callback(null);
}, function(err) {
  // All done
});
```

## Why?

We wanted to work with MongoDB queries the way we work with [async.eachLimit](https://github.com/caolan/async#eachLimit), but without yanking everything into memory at once with `toArray`.

Specifically, we wanted to resize some images in parallel, rather than waiting to do them one at a time. We have a MongoDB collection with information about all of the images. But there are a lot of them, so we don't want to yank all of that information into memory up front.

`broadband` wraps MongoDB's `Cursor.nextObject` with a queueing mechanism that allows several results to be processed at once, but only up to the limit you specify. You don't run out of memory due to too many image processes, you don't wait too long, and you don't have to load the entire array into memory at once. Everybody gets a medal.

## What about errors?

If an error occurs, `broadband` will:

1. Stop starting new iterator callbacks.
2. Wait for any outstanding iterator callbacks to finish.
3. Invoke the final callback (its third argument) with the first error it received.

## Using broadband without mongodb

You can pass any object with a `nextObject` method as the "cursor." That method should invoke its callback with `(err, object)`. If there is no error, `object` should be the next object retrieved from your data source. If there are no more objects, pass `null` as `object`.

## About P'unk Avenue and Apostrophe

`broadband` was created at [P'unk Avenue](https://punkave.com) for use in many projects built with Apostrophe, an open-source content management system built on node.js. If you like `broadband` you should definitely [check out apostrophecms.com](https://apostrophecms.com).

## Support

Feel free to open issues on [github](http://github.com/apostrophecms/broadband).

<a href="https://punkave.com/"><img src="https://raw.githubusercontent.com/boutell/broadband/master/logos/logo-box-builtby.png" /></a>

## Changelog

### 1.1.0

- Adds support for both MongoDB 2 and 3 via the cursor `next` and `nextObject` methods.
- Adds JS linting to the tests.

### 1.0.0

- Declared 1.0.0 stable as this has long been a component of Apostrophe. Updated lodash dependency to satisfy `npm audit`.

### 0.1.1

- Fixed a rare race condition which caused `broadband` to invoke its final callback more than once.

### 0.1.0

- Initial release. With shiny unit tests, of course.

---

### Overview (`packages/cache-on-demand/README.md`)

# cache-on-demand

<a href="http://apostrophenow.org/"><img src="https://raw.githubusercontent.com/punkave/cache-on-demand/master/logos/logo-box-madefor.png" align="right" /></a>

"On demand" caching that kicks in only when requests arrive simultaneously.

```javascript
var cacheOnDemand = require('cache-on-demand');

// Find all the things related to a URL.
// Let's just say this is a slow operation.

function getTheStuff(url, callback) {
  return myDatabase.find({ url: url }).toArray(callback);
}

// Create a new function with on-demand caching.

var cachedGetTheStuff = cacheOnDemand(getTheStuff, function(url) {
  // A URL makes a great hash key
  return url;
});

// Call cachedGetTheStuff just like we'd call the original.

getTheStuff('/welcome', function(err, stuff) {
  // Hooray, let's loop over the stuff
});
```

Under light load, with calls to `cachedGetTheStuff` taking place separated in time, every request for a given URL will get an individually generated response, which gives them the newest content. Just like calling `getTheStuff` directly.

But under heavy load, with new requests arriving while the first request is still being processed, the additional requests are queued up. When the first response is ready, it is simply sent to all of them. And then the response is discarded, so that the next request to arrive will generate a new response with the latest content.

This gives us "on demand" caching. The server is still allowed to generate new responses often, just not many of them simultaneously. It is the shortest practical lifetime for cache requests and largely eliminates concerns about users seeing old content, as well as concerns about cache memory management. In fact most users will get *fresher content than they would without the caching*, because the server is not overwhelmed and generating responses slowly.

## Writing your hash function

The second argument to `cache-on-demand` is a hash function. It receives the same arguments as your original function, except for the callback.

If this call *should not be cached*, just return `false`.

If this call *should* be cached, return a string for use as a hash key. All calls with the same hash key that arrive while your worker function is running will get the same response, without calling the function again.

## What about errors?

If your main function reports an error to its callback, it is reported to the original caller and all of the pending callers as well. In fact, we simply deliver *all of the same arguments* that your worker function passed to its callback.

## Express middleware

There's an easy way to use this module with web apps powered by Express. Check out [express-cache-on-demand](https://github.com/punkave/express-cache-on-demand).

## About P'unk Avenue and Apostrophe

`cache-on-demand` was created at [P'unk Avenue](http://punkave.com) for use in many projects built with Apostrophe, an open-source content management system built on node.js. If you like `cache-on-demand` you should definitely [check out apostrophenow.org](http://apostrophenow.org).

## Support

Feel free to open issues on [github](http://github.com/punkave/cache-on-demand).

<a href="http://punkave.com/"><img src="https://raw.githubusercontent.com/punkave/cache-on-demand/master/logos/logo-box-builtby.png" /></a>

## Changelog

### CHANGES IN 1.0.1

Eliminated use of `var` and introduced use of `...` to make the code more maintainable. No functional changes.

### CHANGES IN 1.0.0

This has been in production use for many moons, so we're declaring 1.0.0 stable. Also bumped the dependencies up to modern releases.

### CHANGES IN 0.2.0

Moved all the expressly Express-related stuff to the [express-cache-on-demand](https://github.com/punkave/express-cache-on-demand) module.

### CHANGES IN 0.1.0

Initial release. With shiny unit tests, of course.

---

### Overview (`packages/cache-redis/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Apostrophe Redis Cache</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Test status" href="https://github.com/apostrophecms/cache-redis/actions">
      <img alt="GitHub Workflow Status (branch)" src="https://img.shields.io/github/workflow/status/apostrophecms/cache-redis/tests/main?label=Tests&logo=github&labelColor=000&style=for-the-badge">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/cache-redis/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

This module enhances the core caching module, `@apostrophecms/cache`, to use [Redis](https://redis.io/) rather than MongoDB. This module does not set up the actual Redis store, but instead allows Apostrophe to access an existing Redis store through the standard Apostrophe caching API and an internal Redis client.

All normal Apostrophe cache API features are maintained in addition to the Redis-specific features described below.

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```
npm install @apostrophecms/cache-redis
```

## Usage

Configure the Redis cache module in the `app.js` file:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    '@apostrophecms/cache-redis': {}
  }
});
```

## Configuring Redis options

**All options for this module should be applied to `@apostrophecms/cache` in project code.** This module simply "improves" that core module (updates its features). The main caching module still does all the work.

Configure the underlying Redis client by adding a configuration object on the cache module's `redis` option.

```javascript
// modules/@apostrophecms/cache/index.js
module.exports = {
  options: {
    redis: {
      url: 'redis://alice:foobared@awesome.redis.server:6380'
    }
  }
};
```

By default, the client will look for a Redis server running on localhost port 6379. See [all the client configuration options](https://github.com/redis/node-redis/blob/master/docs/client-configuration.md) on the Node-Redis documentation.

Note that this module uses the 4.x version of Node-Redis, which changed its configuration options from earlier major versions.

## Cache keys

The Apostrophe cache `get` and `set` methods take two arguments that are used for individual cache item keys:

| Argument | Description |
| -------- | ----------- |
| `namespace` | A namespace for related data. Apostrophe core often uses the active module's name for the namespace, e.g., `@apostrophecms/oembed`. |
| `key` | The unique cache item key within a namespace. |

```javascript
await apos.cache.get(namespace, key)
await apos.cache.set(namespace, key, value)
```

Example:

```javascript
// modules/api-connect/index.js
module.exports = {
  apiRoutes(self) {
    return {
      get: {
        // GET /api/v1/api-connect/set-cache-info
        async setCacheInfo(req) {
          const info = await myApiClient.get({ latest: true });

          // 👇 This status will be "OK" if successful. This is due to the Redis
          // API, not a common Apostrophe pattern.
          const status = await self.apos.cache.set('api-connect', 'latest', info);

          return { status };
        },
        // GET /api/v1/api-connect/get-cache-info
        async getCacheInfo(req) {
          // 👇 This will return the stored information or `undefined` if not
          // set.
          const latest = await self.apos.cache.get('api-connect', 'latest');

          return { latest };
        }
      }
    };
  }
};
```

### Using the `prefix` for multiple sites

By default, this module applies a prefix to cache keys in the store. This prefix is the shortname of the website, which automatically separates cache items if multiple Apostrophe sites share the same Redis store.

To disable the cache key prefix, set the cache module's `prefix` option to `false`.

```javascript
// modules/@apostrophecms/cache/index.js
module.exports = {
  options: {
    prefix: false
  }
};
```

To customize the prefix, set the `prefix` option to the desired string value.

```javascript
// modules/@apostrophecms/cache/index.js
module.exports = {
  options: {
    prefix: 'project-alpha'
  }
};
```

---

### Overview (`packages/cli/README.md`)

# Apostrophe CLI

The Apostrophe CLI scaffolds widgets, pieces, and modules inside an existing [ApostropheCMS](https://github.com/apostrophecms/apostrophe) project, and wraps a few other useful functions into an easy to use command line tool.

**Requires Node.js 22+**

## You don't need this to start a project

To create a new Apostrophe project, run the guided installer directly — no install required:

```bash
npm create apostrophe@latest
```

See the [create-apostrophe README](https://github.com/apostrophecms/apostrophe/tree/main/packages/create-apostrophe#readme) for starter kits, unattended mode, and the full flag list.

Install the CLI when you want the `add` commands, which generate module boilerplate inside a project you already have.

## Installation

```bash
npm install @apostrophecms/cli
```

To view the available commands in a given context, execute the `apos` command with no arguments:

```bash
apos
```

**Note:** All Apostrophe CLI commands can also be run with `apostrophe`, the legacy command, in addition to `apos`.

## Create a project

If you already have the CLI installed, `apos create` is a convenience alias for the guided installer:

```bash
apos create
```

It takes no arguments — project name, starter kit, and database are all chosen through prompts. It does exactly what `npm create apostrophe@latest` does, so use whichever you find handier.

For scripted or CI installs, call the installer directly, since `apos create` doesn't pass arguments through:

```bash
npm create apostrophe@latest -- --unattended \
  --project-name=my-site --password=secret --telemetry=off
```

## Telemetry

Telemetry is opt-in and anonymous. If you have the CLI installed, you can manage your preference through it:

```bash
apos telemetry status    # show current preference
apos telemetry on        # opt in
apos telemetry off       # opt out
apos telemetry preview   # print the exact payload that would be sent
```

These are aliases too — `npm create apostrophe@latest -- telemetry status` works the same way, without the CLI. `create-apostrophe` owns the telemetry implementation; see its [README](https://github.com/apostrophecms/apostrophe/tree/main/packages/create-apostrophe#readme) for exactly what is collected, and the `APOS_TELEMETRY=0` kill switch.

## Astro projects

Hybrid ApostropheCMS + Astro projects are detected by the presence of a `backend/` directory.

**The `add` commands below are not supported in hybrid Astro projects.** Running `apos add` from the root of such a project exits with an error.

To create a hybrid project, choose an Astro starter kit when the installer prompts you.

## Create a widget

To bootstrap the necessary files and basic configuration for a new Apostrophe widget, run the following command from within your Apostrophe project's root directory:

```bash
# "-widgets" will automatically be appended to the end of your module name
apos add widget fancy-button
```

**Note:** You will then need to register this widget module in `app.js` so it is available in your project code. The same is true for the commands below when you create a piece module or generic module.

```javascript
// app.js
module.exports = {
  // ...
  'fancy-button-widgets': {},
  // ...
}
```

Add a `--player` option to the command to include the client-side Javascript "player" boilerplate to the new widget module as well.

```bash
apos add widget tabs --player
```

## Create a piece

To bootstrap the necessary files and basic configuration for a new Apostrophe piece type, run the following command from within your Apostrophe project's root directory:

```bash
apos add piece vegetable
```

Then remember to register `'vegetable': {}` in `app.js` above.

If you run the `add piece` command with the `--page` flag, the command will also set up a corresponding piece-pages module with your new piece type.

```bash
apos add piece vegetable --page
```

## Create an empty Apostrophe module

To bootstrap the necessary files and basic configuration for a brand-new Apostrophe module that doesn't extend one of the usual suspects like pieces or widgets:

```bash
apos add module <module name>
```

Remember to register the module in `app.js` with the other module types.

---------------

For more documentation for ApostropheCMS, visit the [documentation site](https://apostrophecms.com/docs/).

---

### Overview (`packages/create-apostrophe/README.md`)

# create-apostrophe

The guided installer behind `npm create apostrophe`. It clones a starter kit,
wires up your database, installs dependencies, and creates an admin user - so a
fresh Apostrophe project is running in a few minutes.

```bash
npm create apostrophe@latest
```

That runs the interactive flow. A few supporting commands:

```bash
# Run without prompts (CI / scripting)
npm create apostrophe@latest -- --unattended \
  --project-name=my-site --password=secret --telemetry=off

# Inspect or change your telemetry preference
npm create apostrophe@latest -- telemetry status
npm create apostrophe@latest -- telemetry on
npm create apostrophe@latest -- telemetry off
npm create apostrophe@latest -- telemetry preview   # show the exact payload

# Help / version
npm create apostrophe@latest -- --help
```

> Everything after `--` is forwarded to the installer; npm swallows args without it.
> Run `-- --help` any time for the full flag list.

Requires **Node 22+** and **npm** (pnpm and yarn aren't by this CLI yet, but they work fine with apostrophe).

## Architecture

Four layers, with one hard rule: **the headless layers never import the UI.**

- **`bin/`** - entry shim. Guards the Node version, then hands off to the CLI.
- **`cli/`** - argv parsing and command dispatch (interactive vs. unattended vs.
  `telemetry`).
- **`core/`** - the headless installer. `createProject()` orchestrates the
  install as a sequence of steps (clone → scaffold → install → db → sample data
  → admin). No prompts, no `process.exit`, no UI. Returns a structured result.
- **`telemetry/`** - consent, payload building, and the wire transport.
- **`ui/`** - clack prompts and terminal rendering, on top of a known-good core.

**Import rule:** `core/` and `telemetry/` must not import from `ui/`. `ui/` may
import from either freely. This keeps the installer logic testable in isolation
and reusable from a non-interactive host. The boundary is enforced by eslint
(`no-restricted-imports`).

## Public API

Only `create-apostrophe` itself is importable; everything under `core/`, `ui/`,
and `telemetry/` is private.

```js
import {
  createProject,        // headless install orchestration
  runInteractive,       // guided install, no argv parsing
  runTelemetryCommand,  // status | on | off | preview
  runCli                // argv-driven entry (full pass-through)
} from 'create-apostrophe';
```

- **`createProject(options, deps)`** → `Promise<CreateProjectResult>`. The headless
  orchestrator. Takes fully resolved, already-validated inputs; never prompts or
  exits. Resolves with `{ ok, kitId, dbChoice, packageManager, durationMs }`
  (plus `failStage` / `errorCode` on failure) instead of throwing on an expected
  install failure.
- **`runInteractive(deps?)`** → `Promise<number>` (exit code). The guided install
  with no argv parsing - what `@apostrophecms/cli`'s `create` delegate calls, so
  Commander keeps ownership of `--help`.
- **`runTelemetryCommand({ sub }, deps?)`** → `Promise<number>`. Handles the
  `status | on | off | preview` subcommands; used by the `apos telemetry` delegate.
- **`runCli(argv, deps?)`** → `Promise<number>`. The argv-driven entry for the
  standalone bin and any host that wants full pass-through.

Exit codes: `0` success · `1` install failure · `2` argument error · `130`
cancelled (Ctrl-C).

## Telemetry

Telemetry is **opt-in** and anonymous. It exists to tell us which kits and
databases people actually pick, which platforms and Node versions they run on,
and where installs break - nothing more. You can see the exact payload before
sending anything with `telemetry preview`.

- **Consent** is stored locally and starts off. `telemetry on` flips it on and
  generates a stable anonymous id (UUID v4) the first time; `telemetry off` keeps
  the id so opting back in resumes the same identity.
- **Kill switch:** `APOS_TELEMETRY=0` disables telemetry entirely, regardless of
  stored consent.

### Payload

Exactly one event is emitted per install attempt: `install_success` or
`install_fail`. The payload is built from a closed allowlist - it is structurally
impossible to attach anything outside it (no email, paths, project name, db URI,
or env).

Both events carry:

- **`event`** - `install_success` or `install_fail`.
- **`cliVersion`** - version of the installer.
- **`nodeVersion`** - the Node release running the install (e.g. `24.4.1`).
- **`platform`** - the OS family, from `process.platform` (e.g. `darwin`,
  `linux`, `win32`).
- **`kitId`** - the starter kit chosen (e.g. `apostrophe-demo`).
- **`dbChoice`** - `sqlite`, `mongodb`, or `postgres`.
- **`packageManager`** - `npm`, `pnpm`, `yarn`, or `unknown` (detected).
- **`durationMs`** - how long the attempt took.
- **`anonymousId`** - UUID v4, present only once you've opted in.

`install_fail` additionally carries:

- **`failStage`** - which stage failed (see below), or `preflight` for a
  failure in the package-manager check, before any stage runs.
- **`errorCode`** - a symbolic code, present only when it's one of the known
  values below. Unknown codes are dropped, never raw error text.

### Fail stages

- **`preflight`** - the package-manager check, before any stage ran.
- **`clone`** - cloning the starter kit.
- **`scaffold`** - writing project identity, secrets, and `.env`.
- **`dependency_install`** - `npm install` (or pnpm/yarn).
- **`db_connect`** - connecting to / preparing the database.
- **`sample_data`** - downloading and importing demo content.
- **`admin`** - creating the admin user.
- **`unknown`** - unexpected error that didn't map to a stage.

### Error codes

Grouped by the stage that emits them.

**Preflight**

- **`unsupported_pm`** - running under an unsupported package manager.

**clone**

- **`target_exists`** - the target directory already exists.
- **`git_missing`** - `git` isn't installed.
- **`git_spawn_failed`** - `git` failed to launch (not a missing binary).
- **`git_clone_failed`** - `git clone` exited non-zero.

**scaffold**

- **`missing_scaffold_file`** - the clone is missing `app.js` or `package.json`.
- **`scaffold_io`** - an I/O error while writing project files.

**dependency_install**

- **`npm_missing`** - the package manager binary isn't installed.
- **`npm_spawn_failed`** - the package manager failed to launch.
- **`install_failed`** - the install command exited non-zero.
- **`apostrophe_missing`** - apostrophe isn't in `node_modules` after install.

**db_connect**

- **`db_unreachable`** - the database server isn't reachable.
- **`db_auth_failed`** - authentication was rejected.
- **`db_connect_failed`** - some other connection error.
- **`db_drop_failed`** - failed to drop a pre-existing database.

**sample_data**

- **`seed_manifest_invalid`** - the sample-data manifest is malformed.
- **`seed_download_failed`** - downloading the seed archive failed.
- **`seed_checksum_failed`** - the seed archive failed its checksum.
- **`seed_unpack_failed`** - unpacking the seed archive failed.
- **`seed_clear_failed`** - clearing the database before import failed.
- **`seed_restore_failed`** - restoring the seed database failed.
- **`seed_uploads_failed`** - extracting seed uploads (images/attachments) failed.

**admin**

- **`node_missing`** - `node` isn't installed.
- **`node_spawn_failed`** - `node` failed to launch.
- **`admin_user_failed`** - creating the admin user failed.

### Starter-kit sample-data manifest

The `seed_*` codes above all come from the sample-data step, which runs only for
`*-demo-data` kits. By default it pulls a fixed pair of archives (a database dump
and an uploads bundle) baked into the installer. A kit can override that by
shipping a manifest at:

```
<project>/.apostrophe/sample-data.json
```

If present, the manifest **wholesale-replaces** the built-in defaults - it is not
merged. Its shape:

- **`db`** *(optional)* - the database dump archive (`.zip`).
- **`uploads`** *(optional)* - the uploads archive (`.zip`).

Each is an `{ url, sha256 }` pair:

- **`url`** - where to download the archive from.
- **`sha256`** - 64-char lowercase-hex checksum. Each archive is verified against
  it after download and before it's unpacked - a mismatch aborts
  (`seed_checksum_failed`) before anything touches your database or files.

At least one of `db` / `uploads` must be present. No manifest file → the built-in
defaults are used. A file that's unparseable, the wrong shape, or declares
neither asset is a kit misconfiguration and surfaces as `seed_manifest_invalid`.

```json
{
  "db": {
    "url": "https://static.apostrophecms.com/public-demo/starter-database.jsonl.zip",
    "sha256": "862fc4b380675e0eaade8447f6a137807ce54b54acbbee79636e581e0c5f29b1"
  },
  "uploads": {
    "url": "https://static.apostrophecms.com/public-demo/starter-uploads.zip",
    "sha256": "b1a70f3372f8c897fd934abbe711cc1a8b0aeaf7d38b165890dd96939ab62f58"
  }
}
```

## License

MIT

---

### Overview (`packages/csv-to-zone-file/README.md`)

Produces a zone file suitable for import into Amazon Route 53. If your goal is
to actually use it in a standalone DNS server, you'll want to add
an SOA (Start Of Authority) record to the output manually.

Run:

```
npm install csv-to-zone-file
csv-to-zone-file < myfile.csv > myfile.zone
```

The CSV file must have columns as shown in this example:

|  Type  | Host   | Value   |
| ------ | ------ | ------- |
| A      | www    | a.b.c.d |

`Type` may be `A`, `CNAME`, etc. The suffix ` Record` is stripped from `Type` if present.

`TTL` is an optional column. If not present the TTL is 1 hour.

If `MX` records are present there must be a `Priority` column as well.

---

### Overview (`packages/db-connect/README.md`)

# @apostrophecms/db-connect

`@apostrophecms/db-connect` defines the database connection API for [ApostropheCMS](https://apostrophecms.com). It provides adapters for MongoDB, PostgreSQL, and SQLite, and includes the `apos-db-dump` and `apos-db-restore` command-line utilities for database migration and backup.

The db-connect API is compatible with a large subset of the MongoDB API. However, after this introductory note, this document describes the **db-connect API** on its own terms. For projects that need to work across all three databases, it is necessary to use only the functionality defined here.

## Supported Connection URLs

### MongoDB

```
mongodb://localhost:27017/mydb
mongodb+srv://user:pass@cluster.example.com/mydb
```

Standard MongoDB connection strings. The MongoDB adapter is a thin wrapper around the existing driver.

### PostgreSQL

```
postgres://localhost:5432/mydb
```

Single-database mode. All collections are stored as tables in the `public` schema.

### SQLite

```
sqlite:///path/to/database.db
```

File-based SQLite databases using `better-sqlite3`. The parent directory is
created if it does not exist.

Unlike the others, this is not really a URL: everything after `sqlite://` is a
**filesystem path, taken exactly as written**. That matters, because
filesystem paths are not URLs — they are not percent-encoded, they may contain
characters a URL parser treats as significant, and on Windows they begin with a
drive letter that a URL parser reads as `host:port`. So do not percent-encode
the path. A space is a space:

```
sqlite:///Users/jane/My Project/data/site.db
```

`%20` here means a literal percent, two, zero — not a space. Both absolute and
relative paths work, and on Windows both separators do:

```
sqlite:///var/lib/mysite/data.db          absolute (POSIX)
sqlite://data/site.db                     relative to the working directory
sqlite://C:\Users\jane\site\data\site.db  absolute (Windows)
sqlite://C:/Users/jane/site/data/site.db  absolute (Windows, forward slashes)
sqlite:///C:/Users/jane/site/data/site.db absolute (Windows, file:// spelling)
```

In-memory databases (`sqlite://:memory:`) are not supported — the adapter
assumes a persistent store suitable for hosting an ApostropheCMS site.

### Multi-Schema PostgreSQL (multipostgres)

```
multipostgres://localhost:5432/shareddb-tenant1
```

Designed for use with the ApostropheCMS [multisite](https://apostrophecms.com/extensions/multisite-2) module. In this mode, each site gets its own PostgreSQL schema within a single physical database.

The URL path is split at the **last hyphen**:

- Everything before the last hyphen is the real PostgreSQL database name (`shareddb`)
- Everything after is the schema name (`tenant1`)

Each schema is created automatically on first use and dropped cleanly when the database is dropped. This provides true multi-tenant isolation — no cross-tenant data leakage — while sharing a single PostgreSQL instance efficiently.

## Connecting

```js
const connect = require('@apostrophecms/db-connect');

const client = await connect('postgres://localhost:5432/mydb');
const db = client.db();
const articles = db.collection('articles');

// Insert a document
const result = await articles.insertOne({ title: 'Hello', status: 'draft' });

// Query documents
const docs = await articles.find({ status: 'draft' })
  .sort({ title: 1 })
  .limit(10)
  .toArray();

// Update a document
await articles.updateOne(
  { _id: result.insertedId },
  { $set: { status: 'published' } }
);

await client.close();
```

`connect(uri)` returns a client. Call `client.db()` to get a database, then `db.collection(name)` to get a collection.

## Atomicity

`insertOne` and `deleteOne` are always atomic.

`updateOne` without `upsert` is atomic when it uses only the following operators:

- **`$set`**, **`$inc`**, **`$unset`**, **`$currentDate`** — always atomic
- **`$push`**, **`$pull`**, **`$addToSet`** — atomic when all values are **scalars** (strings, numbers, booleans). Object or array values fall back to read-modify-write.

These operators can be freely combined in a single `updateOne` call and the entire update will be atomic. This covers the most common patterns: counters, status flags, timestamps, field cleanup, and adding/removing items to/from arrays.

**The following operations are NOT guaranteed to be atomic** and use a read-modify-write pattern (the document is read, the update is applied in JavaScript, and the result is written back):

- `updateOne` with **`$rename`** or **`upsert: true`**
- `updateOne` with `$push`, `$pull`, or `$addToSet` using **non-scalar** values
- **`updateMany`**, **`findOneAndUpdate`**, and **`replaceOne`**

For operations that must be atomic and aren't covered above, use advisory locking (`apos.lock`) to serialize access. Apostrophe core already uses advisory locking where atomicity matters (e.g., `apos.lock.lock` around critical sections).

## API Reference

- [Database and Client](./docs/database.md) — `client.db(name)`, listing collections, dropping databases
- [Collection Methods](./docs/collections.md) — CRUD operations, cursors, and bulk writes
- [Query Operators](./docs/queries.md) — filtering documents with comparison, logical, element, and array operators
- [Update Operators](./docs/updates.md) — modifying documents with `$set`, `$inc`, `$push`, and more
- [Indexes](./docs/indexes.md) — creating and managing indexes, including numeric and date types
- [Aggregation](./docs/aggregation.md) — pipeline stages and group accumulators
- [Dump and Restore](./docs/dump-restore.md) — CLI tools and programmatic API for backup and migration

---

### Overview (`packages/emulate-mongo-3-driver/README.md`)

# @apostrophecms/emulate-mongo-3-driver

## Purpose

You have legacy code that depends on the 3.x version of the MongoDB Node.js driver.
You don't want to upgrade to the 6.x driver because of backwards compability problems in
[v4](https://github.com/mongodb/node-mongodb-native/blob/v6.0.0/etc/notes/CHANGES_4.0.0.md),
[v5](https://github.com/mongodb/node-mongodb-native/blob/v6.0.0/etc/notes/CHANGES_5.0.0.md),
[v6](https://github.com/mongodb/node-mongodb-native/blob/v6.0.0/etc/notes/CHANGES_6.0.0.md)
but you don't have a choice because of reported vulnerabilities
such as those detected by `npm audit`.

`@apostrophecms/emulate-mongo-3-driver` aims to be a compatible emulation
of the 3.x version of the MongoDB Node.js driver,
implemented as a wrapper for the 6.x driver.

It was created for long term support of [ApostropheCMS](https://apostrophecms.com).
Of course, ApostropheCMS 3.x and 4.x will use the MongoDB 6.x driver directly.

## Usage

If you are using ApostropheCMS, this is **standard** beginning
with versions 3.64.0+ and 4.2.0+. You don't have to do anything.

The example below is for those who wish to use this driver in non-ApostropheCMS projects.

```sh
npm install @apostrophecms/emulate-mongo-3-driver
```

```javascript
const mongo = require('@apostrophecms/emulate-mongo-3-driver');

// Use it here as if it were the 3.x driver
```

## Goals

This module aims for partial compatibility with the features mentioned
as obsolete or changed in
[v4](https://github.com/mongodb/node-mongodb-native/blob/v6.0.0/etc/notes/CHANGES_4.0.0.md),
[v5](https://github.com/mongodb/node-mongodb-native/blob/v6.0.0/etc/notes/CHANGES_5.0.0.md),
[v6](https://github.com/mongodb/node-mongodb-native/blob/v6.0.0/etc/notes/CHANGES_6.0.0.md)
but there are omissions.

An emphasis has been placed on features used by ApostropheCMS
but PRs for further compatibility are welcome.

## What about those warnings?

"What about the warnings re: insert, update and ensureIndex operations being obsolete?"

Although deprecated, some of these operations are still supported by the 6.x driver
and work just fine.

However, since the preferred newer operations were also supported by the 3.x driver,
the path forward is clear.

We will migrate away from using them gradually, and you should do the same.

It doesn't make sense to provide "deprecation-free" wrappers
when doing the right thing is in easy reach.

---

### Overview (`packages/eslint-config-apostrophe/README.md`)

An ESLint configuration for ApostropheCMS core and officials modules.

Add the following to your `.eslintrc` file to use in your project:

```javascript
{
  "extends": "apostrophe"
}
```

## Strict configuration

`eslint-config-apostrophe/strict` is the same configuration with its advisory rules promoted to errors, for a codebase that is already clean and wants to stay that way:

```javascript
import apostrophe from 'eslint-config-apostrophe/strict';
```

Today that means `no-console`, which is a warning in the default configuration: on the server, diagnostics belong in Apostrophe's log pipeline (`self.logInfo`, `self.logWarn`, `self.logError`, `self.logDebug`, or `apos.util.*` outside a module), not on the console. Program output - a task's own listing, help text or report - is the exception, and says so at the call site:

```javascript
// The listing is what the task was invoked to print.
// eslint-disable-next-line no-console
console.log(`${moduleName}:${name}`);
```

## Contributing

Edit `eslint.config.js`, then run `npm run eslint` to lint this package with the updated configuration.

## Changelog

New major versions will be used whenever a new rule is added that returns an `error` on failure. This is to avoid breaking projects using this configuration as they do normal package updates. If a new rule is added that is simply set to `warn`, minor versions may be used since this should not break build, but only create warning messages for you to resolve or override with rules in your project configuration.

- 3.4.1 (2020-10-21): Updates `eslint-plugin-import`.
- 3.4.0 (2020-08-26): Adds a script to check what rules from `eslint-config-standard` that we are not yet setting in this config. The goal of this is to make sure that any new rules we work with are intentional, rather than unexpectedly inherited from `eslint-config-standard`. Also copies over missing eslint rules with the configurations from `eslint-config-standard`. There should be no functional changes in linting.
- 3.3.0: Adds a warning enforcing a single space inside of array brackets.
- 3.2.0: Adds a warning for the `quotes` rule to enforce single quotes. This should change to an error in the next major version.
- 3.1.0: Adds a warning for the `curly` and `brace-style` rules to avoid single line blocks. Also `object-curly-newline` and `object-property-newline` rules to have similar treatment for objects. Adds the changelog versioning guidelines.
- 3.0.0: Adds a warning for the `no-var` rule.
- 2.0.2: packaging issue, no changes.
- 2.0.1: use `import/no-extraneous-dependencies` to detect `require` calls that are not backed by a real dependency of this project or module.
- 2.0.0: initial release.

---

### Overview (`packages/event/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Apostrophe Events</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/event/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

This module bundle helps developers quickly add event content to Apostrophe websites. It provides the event piece type (`@apostrophecms/event`) as well as a special page type (`@apostrophecms/event-page`) for editors to create an event listing.

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```
npm install @apostrophecms/event
```

## Usage

Configure the event modules in the `app.js` file:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    // The main event piece type module
    '@apostrophecms/event': {},
    // The event page module
    '@apostrophecms/event-page': {}
  }
});
```

### Enable the page type

To enable the event page type for editor to select, add it to the `@apostrophecms/page` configuration:

```javascript
// modules/@apostrophecms/page/index.js
module.exports = {
  options: {
    types: [
      {
        name: '@apostrophecms/home-page',
        label: 'Home'
      },
      // Adding the event page type
      {
        name: '@apostrophecms/event-page',
        label: 'Event Page'
      }
    ]
  }
};
```

**Note:** The index page template (`index.html`), filters template partial (`filters.html`), and show page template (`show.html`) are primarily meant as a starting point for a project. They demonstrate much of the available template data, but developers will almost always want to override them to implement proper styles and layout.

### Filtering by year, month, and day

The event page module, `@apostrophecms/event-page`, provides query filters to refine event results by year, month, and day. These are primarily used for index page filters (see the `filters.html` file), but can also be used in REST API requests and server-side queries. Events that span multiple consecutive days are included in results if the query is at least partially included in their date span.

| Filter Name | Description | Expected Format |
|-------------|-------------|-----------------|
| `year` | Filter by event year | `YYYY` |
| `month` | Filter by event month | `YYYY-MM` |
| `day` | Filter by event day | `YYYY-MM-DD` |

### Multiple event piece types

Sometimes a website needs multiple, distinct types of events. If the event types can be managed together, it might be easiest to [add a new field](https://v3.docs.apostrophecms.org/guide/content-schema.html#using-existing-field-groups) and [query builder](https://v3.docs.apostrophecms.org/reference/module-api/module-overview.html#queries-self-query) to customize event views. But if the event types should be managed completely separately, it may be better to create separate  piece types for each.

Just as we [extend `@apostrophecms/piece-type`](https://v3.docs.apostrophecms.org/guide/pieces.html#creating-a-piece-type) to create a new piece type, we can extend `@apostrophecms/event` to create a new event type. The event type will need its own module directory and UI labels. It can simply inherit the original templates, fields, and other configuration or override them in the event type's `index.js` file.

A special event type that has an event URL field might look like this:

```javascript
// modules/special-event/index.js
module.exports = {
  extend: '@apostrophecms/event',
  options: {
    label: 'Special Event',
    pluralLabel: 'Special Events'
  },
  fields: {
    add: {
      eventUrl: {
        label: 'Event URL',
        type: 'url'
      }
    },
    group: {
      basics: { fields: [ 'eventUrl' ] }
    }
  }
}
```

To display custom event types on a page, we would need to do the same for the event page module. All custom modules would need to be instantiated in the `app.js` file like any other module.

```javascript
// modules/special-event-page/index.js
module.exports = {
  extend: '@apostrophecms/event-page'
}
```

Do this as many times as you need custom event types. Adding filtering and new fields to the base event module is usually enough for most use cases, but organizations with more complex event needs will find this strategy helpful.

---

### Overview (`packages/express-cache-on-demand/README.md`)

# express-cache-on-demand

<a href="http://apostrophenow.org/"><img src="https://raw.githubusercontent.com/punkave/express-cache-on-demand/master/logos/logo-box-madefor.png" align="right" /></a>

Express middleware providing "on demand" caching that kicks in only when requests arrive simultaneously.

## Install

```
npm install express-cache-on-demand
```

## Example

```javascript
var expressCacheOnDemand = require('express-cache-on-demand')();

// Fetch a page from a database and build fancy
// navigation, then render the template; this is
// just an example of a big, possibly slow task.
// Use the expressCacheOnDemand middleware to
// send the same response to all simultaneous
// requests.

app.get('/:page', expressCacheOnDemand, function(req, res) {
  return getPage(req.params.page, function(page) {
    return addFancyNavigation(page, function(links) {
      return res.render('page.html', { links: links });
    });
  });
});

```
Under light load, with requests arriving far apart, every request for a given `req.url` will get an individually generated response, which gives them the newest content. This is the same behavior you see without the middleware.

But under heavy load, with new requests arriving while the first request is still being processed, the additional requests are queued up. When the first response is ready, it is simply sent to all of them. And then the response is discarded, so that the next request to arrive will generate a new response with the latest content.

This gives us "on demand" caching. The server is still allowed to generate new responses often, just not many of them simultaneously. It is the shortest practical lifetime for cached data and largely eliminates concerns about users seeing old content, as well as concerns about cache memory management.

## When to use it, when not to

This middleware is intended for routes that potentially take a long time to generate a relatively small response (under a megabyte, let's say). Dynamic web pages with lots of complicated moving parts are a perfect example.

You should *not* use this middleware for your entire site. In particular:

* It does not work and is not suitable anyway for routes that deliver entire files via `res.sendFile` and related methods
* It does not work and is not suitable anyway for routes that `pipe` content into `res`
* It shouldn't be registered globally before the `express.static` middleware

There may be other possible endings for an Express `res` object that are not properly handled by this middleware yet. Pull requests welcome.

## When we cache, when we don't

By default, the middleware only caches requests when:

* `req.method` is `GET` or `HEAD`.
* `req.user` is falsy.
* `req.session` is empty (*).

If the above conditions are not met, every request will generate its own response. This way we don't cause surprising behavior for logged-in users who are modifying site content and seeing personalized displays.

(*) The middleware is smart enough to ignore a few special cases, such as `req.session.cookie`, an empty `req.session.flash`, and an empty `req.session.passport`.

## Deciding when to cache on your own

If you don't like our rules for caching, you can write your own. Just pass a function that returns `false` for requests that should not be hashed, and a hash key such as `req.url` for requests that should be hashed.

```javascript
var expressCacheOnDemand =
  require('express-cache-on-demand')(hasher);

function hasher(req) {
  if (req.url.match(/nevercacheme/)) {
    return false;
  }
  return req.url;
}
```

## Using `cache-on-demand` for other tasks

This module is an Express middleware wrapper for our [cache-on-demand](https://github.com/punkave/cache-on-demand) module. If you would like to do the same trick with code that isn't powered by Express, try using that module directly.

## About P'unk Avenue and Apostrophe

`express-cache-on-demand` was created at [P'unk Avenue](http://punkave.com) for use in many projects built with Apostrophe, an open-source content management system built on node.js. If you like `cache-on-demand` you should definitely [check out apostrophecms.org](http://apostrophecms.org).

## Support

Feel free to open issues on [github](http://github.com/punkave/express-cache-on-demand).

<a href="http://punkave.com/"><img src="https://raw.githubusercontent.com/punkave/express-cache-on-demand/master/logos/logo-box-builtby.png" /></a>

## Changelog

### CHANGES IN 1.0.3

* The default hash function now correctly refuses to cache in the documented circumstances (i.e. logged-in users or a nontrivial `req.session` object). Previously a `return false` was missing, resulting in the possibility of a cached result going to a user with a different session.
* `var` has been eliminated and the code has been lightly refactored without other changes to behavior.

### CHANGES IN 1.0.2

`res.getHeader` support. Thanks to Vadim Fedorov.

### CHANGES IN 1.0.1

`cache-on-demand` is now at 1.0.0 also, plus the lodash dependency now points to a modern release. No functional changes.
### CHANGES IN 1.0.0

`redirect` now supports the optional status code argument properly. Thanks to Alexey Astafiev.

This module has been in successful production use for many moons, so we're declaring it stable (1.0.0).

### CHANGES IN 0.1.1

Fixed a bug in `redirect` support, which now works properly.

### CHANGES IN 0.1.0

Initial release. With shiny unit tests, of course.

---

### Overview (`packages/favicon/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Favicons for ApostropheCMS</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
  </p>
</div>

This module allows users to edit the "favicon" (browser tab icon) of the site via the global settings of the site.
As such, it pairs well with Apostrophe's built-in [global styles](https://docs.apostrophecms.org/guide/global-styles.html) and the multisite module.

## Compatibility
This version requires the latest ApostropheCMS. When adding this module to an existing project, run `npm update` to ensure all ApostropheCMS modules are up-to-date.

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```
npm install @apostrophecms/favicon
```

## Usage

Configure the `@apostrophecms/favicon` module in the `app.js` file:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    '@apostrophecms/favicon': {}
  }
});
```

You do not have to do anything else. You can access the global settings of the site
via the "Gear" button in the upper right. Once there, select the "Favicon" tab and
choose your preferred image. Note that a square portion of the image is automatically
cropped if you do not use the cropping interface manually.

There are no special requirements for images uploaded for this purpose, however you may
wish to use a PNG file in order to achieve transparency effects.

> Browsers vary in terms of how quickly you will see the new favicon image, but a
> page refresh usually suffices.

---

### Overview (`packages/font-size/README.md`)

# @apostrophecms/font-size



Adds a free-form **font size** tool to the ApostropheCMS rich text editor
toolbar. Editors select text and set its size in pixels, either by typing a
value or picking a preset. The size is stored as inline `font-size` styling on
a `<span>`, the same way the core color tool stores its value.

This feature is intentionally shipped as a separate, installable module rather
than in Apostrophe core, so it is only present in the projects that want it.

## When to use this module

If SEO is your primary concern, offer various heading levels instead, optionally with multiple CSS classes for each level and even the paragraph element itself. This yields the best SEO outcome because Google is looking for semantic markup, not explicit font sizes.

However, if you and your customers need the flexibility to match exact font sizes or change the font size in mid-line, this module is right for you.

## Installation

```bash
npm install @apostrophecms/font-size
```

Then enable it in `app.js`:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    '@apostrophecms/font-size': {}
  }
});
```

The module *improves* `@apostrophecms/rich-text-widget`, so there is nothing
else to wire up.

If you are not explicitly configuring the toolbars of your rich text widgets... that's it! The feature is available by default. If you are configuring each rich text widget toolbar explicitly, read on.

## Options

This module **improves** `@apostrophecms/rich-text-widget`. An Apostrophe
improvement has no separate existence — it merges into the module it improves —
so it cannot be configured under its own name. **Set these options on
`@apostrophecms/rich-text-widget`, not on `@apostrophecms/font-size`:**

```javascript
modules: {
  '@apostrophecms/font-size': {},
  // Configure the rich-text-widget module, NOT
  // the font-size module
  '@apostrophecms/rich-text-widget': {
    options: {
      fontSizes: [ 14, 16, 20, 28, 40 ],
      addFontSizeToDefaultToolbar: false
    }
  }
}
```

| Option | Default | Description |
| --- | --- | --- |
| `fontSizes` | `[ 12, 14, 16, 18, 24, 32, 48 ]` | Preset pixel sizes offered as quick choices. The numeric input remains free-form. Set to `[]` for free-form only. |
| `addFontSizeToDefaultToolbar` | `true` | Whether to add `size` to the rich text default toolbar. Set to `false` to make the tool available but opt in per area via the `toolbar` option. |

To offer the tool only in specific widgets, set `addFontSizeToDefaultToolbar: false`
and add `'size'` to each widget's `toolbar` array.

## How it works

- A tiptap extension (`ui/apos/tiptap-extensions/FontSize.js`) adds a `fontSize`
  attribute to the shared `textStyle` mark and provides `setFontSize` /
  `unsetFontSize` commands.
- A Vue toolbar component (`ui/apos/components/AposTiptapFontSize.vue`) provides
  the popover with the numeric input and presets.
- The module extends the widget's `sanitize-html` allowlists so that
  `<span style="font-size: ...px">` survives sanitization — only pixel values
  are permitted, and only where the `size` tool is enabled.

---

### Overview (`packages/form-submission-google/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Apostrophe Forms Google Sheets Submission</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/apostrophe/blob/main/packages/form-submission-google/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

This module adds an additional form submission option to the Apostrophe Forms extension. It allows website managers to configure individual forms to submit to a specific Google Docs spreadsheet.

## Installation

```bash
npm install @apostrophecms/form-submission-google
```

## Usage

### Initialization

Add this module along with the other Apostrophe Form modules in `app.js` to instantiate it.

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    // The main form module
    '@apostrophecms/form': {},
    '@apostrophecms/form-submission-google': {},
    // ... Other form widgets
  }
});
```

### Set up the Google Sheets API project

1. **Create a project in [the Google Cloud Platform API console](https://console.developers.google.com/apis/dashboard).** Enable the Google Sheets API on the project in the API Library.
2. **Create an [API service account](https://cloud.google.com/iam/docs/service-accounts)** in the Google API Console service.
3. **Save the credentials JSON file** provided with the new service account. *You may not be able to download this again.*
4. **Add the credentials file to the `modules/@apostrophecms/form` directory in your Apostrophe project as `credentials.json`.**
  - Note: We do not recommend committing this file to version control if your code is public. You should add it to the `.gitignore` file (for Git) and put it directly on your production server. *Alternately* you can provide the credentials as JSON in an environment variable named `GOOGLE_APPLICATION_CREDENTIALS`.
5. **Copy the service account email address.** You will need to add this as an "Edit"-level user on your Google spreadsheet as you would a human editor.
6. **Plan for the service account credentials to expire in 10 years.** The service account credentials have a long life span, but it is not infinite.

### Create your spreadsheet.

The sheet must exist, but does not necessarily need to be set up with column headings before use. This can be done later by CMS users as well. There is help text in the UI directing them to make note of the spreadsheet ID and sheet name.

Column headers in the Google spreadsheet must match the form field *names* (not the field labels), or else the module will add new columns to the spreadsheet.

#### A warning about editing the spreadsheet

Please note that you must not add any empty, unlabeled columns to the spreadsheet once submissions begin. Due to the [rules of Google's spreadsheet API](https://developers.google.com/sheets/api/guides/values#appending_values) the gap will be considered as the start of a "new table" and newly appended rows will start at that column, which is probably not what you want. If this does happen, move the data over and add a header to the empty column.

### Note on dates and times

"Date Submitted" and "Time Submitted" columns are included in the Google spreadsheet automatically. These are always in [UTC (Coordinated Universal Time)](https://en.wikipedia.org/wiki/Coordinated_Universal_Time). For best results, format the Google spreadsheet columns as plain text.

You can rename these by setting options on the `@apostrophecms/form` module. The column label should be set before the form is in use to keep all date/time data in the same column.

```javascript
// modules/@apostrophecms/form/index.js
module.exports = {
  options: {
    dateColumnLabel: 'Submission date',
    timeColumnLabel: 'Submission time'
  }
};
```


### Modifying the submission before it is sent to Google

If you wish to modify the submitted data just before it goes to Google, for instance to add a new property, you can add a handler to the `@apostrophecms/form:beforeGoogleSheetSubmit` event. This event handler may be asynchronous.

The example below demonstrates adding a "Unique Key" property to the data based on the date submitted, time submitted, and an email field in the submission:

```javascript
// modules/@apostrophecms/form/index.js
module.exports = {
  handlers (self) {
    return {
      beforeGoogleSheetSubmit: {
        addUniqueKey (req, form, data) {
          data['Unique Key'] = `${data['Date Submitted']}_${data['Time Submitted']}_${data.email}`;
        }
      }
    };
  }
};
```

The submitted spreadsheet rows will now include the additional column.

### Issues with column formatting

This module sends data to Google Sheets "as entered," i.e. as if it were typed by the user in Google Sheets. In most cases this does good things: dates are detected as dates, times as times, numbers as numbers, etc.

However in certain cases, the results may be surprising. For instance, a phone number with a leading `0` and no spaces or punctuation will lose its leading `0` because this is the standard behavior of Google Sheets when it believes it has detected a number. Google does not store the zero in this situation, it is truly gone.

Fortunately you can correct this by formatting the column correctly in Google Sheets. Open the sheet, select the column that will contain phone numbers, and select "Format -> Number -> Plain text". Leading zeroes will not be removed from future submissions.

---

### Overview (`packages/form/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Form Builder for ApostropheCMS</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/apostrophe/blob/main/packages/form/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

**Let content teams build and manage forms without developer intervention.** Editors can create contact forms, surveys, applications, and registrations directly in the CMS, then place them anywhere on your site. Forms automatically handle submissions, email notifications, validation, and spam protection.


## Why Form Builder?

- **No-Code Form Creation**: Editors build forms through configurable field widgets—no tickets to developers
- **Automatic Data Collection**: Submissions saved to MongoDB with optional email notifications
- **🛡️ Built-in Security**: reCAPTCHA v3 integration and validation prevent spam
- **Design Freedom**: Custom CSS classes and styling hooks for brand consistency
- **Developer-Friendly**: Event hooks, custom validators, and extensible field types
- **Email Ready**: Route submissions to multiple recipients automatically


## Table of Contents
- [Installation](#installation)
- [Usage](#usage)
  - [Module Configuration](#module-configuration)
  - [How It Works](#how-it-works)
  - [Adding Form Widget to Areas](#adding-form-widget-to-areas)
  - [Editor Workflow](#editor-workflow)
- [Configuration](#configuration)
  - [Main Module Options](#main-module-options)
  - [Available Field Types](#available-field-types)
- [Handling Submissions](#handling-submissions)
  - [Database Storage](#database-storage)
  - [Email Notifications](#email-notifications)
  - [Server-Side Events](#server-side-events)
  - [Browser Events](#browser-events)
- [reCAPTCHA Integration](#recaptcha-integration)
  - [Configuration Options](#configuration-options)
- [Styling](#styling)
  - [Custom CSS Classes](#custom-css-classes)
- [Field-Specific Options](#field-specific-options)
  - [Select Field](#select-field)
  - [File Upload Field](#file-upload-field)
- [Custom Field Validation](#custom-field-validation)
  - [Extending Collectors with the Super Pattern](#extending-collectors-with-the-super-pattern)
  - [Example: Minimum Word Count Validation](#example-minimum-word-count-validation)
  - [Error Handling](#error-handling)
- [Use Cases](#use-cases)
- [💎 Ready for More?](#-ready-for-more)
  - [🚀 **Pro Features for Forms**](#-pro-features-for-forms)


## Installation

```bash
npm install @apostrophecms/form
```

## Usage

### Module Configuration

Configure the form modules in your `app.js` file:

```javascript
import apostrophe from 'apostrophe';

apostrophe({
  root: import.meta,
  shortName: 'my-project',
  modules: {
    // Main form module (must come first)
    '@apostrophecms/form': {},
    // Form widget for adding forms to areas
    '@apostrophecms/form-widget': {},
    // Field widgets (include only the types you need)
    '@apostrophecms/form-text-field-widget': {},
    '@apostrophecms/form-textarea-field-widget': {},
    '@apostrophecms/form-select-field-widget': {},
    '@apostrophecms/form-radio-field-widget': {},
    '@apostrophecms/form-file-field-widget': {},
    '@apostrophecms/form-checkboxes-field-widget': {},
    '@apostrophecms/form-boolean-field-widget': {},
    '@apostrophecms/form-conditional-widget': {},
    '@apostrophecms/form-divider-widget': {},
    '@apostrophecms/form-group-widget': {}
  }
});
```

**Module order matters:** `@apostrophecms/form` must appear before the widget modules. Include only the field types you want editors to use.

### How It Works

The `@apostrophecms/form` module creates a new **piece-type** called "Forms" in your CMS. This means forms are content that editors create once and can reuse across multiple pages—just like blog posts or products. Create a "Contact Form" once, then place it on your contact page, footer, and sidebar using the form widget.

### Adding Form Widget to Areas

To let editors add forms to a page or piece-type, include the form widget in an area:

```javascript
// modules/contact-page/index.js
export default {
  extend: '@apostrophecms/piece-page-type',
  options: {
    label: 'Contact Page'
  },
  fields: {
    add: {
      contactForm: {
        type: 'area',
        options: {
          max: 1,
          widgets: {
            '@apostrophecms/form': {}
          }
        }
      }
    },
    group: {
      basics: {
        fields: ['contactForm']
      }
    }
  }
};
```

### Editor Workflow

Once configured, editors can create and manage forms:

1. **Create a form**: Click "Forms" in the admin bar and create a new form (e.g., "Contact Form")
2. **Build the form**: Add field widgets (text fields, email, checkboxes, etc.) and configure options
3. **Configure submission handling**: Set up email notifications and confirmation messages in the "After-Submission" tab
4. **Place the form**: Edit any page with a form area, add the form widget, and select your created form

Editors can now create and manage forms independently.

## Configuration

### Main Module Options

Configure `@apostrophecms/form` with these options:

| Property | Type | Description |
|---|---|---|
| `disableOptionalLabel` | Boolean | Removes "(Optional)" text from optional fields. Default: `false` |
| `formWidgets` | Object | Widget configuration for allowed field types in forms |
| `saveSubmissions` | Boolean | Set to `false` to prevent saving submissions to MongoDB. Default: `true` |
| `emailSubmissions` | Boolean | Set to `false` to hide email notification fields. Default: `true` |
| `recaptchaSecret` | String | Secret key from reCAPTCHA site configuration |
| `recaptchaSite` | String | Site key for reCAPTCHA integration |
| `classPrefix` | String | Namespace for CSS classes on form elements |
| `uploadLimits` | Object | Limits for multipart form submissions, merged with the defaults: `fileSize` (bytes per file, default: the `maxSize` option of `@apostrophecms/attachment` if set, otherwise 20MB), `files` (default: `20`), `fields` (default: `50`) and `fieldSize` (bytes per non-file field, default: 1MB). Files are only accepted for forms that have a matching file field |

### Available Field Types

The `formWidgets` option controls which widgets editors can use when building forms. Configure this in your project-level `/modules/@apostrophecms/form/index.js` file to override the built-in defaults. This is a **global setting** that applies to all forms in your project.

Default configuration:

```javascript
// modules/@apostrophecms/form/index.js
export default {
  options: {
    formWidgets: {
      '@apostrophecms/form-text-field': {},
      '@apostrophecms/form-textarea-field': {},
      '@apostrophecms/form-boolean-field': {},
      '@apostrophecms/form-select-field': {},
      '@apostrophecms/form-radio-field': {},
      '@apostrophecms/form-checkboxes-field': {},
      '@apostrophecms/form-conditional': {},
      '@apostrophecms/form-divider': {},
      '@apostrophecms/rich-text': {
        toolbar: [
          'styles', 'bold', 'italic', 'link',
          'orderedList', 'bulletList'
        ]
      }
    }
  }
};
```

The rich text widget allows editors to add instructions within forms. Any widget type can be included in this configuration.

> **Need different field types for different forms?** The `formWidgets` option is global and cannot be set per-area or per-page. If you need separate sets of allowed fields (for example, a simple contact form vs. a detailed application form), extend the `@apostrophecms/form` module to create a second form piece-type with its own `formWidgets` configuration. **However**, without additional controls, all editors can use both form types. Use [`@apostrophecms-pro/advanced-permission`](https://apostrophecms.com/extensions/advanced-permission) to restrict which user groups can create and manage each form type—ensuring junior editors only access basic forms while senior staff can use advanced forms. [Learn more about Pro features](#-ready-for-more).

## Handling Submissions

### Database Storage

Submissions are automatically saved to the `aposFormSubmissions` MongoDB collection. To disable database storage:

```javascript
// modules/@apostrophecms/form/index.js
export default {
  options: {
    saveSubmissions: false
  }
};
```


### Email Notifications

If `@apostrophecms/email` is configured, forms can automatically email submissions to multiple recipients. In the form editor, navigate to the "After-Submission" tab and enter comma-separated email addresses in the "Email Address(es) for Results" field.

To hide email notification fields:

```javascript
// modules/@apostrophecms/form/index.js
export default {
  options: {
    emailSubmissions: false
  }
};
```

> **📧 Email Configuration**: To send form submissions via email, you must first configure the `@apostrophecms/email` module. See the [email configuration guide](https://docs.apostrophecms.org/guide/sending-email.html) for setup instructions. Forms can still save submissions to the database without email configuration.

### Server-Side Events

Form submissions trigger events you can handle in your code for custom processing, integrations, or modifying submission data. For example, you could send submissions to an external CRM, add server-side metadata like query parameters, or trigger custom workflows.

**`submission` event** - Fires on every form submission:

```javascript
// modules/@apostrophecms/form/index.js
export default {
  handlers(self) {
    return {
      'submission': {
        async handleSubmission(req, form, submission) {
          // Your custom logic here
          console.log('Form submitted:', form.title);
          console.log('Data:', submission);
        }
      }
    };
  }
};
```

**`beforeSaveSubmission` event** - Fires before saving the `info.submission` to the database (if enabled):

```javascript
// modules/@apostrophecms/form/index.js
export default {
  handlers(self) {
    return {
      'beforeSaveSubmission': {
        async modifySubmission(req, info) {
          // Modify info.submission before it's saved
          info.submission.processedAt = new Date();
        }
      }
    };
  }
};
```

Event handler arguments:

| Event | Arguments | Description |
|---|---|---|
| `submission` | `req`, `form`, `submission` | Request object, form document, submission data |
| `beforeSaveSubmission` | `req`, `info` | Request object, object with `form`, `data`, and `submission` properties |

### Browser Events

The form module emits browser events on the `body` element after a submission attempt. You can listen for these to add **custom client-side feedback or analytics**.

#### Success Event

`@apostrophecms/form:submission-form`
Fires when a submission is successfully processed. The event detail includes a `form` property.

```js
document.body.addEventListener('@apostrophecms/form:submission-form', e => {
  // e.detail.form contains the form element/config
  console.log('Form submitted successfully:', e.detail.form);

  // Example: show a toast notification
  apos.notify('✅ Thanks for your submission!', { type: 'success' });

  // Example: send analytics event
  gtag('event', 'form_submission', {
    formTitle: e.detail.form.title || 'Untitled Form'
  });
});
```
#### Failure Event

`@apostrophecms/form:submission-failed`
Fires when a submission fails due to validation errors, spam protection, or server issues. The event detail includes a `formError` property.

```js
document.body.addEventListener('@apostrophecms/form:submission-failed', e => {
  // e.detail.formError contains the error info
  console.error('Form submission failed:', e.detail.formError);

  // Example: show a custom error banner
  apos.notify('⚠️ Something went wrong. Please try again.', { type: 'danger' });

  // Example: track failed attempts
  gtag('event', 'form_submission_failed', {
    error: e.detail.formError.message || 'Unknown'
  });
});
```
**Use Cases**
* Replace the default "thank you" UI with a custom success message
* Push events into Google Tag Manager, Segment, or other analytics
* Redirect or scroll the page after a successful submission
* Display tailored error messages on failure

> **Note:** Forms already support built-in after-submission messages (`thankYouHeading`, `thankYouBody`) and inline error handling. You only need these browser events if you want extra client-side behavior beyond what the module provides out of the box.

## reCAPTCHA Integration

Protect forms from spam with Google reCAPTCHA v3. Set up reCAPTCHA at [google.com/recaptcha](https://www.google.com/recaptcha/) using version 3, then configure your site and secret keys.

### Configuration Options

**Option 1: Hard-code in module configuration**

```javascript
// modules/@apostrophecms/form/index.js
export default {
  options: {
    recaptchaSecret: 'YOUR_SECRET_KEY',
    recaptchaSite: 'YOUR_SITE_KEY'
  }
};
```

**Option 2: Allow editors to configure in UI**

If you don't hard-code both keys, a global settings UI appears where admins can enter them. Once configured, each form has a checkbox to enable reCAPTCHA independently.

## Styling

### Custom CSS Classes

Add your own class prefix to form elements for complete styling control:

```javascript
// modules/@apostrophecms/form/index.js
export default {
  options: {
    classPrefix: 'my-form'
  }
};
```

This generates BEM-style classes like `my-form__input`, `my-form__label`, and `my-form__error` on form elements.

For teams who prefer visual design tools, Apostrophe's built-in [global styles](https://docs.apostrophecms.org/guide/global-styles.html) let editors adjust CSS properties from the admin interface without writing code.

## Field-Specific Options

### Select Field

The select field widget supports multiple selections:

```javascript
// modules/@apostrophecms/form-select-field-widget/index.js
export default {
  options: {
    allowMultiple: true  // Default: false
  }
};
```

When enabled, two additional fields appear in the widget schema:

| Property | Type | Description | Default |
|---|---|---|---|
| `allowMultiple` | Boolean | Enable multiple selections. | `false` |
| `size` | Integer | Number of visible options. Set to `0` to use the default compact dropdown; set to `2` or higher to render a listbox showing that many options at once. | `0` |

### File Upload Field

⚠️ **Security Warning**: File upload fields allow any visitor to upload files to your server, creating potential risks for storage abuse and malicious uploads. This widget is **not included by default**—you must explicitly enable it.

**Where to implement security measures:**

1. **Storage provider level** (AWS S3, Google Cloud Storage, Azure Blob):
   - Configure file type restrictions, size limits, and lifecycle policies in your provider's console
   - Set up bucket quotas and alerts for unusual upload patterns
   - See your provider's documentation for content validation features

2. **ApostropheCMS attachment module** (`modules/@apostrophecms/attachment/index.js`):
   ```javascript
   export default {
     options: {
       // Restrict file types (extension allowlist)
       fileGroups: [
         {
           name: 'images',
           label: 'Images',
           extensions: [ 'jpg', 'jpeg', 'png', 'gif', 'webp' ],
           extensionMaps: { jpeg: 'jpg' }
         },
         {
           name: 'office',
           label: 'Documents',
           extensions: [ 'pdf', 'doc', 'docx', 'xls', 'xlsx' ],
           extensionMaps: {}
         }
       ],
       // Set maximum file size (in bytes)
       maxSize: 10485760  // 10MB
     }
   };
   ```
   See the [attachment module documentation](https://docs.apostrophecms.org/reference/modules/attachment.html) for complete configuration options.

3. **Form submission handler** (for additional validation):
   ```javascript
   // modules/@apostrophecms/form/index.js
   export default {
     handlers(self) {
       return {
         'beforeSaveSubmission': {
           async validateFiles(req, info) {
             // Add custom file validation logic here
             // Access uploaded files via info.data
           }
         }
       };
     }
   };
   ```

4. **Spam protection**: Enable reCAPTCHA v3 (see [reCAPTCHA Integration](#recaptcha-integration) section)

Files are stored in your configured attachment storage (local uploads or cloud bucket). Form submissions save attachment URLs, not the files themselves.

**Multiple file uploads**: Like the select field, the file field widget supports an `allowMultiple` option:

```javascript
// modules/@apostrophecms/form-file-field-widget/index.js
export default {
  options: {
    allowMultiple: true  // Default: false
  }
};
```

When enabled, users can select and upload multiple files in a single form submission.

## Custom Field Validation

Need business-specific rules like minimum word counts, format requirements, or cross-field dependencies? Extend the built-in field collectors to add custom validation logic before submission. This runs client-side for immediate feedback without server round-trips.

Each field returns its value from a collector function located on the `apos.aposForm.collectors` array in the browser. You can extend these collector functions to adjust the value or do additional validation before the form posts to the server. Collector functions can be written as asynchronous functions if needed.

Collector functions take the widget element as an argument and return a response object on a successful submission. The response object properties are:

| Property | Description |
|---|---|
| `field` | The field element's `name` attribute (identical to the field widget's `name` property) |
| `value` | The field value |

### Extending Collectors with the Super Pattern

These functions can be extended for project-level validation using the super pattern. This involves:

1. Assigning the original function to a variable
2. Creating a new function that uses the original one, adds functionality, and returns an identically structured response
3. Assigning the new function to the original function property

### Example: Minimum Word Count Validation

```javascript
// modules/@apostrophecms/form-textarea-field-widget/ui/src/index.js

export default () => {
  const TEXTAREA_WIDGET = '@apostrophecms/form-textarea-field';

  // 1️⃣ Store the original collector function on `superCollector`.
  const superCollector = apos.aposForm.collectors[TEXTAREA_WIDGET].collector;

  // 2️⃣ Create a new collector function that accepts the same widget element
  // parameter.
  function newCollector(el) {
    // Get the response from the original collector.
    const response = superCollector(el);

    if (response.value && response.value.split(' ').length < 10) {
      // Throwing an object if there are fewer than ten words.
      throw {
        field: response.field,
        message: 'Write at least 10 words'
      };
    } else {
      // Returning the original response if everything is okay.
      return response;
    }
  }

  // 3️⃣ Assign our new collector to the original property.
  apos.aposForm.collectors[TEXTAREA_WIDGET].collector = newCollector;
};
```

### Error Handling

If you want to indicate an error on the field, `throw` an object with the following values (as shown above):

| Property | Description |
|---|---|
| `field` | The field element's `name` attribute (identical to the field widget's `name` property) |
| `message` | A string to display on the field as an error message |

## Use Cases

**Contact Forms**: Let teams create department-specific contact forms without developer involvement.

**Lead Generation**: Build conversion-optimized forms with conditional fields and reCAPTCHA protection.

**Event Registration**: Collect attendee information with file uploads for documents or photos.

**User Feedback**: Create surveys and feedback forms that route to appropriate team members.

**Job Applications**: Accept resumes and application materials with validation and email routing.

## 💎 Ready for More?

The open-source form builder provides powerful form creation capabilities, but enterprise teams often need advanced control and workflow features. [**ApostropheCMS Pro**](https://apostrophecms.com/pro) extends form functionality with professional-grade features:

### 🚀 **Pro Features for Forms**

- **🔐 Advanced Permissions** - Control which teams can create, edit, and manage specific form types. Restrict access to sensitive forms like HR applications or customer data collection. Perfect for multi-team organizations that need different form capabilities for different departments.

- **🌍 Automated Translation** - Automatically translate forms and confirmation messages into multiple languages with AI-powered translation services (DeepL, Google Translate, Azure). Deploy multilingual forms without manual translation work.

- **📄 Document Management** - Version control for forms with complete audit trails. Track changes to form fields, restore previous versions, and maintain compliance with form modification history.

Create an account on Apostrophe Workspaces and upgrade to [**ApostropheCMS Pro**](https://app.apostrophecms.com/login) or **[contact our team](https://apostrophecms.com/contact-us)** to learn about Pro licensing and access enterprise features that enhance form management, compliance, and team collaboration.

---

<div>
  <p>Made with ❤️ by the <a href="https://apostrophecms.com">ApostropheCMS</a> team. <strong>Found this useful? <a href="https://github.com/apostrophecms/apostrophe">Give us a star on GitHub!</a> ⭐</strong></p>
</div>

---

### Overview (`packages/i18n-static/README.md`)

<a id="contents"></a>

# i18n-static
Edit translations of static text with i18next

1. [Installation](#1)<br>
2. [Configuration](#2)<br>
3. [Behavior](#3)<br>
4. [Import and export](#4)<br>

This module makes it possible to edit the [translations of static text strings found in templates](https://v3.docs.apostrophecms.org/guide/localization/static.html) through the ApostropheCMS admin UI.

> This module is intended to localize static text in templates i.e text wrapped with `__("...")`, **not localize editable content.** If your goal is content localization, you should use the [default localization feature in A3](https://v3.docs.apostrophecms.org/guide/localization/) instead.

---

JSON files used in Apostrophe are used by i18next. These resources are automatically converted as pieces through this module.

This will add an entry in the admin bar "i18n Static Phrases".

Only admin users have access to this module for now.

<a id="1"></a>

## 1 Installation [&#x2B06;](#contents)

Install the module with `npm i @apostrophecms/i18n-static` then declare it in the modules configuration in `app.js`:

```js
  modules: {
    '@apostrophecms/i18n-static': {},
  }
```

<a id="2"></a>


## 2 Configuration [&#x2B06;](#contents)

The locales configuration comes from the i18n module in a A3 project. Usually, in `modules/@apostrophecms/i18n/index.js`. Example:

```js
module.exports = {
  options: {
    defaultLocale: 'en',
    locales: {
      en: { label: 'English' },
      fr: {
        label: 'French',
        prefix: '/fr'
      },
      sk: {
        label: 'Slovak',
        prefix: '/sk'
      },
      es: {
        label: 'Spanish',
        prefix: '/es'
      },
      'pt-BR': {
        label: 'Portuguese',
        prefix: '/pt'
      },
      ar: {
        label: 'Arabic',
        prefix: '/ar'
      }
    }
  }
};
```

Namespaces group static texts. But if resources related to a specific namespace should not be created, it has to be excluded by configuring the array `excludeNamespaces` in the module's options.

```js
  modules: {
    '@apostrophecms/i18n-static': {
      options: {
        excludeNamespaces: [ 'aposEvent', 'aposForm' ]
      }
    },
  }
```

As the `autopublish` option is set to `true` by default, new pieces will be created as draft and live. Change this option to `false` if `draft` is necessary before publishing them later (the `publish` batch feature will be helpful in this case).

<a id="3"></a>

## 3 Behavior [&#x2B06;](#contents)

Pieces are automatically created based on i18next resources present in the A3 project. They can also be imported as explained in [the following section](#4).

When an i18n-static piece is edited, an ID is generated and stored in the `global` object. If multiple A3 instances are connected to the same database, they may not see the latest ID until the next request. When the global one changes, they will detect it and add the missing resource to i18next.

<a id="4"></a>

## 4 Import and export [&#x2B06;](#contents)

It is possible to import and export pieces through existing A3 modules: `npm i @apostrophecms/piece-type-exporter @apostrophecms/piece-type-importer`

These options must be added to the module's options at project level:

```js
  // in app.js
  modules: {
    ...
    '@apostrophecms/piece-type-exporter': {},
    '@apostrophecms/piece-type-importer': {},
    '@apostrophecms/i18n-static': {
      options: {
        export: true,
        import: true
      }
    }
  }
```

The import feature is then available in the manager modal: 

As the [piece-type-importer plugin](https://github.com/apostrophecms/piece-type-importer) is used, the CSV file used for import must include column names that match exactly the field names (optional empty column names can be excluded).

Example of a CSV:

```
title,namespace,valueSingular,valuePlural,valueZero
test 1,default,test singular,test plural,test zero
test 2,apostrophe,test,,
```

Pieces can also be updated with the update feature from the plugin, [as explained here](https://github.com/apostrophecms/piece-type-importer#updating-existing-pieces).

The export feature is available in the context menu when items are selected for import. 

[The piece-type-exporter plugin](https://github.com/apostrophecms/piece-type-exporter) is used, so it will generate a CSV file following the same type of structure used for the importer plugin.

---

### Overview (`packages/import-export-xlsx/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>XLSX format for Import Export Module</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/import-export/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

This module improves [@apostrophecms/import-export](https://apostrophecms.com/extensions/import-export) by adding the `xlsx` format.

> Why does this specific format live in another module?

Historically because it relied on a [dependency that was not hosted on NPM](https://github.com/SheetJS/sheetjs/issues/2667). It now depends on [`@e965/xlsx`](https://www.npmjs.com/package/@e965/xlsx), a registry-published mirror of SheetJS Community Edition with the same API, so it installs from the npm registry like any other module. The format remains in its own module for backwards compatibility.

## Requirement

[@apostrophecms/import-export](https://apostrophecms.com/extensions/import-export) should be installed and instantiated.

Please refer to the module's [README](https://apostrophecms.com/extensions/import-export) before continuing.

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```
npm install @apostrophecms/import-export-xlsx
```

## Usage

Configure the module in the `app.js` file:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    '@apostrophecms/import-export': {},
    '@apostrophecms/import-export-xlsx': {}
  }
});
```

> Please note that `apos` is not passed to the `input` and `output` functions of the format, as they should remain agnostic of apostrophe and only deal with retrieving and writing the documents from/to the `.xlsx` file.

---

### Overview (`packages/import-export/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Apostrophe Import Export Module</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/apostrophe/blob/main/packages/import-export/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

This module enables import and export of pages and pieces, with or without related
documents such as files, images and other related types.

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```
npm install @apostrophecms/import-export
```

## Usage

Configure the module in the `app.js` file:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    '@apostrophecms/import-export': {}
  }
});
```
## Exporting Files

### Pages


Pages can be exported by either selecting "Export" from the context menu for an individual page, or by selecting multiple pages and then selecting "Export" from the bulk operations context menu. When exporting a page it will retain its publication status upon import. A page that is published when exported will be published on import unless you select the option to only import as a draft document. Draft pages will remain in draft status.

Exporting the parent page in a set of nested documents will **not** export the child pages. Each child page must be exported separately. To maintain the page order, import the file for the parent document first, and then the files for the child documents.

If a page exists in multiple locales, only the page for the current locale will be exported, not for any other locale. The page must be exported separately for each locale.

<hr>



Clicking export will bring up a dialog box with two input controls.

The first is for selecting the file format to use for the exported file. Currently, the module only exports in the `.tar.gz` format, but this will possibly be expanded in the future.

The second input toggles whether documents associated with the page, like images, files, pieces, or pieces related via relationship fields in the document or its widgets, should be included in the download. Any or all can be unselected. Note that this doesn't mean that the particular page includes all of those document types, just that those document types both exist in your project and aren't disabled for export.

Clicking on the "Export Page" button will trigger a download of the export file to your local computer.

<hr>

### Pieces


Unlike pages, pieces can be exported either individually from the context menu to the right of the piece, or as a batch using the context menu above the pieces list in the manager. For example, in the image above two articles have been selected for export. As with the page-type, all exporting is per-locale.

Clicking export will bring up the same dialog box that appears when exporting pages, allowing for the selection of related documents that should also be exported.

<hr>

### Templates


If you have the [Template Library Pro](https://apostrophecms.com/extensions/template-library) module installed, templates are exported by selecting one or more within the template manager and then using the context menu located to the right of the batch operations. Like with the piece-type exporter you can elect to batch export just one or multiple documents depending on how many are checked. As with the page-type, all exporting is per-locale.

Clicking export will bring up the same dialog box that appears when exporting pages, allowing for the selection of related documents that also should be exported.

<hr>

## Importing files


Any export file, no matter the content, can be imported using the utility context menu located at the top of the content managers, typically located to the left of the button to add new content of that type. This includes the page manager, any piece manager, or the document template manager if installed.



Clicking on the "Import" menu item will bring up a dialog box to select the export file you wish to import. You can only select one file at a time and the selection of an additional file will replace the first. At this stage, you can select to import any published pages as [drafts](#importing-as-drafts-only). If you have the optional [`@apostrophecms-pro/automatic-translation` extension](https://apostrophecms.com/extensions/automatic-translation) installed and configured you can elect to translate the content into the language of the locale where you are performing the import. After you select the exported file and click on the import button, a progress bar will be shown and a success or failure notification when the file has been fully imported.

If the file you select has documents that already exist in your project, you'll get a notification and list of the documents that would be over-written. From that list you can choose documents you don't want imported. Note, if you have any documents that were previously published and then archived, they will trigger a duplicate overwrite warning.

When importing a page, piece(s), or template(s) that was exported from one locale while currently in another locale, and you do not have the automatic translation module or have not elected to translate content, you will get a notification asking if you want to proceed.

When importing several files that comprise a set of nested pages, importing the file with the parental file before importing the child files will preserve the page order in the tree. If a file for a child document is imported before or independently of the parent document, then it will be added as a child of the homepage.

Warning - While you can rename the exported file, you must not change the file extension (`.tar.gz`) or content, or the import might not go as planned. You will also get an error when trying to import an incompatible file made with another file exporter, a file with an incorrect extension, or a file made with a version of the `Import/Export` module that has breaking changes from the currently installed version.

>One caveat of sharing documents between sites is that the modules for page-types and piece-types must be the same in each. For example, if site A is using a page-type of `contact-page`, a page of that type can only be imported into a site that also has a `contact-page` module. If the codebase of the two projects are significantly different, it can either cause the import to fail, or some data to be lost if schema fields can't be reconciled.

### Permissions

Users who have both 'create' and 'edit' permissions for a document type can export and import those documents. However, users with other permissions for a document type, but lacking both 'create' or 'edit' permissions, cannot import documents. They can still export documents of that type.

### Options

You can disable the export and/or the import for any page- or piece-type using the `importExport` option. This option takes an object with `import` and `export` keys that take both can take boolean values.

```javascript
export default {
  extend: '@apostrophecms/piece-type',
  options: {
    importExport: {
      import: true,
      export: false
    },
    label: 'Article',
    pluralLabel: 'Articles'
```

In this example, the 'Article' piece-type can be imported from a file, but users won't be allowed to export any of this type. This will also cause the export option to disappear from both the batch and individual 'Article' piece context menus in the 'Article' piece-type manager. Note that this will not impact other piece-types. The removal of the export menus from the respective managers will also occur if page-type or document templates are disabled for exporting. Finally, any disabled document type will not appear on the list of related documents.

Disabling a document type from being imported will remove the import item from the utility context menu of the respective document manager. It will also block insertion of any documents of that type when importing from the context menu of another manager. Finally, any export file that contains a related document type that is disabled for import will still successfully import for those document types that are allowed.

The `export` key can also take an object with an `expiration` property. The value of this property sets how long the export file will persist before being deleted. The default is 600000ms (10 min.), but can be extended if it is anticipated that the download will be delayed for some reason.

```javascript
export default {
  extend: '@apostrophecms/piece-type',
  options: {
    importExport: {
      import: true,
      export: {
        expiration: 600000
      }
    },
    label: 'Article',
    pluralLabel: 'Articles'
```

### Archive size limits

To protect the server's storage, extracting an import archive (`.tar.gz`) stops, and the import fails, as soon as the archive goes over one of these limits. Whatever was uploaded and extracted is removed whenever an import fails. Set the `importLimits` option of this module to change them:

```javascript
export default {
  options: {
    importLimits: {
      // Maximum number of files and directories in the archive
      maxEntries: 100000,
      // Maximum total size of the extracted files, in bytes.
      // No absolute maximum by default
      maxExtractedSize: null,
      // Maximum total size of the extracted files, as a multiple of the
      // size of the archive. Up to 64MB may always be extracted.
      // Set to `false` to disable this check
      maxCompressionRatio: 100
    }
  }
};
```

Exports made by this module stay well within the defaults, because media files barely compress. The defaults mostly stop "compression bombs", small archives that would expand into huge amounts of data. If your server has limited disk space, also set `maxExtractedSize`.

## Importing documents from another locale

Regardless of the original locale, imported documents are imported into the current locale. You will be prompted to confirm if the locale is different and you do not have, or are not opting to use, the automatic translation module.
	
If multiple locales are set up, the user will be prompted to choose between canceling the import or proceeding with it, unless the user has elected to translate the content.



### Automatic translation

If the `@apostrophecms-pro/automatic-translation` module is installed, the import modal will offer a "Translate" checkbox. When selected, the module will automatically translate the imported documents to the current site's locale using the configured translation service.

When the "Translate" checkbox is selected, the confirm modal letting the user choose between aborting on continuing the import when the docs locale is different from the site, will be suppressed and the import will proceed without any further user interaction.

## Importing as drafts only

The import modal includes a checkbox labeled "_Import all documents as drafts_" that, when selected, imports the published version of documents in draft status only. This feature allows users to review and make modifications to imported documents before they are published.

By default, this checkbox can be set to selected by enabling the `importDraftsOnlyDefault: true` option in this module.

If a document in the import file has no published version, it will still be imported. Content types that do not have separate drafts will be imported normally.

## Updating existing pieces using CSV format

You can also update existing pieces via this module.

To do that, you will need one (and only one) **key column** in your file. This column's name **must be exactly the name of the existing field** that uniquely identifies each row as an update of a specific existing piece, **followed by `:key`**.

For instance, if you need to change the usernames of users in bulk, you might prepare a CSV file like this:

```
username:key,username
bobsmith,bob.smith
janedoe,jane.doe
```

The key column is the *old value*. You may optionally also present a *new value* for that same column in a separate column without `:key`. You may also include other columns, as you see fit. The important thing is that you must have one and only one `:key` column in order to carry out updates.

Please note that both the draft and the published versions will be updated, even if one of them does not match the key column value.

## Mixing inserts and updates

If a row has no value for your `:key` column, it is treated as an insert, rather than an update.

## Importing rich text (HTML) rather than plaintext

By default, if you create a column in your CSV file for a field of type `area`, it will be imported as plaintext. Any special characters like `<` and `>` will be escaped so the user can see them. HTML is not supported.

To import areas as rich text HTML markup, add the `importAsRichText: true` option to the `area` field in your schema.

## How to add a new format?

### Create a file for your format:

Add your format under `lib/formats/<format_name>.js` and export it in `lib/formats/index.js`.

**Simple example** (for a single file without attachment files):

```js
// lib/formats/ods.js
module.exports = {
  label: 'ODS',
  extension: '.ods',
  allowedExtension: '.ods',
  allowedTypes: [ 'application/vnd.oasis.opendocument.spreadsheet' ],
  async input(filepath) {
    // Read `filepath` using `fs.createReadStream`
    // or any reader provided by a third-party library

    // Return parsed docs as an array
    return { docs };
  },
  async output(filepath, { docs }) {
    // Write `docs` into `filepath` using `fs.createWriteStream`
    // or any writer provided by a third-party library
  }
};
```

**Note**: The `input` and `output` functions should remain agnostic of any apostrophe logic.

```js
// lib/formats/index.js
const ods = require('./ods');

module.exports = {
  // ...
  ods
};
```

### For formats with attachment files:

If you want to add a format that includes attachment files such as an archive, you can enable the `includeAttachments` option and utilize extra arguments provided in the `input` and `output` functions.

**Advanced example**:

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Add formats via a separate module

You might want to scope one or multiple formats in another module for several reasons:

- The formats rely on a dependency that is not hosted on NPM (which is the case with [@apostrophecms/import-export-xlsx](https://apostrophecms.com/extensions/import-export-xlsx))
- You want to fully scope the format in a separate module and repository for an easier maintenance
- ...

To do so, simply create an apostrophe module that improves `@apostrophecms/import-export` and register the formats in the `init` method.


Example with an `import-export-excel` module:

```js
const formats = {
  xls: {
    label: 'XLS',
    extension: '.xls',
    allowedExtension: '.xls',
    allowedTypes: [ 'application/vnd.ms-excel' ],
    async input(filepath) {},
    async output(filepath, { docs }) {}
  },
  xlsx: {
    label: 'XLSX',
    extension: '.xlsx',
    allowedExtension: '.xlsx',
    allowedTypes: [ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ],
    async input(filepath) {},
    async output(filepath, { docs }) {}
  }
};

module.exports = {
  improve: '@apostrophecms/import-export',
  init(self) {
    self.registerFormats(formats);
  }
};
```

Then add the module to the project **package.json** and **app.js**.

---

### Overview (`packages/launder/README.md`)

<a href="https://apostrophecms.com/"><img src="https://raw.github.com/apostrophecms/launder/master/logos/logo-box-madefor.png" align="right" /></a>

A sanitization module for the people. Built for use in the [ApostropheCMS](https://apostrophecms.com), useful for many other things.

## Purpose

Launder can be used to sanitize strings, integers, floats, urls, and more. It's best for cases where you've already used front-end validation to encourage smart input, and now you want to make sure your inputs are reasonable.

Launder does not always assume your data is a string, which makes it highly compatible with the use of JSON to deliver data from browser to server. For instance, `launder.boolean` accepts the actual JavaScript values `true` and `false` as well as various string representations.

Launder's support for dates and times permits users to enter both in colloquial formats like `8/25` or `3pm` and "just does the right thing," converting to `2015-08-25` and `15:00:00` respectively.

In addition to sanitization methods, Launder does contain a few other tools, such as `formatDate` and `formatTime` which simply output a Date object in the `2015-08-25` and `15:00:00` formats.

## Usage

```javascript
const launder = require('launder')();

app.post('/form', function(req, res) {
  const units = launder.integer(req.body.units, 0, 0, 100);
  const birthdate = launder.date(req.body.birthdate);
});
```

You can also specify global options:

```javascript
const launder = require('launder')({
  filterTag: function(tag) { return tag.toLowerCase(); }
});
```

## Frequently used methods

### `launder.string(s, def)`

Converts `s` to a string. `s` is coerced to a string, then leading and trailing whitespace is trimmed. If `def` is provided, it is returned when the string is empty or the value passed is not a string. If `def` is undefined, empty strings are left alone, and values that are not strings become empty strings.

### `launder.strings(arr)`

If `arr` is an array, each element is sanitized with `launder.string`, and a new array containing the result is returned. If `arr` is not an array, an empty array is returned.

### `launder.integer(i, def, min, max)`

Converts `i` to an integer. `i` is first coerced to an integer, if needed; if it is an empty string, undefined or otherwise not convertible, `def` is returned. If `min` is provided, and the result would be less than `min`, `min` is returned. If `max` is provided, and the result would be greater than `max`, `max` is returned. If `def` is not provided, the default is `0`. If a number has a fractional part it is discarded, not rounded.

### `launder.float(f, def, min, max)`

Converts `f` to a floating-point number. `f` is first coerced to a floating-point number, if needed; if it is an empty string, undefined or otherwise not convertible, `def` is returned. If `min` is provided, and the result would be less than `min`, `min` is returned. If `max` is provided, and the result would be greater than `max`, `max` is returned. If `def` is not provided, the default is `0`.

### `launder.url(s, def, httpsFix)`

Attempts to ensure that `s` is a valid URL. This method allows only the `http:`, `https:`, `ftp:`, `mailto:`, `tel:` and `sms:` URL schemes, but does allow relative URLs.

It attempts to automatically fix common user mistakes such as typing: `www.mycompany.com` or `www.mycompany.com/my/page.html`, not supplying the URL protocol. By default it prepends `http://`. If `httpsFix` is `true`, it prepends `https://`.

`s` is first sanitized with `launder.string()`.

`def` is returned if the input is an empty string, not convertible to a URL, or suspicious (such as a `javascript:` URL). Spaces are removed as they are ignored by browsers in a surprising number of situations.

### `launder.select(choice, choices, def)`

Sanitize a choice made via a `select` element. If `choice` is one of the `choices`, it is returned, otherwise `def` is returned. If `choices` is an array of objects, then `choice` is compared to the `value` property of each object to find a match.

Choices can be numbers or strings. The choices and the input value are compared as strings. The matching choice is returned with its original type.

### `launder.boolean(b, def)`

Sanitize a boolean value.

If the value is any of the following, `true` is returned:

`true`
`'true'`
`'True'`
`'t'`
`'yes'`
`'Yes'`
`'y'`
`'1'`
*Any other string starting with `t`, `y`, `T`, `Y`, or `1`)*
`1`

Note that both the string `'1'` and the number `1` are accepted.

If `b` is not `true` or `false`, and `launder.string(b)` returns the empty string, then `false` is returned unless `def` is defined, in which case `def` is returned.

### `launder.date(d, def, now)`

Converts `d` to a date string in `YYYY-MM-DD` format, such as `2015-02-20`.

`d` must be either a string or a `Date` object, otherwise `def` is returned. *If `def` is undefined, the current date is returned. If `def` is `null`, `null` is returned.*

`now` can be the current date object for resolving ambiguous dates. If it is not provided, a new `Date` object is created.

The following date string formats are supported:

`YYYY-MM-DD`
`MM/DD/YYYY`
`MM/DD/YY` (*)
`MM/DD` (implies current year)

(*) Implies the current century, unless the result would be more than 50 years in the future, in which case it implies the previous century. This works well for the popular usage of two-digit years. If it bothers you, use four-digit years!

### `launder.time(t, def)`

Converts `t` to a time string in `HH:MM:SS` format, such as `16:30:00`.

The following formats are accepted:

`16:30:00`
`16:30`
`16`
`1pm`
`2:37am`
`2:37:12am`
`2PM` (case insensitive, in general)
`2p` (`m` is optional)
`2 pm` (spaces don't matter)
`4:30a`

If `launder.string(t)` returns the empty string, `def` is returned. *If `def` is not provided, the current time is returned.*

### `launder.tags(arr, filter)`

Sanitize an array of tags. All strings and numbers in the supplied array are passed through `launder.string`, then through `filter`. If `filter` is not passed, the `filterTag` function provided as an option when configuring `launder` is used. If that option is not passed, the default `filterTag` function is used.

The default `filterTag` function trims whitespace and converts to lowercase.

Any elements which have been laundered to the empty string are discarded.

### `launder.id(s, def)`

Sanitize an ID. For our purposes an ID is made up of the characters `A-Z`, `a-z`, `0-9` and `_`. An ID may begin with any of these characters. An ID must contain at least one character. `launder.string` is first used to coerce `s` to a string.

If any of these criteria are not met, `def` is returned.

### `launder.ids(ids)`

Sanitize an array of IDs. Each element is passed through `launder.id`. Any IDs that do not meet the criteria are omitted from the returned array.

## Miscellaneous methods

We use these a lot in Apostrophe, but they might not feel as relevant for other applications. Use them if you wish!

### `launder.addBooleanFilterCriteria(options, name, criteria, def)`

Use a tri-state filter value such as `'true'`, `'false'`, or `'any'` to build a MongoDB-style query criteria object.

`options[name]` should be a string such as `'true'`, `'false'` or `'any'`.

`criteria[name]` will then be set to `true`, `{ $ne: true }`, or left entirely unset.

Any value accepted by `launder.boolean` is acceptable to specify `true` and `false`. Also, `null` is accepted as a synonym for `'any'`.

If `def` is not specified, the default behavior is `any`.

### `launder.formatDate(date)`

Output the given `Date` object in `YYYY-MM-DD` format. This is the canonical date format for Apostrophe.

### `launder.formatTime(date)`

Output the given `Date` object in `HH:mm:ss` format. This is the canonical time format for Apostrophe.

### `launder.padInteger(i, places)`

Pads the specified integer with leading zeroes to ensure it has at least `places` digits and returns the resulting string.

## About ApostropheCMS

`launder` was created for use in ApostropheCMS, an open-source content management system built on Node.js. If you like `launder` you should definitely [check out apostrophecms.org](https://apostrophecms.com). Also be sure to visit us on [github](http://github.com/apostrophecms).

## Support

Feel free to open issues on [github](http://github.com/apostrophecms/launder).

---

### Overview (`packages/login-hcaptcha/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Apostrophe hCaptcha Login Verification</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Test status" href="https://github.com/apostrophecms/login-hcaptcha/actions">
      <img alt="GitHub Workflow Status (branch)" src="https://img.shields.io/github/workflow/status/apostrophecms/login-hcaptcha/Tests/main?label=Tests&labelColor=000000&style=for-the-badge">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/login-hcaptcha/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

This login verification module adds a [hCaptcha](https://hcaptcha.com) check when any user logs into the site.

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```
npm install @apostrophecms/login-hcaptcha
```

## Usage

Instantiate the hCaptcha login module in the `app.js` file:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    '@apostrophecms/login-hcaptcha': {}
  }
});
```

The other requirement is to add your [hCaptcha public API site key](https://docs.hcaptcha.com/configuration#hcaptcha-container-configuration) to the `@apostrophecms/login` module (*not* this module). This module adds functionality to that module (it "improves" it, in Apostrophe speak), so most configuration should be directly on the core login module.


```javascript
// modules/@apostrophecms/login/index.js
module.exports = {
  options: {
    hcaptcha: {
      site: 'ADD YOUR SITE KEY',
      secret: 'ADD YOUR SECRET KEY'
    }
  }
};
```

Once configured, hCaptcha verification should work on all login attempts.

### Language

The hCaptcha is displayed in the language of the admin UI: the `defaultAdminLocale` of the `@apostrophecms/i18n` module when set, otherwise the locale of the login page. Set `hl` to force a specific language ([ISO 639-1 code](https://docs.hcaptcha.com/languages)). hCaptcha falls back to auto-detection when the code is not supported.

```javascript
// modules/@apostrophecms/login/index.js
module.exports = {
  options: {
    hcaptcha: {
      site: 'ADD YOUR SITE KEY',
      secret: 'ADD YOUR SECRET KEY',
      hl: 'en'
    }
  }
};
```

### Right-to-left websites

On a website whose current locale is right-to-left, the login page and the hCaptcha widget are laid out right-to-left as well. If your editors prefer a left-to-right login page, set the `direction` option of the `@apostrophecms/login` module (*not* this module):

```javascript
// modules/@apostrophecms/login/index.js
module.exports = {
  options: {
    direction: 'ltr'
  }
};
```

### Content security headers

If your site has a content security policy, including if you use the [Apostrophe Security Headers](https://www.npmjs.com/package/@apostrophecms/security-headers) module, you will need to add additional configuration to use this module. This module adds a script tag to the site's `head` tag fetching hCaptcha code, so we need to allow resources from that domain.

**If you are using the Apostrophe Security Headers module**, add the following policy configuration for that module:

```javascript
module.exports = {
  options: {
    policies: {
      'login-hcaptcha': {
        'script-src': 'hcaptcha.com *.hcaptcha.com',
        'frame-src': 'hcaptcha.com *.hcaptcha.com',
        'style-src': 'hcaptcha.com *.hcaptcha.com',
        'connect-src': 'hcaptcha.com *.hcaptcha.com'
      },
      // Any other policies...
    }
  }
};
```

**If your content security policy is configured some other way**, add `hcaptcha.com  *.hcaptcha.com` to the `script-src`, `frame-src`, `style-src` and `connect-src` directives.

Please refer to the list at https://docs.hcaptcha.com/#content-security-policy-settings for any additional settings.

---

### Overview (`packages/login-recaptcha/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Apostrophe reCAPTCHA Login Verification</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Test status" href="https://github.com/apostrophecms/login-recaptcha/actions">
      <img alt="GitHub Workflow Status (branch)" src="https://img.shields.io/github/workflow/status/apostrophecms/login-recaptcha/Tests/main?label=Tests&labelColor=000000&style=for-the-badge">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/login-recaptcha/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

This login verification module adds a [reCAPTCHA](https://developers.google.com/recaptcha/intro) check when any user logs into the site. It uses reCAPTCHA v3, which means that the test is invisible aside from a reCAPTCHA logo at the bottom of the screen.

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```
npm install @apostrophecms/login-recaptcha
```

## Usage

Instantiate the reCAPTCHA login module in the `app.js` file:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    '@apostrophecms/login-recaptcha': {}
  }
});
```

The other requirement is to add [reCAPTCHA site and secret keys](https://developers.google.com/recaptcha/intro#recaptcha-overview) to the `@apostrophecms/login` module (*not* this module). This module adds functionality to that module (it "improves" it, in Apostrophe speak), so most configuration should be directly on the core login module.


```javascript
// modules/@apostrophecms/login/index.js
module.exports = {
  options: {
    recaptcha: {
      site: 'ADD YOUR SITE KEY',
      secret: 'ADD YOUR SECRET KEY'
    }
  }
};
```

Once configured, reCAPTCHA verification should work on all login attempts.

### Right-to-left websites

On a website whose current locale is right-to-left, the login page is laid out right-to-left as well (the reCAPTCHA badge keeps its own fixed position). If your editors prefer a left-to-right login page, set the `direction` option of the `@apostrophecms/login` module (*not* this module):

```javascript
// modules/@apostrophecms/login/index.js
module.exports = {
  options: {
    direction: 'ltr'
  }
};
```

### Content security headers

If your site has a content security policy, including if you use the [Apostrophe Security Headers](https://www.npmjs.com/package/@apostrophecms/security-headers) module, you will need to add additional configuration to use this module. This module adds a script tag to the site's `head` tag fetching reCAPTCHA code. That reCAPTCHA code also constructs an iframe. The external script and iframe use the `www.google.com` and `www.gstatic.com` domains, so we need to allow resources from that domain.

**If you are using the Apostrophe Security Headers module**, add the following policy configuration for that module:

```javascript
module.exports = {
  options: {
    policies: {
      'login-recaptcha': {
        'script-src': 'www.google.com www.gstatic.com',
        'frame-src': 'www.google.com'
      },
      // Any other policies...
    }
  }
};
```

**If your content security policy is configured some other way**, add `www.google.com` to the `frame-src` directive and  `www.google.com www.gstatic.com` to the `script-src` directive.

---

### Overview (`packages/login-totp/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Apostrophe TOTP Login Verification</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Test status" href="https://github.com/apostrophecms/login-totp/actions">
      <img alt="GitHub Workflow Status (branch)" src="https://img.shields.io/github/workflow/status/apostrophecms/login-totp/Tests/main?label=Tests&labelColor=000000&style=for-the-badge">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/login-totp/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

This login verification module adds a [TOTP (Time-based One-Time Password)](https://en.wikipedia.org/wiki/Time-based_one-time_password) check when any user logs into the site, compatible with Google Authenticator or any TOTP app.
When activated, it will ask unregistered users to add a token to their app through a QR code. Once done, it will ask users to enter the code provided by their app after the initial login step.

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```
npm install @apostrophecms/login-totp
```

## Usage

Instantiate the TOTP login module in the `app.js` file:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    '@apostrophecms/login-totp': {}
  }
});
```

You must configure the `@apostrophecms/login` module with a TOTP secret, as shown. The secret must be **exactly 10 characters long.**

```javascript
// modules/@apostrophecms/login/index.js
module.exports = {
  options: {
    totp: {
      // Should be a random string, exactly 10 characters long
      secret: 'totpsecret'
    }
  }
};
```

> ⚠️ All configuration of TOTP related options is done on the `@apostrophecms/login` module. The `@apostrophecms/login-totp` module is just an "improvement" to that module, so it has no configuration options of its own.

### Right-to-left websites

On a website whose current locale is right-to-left, the login page, including the TOTP setup and verification screens, is laid out right-to-left as well. If your editors prefer a left-to-right login page, set the `direction` option of the `@apostrophecms/login` module (*not* this module):

```javascript
// modules/@apostrophecms/login/index.js
module.exports = {
  options: {
    direction: 'ltr'
  }
};
```

### Resetting TOTP when a user loses their device

If a user loses their device, an admin can edit the appropriate user via the admin bar. Select "Yes" for the "Reset TOTP" field and save the user.

If an admin user loses their own device, they can reset TOTP via a command line task. Pass the username as the sole argument:

```
node app @apostrophecms/user:reset-totp username-goes-here
```

Once TOTP is reset, the user is able to set it up again on their next login.

---

### Overview (`packages/mongodb-snapshot/README.md`)

# @apostrophecms/mongodb-snapshot

A simple nodejs utility library to create and restore snapshots of mongodb databases without the need for `mongodump` and `mongorestore` to be installed. This reduces unnecessary dependencies on packages users may not be eager to install, especially developers relying solely on MongoDB Atlas for MongoDB hosting.

Because the format of mongodb archive files is a bit more complicated and apparently undocumented, and the BSON format has no readily available streaming support in JavaScript, this module **does not** read and write mongodump files. Instead it reads and writes a simple format based on EJSON (Extended JSON).

As a bonus, command line `mongodb-snapshot-write` and `mongodb-snapshot-read` utilities are provided. Of course these do not perform as quickly as `mongodump` and `mongorestore`.

## Installation
```bash
# If you want to use the utilities
npm install @apostrophecms/mongodb-snapshot

# If you want to use the library in your nodejs code
npm install @apostrophecms/mongodb-snapshot
```

## Command line usage

```bash
mongodb-snapshot-write --from=mongodb://localhost:27017/mydbname --to=myfile.snapshot
# add --erase ONLY if you want the previous contents removed first!
mongodb-snapshot-read --from=myfile.snapshot --to=mongodb://localhost:27017/mydbname --erase
```

### Including and excluding collections

With both commands, if you want to exclude certain certain collections, just add:

```
--exclude=name1,name2
```

### Filtering individual documents

With the `mongodb-snapshot-write` command **only**, you can specify a MongoDB query to
filter the documents for any collection. For example:

```bash
mongodb-snapshot-write --from=mongodb://localhost:27017/mydbname --to=myfile.snapshot --filter-COLLECTION-NAME-HERE='{"type":"interesting"}'
```

The query must be valid JSON. Watch out for shell escaping rules (note the single quotes in the example). Note that
MongoDB has a `$regex` operator which can be useful here.

You may specify filters for as many collections as you wish. Note that the parameter name begins with `--filter-` followed by the collection name.

## Node.js API usage

```javascript
// Note: ESM import syntax also works.

// writing

const { MongoClient } = require('mongodb');
const { write } = require('@apostrophecms/mongodb-snapshot');

async function saveASnapshot() {
  const client = new MongoClient(yourMongodbUriGoesHere);
  await client.connect();
  const db = await client.db();
  // Writes the contents of db to myfilename.snapshot, including indexes
  // Two collections are excluded (optional third argument)
  await write(db, 'myfilename.snapshot', {
    // No password hashes please
    exclude: [ 'aposUsersSafe' ],
    filters: {
      aposDocs: {
        type: {
          // No users please
          $ne: '@apostrophecms/user'
        }
      }
    }});
}
```

```javascript
const { MongoClient } = require('mongodb');
const { erase, read } = require('@apostrophecms/mongodb-snapshot');

async function saveASnapshot() {
  const client = new MongoClient(yourMongodbUriGoesHere);
  await client.connect();
  const db = await client.db();
  // If you want to replace the current contents and avoid unique key errors and/or duplicate
  // documents, call erase first
  await erase(db);
  // Two collections are excluded (optional third argument)
  await read(db, 'myfilename.snapshot', { exclude: [ 'coll3', 'coll4' ]});
}
```

## Limitations

Correctness comes before performance, for now. Sensible amounts of parallelism might help.

## Credits

This module was [originally created for use with ApostropheCMS](https://apostrophecms.com), an open-source Node.js CMS with robust support for in-context, on-page editing, multitenant, multisite projects and lots of other great features worth checking out.

---

### Overview (`packages/moog-require/README.md`)

[](https://travis-ci.org/punkave/moog-require)

# moog-require

`moog-require` provides powerful module subclassing for server-side development. It extends the features of [moog](https://github.com/punkave/moog) with the following additions:

* Fetches modules from a local modules folder if they are not defined explicitly
* If a module is defined explicitly and also exists in localModules, the local modules folder becomes a source of defaults for properties not defined explicitly
* Fetches modules from npm if they are not defined either explicitly or via the local modules folder
* If a module exists by the same name both in npm and via explicit definition or local modules, automatically extends the npm module without the need for a new name (like the "category" feature of Objective C)
* Provides access to an "asset chain" of subclass module directories and type names, to implement template overrides and the like
* Also supports bundling moog modules in a single npm module, if explicitly configured

## Example

```
/* Detailed source-code truncated for AI context efficiency. */
```

## Replacing a module with another npm module

The `monsters` npm module works great for most people, but you've created a superior replacement, `scary-monsters`. And you want people to be able to use it as a drop-in replacement, without changing code that refers to the `monsters` module.

This is especially useful if you want other moog types that subclass `monsters` to automatically subclass `scary-monsters` instead.

So the `index.js` of your `scary-monsters` npm module might look like:

```javascript
module.exports = {
  replace: 'monsters',
  construct: function(self, options) { ... }
}
```

Note the `replace` property.

Now, an application developer who wants to use `scary-monsters` instead of the usual `monsters` module can simply configure it instead of `monsters`. The type name `monsters` will still be defined. The type name `scary-monsters` is **not** defined.

```javascript
var synth = require('moog-require')({
  localModules: __dirname + '/lib/modules',
  defaultBaseClass: 'module'
});

synth.define({
  'scary-monsters': { ... configuration ... }
});

// This works
synth.create('monsters', {});

// This does NOT work
synth.create('scary-monsters', {});
```

Note that if you want to further extend `scary-monsters` at project level, you should use a `lib/modules/scary-monsters` folder. Anything in `lib/modules/monsters` will be ignored. Similarly, in `app.js`, don't configure `monsters`, just configure `scary-monsters`.

## Improving a module with another npm module: implicit subclassing

The `improve` property is similar to `replace`, but allows you to implicitly subclass an existing type rather than completely replacing it. If you `improve` the `monsters` type, all other code will regard your subclass as the `monsters` type.

This is useful if you wish to release an npm module that subclasses a well-known module to add more functionality, without requiring developers to change the source code of other modules in order to use it.

Here is an example:

So the `index.js` of your `scary-monsters` npm module might look like:

```javascript
module.exports = {
  improve: 'monsters',
  construct: function(self, options) {
    var superJump = self.jump;
    self.jump = function(howHigh) {
      // Limit height of jumps
      if (howHigh > 100) {
        howHigh = 100;
      }
      // Call original version
      superJump(howHigh / 2);
    };
  }
}
```

Note the `improve` property.

Just like the `replace` option, the `improve` option defines the type with the name specified by `improve`. That is, your subclass is substituted everywhere for the `monsters` type. The `scary-monsters` type is **not** defined.

Here is an example of application-level code:

```javascript
var synth = require('moog-require')({
  localModules: __dirname + '/lib/modules',
  defaultBaseClass: 'module'
});

synth.define({
  'scary-monsters': { ... configuration ... }
});

// This works
synth.create('monsters', {});

// This does NOT work
synth.create('scary-monsters', {});
```

Note that if you want to further extend `scary-monsters` at project level, you should use a `lib/modules/scary-monsters` folder. Code in `lib/modules/monsters` will be loaded, but it will subclass the original `monsters` module, and then `scary-monsters` will subclass that. This is probably not what you want. Similarly, in `app.js`, don't configure `monsters`, just configure `scary-monsters`.

## Calling `require` yourself

Don't.

Well, okay...

If you want to write this:

```javascript
`extend': require('./lib/weird-place/my-module/index.js')
```

You may do so, but in that case your module must export its `__name`, `__dirname` and `__filename`, like so:

```javascript
module.exports = {
  __name: 'my-module',
  __dirname: __dirname,
  __filename: __filename,
  construct: function(self, options) { ... }
};
```

This is only necessary if you are using `require` directly. Most of the time, you will be happier if you just specify a module name and let us `require` it for you. This even works in npm modules. (Yes, it will still find it if it is an npm dependency of your own module.)

## Packaging multiple moog-require modules in a single npm module

Sometimes several modules are conceptually distinct, but are developed and versioned in tandem. In these cases there is no benefit from separate packaging, just a significant delay in `npm install`. npm peer dependencies are one way to handle this, but [npm peer dependencies may be on the chopping block](http://dailyjs.com/2014/04/16/node-roundup/), and they are significantly slower than pre-packaging modules together.

The difficulty of course is that the link between npm module names and moog-require module names is broken when we do this. So we need another way to indicate to moog-require that it should look in the appropriate place.

Since searching for "X", where X is actually provided by module "Y", is not a core feature of npm itself we have kept this mechanism simple: you can give `moog-require` an array of npm module names that contain a "bundle" of definitions rather than a single definition. An npm "bundle" module then must export a `moogBundle` array property which contains the names of the moog-require modules it defines. The actual definitions live in `lib/modules/module-one/index.js`, `lib/modules/module-two/index.js`, etc. *within the bundle npm module*. `moog-require` will find these automatically and will consider these first before requiring normally from npm.

Here's an example:

```javascript
// In node_modules/mybundle/index.js

module.exports = {
  moogBundle: {
    modules: [ 'module-one', 'module-two' ],
    directory: 'lib/modules'
  }
};

// In node_modules/mybundle/lib/modules/module-one/index.js

module.exports = {
  construct: function(self, options) { ... }
};

// In node_modules/mybundle/lib/modules/module-two/index.js

module.exports = {
  construct: function(self, options) { ... }
};
```

```javascript
// In our application

var synth = require('moog-require')({
  bundles: [ 'mybundle' ],
  localModules: __dirname + '/lib/modules',
  defaultBaseClass: 'module'
});

synth.define({
  'module-one': {},
  'module-two': {}
});
```

Note that just as before, we must include these modules in our explicit `define` calls if we want to instantiate them with `createAll`, although we don't have to override any properties; we can pass empty objects to just use the defaults defined in the project level folder, and/or implicitly inherit from npm.

However, you may explicitly `create` a type that exists only in the project level folder and/or npm.

## Nesting modules in subdirectories

For your convenience, `moog-require` has optional support for loading modules from nested subdirectories. The rules of the game are very simple:

* You must set the `nestedModuleSubdirs` option to `true`.
* Modules can now be found nested beneath your `localModules` folder, at any depth.
* The names of the parent directories **do not matter**. They are purely for your organizational convenience.
* The name of the actual module directory must still be the full name of the module.

If the same module exists in two places, an exception is thrown.

## Changelog

**The 2.x series is deprecated for new work, as its functionality was folded into Apostrophe 3.x. See below for 1.x release notes relevant to maintenance of Apostrophe 2.x.**

1.3.2: starting in version 1.3.1, this module only loads other modules via `npm` if they are explicit npm dependencies, which is necessary for stability and security. However, it is too strict: if the project has no `package.json` at all at the level of `app.js`, `npm` search up the tree, and this module should too. Beginning in verison 1.3.2, it does search up the tree. However it stops at the first `package.json` found.

1.3.1: `moog-require` loads modules from npm if they exist there and are configured by name in the application. This was always intended only as a way to load direct, intentional dependencies of your project. However, since npm "flattens" the dependency tree, dependencies of dependencies that happen to have the same name as a project-level module could be loaded by default, crashing the site or causing unexpected behavior. So beginning with this release, `moog-require` scans `package.json` to verify an npm module is actually a dependency of the project itself before attempting to load it.

1.3.0: achieved an approximately 100x performance improvement when `nestedModuleSubdirs` is in use by fetching
a list of index.js files on the first `define` call and then searching that prefetched list each
time. This solution is much faster than the glob module cache.

1.2.0: use `originalToMy` to handle moog class names with npm namespaces in them.

1.1.1: use `importFresh` to avoid bugs when two instances of `moog-require` are loading the same module definitions. Previously if modules created by the two instances later modified sub-properties of `options`, they would inadvertently share values. This fix is critical for both `apostrophe-monitor` and `apostrophe-multisite`.

1.1.0: support for the `nestedModuleSubdirs` option.

1.0.1: shallowly clone the result of `require` rather than attaching `.__meta` to a potentially shared object. This allows multiple instances of `moog-require` in multiple instances of `apostrophe` to independently track where modules were loaded from.

1.0.0: `moog`, `async` and `lodash` dependencies updated to satisfy `npm audit`. Declared 1.x as this has been a stable part of Apostrophe 2.x for a long time.

0.4.1: fixed `moog` dependency to use the version that supports `autoload: false`.

0.4.0: added `isImprovement` method which returns true if a type name turned out to be an improvement of another type via the `improve` keyword. This is useful when you wish to instantiate all of the types except for those that are just improvements of others.

0.3.0: introduced the `replace` and `improve` options, which allow an npm module to substitute itself for another moog type completely, or enhance it via implicit subclassing. This is useful when releasing a drop-in replacement for a well-known module.

0.2.0: depends on `moog` 0.2.0 which introduces the `mirror` method.

0.1.0: compatible with `moog` 0.1.0 in which the `__meta` property became an object with `chain` and `name` properties.

---

### Overview (`packages/oembetter/README.md`)

# oembetter

<a href="https://apostrophecms.org/"><img src="https://raw.github.com/punkave/oembetter/master/logos/logo-box-madefor.png" align="right" /></a>

`oembetter` is a modern [oembed](http://oembed.com) client which allows you to add filters that provide or improve oembed support for services that either don't have it or don't do it very well.

`oembetter` fully supports the `oembed` standard including both XML and JSON responses from servers, and delivers the result as a neatly parsed JavaScript object.

`oembetter` also allows you to allowlist trusted `oembed` domains. We strongly recommend this to prevent session cookie theft and other attacks.

`oembetter` intentionally sticks to the oembed standard so you can use it to implement straightforward proxies that provide "better oembed."

## Basic Usage

```javascript
const oembetter = require('oembetter')();

oembetter.fetch(url, function(err, response) {
  if (!err) {
    // thumbnail_url points to an image
    console.log(response.thumbnail_url);

    // response.html contains markup to embed the video or
    // whatever it might be
    console.log(response.html);
  }
});
```

oembetter is not restricted to handling responses of type `video`. See the [oembed documentation](http://oembed.com) for other response types that may come down the pipe.

## Usage with `maxwidth` and `maxheight`

You can pass an object containing `maxwidth` and `maxheight` options. Sites vary in how well they support them.

```javascript
const oembetter = require('oembetter')();

oembetter.fetch(url, { maxwidth: 480, maxheight: 480 }, function(err, response) {
  if (!err) {
    // response.html contains markup to embed the video or
    // whatever it might be
  }
});
```

## Usage with custom headers

You may pass on custom headers to be included in all HTTP requests made by oembetter. `Referer` is particularly useful to ensure that Vimeo allows you to embed videos when they are private and embedding is restricted on a per-domain basis:

```javascript
const oembetter = require('oembetter')();

oembetter.fetch('https://vimeo.com/abc/def', {
  headers: {
    'Referer': 'https://example.com'
  }
}, function(err, response) {
  if (!err) {
    // response.html contains markup to embed the video
  }
});
```

## Important security note: allowlisting

**Trusting `oembed` completely isn't safe for your users,** especially if you are allowing untrusted users to embed things. The HTML returned by third party sites could do nasty things like running JavaScript that sniffs user sessions or just displaying a fake login prompt.

But sites like YouTube, Vimeo and Flickr do play nicely with others. So we use a allowlist to decide which domains are OK:

```javascript
oembetter.allowlist([ 'youtube.com', 'vimeo.com', 'wufoo.com' ]);
```

Just list acceptable domain names and `oembetter` will make sure URLs are in one of those domains (or a subdomain) before doing anything else. If not, an error is delivered to the callback.

For your convenience, there is a standard allowlist available. Use it at your own risk:

```javascript
oembetter.allowlist(oembetter.suggestedAllowlist);
```

## Suggesting Endpoints

Some services support `oembed` but aren't discoverable. You can tell oembetter where to point for the oembed api by passing in an array of endpoints.

```javascript
oembetter.endpoints([
  { domain: 'instagram.com', endpoint: 'http://api.instagram.com/oembed' },
  { domain: 'facebook.com', path: /\/videos\//, endpoint: 'https://www.facebook.com/plugins/video/oembed.json/' },
]);
```

There is also a list of suggested endpoints which you can elect to use:

```javascript
oembetter.endpoints(oembetter.suggestedEndpoints);
```

(Hint: you can concatenate an additional array with your own endpoints if desired.)

## Adding Filters

### Filtering before the oembed request

Some services don't support `oembed`. In these cases you'll want to fake it by providing a filter that substitutes its own response.

Other services support `oembed`, but only for certain URLs. In these cases you'll want to change the URL that `oembed` will use.

Pass a function to the `addBefore` method. This function will receive the URL, the options object (which might contain `maxwidth` and `maxheight`), the response so far (usually undefined at this point), and a callback function. Your function should invoke the callback with an error if any, and a new URL, options object and response object if desired. You may also modify the objects you are given and skip passing any arguments to the callback.

"When would `response` already be defined?" If another `before` filter has already suggested one, you'll see it even though we haven't made a real oembed call yet.

**If any filter provides a response object, then an actual oembed call is not made.** Not all `before` filters do this. Some just change the URL.

**Your filter must begin by making sure this URL is relevant to its interests.**

Here's an example: `hootenanny.com` (yes, I made it up) has pages like `/pages/50.html`. We know each one has a thumbnail at `/thumbnails/50.jpg` and a video page suitable for iframes at `/videos/50`. Let's create our own oembed response since `hootenanny.com` doesn't support it.

```javascript

oembetter.addBefore(function(url, options, response, callback) {
  const parsed = new URL(url);
  if (!oembetter.inDomain('hootenanny.com', parsed.hostname)) {
    return setImmediate(callback);
  }
  const matches = parsed.pathname.match(/pages\/(\d+).html/);
  if (!matches) {
    return setImmediate(callback);
  }
  const id = matches[1];
  const newResponse = {
    thumbnail_url: 'http://hootenanny.com/thumbnails/' + id + '.jpg',
    html: '<iframe src="http://hootenanny.com/videos/' + id + '"></iframe>'
  };
  return callback(null, url, options, newResponse);
});
```

You can also write a filter that just adjusts URLs. This filter knows that `wiggypants.com` URLs will work better if we point them at `jiggypants.com`:

```javascript
oembetter.addBefore(function(url, options, response, callback) {
  const parsed = new URL(url);
  if (!oembetter.inDomain('wiggypants.com', parsed.hostname)) {
    return setImmediate(callback);
  }
  url = url.replace(/wiggypants\.com/g, 'jiggypants.com');
  return callback(null, url);
});
```

### Filtering after the oembed request

Some services support `oembed`, but not quite well enough. So you want to make a small adjustment to the standard response. You want an `after` filter.

Here's an async filter that makes sure YouTube's embed codes use `wmode=opaque` and also upgrades to a high-res thumbnail if possible.

```javascript
oembetter.addAfter(function(url, options, response, callback) {
  if (!url.match(/youtube/)) {
    return setImmediate(callback);
  }

  // Fix YouTube iframes to use wmode=opaque so they don't
  // ignore z-index in Windows Chrome
  response.html = response.html.replace('feature=oembed', 'feature=oembed&wmode=opaque');

  // Change thumbnail to be largest available if it exists
  const maxResImage = result.thumbnail_url.replace('hqdefault.jpg', 'maxresdefault.jpg');

  return request.head(maxResImage, function(err, httpResponse) {
    if (response.statusCode < 400) {
      result.thumbnail_url = maxResImage;
    }
    return callback(null);
  });
});
```

This filter modifies the `result` object directly. You may also pass a new `result` object as the second argument to the callback.

### Fallback filters: when all else fails

`after` filters are only called if there *is* a response.

If you wish to provide a fallback solution for cases where there is **no** response from oembed, use a fallback filter.

This only makes sense when you're hopeful that oembed will work some of the time. If not, write a `before` filter that supplies its own response.

```javascript
// "fallback" filter can create a response when oembed fails
oembetter.addFallback(function(url, options, callback) {
  const parsed = new URL(url);
  if (!oembetter.inDomain('wonkypants83742938.com', parsed.hostname)) {
    return setImmediate(callback);
  }
  return callback(null, { html: 'so wonky' });
});
```

## About P'unk Avenue and ApostropheCMS

`oembetter` was created at [P'unk Avenue](https://punkave.com) for use in many projects built with ApostropheCMS, an open-source content management system built on node.js. `oembetter` isn't mandatory for Apostrophe and vice versa, but they play very well together. If you like `oembetter` you should definitely [check out Apostrophe](https://apostrophecms.com/).

---

### Overview (`packages/open-graph/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Open Graph for ApostropheCMS</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/blog/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

## Compatibility
This version requires the latest ApostropheCMS. When adding this module to an existing project, run `npm update` to ensure all ApostropheCMS modules are up-to-date.

## Installation

```bash
npm install @apostrophecms/open-graph
```

## Use

Configure `@apostrophecms/open-graph` in `app.js`.

```js
const apos = require('apostrophe')({
  shortName: 'project',
  modules: {
    '@apostrophecms/open-graph': {}
  }
});
```

### Setting the `baseUrl`

Open Graph images *will not be set* with absolute URLs if the `baseUrl` is not set. This should either be set statically in `app.js`, but more likely, in the environment configuration, such as in `data/local.js`. Some social media platforms consider an aboslute URL to be a requirement and *will not accept the image URL without it*.

#### In `app.js` as part of your main Apostrophe app
```js
require('apostrophe')({
  shortName: 'mysite',
  baseUrl: 'https://mysite.com',
  modules: {
    // other module configurations
  }
});
```
#### As part of an environment configuration in `data/local.js`
```js
  module.exports = {
    baseUrl: 'https://mysite.com',
    modules: {
      // other environment-specific module configurations
    }
  };
```
### Opting out of Open Graph Fields

Adding `openGraph: false` to any module will prevent Open Graph fields from being added. Good use cases for this are utility modules, special page types, or piece types that don't have index or show pages.

```js
require('apostrophe')({
  shortName: 'mysite',
  baseUrl: 'https://mysite.com',
  modules: {
    category: {
      options: {
        openGraph: false;
      }
    }
  }
});
```

The following modules opt out of the Open Graph fields by default:
 - `@apostrophecms/global`
 - `@apostrophecms/user`
 - `@apostrophecms/image`
 - `@apostrophecms/image-tag`
 - `@apostrophecms/file`
 - `@apostrophecms/file-tag`

### Field Reference
The following are the fields that are added to pieces and pages

|Name |Description  | Module Effected |
--- | --- | --- 
|`openGraphTitle`|OG Title, populates `<meta property="og:title" />`|`@apostrophecms/doc-type`
|`openGraphDescription`|OG Description, populates `<meta property="og:description" />`|`@apostrophecms/doc-type`
|`openGraphType`|OG Type, populates `<meta property="og:type" />`, defaults to 'website'|`@apostrophecms/doc-type`
|`openGraphImage`|OG Image, populates `<meta property="og:image" />`|`@apostrophecms/doc-type`

---

### Overview (`packages/openapi-generator/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80" />

  <h1>OpenAPI Generator for ApostropheCMS</h1>

  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20" />
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/openapi-generator/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639" />
    </a>
  </p>
</div>

**Automatically generate professional API documentation and client SDKs** for your ApostropheCMS project. Discover all routes—including Pro module endpoints—and create comprehensive OpenAPI 3.1 specs with zero configuration.

---

## Table of Contents

- [Why ApostropheCMS OpenAPI Generator?](#why-apostrophecms-openapi-generator)
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick Start](#quick-start)
- [How It Works](#how-it-works)
- [Commands & Usage](#commands--usage)
- [Client SDK Generation](#client-sdk-generation)
- [Authentication](#authentication)
- [Configuration](#configuration)
- [Development Workflow](#development-workflow)
- [Contributing](#contributing)
- [Community & Support](#community--support)

---

## Why ApostropheCMS OpenAPI Generator?

- **🎯 Your Complete API**: Documents YOUR content types, custom pieces, and routes—not just core CMS
- **🚀 Zero Configuration**: Works instantly with any ApostropheCMS 4+ project
- **🔍 Total Discovery**: Automatically finds all routes, schemas, and Pro module endpoints
- **📱 Production-Ready SDKs**: Generate enterprise-grade clients in TypeScript, Python, PHP, and 20+ other languages
- **⚡ Live Documentation**: Interactive testing environment for your specific API endpoints
- **🛠️ DevOps Ready**: Built for modern development workflows and CI/CD pipelines
- **🔧 Extensible**: Custom field mappers and route filtering for project-specific needs

## Requirements

- ApostropheCMS 4.0 or higher
- Node.js 22+
- For SDK generation:
  - **Java 8+** (required for npx or global usage)
  - **Optional:** global installation of `@openapitools/openapi-generator-cli` for faster repeat runs (uses `npx` by default)
  - **Optional:** Docker (if you prefer not to install Java). The generator will use the official `openapitools/openapi-generator-cli` image, which is pulled automatically the first time you run it.

## Installation

```bash
npm install @apostrophecms/openapi-generator
```

## Quick Start

1. **Add to your ApostropheCMS project:**
Configure the module in your `app.js` file:
```javascript
import apostrophe from 'apostrophe';

apostrophe({
  root: import.meta,
  shortName: 'my-project',
  modules: {
    '@apostrophecms/openapi-generator': {}
  }
});
```

2. **Generate your OpenAPI spec:**

```bash
node app openapi-generator:generate
```

3. **View interactive documentation:**

```bash
node app openapi-generator:docs --open
```

4. **Generate a client SDK:**

```bash
# Generate TypeScript client
node app openapi-generator:generateSDK typescript
```

5. **Use your generated SDK:**

```bash
# Copy the generated client to your frontend/application project
cp -r generated/typescript/* ../my-frontend-app/src/api/

# Install dependencies and build (varies by language)
cd ../my-frontend-app && npm install && npm run build
```

## How It Works

The generator uses a three-step process:

1. **Route Discovery**: Scans ApostropheCMS's route registry to find all registered endpoints
2. **Schema Discovery**: Examines module schemas to understand your content types
3. **Intelligent Merging**: Combines discoveries with a comprehensive base specification

### What Gets Discovered

**Routes:**
- All core ApostropheCMS API endpoints
- ApostropheCMS Pro module endpoints
- Your custom piece type endpoints
- Custom routes added via ApostropheCMS route methods (e.g. `restApiRoutes(self)`)

**Schemas:**
- Built-in piece types (User, Image, File, etc.)
- Your custom piece types
- Extended modules with additional fields
- Widget schemas and field definitions

### Base Specification

The generator includes a comprehensive base specification with:
- Core API documentation
- Complete field type definitions for all ApostropheCMS fields
- Standard error responses and authentication schemes
- Security configurations for all endpoints
- Organized tag groups and parameter definitions

Your discovered content extends this base rather than replacing it.

### Output Structure

Generated specs include:

- **Complete route coverage**: Every endpoint in your project
- **Semantic operation IDs**: `listArticles`, `getUser`, `createImage`
- **Logical grouping**: Operations grouped by resource type
- **Rich schemas**: Full property definitions with validation
- **Standard responses**: Consistent error handling and success responses

## Commands & Usage

| Command | Description | Key Options |
|------------|-------------|-------------|
| `generate` | Generate OpenAPI specification | `--output=FILE`, `--dry-run`, `--routes-only`, `--schemas-only`, `--verbose` |
| `validate` | Validate OpenAPI specification | `--spec=FILE` |
| `docs` | Serve interactive documentation | `--open` |
| `generateSDK` | Generate client SDKs | `<language>`, `--output=DIR`, `--props=PROPS`, `--config=FILE` |

### Basic Generation

```bash
# Generate complete OpenAPI spec (outputs to openapi/apostrophecms-openapi.yaml)
node app openapi-generator:generate

# Custom output file
node app openapi-generator:generate --output=my-api.yaml
```

### Development & Testing

```bash
# See what routes would be discovered (no file output)
node app openapi-generator:generate --routes-only

# See what schemas would be discovered (no file output)
node app openapi-generator:generate --schemas-only

# Preview everything without writing files
node app openapi-generator:generate --dry-run

# Get detailed error information
node app openapi-generator:generate --verbose
```

### File Validation
Once you have an OpenAPI file on disk, you can use the `validate` task to confirm that it is still a **valid OpenAPI 3.1 specification**.

This command does **not** regenerate or modify your spec — it only runs validation against an existing file, which is useful when:

- You manually edited the generated spec (e.g., added custom descriptions or examples).
- You renamed/moved the spec file and want to be sure it’s still valid before publishing it to Postman, Redoc, or other tooling.
- You want a quick check in CI to fail the build if someone introduces an invalid change.

```bash
# Validate existing specification using either the default
node app openapi-generator:validate
# Or custom output file name
node app openapi-generator:validate --spec=custom-spec.yaml
```

### Documentation

```bash
# Serve interactive documentation
node app openapi-generator:docs
node app openapi-generator:docs --open
```

## Client SDK Generation

Generate type-safe client libraries in multiple languages for easy API integration.

### Supported Languages

The generator supports 20+ languages, with these three built into the CLI for common use cases:

- **TypeScript**: Universal for modern web frontends and Node.js backends
- **Python**: Dominant in data science, automation, and content migration workflows—essential for ETL processes and AI-powered content applications
- **PHP**: The web's most widely-used server-side language, powering 77% of websites—critical for existing LAMP stack integrations and legacy system migrations

### SDK Requirements

The `generateSDK` task automatically uses **npx**, so in most cases you don't need to install anything extra.
All you need is **Java 8+** available on your system.

The generator tries multiple approaches in order of convenience:

1. **NPX (default)**
   - Runs `npx @openapitools/openapi-generator-cli` automatically
   - Downloads the generator each time you use it
   - Works out of the box with Java 8+ installed

2. **Global install (optional)** — *faster repeat runs*
   - Install once with:
     ```bash
     npm install @openapitools/openapi-generator-cli
     ```
   - Also requires Java 8+
   - Speeds up subsequent SDK generations since the generator is cached locally

3. **Docker (optional)** — *no Java required*
   - Uses the official `openapitools/openapi-generator-cli` Docker image
   - Great if you prefer not to install Java locally
   - Requires Docker installed and running

**Check your Java installation** (needed for npx or global):
```bash
java -version
```
If not installed, get OpenJDK 8+ from your package manager or [OpenJDK](https://openjdk.org).

### Quick SDK Generation

```bash
# TypeScript/JavaScript client with Axios
node app openapi-generator:generateSDK typescript

# Python client
node app openapi-generator:generateSDK python

# PHP client
node app openapi-generator:generateSDK php
```

### Advanced SDK Options

Use any [OpenAPI Generator](https://openapi-generator.tech/docs/generators) language:

```bash
# Java client with custom properties
node app openapi-generator:generateSDK java --props "groupId=com.example,artifactId=apostrophe-client"

# Go client with configuration file
node app openapi-generator:generateSDK go --config=./go-config.json

# Kotlin client
node app openapi-generator:generateSDK kotlin
```

### What's Included in Generated SDKs

Every generated SDK comes with comprehensive documentation and features:

**Rich Documentation:**
- **Complete API documentation** in the `docs/` folder
- **Comprehensive README** with examples for every endpoint
- **Language-appropriate type safety** (full TypeScript definitions, Python type hints, strongly-typed clients for Go/Rust/Java)
- **Authentication helpers** and error handling examples

**Client Features by Language:**

**TypeScript / JavaScript:**
- Modern `async/await` with Axios HTTP client
- Full TypeScript definitions with IntelliSense support
- Works in both Node.js and browser environments
- Tree-shakable imports for optimal bundle size

**Python:**
- Standard Python package with pip compatibility
- Type hints for better IDE support (where applicable)
- Perfect for data migration scripts and ETL processes
- Excellent for AI/ML applications working with your content

**PHP:**
- Composer-friendly package structure
- Basic type annotations and IDE autocomplete
- Great for WordPress → ApostropheCMS migration scripts
- Server-rendered applications and legacy system integration

### Using Your Generated SDK

1. **Copy to your project:**
```bash
# Copy TypeScript client
cp -r generated/typescript/* ../my-frontend-app/src/api/

# Copy Python client
cp -r generated/python/* ../my-python-app/apostrophe_client/

# Copy PHP client
cp -r generated/php/* ../my-php-app/src/ApostropheCMS/
```

2. **Install and build:**
```bash
# TypeScript
cd ../my-frontend-app && npm install && npm run build

# Python
cd ../my-python-app && pip install -r requirements.txt

# PHP
cd ../my-php-app && composer install
```

3. **Use in your application:**

**TypeScript/JavaScript Example:**
**TypeScript/JavaScript Example:**
```typescript
import 'dotenv/config';
import { Configuration, ArticlesApi, UsersApi, EventsApi } from './generated-client';

const config = new Configuration({
  basePath: process.env.APOSTROPHE_BASE_URL || 'http://localhost:3000/api/v1',
  apiKey: process.env.APOSTROPHE_API_KEY || 'your-api-key-here'
});

// Resource-scoped clients for your custom content types
const articles = new ArticlesApi(config);
const events = new EventsApi(config);
const users = new UsersApi(config);

async function run() {
  // --- Your Custom Articles ---
  // List articles with filtering
  const articleList = await articles.listArticles(1, 10);
  console.log('Articles:', articleList.data);

  // Create a new article
  const newArticle = await articles.createArticle({
    title: 'My New Article',
    body: 'Article content here...',
    tags: ['news', 'updates'],
    publishedAt: new Date().toISOString()
  });
  console.log('Created article:', newArticle.data);

  // --- Your Custom Events ---
  // Get upcoming events
  const upcomingEvents = await events.listEvents(1, 5);
  console.log('Upcoming events:', upcomingEvents.data);

  // --- Users ---
  // List users
  const userList = await users.userList();
  console.log('Users:', userList.data);
}

try {
  await run();
} catch (err: any) {
  console.error(err?.response?.data ?? err.message);
}
```

**Python Example:**
```python
# Python
from apostrophecms_client import DefaultApi, Configuration

config = Configuration(host="https://your-apostrophe-site.com/api/v1")
api = DefaultApi(config)

articles = api.list_articles()
```

**PHP Example:**
```php
<?php
// PHP
use ApostropheCMS\DefaultApi;
use ApostropheCMS\Configuration;

$config = new Configuration();
$config->setHost('https://your-apostrophe-site.com/api/v1');
$api = new DefaultApi($config);

$articles = $api->listArticles();
```

## Authentication

The generated SDKs support all ApostropheCMS authentication methods:

### API Keys
```javascript
const config = new Configuration({
  basePath: 'http://localhost:3000/api/v1',
  apiKey: 'your-api-key-here'
});
```

### Bearer Tokens
```javascript
const config = new Configuration({
  basePath: 'http://localhost:3000/api/v1',
  accessToken: 'your-jwt-token'
});
```

### Session-based Authentication
For browser-based applications, you can use standard session cookies alongside the API:
```javascript
const config = new Configuration({
  basePath: 'http://localhost:3000/api/v1',
  withCredentials: true // Include session cookies
});
```

**Authentication Priority:**
- **API key** (query string): pass `apiKey` into `Configuration({ apiKey: '…' })`
- **Bearer token**: pass `accessToken` into `Configuration({ accessToken: '…' })`
- **Session/cookie**: enable `withCredentials` and run in an environment that includes the session cookie

### Security

All generated endpoints include appropriate security schemes:

```yaml
security:
  - ApiKeyAuth: []
  - BearerAuth: []
  - SessionAuth: []
  - {} # Allows unauthenticated access as fallback
```

This provides multiple authentication options while maintaining compatibility with the ApostropheCMS authentication systems.

## Configuration

The generator works without configuration, but you can customize its behavior:

```javascript
// In your module configuration
'@apostrophecms/openapi-generator': {
  options: {
    // Exclude specific routes
    openapiRoutes: {
      exclude: ['debug', 'internal-test', 'admin-only']
    },

    // Custom field type mappings
    openapiFieldMappers: {
      customColor: (field) => ({
        type: 'string',
        pattern: '^#(?:[0-9a-fA-F]{3}){1,2}$',
        description: field.help || 'Hex color value'
      }),

      geoLocation: (field) => ({
        type: 'object',
        properties: {
          lat: { type: 'number', minimum: -90, maximum: 90 },
          lng: { type: 'number', minimum: -180, maximum: 180 }
        },
        required: ['lat', 'lng'],
        description: field.help || 'Geographic coordinates'
      })
    }
  }
}
```

### Route Exclusion

Use `openapiRoutes.exclude` to filter out individual routes or entire resources:

```javascript
openapiRoutes: {
  exclude: [
    'debug',               // Excludes any route containing this string
    'article/publish',     // Excludes specific route
    'internal'             // Excludes entire internal resource
  ]
}
```

**Filtering Logic:**
- **Partial matches**: `'debug'` excludes `/api/v1/user/debug-stats`
- **Specific routes**: `'article/publish'` excludes only `POST /api/v1/article/{id}/publish`
- **Entire resources**: `'internal'` excludes all internal endpoints

### Custom Field Mappers

Define how custom field types should appear in the OpenAPI schema:

```javascript
openapiFieldMappers: {
  // Override built-in field types
  email: (field) => ({
    type: 'string',
    format: 'email',
    pattern: '^[^@]+@[^@]+\\.[^@]+$',
    description: field.help || 'Email address with enhanced validation'
  }),

  // Handle project-specific field types
  geoLocation: (field) => ({
    type: 'object',
    properties: {
      lat: { type: 'number' },
      lng: { type: 'number' }
    },
    description: field.help || 'Geographic coordinates'
  })
}
```

## Development Workflow

### 1. During Development
```bash
# Regenerate docs as you add custom fields/pieces
node app openapi-generator:generate
node app openapi-generator:docs --open  # Test your new endpoints
```

### 2. Frontend Integration
```bash
# Update your frontend client when schema changes
node app openapi-generator:generateSDK typescript
cp -r generated/typescript/* ../my-next-app/src/lib/api/
cd ../my-next-app && npm run build
```

### 3. CI/CD Integration
```bash
# Add to your deployment pipeline
node app openapi-generator:validate  # Ensure spec is valid
node app openapi-generator:generateSDK typescript --output=../frontend/src/api
```

### 4. API Testing & Validation
```bash
# Mock server for development
npm install @stoplight/prism-cli
prism mock openapi/apostrophecms-openapi.yaml

# Mock server runs at http://localhost:4010
```

## Contributing

We welcome contributions to improve the generator:

1. **Fork the repository** and create a feature branch
2. **Make your changes** and add tests
3. **Run tests** to ensure everything works
4. **Submit a pull request** with a clear description

Please ensure all changes:
- Follow existing code patterns
- Include appropriate tests
- Update documentation if needed
- Work with the latest ApostropheCMS version

## Community & Support

- **[Discord Community](https://discord.com/invite/HwntQpADJr)** - Get help from other developers
- **[GitHub Issues](https://github.com/apostrophecms/openapi-generator/issues)** - Report bugs or request features
- **[Documentation](https://docs.apostrophecms.org/)** - Comprehensive guides and API reference
- **[Professional Support](https://apostrophecms.com/contact-us)** - Enterprise support and consulting

---

## 💎 Ready for Enterprise Features?

**Need advanced API capabilities for your development workflow?** This free OpenAPI generator works great with [**ApostropheCMS Pro**](https://apostrophecms.com/extensions?autocomplete=&license=pro) extensions to document enterprise-grade endpoints:

### 🚀 **ApostropheCMS Pro Features**
- [**🤖 SEO Assistant**](https://apostrophecms.com/extensions/seo-assistant) - AI-powered content optimization with dedicated API endpoints
- [**📄 Document Versions**](https://apostrophecms.com/extensions/document-version) - Complete revision management and rollback APIs
- [**🔐 Advanced Permissions**](https://apostrophecms.com/extensions/advanced-permission) - Granular access control with user group management
- [**🌍 Automatic Translation**](https://apostrophecms.com/extensions/automatic-translation) - Instant multilingual content generation with AI-powered translations

**Create an account** on Apostrophe Workspaces and upgrade to [**ApostropheCMS Pro**](https://app.apostrophecms.com/login) or **[contact our team](https://apostrophecms.com/contact-us)** to learn more about ApostropheCMS Pro licensing and unlock enterprise-grade API endpoints that will enhance your documentation and development capabilities.

---

<div>
  <p>Made with ❤️ by the <a href="https://apostrophecms.com">ApostropheCMS</a> team. <strong>Found this useful? <a href="https://github.com/apostrophecms/openapi-generator">Give us a star on GitHub!</a> ⭐</strong>
  </p>
</div>

---

### Overview (`packages/passport-bridge/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Apostrophe Passport Bridge</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a><img src="https://img.shields.io/badge/ASTRO%20READY-FF5D01.svg?style=for-the-badge&logo=Astro&labelColor=000000" alt="Astro Ready">
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/passport-bridge/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

**Enable enterprise-grade single sign-on (SSO) and social login** for your ApostropheCMS applications. Seamlessly integrate with Google Workspace, GitHub, GitLab, Auth0, and dozens of other identity providers to streamline user authentication and improve security.

## Why Passport Bridge?

- **🔐 Enterprise Security**: Leverage your existing identity infrastructure (Google Workspace, Azure AD, Okta)
- **⚡ Zero Password Fatigue**: Users log in once with credentials they already know and trust
- **🛡️ Reduced Security Risk**: Eliminate password storage and management on your site
- **👥 Team-Ready**: Perfect for organizations where users already have company accounts
- **🚀 Developer Friendly**: Works with 500+ [Passport.js strategies](https://www.passportjs.org/) with minimal configuration
- **💰 Cost Effective**: Reduce support overhead from password resets and account management

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```bash
npm install @apostrophecms/passport-bridge
# Example: Google OAuth (many other providers available)
npm install --save passport-google-oauth20
```

Most modules that have "passport" in the name and let you log in via a third-party website will work.

## Usage

Enable the `@apostrophecms/passport-bridge` module in the `app.js` file:

```javascript
import apostrophe from 'apostrophe';

apostrophe ({
  root: import.meta,
  // Configuring baseUrl is mandatory for this module. For local dev
  // testing you can set it to http://localhost:3000 while in production
  // it must be real and correct
  baseUrl: 'http://myproductionurl.com',
  shortName: 'my-project',
  modules: {
    '@apostrophecms/passport-bridge': {}
  }
});
```

Then configure the module in `modules/@apostrophecms/passport-bridge/index.js` in your project folder:

```javascript
export default {
  // In modules/@apostrophecms/passport-bridge/index.js
  options: {
    strategies: [
      {
        // You must npm install --save this module in your project first
        module: 'passport-google-oauth20',
        options: {
          // Options for passport-google-oauth20
          clientID: process.env.GOOGLE_CLIENT_ID,
          clientSecret: process.env.GOOGLE_CLIENT_SECRET
        },
        // Ignore users whose email address does not match this domain
        // according to the identity provider
        emailDomain: 'YOUR-DOMAIN-HERE.com',
        // Use the user's email address as their identity
        match: 'email',
        // Strategy-specific options that must be passed to the authenticate middleware.
        // See the documentation of the strategy module you are using
        authenticate: {
          // 'email' for the obvious, 'profile' for the displayName (for the create option)
          scope: [ 'email', 'profile' ]
        }
      }
    ]
  }
};
```

> ⚠️ Since we're not using the `create` option, users must actually exist in
> Apostrophe with the same username or email address, depending on the
> `match` option. If you want to automatically create users in Apostrophe,
> see [creating users on demand](#creating-users-on-demand) below.

### Working with passport strategies that expect different arguments to the `verify` callback

All passport strategies expect us to provide a `verify` callback. By default, `@apostrophecms/passport-bridge` passes its `findOrCreateUser` function as the `verify` callback, which works for many strategies including `passport-oauth2`, `passport-github2` and `passport-gitlab2`. For other strategies, you can pass an explicit `verify` option which will remap the strategy `verify` method to our `@apostrophecms/passport-bridge` `findOrCreateUser` method.

This method is responsible for retrieving the user in the ApostropheCMS database, or creating it. It is the `@apostrophecms/passport-bridge` equivalent of the strategy `verify` method.

For example, for `passport-oauth2`, the module's documentation shows the following:

```javascript
// https://www.passportjs.org/packages/passport-oauth2/
passport.use(new OAuth2Strategy({
    authorizationURL: 'https://www.example.com/oauth2/authorize',
    tokenURL: 'https://www.example.com/oauth2/token',
    clientID: EXAMPLE_CLIENT_ID,
    clientSecret: EXAMPLE_CLIENT_SECRET,
    callbackURL: "http://localhost:3000/auth/example/callback"
  },
  function(accessToken, refreshToken, profile, cb) {
    User.findOrCreate({ exampleId: profile.id }, function (err, user) {
      return cb(err, user);
    });
  }
));
```

The second parameter of the strategy is the stratey `verify` method (`accessToken`, `refreshToken`, `profile`, `done`).

The default value for the `verify` option is equivalent to the following

```javascript
module.exports = {
  // In modules/@apostrophecms/passport-bridge/index.js
  options: {
    strategies: [
      {
        module: 'passport-oauth2|passport-github2|passport-gitlab2',
        options: {
          // ...
          // Default value for the verify option
          // verify: findOrCreateUser =>
          //   async (req, accessToken, refreshToken, profile, done) =>
          //     findOrCreateUser(req, accessToken, refreshToken, profile, done)
          }
        },
        // ...
      }
    ]
  }
};
```

If you're using `passport-auth0` or any other auth strategy for which the strategy `verify` method is different, please use the new `@apostrophecms/passport-bridge` `verify` option.

For instance, the `passport-auth0` module has a `verify` method that expects different arguments:

```javascript
// https://www.passportjs.org/packages/passport-auth0/
const Auth0Strategy = require('passport-auth0');
const strategy = new Auth0Strategy({
     // ...
     state: false
  },
  function(accessToken, refreshToken, extraParams, profile, done) {
    // ...
  }
);
```

To solve for this, you can do the following:

```javascript
module.exports = {
  // In modules/@apostrophecms/passport-bridge/index.js
  options: {
    strategies: [
      {
        module: 'passport-auth0',
        options: {
          // ...
          verify: findOrCreateUser =>
            async (req, accessToken, refreshToken, extraParams, profile, done) =>
              findOrCreateUser(req, accessToken, refreshToken, profile, done)
          }
        },
        // ...
      }
    ]
  }
};
```

If provided, the `verify` option must be a function.

That function accepts the normal verify callback from the passport bridge module, and returns an alternative function which accepts `req`, plus the arguments that are typical for `passport-auth0` or the strategy of your choice, and then invokes the normal verify callback with the arguments it expects, returning the result.

### Working with `oidc-client` and other passport strategies that don't follow typical patterns

Some passport strategies are more challenging than others. In particular, OIDC is best implemented with the [oidc-client](https://www.npmjs.com/package/oidc-client) module, but there are several challenges:

* Because of the way it is exported, the strategy cannot simply be `require`d for you by passport-bridge.
* The strategy does not accept a simple object of parameters for initialization.
* You will likely want to use the built-in discovery feature.
* The `verify` function has a different pattern.
* OIDC's standard "claims" are named differently from the profile properties commonly seen with oauth2-based strategies.

To solve for all of these, use the `factory` option. Here is a complete solution for `oidc-client`.

First, make sure you have the prerequisites:

* `npm install openid-client`
* `npm install @apostrophecms/passport-bridge`
* Add `@apostrophecms/passport-bridge: {}` to your `modules` section in `app.js`
* Set the `OIDC_ISSUER`, `OIDC_CLIENT_ID`, and `OIDC_CLIENT_SECRET` environment variables

*Note that `OIDC_ISSUER` should be the URL of an identity provider that supports OIDC discovery, which most
OIDC providers do.*

Now configure the passport bridge module:

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Adding login links for a traditional ApostropheCMS project

The easiest way to enable login is to use the `loginLinks` async component in your template:

```markup
{% component "@apostrophecms/passport-bridge:loginLinks" %}
```

This component will output links that attempt to bring the user back to the same page after login, and to keep them in the same locale even if your site has separate hostnames configured for separate locales.

You can override this template's markup by copying `views/loginLinks.html` from this npm module to your project-level `modules/@apostrophecms/passport-bridge/views` folder.

You can also determine the login URLs by invoking the `@apostrophecms/passport-bridge:list-urls` task, however this method does not give you a way to preserve the current URL or redirect back to the current locale's hostname.

### Using login links with headless ApostropheCMS and an Astro Frontend

When using `@apostrophecms/passport-bridge` with an Astro frontend (via [`@apostrophecms/apostrophe-astro`](https://apostrophecms.com/extensions/apostrophe-astro)), the built-in `loginLinks` component won't work since Astro handles template rendering instead of Nunjucks.

**1. Configure Astro Proxy Routes**

First, configure your Astro frontend to proxy authentication routes to ApostropheCMS. In your `astro.config.mjs` within the `apostrophe` configuration:

```javascript
integrations: [
  apostrophe({
    aposHost: 'http://localhost:3000',
    proxyRoutes: [
      '/auth/[...slug]'
    ]
    // remainder of configuration
  })
]
```

This ensures all authentication-related routes (such as `/auth/google/login`, `/auth/github/login`, `/auth/gitlab/callback`, etc.) are properly forwarded to your ApostropheCMS backend.

**2. Configure Passport Bridge with Redirects**

Update your passport bridge configuration to handle successful and failed authentication redirects to your Astro frontend. The key addition is the `successRedirect` and `failureRedirect` properties in the authenticate section:

```javascript
// backend/modules/@apostrophecms/passport-bridge/index.js
export default {
  options: {
    strategies: [
      {
        // Example with Google OAuth - adapt for your chosen strategy
        module: 'passport-google-oauth20',
        options: {
          clientID: process.env.GOOGLE_CLIENT_ID,
          clientSecret: process.env.GOOGLE_CLIENT_SECRET,
          callbackURL: `${process.env.APOS_HOST || 'http://localhost:3000'}/auth/google/callback`
        },
        match: 'email',
        authenticate: {
          scope: ['email', 'profile'],
          // These redirects are crucial for Astro integration:
          successRedirect: process.env.APOS_BASE_URL || 'http://localhost:4321/',
          failureRedirect: `${process.env.APOS_BASE_URL || 'http://localhost:4321'}/login?error=oauth_failed`
        }
      }
    ]
  }
};
```

**3. Add a login link to your frontend template**

In the backend portion of the project use the CLI task to list the configured authentication URLs.

```bash
node app @apostrophecms/passport-bridge:listUrls
```
This will provide the URL (or multiple URLs if you have multiple strategies defined) that you can add to your desired frontend component.

## Environment Variables

Make sure to set the following environment variables:

- `APOS_HOST`: Your ApostropheCMS backend URL (e.g., `http://localhost:3000`)
- `APOS_BASE_URL`: Your Astro frontend URL (e.g., `http://localhost:4321`)
- Strategy-specific variables like `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`

## Handling Authentication State

After successful authentication, users will be redirected to your Astro frontend. You can check authentication state by making requests to your ApostropheCMS backend's session endpoints or by implementing additional API routes to expose user information.

## Error Handling

The `failureRedirect` configuration will send users to your specified error page (e.g., `/login?error=oauth_failed`) where you can display appropriate error messages based on URL parameters.

### Right-to-left websites

The error page shown when a login attempt is rejected follows the text direction of the current locale. Set the `direction` option of the `@apostrophecms/login` module to `ltr` or `rtl` to force a direction for it, as for the login page itself.

### Configuring your identity provider

#### What is my oauth callback URL?

Many strategies require an oauth callback URL. To discover those, run this command line task to print the URLs for login, and for the oauth callback URLs:

```
node app @apostrophecms/passport-bridge:listUrls
```

You'll see something like:

```
These are the login URLs you may wish to link users to:

/auth/gitlab/login

These are the callback URLs you may need to configure on sites:

http://localhost:3000/auth/gitlab/callback
```

⚠️ You can use a URL like `http://localhost:3000` for testing but in production you must use your production URL. Most identity providers will reject a URL beginning with `http:` or an IP address, except for `http://localhost:3000` which is often accepted for testing purposes only.

#### Where do I get my `clientID`, `clientSecret`, etc.?

You get these from the identity provider, usually by adding an "app" to your profile or developer console. In the case of Google you will need to [create an application in the Google API console and authorize it to perform oauth logins](https://developers.google.com/). See the documentation of the passport strategy module you're using.

### Creating users on demand

If you wish you can enable automatic creation of new accounts for any user who is valid according to your login strategy, for instance any user in your Google workspace.

```javascript
module.exports = {
  // In modules/@apostrophecms/passport-bridge/index.js
  options: {
    ...
    create: {
      // If you wish to treat all valid google users in your domain as
      // admins of the site. See also `guest`, `contributor`, `editor`
      //
      role: 'admin'
    }
  }
};
```

### Beefing up the "create" option: copying extra properties

The "create" option shown above will create a user with minimal information: first name, last name, full name, username, and email address (where available).

If you wish to import other fields from the profile object provided by the passport strategy, add an `import` function to your configuration for that strategy. The `import` function receives `(profile, user)` and may copy properties from `profile` to `user` as it sees fit. It may not be an async function.

### Multiple strategies

You may enable more than one strategy at the same time. Just configure them consecutively in the `strategies` array. This means you can have login via Twitter, Google, etc. on the same site.

> ⚠️ Take care when choosing what identity providers to trust. When using single sign-on, your site's security is only as good as that of the identity provider you are trusting. If multiple strategies are enabled with `email` as the matching method, and a malicious user succeeds in creating an account with that email address that matches any of the strategies, then that is sufficient for them to log in. Most major public providers, like Facebook, Twitter or Google, do require the user to prove they control an email address before associating it with an account.

## Accessing the user's `accessToken` and `refreshToken` to make API calls

When we authenticate the user via an identity provider like `github` that has APIs
of its own, it is often desirable to call additional APIs of that provider.

Setting the `retainAccessToken` option to `true` retains the `accessToken` and `refreshToken` in Apostrophe's "safe," which is a special storage place for sensitive data associated with a user.

You can then access that data like this:

```javascript
const tokens = await self.apos.user.getTokens(req.user, 'github');
if (tokens) {
  // Use tokens.accessToken and, sometimes, tokens.refreshToken
} else {
  // Tell the user to connect with github again
}
```

A passport strategy name is always required. Unfortunately, this is not the same thing as
the npm module name. If you do not know the strategy name, check
the `strategy.js` file in the source code of the Passport strategy module you are
using, such as `passport-github`.

There is no guarantee that a particular strategy supports tokens, or requires both
`accessToken` and `refreshToken`.

Access tokens can expire. If the access token expires and the strategy you are using
supports OAuth refresh tokens (not OIDC), you can ask Apostrophe to refresh it:

```javascript
// Passing in the existing refresh token is optional, but avoids an extra database call
const { accessToken, refreshToken } = await self.apos.user.refreshTokens(req.user, 'github', refreshToken);
```

If the refresh fails, an exception is thrown. In addition, if it fails with a
"401: Unauthorized" error, the tokens are removed, so that the next call
to `getTokens` will return null.

If you need to refresh the tokens yourself by other means, you can pass in the result:

```javascript
// We obtained these new tokens by means of our own
await self.apos.user.updateTokens(req.user, 'github', { accessToken, refreshToken });
```

Passing in the existing access token and refresh token is optional, and avoids
waiting for an extra database call.

> Determining whether an access token has expired will depend on the platform-specific APIs you
are calling, but most will return a `401` status code in this situation.

To simplify this flow, use `withAccessToken`. Here is an example
where the github Octokit API is used. The API request in the nested function is first made with
the existing access token. If an exception with a `status` property equal to `401`
is thrown, the token is refreshed and updated, and the nested function is invoked again
with the new token. If the refreshed access token also fails with a `401`, the error is
allowed to throw. All other errors are allowed to pass through.

```javascript
const { Octokit } = require("@octokit/rest");

const repos = await self.apos.user.withAccessToken(req.user, 'github', async (accessToken, unauthorized) => {
  const octokit = Octokit({ auth: accessToken });
  return req.octokit.rest.repos.listForAuthenticatedUser({
    affiliation: 'owner',
    // 100 is the max allowed per page
    per_page: 100
  });
});
// Do something cool with `repos`
```

Not all APIs that expect access tokens are created equal. If the API you are calling throws
an error in this situation that doesn't have `status: 401`, you can throw a suitable
object yourself (pseudocode):

```javascript
try {
  await someStrangeAPI(accessToken);
} catch (e) {
  // Just an example, your mileage will vary
  if (e.toString().includes('unauthorized')) {
    throw {
      status: 401
    };
  } else {
    // Some other error, let it fail
    throw e;
  }
}
```

## Issues with multiple services

### Conflicting usernames

If a user is already logged in, for instance via Apostrophe's standard login screen,
and then passes through the Passport flow to log in via a second identity provider,
Passport will log the user out of the first account by default, and in most cases
will wind up creating a second account, or mistakenly reuse an account associated
with a different service.

This problem can be mitigated by setting `match` to `email` for each strategy, as long
as the user has the same email address in each case and the service in question
offers email addresses as an option.

### "Connecting" accounts without creating a second account

An individual may want to associate an ordinary Apostrophe account with a secondary service,
such as a github account, that has a different email address. Unfortunately, in this case,
simply following a link to the login URL for a second service this will log the user out of
the first account and log them into an entirely separate account based on the email address
from github when using `match: 'email'` as described above. If using `match: 'id'`, the
behavior is more consistent, but still undesirable: a separate account is always created.

This can be addressed via the following flow:

1. The user logs in normally to their Apostrophe account.

2. Await `requestConnection` to generate a confirmation link and email it
to the current user's email address. When this method resolves, the email has been
handed off for delivery, and it is appropriate to tell the user to expect it soon.

> Apostrophe must be
> [correctly configured for reliable email delivery](https://v3.docs.apostrophecms.org/guide/sending-email.html#sending-email-from-your-apostrophe-project).
> If you do not take appropriate steps to ensure this, the email probably will not get through.

```javascript
await self.apos.user.requestConnection(req, 'STRATEGY NAME HERE', {
  redirectTo: '/site/relative/url/here',
});
```

> The strategy name depends on the passport strategy in question. `passport-github` uses
> the strategy name `github`. You can find it in the source of the strategy module
> you are using and it is usually your first guess as well.

3. The user receives the email and follows the link provided.

4. The user is redirected to authorize access to their `github` account (in this example).

5. The user is redirected to the home page, or to the URL you optionally specify via
`redirectTo`. They are still logged into the original account. Their strategy-specific id
is captured in their `user` piece as `githubId` (in the case of the github strategy;
substitute the appropriate strategy name), and their tokens are available as described
earlier if `retainSessionToken: true` is set.

> Note that for security reasons, the link in the email is only valid for twenty-four hours.

### Overriding the email template

To override the email message that is sent, copy `views/connectEmail.html` from
the `@apostrophecms/passport-bridge` npm module to your project-level
`modules/@apostrophecms/passport-bridge/views` folder, and edit that template you see fit.

### Session properties

Note that when following this flow the user's original req.session properties are
preserved. Normally this is not possible, because Passport 0.6 or better always
regenerates the session on a new login.

### Logging in via the secondary strategy

In this example, a user who "connects" their account to github will be able to
"log in via github" in the future, if they so choose. Since we trust that github
maintains good security, and they proved control of the original account before
connecting with github, this is usually acceptable.

However, if you wish to block this for a particular strategy you can specify
the `login: false` option when configuring that strategy. If you take this
path, users will be able to "connect" an account using that strategy to their
original account, but will not be able to log in via that strategy alone. In this
situation the secondary strategy is present for API token access only.

### Disconnecting a strategy from an account

You can disconnect a strategy at any time:

```javascript
await self.apos.user.removeConnection(req, 'STRATEGY NAME HERE');
```

This will clear the related strategy-specific id, e.g. it will purge `githubId`
if the strategy name is `github`.

## Frequently asked questions

### Where do I `require` or `import` the passport strategy?

You *can* do that, if you use the `factory` option, as shown above. This is useful with modules that have an unusual initialization process, like `openid-client`.

But in many cases, you don't have to. Apostrophe can do it for you. Passing the module name in the appropriate entry in your `strategies` array is enough. The `options` sub-property and sometimes also the `authenticate` sub-property are useful if your chosen strategy  has options that must be passed to its `authenticate` middleware, as with Google and most others (you'll see this in the documentation of the strategy you are using).

### Can I change how users are mapped between the identity provider and my site?

If you don't like the default behavior, you can change it. The mapping is up to you. Usernames and emails are *almost* permanent, but people do change them and that can be problematic, especially if they are reused by someone else.

On the other hand, IDs are a pain to work with if you are creating users in advance and not using the `create` feature of the module.

You can set the `match` option for any strategy to one of the following choices:

#### `id`

Matches on the id of their profile as returned by the strategy module. This is most unique, however if you don't set `create`, then you'll need to find out the ids of users in advance and populate them in your database. You could do that by adding a string field to the `fields` configuration of the `@apostrophecms/user` module in your project.

To accommodate multiple strategies, If the strategy name is `google`, then the id needs to be in the `googleId` field of the user. If the strategy name is `gitlab`, the id needs to be in `gitlabId`, and so on. If you are using the `create` feature, these properties are automatically populated for you.

**The strategy name and the npm module name are not quite the same thing.** Look at the output of `node app @apostrophecms/passport-bridge:list-urls`. The word that follows `/auth` is the strategy name.

#### `email`

This will match on any email the authentication provider indicates they own, whether it is an array in the `.emails` property of their profile containing objects with `.value` properties (as with Google), an array of strings in `.emails`, or just an `email` string property. *To minimize confusion you can also set `match` to `emails` which has the same effect. Either way it will check all three cases.*

#### `username`

The default. Users are matched based on having the same username.

#### A function of your choice

If you provide a function rather than a string, it will receive the user's profile from the passport strategy, and must return a MongoDB criteria object matching the appropriate user. Do not worry about checking the `disabled` or `type` properties, Apostrophe will handle that.

### How can I reject users in a customized way?

You can set your own policy for rejecting users by passing an `accept` function for any strategy. This function takes the `profile` object provided by the passport strategy and must return `true` otherwise the user is not permitted to log in.

### How can I lock down my site by email address domain name?

You may wish to accept only users from one email domain, which is very handy if your company's email is hosted by Google (aka "G Suite", aka "Google Workspaces"). For that, also set the `emailDomain` option to the domain name you wish to allow. All others are rejected. This is very important if you are using the `create` option.

### How can I reject direct logins via Apostrophe's login form?

"This is great, but I want to disable the regular `/login` page." You can:

```javascript
// in app.js
modules: {
  '@apostrophecms/passport-bridge': {
    // As above; this is not where we disable local login...
  },
  '@apostrophecms/login': {
    // We disable it here, by configuring the built-in @apostrophecms/login module
    localLogin: false
  }
}
```

The built-in login page is powered by Passport's `local` strategy, which is added to Apostrophe by the standard `@apostrophecms/login` module. That's why we disable it there and not in `@apostrophecms/passport-bridge`'s options.

### How can I override the error page?

If login fails, for instance because you are matching on `email` but the `username` duplicates another account, or because a user is valid in Google but `emailDomain` does not match, the `error.html` template of the `apostrophe-passport` module is rendered. By default, it works, but it's pretty ugly! You'll want to customize it to your project's needs.

Like other templates in Apostrophe, you can override this template by copying it to `modules/@apostrophecms/passport-bridge/views/error.html` *in your project* (**never modify the npm module itself**). You can then extend your own layout template and so on, just as you have most likely already done for the 404 Not Found page.

### How can I redirect the standard `/login` page to one of my strategies?

Once you have disabled the regular login page, it's possible for you to decide what happens at that URL. Use the [@apostrophecms/redirect](https://npmjs.org/package/@apostrophecms/redirect) module to set it up through a nice UI, or add an Express route and a redirect in your own code.

### What if it doesn't work?

Feel free to open an issue but be sure to provide full specifics and a test project. Note that some strategies may not follow the standard practices this module is built upon. Those written by Jared Hanson, the author of Passport, or following his best practices should work well. You might want to test directly with the sample code provided with that strategy module first, to rule out problems with the module or with your configuration of it.

### How can I debug the system?

By default this module will log quite a bit of information in a development environment, using the `debug` ApostropheCMS log level. When `NODE_ENV` is production this logging is suppressed by default. See the [`@apostrophecms/log` module documentation](https://docs.apostrophecms.org/guide/logging.html) for information how to change this.

---

<div>
  <p>Made with ❤️ by the <a href="https://apostrophecms.com">ApostropheCMS</a> team. <strong>Found this useful? <a href="https://github.com/apostrophecms/passport-bridge">Give us a star on GitHub!</a> ⭐</strong>
  </p>
</div>

---

### Overview (`packages/postcss-viewport-to-container-toggle/README.md`)

# postcss-viewport-to-container-toggle 

A plugin for [PostCSS](https://github.com/postcss/postcss) that allows to toggle between viewport and container units based on the presence of a container data attribute.

## Why?

This plugin has been originally developed to allow mobile preview without using any `iframe` in order to be as close as possible to the real rendering.

For examples, let's say we have a block in our page that is taking `50vw`. 
We want in the case of the mobile preview (where body is a container), this block to take `50cqw` instead of `50vw`.

Here is what it looks like, in normal mode, our block takes `50vw` as it did before we the initial code:



In mobile preview, body being a container, this block will take `50cqw` of the container:




## Demo

This css:

```css
.hello {
    width: 100vw;
    height: 100vh;
}
```

If you set the `modifierAttr` to `data-breakpoint-preview-mode` and `containerEl` to `body` (default), it'll be converted this way:

```css
.hello {
    width: 100vw;
    height: 100vh;
}

:where(body[data-breakpoint-preview-mode]) .hello {
    width: 100cqw;
    height: 100cqh;
}
```

The purpose being here to keep the existing behavior but to make the code work compatible for containers when body is in container mode.

Here is another examples with media queries:

```css
@media only screen and (width > 600px) and (max-width: 1000px) {
  .hello {
    top: 0;
    width: 100vw;
    height: calc(100vh - 50px);
  }
  .goodbye {
    width: 100%;
    color: #fff;
    transform: translateX(20vw);
  }
}

.toto {
  width: 100vh;
  color: white;
}
```


will become:

```css
@media only screen and (width > 600px) and (max-width: 1000px) {
  :where(body:not([data-breakpoint-preview-mode])) .hello {
    top: 0;
    width: 100vw;
    height: calc(100vh - 50px);
  }
  :where(body:not([data-breakpoint-preview-mode])) .goodbye {
    width: 100%;
    color: #fff;
    transform: translateX(20vw);
  }
}

@container (width > 600px) and (max-width: 1000px) {
  .hello {
    top: 0;
    width: 100cqw;
    height: calc(100cqh - 50px);
  }
  .goodbye {
    width: 100%;
    color: #fff;
    transform: translateX(20cqw);
  }
}

.toto {
  width: 100vh;
  color: white;
}

:where(body[data-breakpoint-preview-mode]) .toto {
  width: 100cqh;
}
```

As you can see, if body has no specific attribute, the behavior stays the same. 
When adding `data-breakpoint-preview-mode`, data in media queries are converted to container units and moved to container queries.

## Installation

```bash
npm install postcss-viewport-to-container-toggle
```

## Getting started

### Webpack

```javascript
const postcssViewportToContainerToggle = require('postcss-viewport-to-container-toggle');

{
  loader: 'postcss-loader',
  options: {
    sourceMap: true,
    postcssOptions: {
      plugins: [
        [
          postcssViewportToContainerToggle({
            modifierAttr: 'data-breakpoint-preview-mode',
            containerEl: 'body',
            debug: false
          }),
          'autoprefixer'
        ]
      ]
    }
  }
}
```

### Vite

```javascript
const postcssViewportToContainerToggle = require('postcss-viewport-to-container-toggle');

{
    css: {
      postcss: {
        plugins: [
          postcssViewportToContainerToggle({
            modifierAttr: 'data-breakpoint-preview-mode',
            containerEl: 'body',
            debug: false
          })
        ]
      }
    }
}
```

### Options

* `modifierAttr`: The attribute that will be used to toggle between viewport and container units.
* `containerEl`: The element that will be used as container. Default: `body`
* `debug`: If set to `true`, will output debug information. Default: `false`
* `transform`: A function that will be called for each media query, allowing to modify its params when creating the `container`.

---

### Overview (`packages/random-words/README.md`)

# random-words

## Generate one or more common English words

`random-words` generates random words for use as sample text. We use it to generate random blog posts when testing [Apostrophe](http://apostrophecms.org).

Cryptographic-quality randomness is NOT the goal, as speed matters for generating sample text and security does not. As such, `Math.random()` is used in most cases.

The `seed` option can be used with the `generate` function for situations that require deterministic output. When given the same `seed` with the same input, `generate()` will yield deterministic results, in regards to both actual word selection and the number of words returned (when using `min` and `max`). The underlying implementation of this option utilizes the [seedrandom](https://www.npmjs.com/package/seedrandom) package as a replacement for `Math.random()`.

The `count` function can be used to calculate the total number of words in the word list that meet the specified minimum and maximum length criteria.

Installation:

    npm install random-words

Examples:

```
/* Detailed source-code truncated for AI context efficiency. */
```

---

### Overview (`packages/redirect/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Manage site redirects for ApostropheCMS</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/apostrophe/blob/main/packages/redirect/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

## Installation

First make sure you have an [Apostrophe project](https://apostrophecms.com)!

Then:

```javascript
npm install @apostrophecms/redirect
```

## Configuration

In `app.js`, add the module to your configuration:

  ```js
  require('apostrophe')({
    shortName: 'MYPROJECT',
    modules: {
      '@apostrophecms/redirect': {}
    }
  });
  ```
### `statusCode`
*Defaults to `302`*
By passing `statusCode` to your configuration you can change the default status code value.
Accepted values are `301` and `302`

```javascript
// Other modules, then...
'@apostrophecms/redirect': {
  options: {
    statusCode: 301
  }
}
```
> Note that permanent redirects are cached by Google for a long time. It is a good idea to encourage users to test with a temporary redirect first, then switch to permanent which is an SEO best practice — as long as it's correct.

### `withType`
*Defaults to `@apostrophecms/page`*
By passing `withType` to your configuration you can specify the document type your internal redirects can redirect to.

```javascript
// Other modules, then...
'@apostrophecms/redirect': {
  options: {
    withType: 'article'
  }
}
```

**Note:** Apostrophe 2 supported creating relationships to multiple doc types from a single interface. This feature doesn't yet exist in the newer versions of Apostrophe, as such redirects can only specify a single doc type to redirect to.

### `skip`

For performance the redirect check can be skipped for URLs matching certain regular expressions.
The default configuration of the `skip` option is:

```javascript
// Other modules, then...
'@apostrophecms/redirect': {
  options: {
    skip: [ /\/api\/v1\/.*/ ]
  }
}
```

If you wish to skip other patterns, we recommend keeping the default one as it speeds up API calls.

### `caseInsensitive`
*Defaults to `false`*

By passing `caseInsensitive: true` to your configuration, the "Old URL" of every redirect is matched against incoming request URLs without regard to case. This is useful when migrating from a case-insensitive web server (e.g. IIS), where redirect rules may have been entered with mixed-case slugs such as `/Events` or `/About-Us/Partners`, but inbound traffic (old bookmarks, search engine results, external links) may arrive with any casing, such as `/events` or `/EVENTS`.

```javascript
// Other modules, then...
'@apostrophecms/redirect': {
  options: {
    caseInsensitive: true
  }
}
```

When this option is enabled:

- New and edited redirects have their "Old URL" stored in lowercase automatically.
- A migration runs to lowercase the "Old URL" of any existing redirects.
- Incoming request URLs are lowercased before being matched against stored redirects (the original casing of the request is still used when substituting a wildcard match into a destination URL).

⚠️ **This option does not support "mixing" case-sensitive and case-insensitive redirects.** Enabling it lowercases all existing "Old URL" values, and this migration is **not reversible**. If you later disable `caseInsensitive`, your pre-existing redirects will only match lowercase request URLs, since their stored "Old URL" values remain lowercase. If this matters to you, you would need to manually recreate the affected redirects with their original mixed-case "Old URL" values.

## Usage

While logged in as an admin, click the "Redirects" button. A list of redirects appears, initially empty. Add as many redirects as you like. The "from" URL must begin with a `/`. The "to" URL may be anything and need not be on your site. The "description" field is for your own convenience.

### Matching the query string

By default a redirect includes any query string (the `?` and whatever follows it, up to but not including any `#`) on incoming requests when matching for redirection. 

You can toggle the "ignore query string when matching" option in a redirect definition to ignore query strings on incoming requests and only match on the base URL path. A redirect that does not use this option will always match first, so you can match various specific query strings and then have a fallback rule for other cases.

### Matching wildcards

Normally, aside from the query string, only an exact match is honored. If you wish to redirect an entire subdirectory, you may use a `*` immediately after a `/`, for example:

```
/auto/*
```

This will redirect **all** URLs beginning with `/auto/` to your destination, like this:

```
/auto/hyundai -> /fr/auto
/auto/hyundai/ioniq-5 -> /fr/auto
```

⚠️ Note that this **discards the rest of the URL**. If this is not what you want, see below for ways to avoid it.

### Exact matches always win

If you have a redirect rule with no `*`, it will always beat a redirect rule with an `*`.

For instance, if you have rules like this:

```
/auto/gm/bolt -> /en/auto/gm/bolt
/auto/* -> /fr/auto
```

Then actual redirects will play out like this:

```
/auto/gm/bolt -> /en/auto/gm/bolt
/auto/toyota/tercel -> /fr/auto
```

### Capturing wildcards

If you are creating a "URL" redirect, and not an "Internal Page" one, you may also "capture" and reuse the rest of the URL by including the `*` . This is useful when the rest of the URL is still correct.

For instance, if your "Old URL" is:

```
/auto/*
```

And you select "URL" as the redirect type and type the following in the "URL" field:

```
/fr/auto/*
```

Then redirects will occur like this:

```
/auto/hyundai -> /fr/auto/hyundai
/auto/hyundai/ioniq-5 -> /fr/auto/hyundai/ioniq-5
```

### Creating fallbacks with multiple wildcard rules

Note that if you have two redirect rules involving an `*` and one is longer than the other, **the longer match always wins.**

For example, if your redirects are set up like this:

```
/auto/hyundai/* -> /en/car/hyundai/*
/auto/* -> /fr/auto/*
```

Then redirects will play out like this:

```
/auto/mercedes -> /fr/auto/mercedes
/auto/mercedes/w123 -> /fr/auto/mercedes/w123
/auto/hyundai -> /en/car/hyundai
/auto/hyundai/ioniq-5 -> /en/car/hyundai/ioniq-5
```

### Safety concerns

Be aware that each redirect is live as soon as you save it and that it is possible to make a mess with redirects. A few obvious dangers like redirecting `/*` or `/api/v1/*` are locked out, but it is still possible to cause problems depending on your site's structure. In a pinch, you can remove unwanted redirects via the MongoDB command line client (look for `{ type: "@apostrophecms/redirect" }` in the `aposDocs` collection in MongoDB).

### Soft redirects: when you don't need this module at all

For your convenience, ApostropheCMS automatically creates "soft redirects" every time you change the slug of a page or piece, provided the document was accessed at least once at the old URL. So you shouldn't need to manually create a "hard redirect" in that situation.

## Extending the module

### Providing a fallback handler

If you wish to handle redirects in another way when this module does not find a match, you can do so by listening for the `@apostrophecms/redirect:noMatch` event. This event handler receives `req, result`. To issue a redirect, set `result.redirect` in your event handler. To issue a "raw" redirect to which any sitewide prefix is not appended automatically, set `result.rawRedirect` in your event handler. You can also set `result.status` to match your need as the status code of the redirection, default is `302`.   
If you wish to alter the target url when a redirection is about to come, like for example to change the domain, you can listen to the `@apostrophecms/redirect:beforeRedirect`. This event handler receives `req, result`. `result` will have two properties that you can alter before the redirection: `status` and `url`. **Do not** call `req.res.redirect()` yourself in your event handler in those two cases.

For example:

```javascript
// modules/redirect-fallback/index.js
module.exports = {
  handlers(self) {
    return {
      '@apostrophecms/redirect:noMatch': {
        // will be awaited, you can do queries here if needed
        async fallback(req, result) {
          if (req.url.match(/pattern/)) {
            result.redirect = '/destination';
          }
        }
      }
    }
  }
}
```

### Preempting the redirect module

If your goal is to preempt this module by making a decision to redirect differently in some cases before this module looks for a match, register your own middleware and perform the redirect there. Use `before` to specify that your own module's middleware comes first.

For example:

```javascript
// modules/early-redirect/index.js
module.exports = {
  middleware(self) {
    return {
      earlyRedirect: {
        before: '@apostrophecms/redirect',
        middleware(req, res, next) {
          if (req.url.match(/pattern/)) {
            return res.redirect('/destination');
          } else {
            return next();
          }
        }
      }
    };
  }
};
```

### Executing the middleware sooner

By default the middleware of this module that checks for redirect is executed depending on the place of this module in your modules configuration.   
You can choose to execute it before any other module by setting the `before` option, indicating before which module middleware this one should run (for example `@apostrophecms/global`).   
Note that by doing this, the above preemption will not work anymore.   


## Redirecting to other locales

It's possible to redirect from one locale to another one, with external redirections, 
since you manually define the url to redirect to.

As for internal redirects (relationships with pages), this works across locales as well, 
but keep in mind that you will only see the internal redirects that target the current locale when managing redirects. 
To find redirects that target an internal page in a different locale, switch locales before viewing "Manage Redirects."

A note for developers: a query builder called `currentLocaleTarget` hides redirects that have relationships to other locales (different from the current one).
If you want to get all redirects whatever the locale of their internal redirects you can undo this behavior using the query builder:
```javascript
const redirects = await self.apos.modules['@apostrophecms/redirect']
    .find(req)
    .currentLocaleTarget(false)
    .toArray();
```

---

### Overview (`packages/sanitize-html/README.md`)

# sanitize-html

<a href="https://apostrophecms.com/"><img src="https://raw.githubusercontent.com/apostrophecms/sanitize-html/main/logos/logo-box-madefor.png" align="right" /></a>

sanitize-html provides a simple HTML sanitizer with a clear API.

sanitize-html is tolerant. It is well suited for cleaning up HTML fragments such as those created by CKEditor and other rich text editors. It is especially handy for removing unwanted CSS when copying and pasting from Word.

sanitize-html allows you to specify the tags you want to permit, and the permitted
attributes for each of those tags. If an attribute is a known non-boolean value,
and it is empty, it will be removed. For example `checked` can be empty, but `href`
cannot.

If a tag is not permitted, the contents of the tag are not discarded. There are
some exceptions to this, discussed below in the "Discarding the entire contents
of a disallowed tag" section.

The syntax of poorly closed `p` and `img` elements is cleaned up.

`href` attributes are validated to ensure they only contain `http`, `https`, `ftp` and `mailto` URLs. Relative URLs are also allowed. Ditto for `src` attributes.

Allowing particular urls as a `src` to an iframe tag by filtering hostnames is also supported.

HTML comments are not preserved.
Additionally, `sanitize-html` escapes _ALL_ text content - this means that ampersands, greater-than, and less-than signs are converted to their equivalent HTML character references (`&` --> `&amp;`, `<` --> `&lt;`, and so on). Additionally, in attribute values, quotation marks are escaped as well (`"` --> `&quot;`).

## Requirements

sanitize-html is intended for use with Node.js and supports Node 10+. All of its npm dependencies are pure JavaScript. sanitize-html is built on the excellent `htmlparser2` module.

### Regarding TypeScript

sanitize-html is not written in TypeScript and there is no plan to directly support it. There is a community supported typing definition, [`@types/sanitize-html`](https://www.npmjs.com/package/@types/sanitize-html), however.
```bash
npm install -D @types/sanitize-html
```
If `esModuleInterop=true` is not set in your `tsconfig.json` file, you have to import it with:

```javascript
import * as sanitizeHtml from 'sanitize-html';
```

When using TypeScript, there is a minimum supported version of >=4.5 because of a dependency on the `htmlparser2` types.

Any questions or problems while using `@types/sanitize-html` should be directed to its maintainers as directed by that project's contribution guidelines.

## How to use

### Browser

*Think first: why do you want to use it in the browser?* Remember, *servers must never trust browsers.* You can't sanitize HTML for saving on the server anywhere else but on the server.

But, perhaps you'd like to display sanitized HTML immediately in the browser for preview. Or ask the browser to do the sanitization work on every page load. You can if you want to!

* Install the package:

```bash
npm install sanitize-html
```
or
```
yarn add sanitize-html
```

The primary change in the 2.x version of sanitize-html is that it no longer includes a build that is ready for browser use. Developers are expected to include sanitize-html in their project builds (e.g., webpack) as they would any other dependency. So while sanitize-html is no longer ready to link to directly in HTML, developers can now more easily process it according to their needs.

Once built and linked in the browser with other project Javascript, it can be used to sanitize HTML strings in front end code:

```javascript
import sanitizeHtml from 'sanitize-html';

const html = "<strong>hello world</strong>";
console.log(sanitizeHtml(html));
console.log(sanitizeHtml("<img src=x onerror=alert('img') />"));
console.log(sanitizeHtml("console.log('hello world')"));
console.log(sanitizeHtml("<script>alert('hello world')</script>"));
```

### Node (Recommended)

Install module from console:

```bash
npm install sanitize-html
```

Import the module:

```js
// In ES modules
import sanitizeHtml from 'sanitize-html';

// Or in CommonJS
const sanitizeHtml = require('sanitize-html');
```

Use it in your JavaScript app:

```js
const dirty = 'some really tacky HTML';
const clean = sanitizeHtml(dirty);
```

That will allow our [default list of allowed tags and attributes](#default-options) through. It's a nice set, but probably not quite what you want. So:

```js
// Allow only a super restricted set of tags and attributes
const clean = sanitizeHtml(dirty, {
  allowedTags: [ 'b', 'i', 'em', 'strong', 'a' ],
  allowedAttributes: {
    'a': [ 'href' ]
  },
  allowedIframeHostnames: ['www.youtube.com']
});
```

Boom!

### Default options

```
/* Detailed source-code truncated for AI context efficiency. */
```

### Common use cases

#### "I like your set but I want to add one more tag. Is there a convenient way?"

Sure:

```js
const clean = sanitizeHtml(dirty, {
  allowedTags: sanitizeHtml.defaults.allowedTags.concat([ 'img' ])
});
```

If you do not specify `allowedTags` or `allowedAttributes`, our default list is applied. So if you really want an empty list, specify one.

#### "What if I want to allow all tags or all attributes?"

Simple! Instead of leaving `allowedTags` or `allowedAttributes` out of the options, set either
one or both to `false`:

```js
allowedTags: false,
allowedAttributes: false
```

#### "What if I want to allow empty attributes, even for cases like href that normally don't make sense?"

Very simple! Set `nonBooleanAttributes` to `[]`.

```js
nonBooleanAttributes: []
```

#### "What if I want to remove all empty attributes, including valid ones?"

Also very simple! Set `nonBooleanAttributes` to `['*']`.

**Note**: This will break common valid cases like `checked` and `selected`, so this is
unlikely to be what you want. For most ordinary HTML use, it is best to avoid making
this change.

```js
nonBooleanAttributes: ['*']
```

#### "What if I don't want to allow *any* tags?"

Also simple!  Set `allowedTags` to `[]` and `allowedAttributes` to `{}`.

```js
allowedTags: [],
allowedAttributes: {}
```

#### "What if I want disallowed tags to be escaped rather than discarded?"

If you set `disallowedTagsMode` to `discard` (the default), disallowed tags are discarded. Any text content or subtags are still included, depending on whether the individual subtags are allowed.

If you set `disallowedTagsMode` to `completelyDiscard`, disallowed tags and any content they contain are discarded. Any subtags are still included, as long as those individual subtags are allowed.

If you set `disallowedTagsMode` to `escape`, the disallowed tags are escaped rather than discarded. Any text or subtags are handled normally.

If you set `disallowedTagsMode` to `recursiveEscape`, the disallowed tags are escaped rather than discarded, and the same treatment is applied to all subtags, whether otherwise allowed or not.

#### "What if I want to allow only specific values on some attributes?"

When configuring the attribute in `allowedAttributes` simply use an object with attribute `name` and an allowed `values` array. In the following example `sandbox="allow-forms allow-modals allow-orientation-lock allow-pointer-lock allow-popups allow-popups-to-escape-sandbox allow-scripts"` would become `sandbox="allow-popups allow-scripts"`:

```js
allowedAttributes: {
  iframe: [
    {
      name: 'sandbox',
      multiple: true,
      values: ['allow-popups', 'allow-same-origin', 'allow-scripts']
    }
  ]
}
```

With `multiple: true`, several allowed values may appear in the same attribute, separated by spaces. Otherwise the attribute must exactly match one and only one of the allowed values.

#### "What if I want to maintain the original case for SVG elements and attributes?"

If you're incorporating SVG elements like `linearGradient` into your content and notice that they're not rendering as expected due to case sensitivity issues, it's essential to prevent `sanitize-html` from converting element and attribute names to lowercase. This situation often arises when SVGs fail to display correctly because their case-sensitive tags, such as `linearGradient` and attributes like `viewBox`, are inadvertently lowercased.

To address this, ensure you set `lowerCaseTags: false` and `lowerCaseAttributeNames: false` in the parser options of your sanitize-html configuration. This adjustment stops the library from altering the case of your tags and attributes, preserving the integrity of your SVG content.

```js
allowedTags: [ 'svg', 'g', 'defs', 'linearGradient', 'stop', 'circle' ],
allowedAttributes: false,
parser: {
  lowerCaseTags: false,
  lowerCaseAttributeNames: false
}
```

### Wildcards for attributes

You can use the `*` wildcard to allow all attributes with a certain prefix:

```js
allowedAttributes: {
  a: [ 'href', 'data-*' ]
}
```

Also you can use the `*` as name for a tag, to allow listed attributes to be valid for any tag:

```js
allowedAttributes: {
  '*': [ 'href', 'align', 'alt', 'center', 'bgcolor' ]
}
```

## Additional options

### Allowed CSS Classes

If you wish to allow specific CSS classes on a particular element, you can do so with the `allowedClasses` option. Any other CSS classes are discarded.

This implies that the `class` attribute is allowed on that element.

```javascript
// Allow only a restricted set of CSS classes and only on the p tag
const clean = sanitizeHtml(dirty, {
  allowedTags: [ 'p', 'em', 'strong' ],
  allowedClasses: {
    'p': [ 'fancy', 'simple' ]
  }
});
```

Similar to `allowedAttributes`, you can use `*` to allow classes with a certain prefix, or use `*` as a tag name to allow listed classes to be valid for any tag:

```js
allowedClasses: {
  'code': [ 'language-*', 'lang-*' ],
  '*': [ 'fancy', 'simple' ]
}
```

Furthermore, regular expressions are supported too:

```js
allowedClasses: {
  p: [ /^regex\d{2}$/ ]
}
```

If `allowedClasses` for a certain tag is `false`, all the classes for this tag will be allowed.

> Note: It is advised that your regular expressions always begin with `^` so that you are requiring a known prefix. A regular expression with neither `^` nor `$` just requires that something appear in the middle.

### Allowed CSS Styles

If you wish to allow specific CSS _styles_ on a particular element, you can do that with the `allowedStyles` option. Simply declare your desired attributes as regular expression options within an array for the given attribute. Specific elements will inherit allowlisted attributes from the global (`*`) attribute. Any other CSS classes are discarded.

**You must also use `allowedAttributes`** to activate the `style` attribute for the relevant elements. Otherwise this feature will never come into play.

**When constructing regular expressions, don't forget `^` and `$`.** It's not enough to say "the string should contain this." It must also say "and only this."

**URLs in inline styles are NOT filtered by any mechanism other than your regular expression.**

```javascript
const clean = sanitizeHtml(dirty, {
        allowedTags: ['p'],
        allowedAttributes: {
          'p': ["style"],
        },
        allowedStyles: {
          '*': {
            // Match HEX and RGB
            'color': [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/],
            'text-align': [/^left$/, /^right$/, /^center$/],
            // Match any number with px, em, or %
            'font-size': [/^\d+(?:px|em|%)$/]
          },
          'p': {
            'font-size': [/^\d+rem$/]
          }
        }
      });
```

### Discarding text outside of ```<html></html>``` tags

Some text editing applications generate HTML to allow copying over to a web application. These can sometimes include undesirable control characters after terminating `html` tag. By default sanitize-html will not discard these characters, instead returning them in sanitized string. This behaviour can be modified using `enforceHtmlBoundary` option.

Setting this option to true will instruct sanitize-html to discard all characters outside of `html` tag boundaries -- before `<html>` and after `</html>` tags.

```js
enforceHtmlBoundary: true
```

### htmlparser2 Options

sanitize-html is built on `htmlparser2`. By default the only option passed down is `decodeEntities: true`. You can set the options to pass by using the parser option.

**Security note: changing the `parser` settings can be risky.** In particular, `decodeEntities: false` has known security concerns and a complete test suite does not exist for every possible combination of settings when used with `sanitize-html`. If security is your goal we recommend you use the defaults rather than changing `parser`, except for the `lowerCaseTags` option.

```javascript
const clean = sanitizeHtml(dirty, {
  allowedTags: ['a'],
  parser: {
    lowerCaseTags: true
  }
});
```
See the [htmlparser2 wiki](https://github.com/fb55/htmlparser2/wiki/Parser-options) for the full list of possible options.

### Transformations

What if you want to add or change an attribute? What if you want to transform one tag to another? No problem, it's simple!

The easiest way (will change all `ol` tags to `ul` tags):

```js
const clean = sanitizeHtml(dirty, {
  transformTags: {
    'ol': 'ul',
  }
});
```

The most advanced usage:

```js
const clean = sanitizeHtml(dirty, {
  transformTags: {
    'ol': function(tagName, attribs) {
      // My own custom magic goes here
      return {
        tagName: 'ul',
        attribs: {
          class: 'foo'
        }
      };
    }
  }
});
```

You can specify the `*` wildcard instead of a tag name to transform all tags.

There is also a helper method which should be enough for simple cases in which you want to change the tag and/or add some attributes:

```js
const clean = sanitizeHtml(dirty, {
  transformTags: {
    'ol': sanitizeHtml.simpleTransform('ul', {class: 'foo'}),
  }
});
```

The `simpleTransform` helper method has 3 parameters:

```js
simpleTransform(newTag, newAttributes, shouldMerge)
```

The last parameter (`shouldMerge`) is set to `true` by default. When `true`, `simpleTransform` will merge the current attributes with the new ones (`newAttributes`). When `false`, all existing attributes are discarded.

You can also add or modify the text contents of a tag:

```js
const clean = sanitizeHtml(dirty, {
  transformTags: {
    'a': function(tagName, attribs) {
      return {
        tagName: 'a',
        text: 'Some text'
      };
    }
  }
});
```
For example, you could transform a link element with missing anchor text:
```js
<a href="http://somelink.com"></a>
```
To a link with anchor text:
```js
<a href="http://somelink.com">Some text</a>
```

### Filters

You can provide a filter function to remove unwanted tags. Let's suppose we need to remove empty `a` tags like:

```html
<a href="page.html"></a>
```

We can do that with the following filter:

```js
sanitizeHtml(
  '<p>This is <a href="http://www.linux.org"></a><br/>Linux</p>',
  {
    exclusiveFilter: function(frame) {
      return frame.tag === 'a' && !frame.text.trim();
    }
  }
);
```

The filter function can also return the string `"excludeTag"` to only remove the tag, while keeping its content. For example, you can remove tags for anchors with invalid links:

```js
sanitizeHtml(
  'This is a <a href="javascript:alert(123)">bad link</a> and a <a href="https://www.linux.org">good link</a>',
  {
    exclusiveFilter: function(frame) {
      // the href attribute is removed by the URL protocol check
      return frame.tag === 'a' && !frame.attribs.href ? 'excludeTag' : false;
    }
  }
);
// Output: 'This is a bad link and a <a href="https://www.linux.org">good link</a>'
```

The `frame` object supplied to the callback provides the following attributes:

- `tag`: The tag name, i.e. `'img'`.
- `attribs`: The tag's attributes, i.e. `{ src: "/path/to/tux.png" }`.
- `text`: The text content of the tag.
- `mediaChildren`: Immediate child tags that are likely to represent self-contained media (e.g., `img`, `video`, `picture`, `iframe`). See the `mediaTags` variable in `src/index.js` for the full list.
- `tagPosition`: The index of the tag's position in the result string.

You can also process all text content with a provided filter function. Let's say we want an ellipsis instead of three dots.

```html
<p>some text...</p>
```

We can do that with the following filter:

```js
sanitizeHtml(
  '<p>some text...</p>',
  {
    textFilter: function(text, tagName) {
      if (['a'].indexOf(tagName) > -1) return //Skip anchor tags

      return text.replace(/\.\.\./, '&hellip;');
    }
  }
);
```

Note that the text passed to the `textFilter` method is already escaped for safe display as HTML. You may add markup and use entity escape sequences in your `textFilter`.

### Iframe Filters

If you would like to allow iframe tags but want to control the domains that are allowed through, you can provide an array of hostnames and/or array of domains that you would like to allow as iframe sources. This hostname is a property in the options object passed as an argument to the sanitize-html function.

These arrays will be checked against the html that is passed to the function and return only `src` urls that include the allowed hostnames or domains in the object. The url in the html that is passed must be formatted correctly (valid hostname) as an embedded iframe otherwise the module will strip out the src from the iframe.

Make sure to pass a valid hostname along with the domain you wish to allow, i.e.:

```js
allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com'],
allowedIframeDomains: ['zoom.us']
```

You may also specify whether or not to allow relative URLs as iframe sources.

```js
allowIframeRelativeUrls: true
```

Note that if unspecified, relative URLs will be allowed by default if no hostname or domain filter is provided but removed by default if a hostname or domain filter is provided.

**Remember that the `iframe` tag must be allowed as well as the `src` attribute.**

For example:

```javascript
const clean = sanitizeHtml('<p><iframe src="https://www.youtube.com/embed/nykIhs12345"></iframe><p>', {
  allowedTags: [ 'p', 'em', 'strong', 'iframe' ],
  allowedClasses: {
    'p': [ 'fancy', 'simple' ],
  },
  allowedAttributes: {
    'iframe': ['src']
  },
  allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com']
});
```

will pass through as safe whereas:

```javascript
const clean = sanitizeHtml('<p><iframe src="https://www.youtube.net/embed/nykIhs12345"></iframe><p>', {
  allowedTags: [ 'p', 'em', 'strong', 'iframe' ],
  allowedClasses: {
    'p': [ 'fancy', 'simple' ],
  },
  allowedAttributes: {
    'iframe': ['src']
  },
  allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com']
});
```

or

```javascript
const clean = sanitizeHtml('<p><iframe src="https://www.vimeo/video/12345"></iframe><p>', {
  allowedTags: [ 'p', 'em', 'strong', 'iframe' ],
  allowedClasses: {
    'p': [ 'fancy', 'simple' ],
  },
  allowedAttributes: {
    'iframe': ['src']
  },
  allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com']
});
```

will return an empty iframe tag.

If you want to allow any subdomain of any level you can provide the domain in `allowedIframeDomains`

```javascript
// This iframe markup will pass through as safe.
const clean = sanitizeHtml('<p><iframe src="https://us02web.zoom.us/embed/12345"></iframe><p>', {
  allowedTags: [ 'p', 'em', 'strong', 'iframe' ],
  allowedClasses: {
    'p': [ 'fancy', 'simple' ],
  },
  allowedAttributes: {
    'iframe': ['src']
  },
  allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com'],
  allowedIframeDomains: ['zoom.us']
});
```

### Script Filters

Similarly to iframes you can allow a script tag on a list of allowlisted domains

```js
const clean = sanitizeHtml('<script src="https://www.safe.authorized.com/lib.js"></script>', {
    allowedTags: ['script'],
    allowedAttributes: {
        script: ['src']
    },
    allowedScriptDomains: ['authorized.com'],
})
```

You can allow a script tag on a list of allowlisted hostnames too

```js
const clean = sanitizeHtml('<script src="https://www.authorized.com/lib.js"></script>', {
    allowedTags: ['script'],
    allowedAttributes: {
        script: ['src']
    },
    allowedScriptHostnames: [ 'www.authorized.com' ],
})
```

### Allowed URL schemes

By default, we allow the following URL schemes in cases where `href`, `src`, etc. are allowed:

```js
[ 'http', 'https', 'ftp', 'mailto' ]
```

You can override this if you want to:

```js
sanitizeHtml(
  // teeny-tiny valid transparent GIF in a data URL
  '<img src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" />',
  {
    allowedTags: [ 'img', 'p' ],
    allowedSchemes: [ 'data', 'http' ]
  }
);
```

You can also allow a scheme for a particular tag only:

```js
allowedSchemes: [ 'http', 'https' ],
allowedSchemesByTag: {
  img: [ 'data' ]
}
```

And you can forbid the use of protocol-relative URLs (starting with `//`) to access another site using the current protocol, which is allowed by default:

```js
allowProtocolRelative: false
```

### SVG animations of URL attributes are discarded

For security reasons, if your `allowedTags` includes the SVG animation elements (`animate`, `animateColor`, `animateMotion`, `animateTransform` and `set`), note that an animation which targets a URL attribute is always discarded:

```html

<animate attributeName="href" values="#safe;javascript:alert(1)" dur=".01s" fill="freeze">
```

An animation element carries no URL itself. It names the attribute it animates with `attributeName` and supplies the new value in `values`, `from`, `to` or `by`, which the browser copies into the target attribute after sanitization. Scheme checking those values is not sufficient, because `values` is a semicolon-separated *list* of destinations, so we discard the animation instead whenever `attributeName` selects `href`, `xlink:href` or any other attribute listed in `allowedSchemesAppliedToAttributes`.

Animations of attributes that are not URLs, such as `fill` or `opacity`, are unaffected.

### Discarding the entire contents of a disallowed tag

Normally, with a few exceptions, if a tag is not allowed, all of the text within it is preserved, and so are any allowed tags within it.

The exceptions are:

`style`, `script`, `textarea`, `option`, `xmp`

If you wish to replace this list, for instance to discard whatever is found
inside a `noscript` tag, use the `nonTextTags` option:

```js
nonTextTags: [ 'style', 'script', 'textarea', 'option', 'xmp', 'noscript' ]
```

Note that if you use this option you are responsible for stating the entire list. This gives you the power to retain the content of `textarea`, if you want to.

The content still gets escaped properly, with the exception of the `script` and
`style` tags. *Allowing either `script` or `style` leaves you open to XSS
attacks. Don't do that* unless you have good reason to trust their origin.
sanitize-html will log a warning if these tags are allowed, which can be
disabled with the `allowVulnerableTags: true` option.

### Choose what to do with disallowed tags

Instead of discarding, or keeping text only, you may enable escaping of the entire content:

```js
disallowedTagsMode: 'escape'
```

This will transform `<disallowed>content</disallowed>` to `&lt;disallowed&gt;content&lt;/disallowed&gt;`

Valid values are: `'discard'` (default), `'completelyDiscard'` (remove disallowed tag's content), `'escape'` (escape the tag) and `'recursiveEscape'` (to escape the tag and all its content).

#### Discard disallowed but the inner content of disallowed tags is kept.

If you set `disallowedTagsMode` to `discard`, disallowed tags are discarded but the inner content of disallowed tags is kept.

```js
disallowedTagsMode: 'discard'
```
This will transform `<disallowed>content</disallowed>` to `content`

#### Discard entire content of a disallowed tag

If you set `disallowedTagsMode` to `completelyDiscard`, disallowed tags and any text they contain are discarded. This also discards top-level text. Any subtags are still included, as long as those individual subtags are allowed.

```js
disallowedTagsMode: 'completelyDiscard'
```

This will transform `<disallowed>content <allowed>content</allowed> </disallowed>` to `<allowed>content</allowed>`

#### Escape the disallowed tag and all its children even for allowed tags.

if you set `disallowedTagsMode` to `recursiveEscape`, disallowed tags and their children will be escaped even for allowed tags:

```js
disallowedTagsMode: `recursiveEscape`
```

This will transform `<disallowed>hello<p>world</p></disallowed>` to `&lt;disallowed&gt;hello&lt;p&gt;world&lt;/p&gt;&lt;/disallowed&gt;`

#### Escape the disallowed tag, including all its attributes.

By default, attributes are not preserved when tags are escaped. You can set `preserveEscapedAttributes` to `true` to 
keep the attributes, which will also be escaped and therefore have no effect on the browser.

```js
preserveEscapedAttributes: true
```

### Ignore style attribute contents

Instead of discarding faulty style attributes, you can allow them by disabling the parsing of style attributes:

```js
parseStyleAttributes: false
```

This will transform `<div style="invalid-prop: non-existing-value">content</div>` to `<div style="invalid-prop: non-existing-value">content</div>` instead of stripping it: `<div>content</div>`

By default the parseStyleAttributes option is true.

When you disable parsing of the style attribute (`parseStyleAttributes: false`) and you pass in options for the allowedStyles property, an error will be thrown. This combination is not permitted.

we recommend sanitizing content server-side in a Node.js environment, as you cannot trust a browser to sanitize things anyway. Consider what a malicious user could do via the network panel, 
the browser console, or just by writing scripts that submit content similar to what your JavaScript submits. But if you really need to run it on the client in the browser, 
you may find you need to disable parseStyleAttributes. This is subject to change as it is [an upstream issue with postcss](https://github.com/postcss/postcss/issues/1727), not sanitize-html itself.

### Restricting deep nesting

You can limit the depth of HTML tags in the document with the `nestingLimit` option:

```javascript
nestingLimit: 6
```

This will prevent the user from nesting tags more than 6 levels deep. Tags deeper than that are stripped out exactly as if they were disallowed. Note that this means text is preserved in the usual ways where appropriate.

### Routing warnings to your own logger

sanitize-html writes its own diagnostics - the vulnerable tag notice above, and the style parsing notice in the browser - to the console. If your application has a logging pipeline of its own, pass any console-shaped object as the `logger` option and they are delivered to it instead:

```javascript
sanitizeHtml(dirty, {
  logger: myLogger // an object with debug, info, warn and error methods
});
```

Any of the four methods that your object does not provide falls back to the console, and without the option at all the console is still the destination, exactly as before.

This matters for applications that write structured logs: a raw `console.warn` bypasses their filtering and formatting, and puts unparseable text on a stream that is expected to be one JSON object per line.

### Advanced filtering

For more advanced filtering you can hook directly into the parsing process using tag open and tag close events.

The `onOpenTag` event is triggered when an opening tag is encountered. It has two arguments:
- `tagName`: The name of the tag.
- `attribs`: An object containing the tag's attributes, e.g. `{ src: "/path/to/tux.png" }`.

The `onCloseTag` event is triggered when a closing tag is encountered. It has the following arguments:
- `tagName`: The name of the tag.
- `isImplied`: A boolean indicating whether the closing tag is implied (e.g. `<p>foo<p>bar`) or explicit (e.g. `<p>foo</p><p>bar</p>`).

For example, you may want to add spaces around a removed tag, like this:
```js
const allowedTags = [ 'b' ];
let addSpace = false;
const sanitizedHtml = sanitizeHtml(
  'There should be<div><p>spaces</p></div>between <b>these</b> words.',
  {
    allowedTags,
    onOpenTag: (tagName, attribs) => {
      addSpace = !allowedTags.includes(tagName);
    },
    onCloseTag: (tagName, isImplied) => {
      addSpace = !allowedTags.includes(tagName);
    },
    textFilter: (text) => {
      if (addSpace) {
        addSpace = false;
        return ' ' + text;
      }
      return text;
    }
  }
);
```

In this example, we are setting a flag when a tag that will be removed has been opened or closed. Then we use the `textFilter` to modify the text to include spaces. The example should produce:
```
There should be spaces between <b>these</b> words.
```

This is a simplified example that is not meant to be production-ready. For your specific case, you may want to keep track of currently open tags, using the open and close events to push and pop items on the stack, or only insert spaces next to a subset of disallowed tags.

## About ApostropheCMS

sanitize-html was created at [P'unk Avenue](https://punkave.com) for use in [ApostropheCMS](https://apostrophecms.com), an open-source content management system built on Node.js. If you like sanitize-html you should definitely check out ApostropheCMS.

## Support

Feel free to open issues on [github](https://github.com/apostrophecms/sanitize-html).

---

### Overview (`packages/scheduled-publishing/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Scheduled Publishing</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Test status" href="https://github.com/apostrophecms/apostrophe/actions">
      <img alt="GitHub Workflow Status (branch)" src="https://img.shields.io/github/workflow/status/apostrophecms/apostrophe/Tests/main?label=Tests&labelColor=000000&style=for-the-badge">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/module-template/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

This module allows you to schedule publishing of your pieces (which includes pages) to specific dates.

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```
npm install @apostrophecms/scheduled-publishing
```

## Usage

Configure the Scheduled Publishing module in the `app.js` file:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    '@apostrophecms/scheduled-publishing': {}
  }
});
```

Editors can now schedule publication and un-publication times for documents. Note that un-publishing a document means it is no longer available except in draft form. Since the home page must always be available, the home page cannot be scheduled for un-publishing.

For publication and un-publication to actually occur, you'll need to run the appropriate command line task on a periodic basis, as described below.

## Using the command line task

To actually publish or unpublish the documents at scheduled times, you must execute the appropriate command line task:

```bash
node app @apostrophecms/scheduled-publishing:update
```

Typically you will want to schedule this to run periodically using a standard Linux scheduling tool like `cron`. For instance, here is a crontab entry to run the task once per hour:

```bash
# At the top of every hour
0 * * * * (cd /path/to/your/apostrophe/project && node app @apostrophecms/scheduled-publishing:update)
```

You can schedule the task to run more frequently if you plan to schedule publication times in the middle of the hour. Note that the task is safe to run as frequently as you wish, as it won't do anything if no documents are scheduled to be published yet.

For more information, see [how to use cron on Linux](https://opensource.com/article/21/7/cron-linux).

> If you are using our `stagecoach` utility for deployment, don't forget `/current` at the end of the path.

---

### Overview (`packages/security-headers/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Security Headers</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Test status" href="https://github.com/apostrophecms/security-headers/actions">
      <img alt="GitHub Workflow Status (branch)" src="https://img.shields.io/github/workflow/status/apostrophecms/security-headers/tests/main?label=Tests&logo=github&labelColor=000&style=for-the-badge">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/security-headers/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

This module sends the modern HTTP security headers that are expected by various security scanners. The default settings are strict in most regards, so see below for adjustments you may wish to make.

## Warning

Some third-party services, including Google Analytics, Google Fonts, YouTube and Vimeo, are included as allowed sources for HTML, CSS and scripts in the standard configuration. However even with these permissive settings not all third-party services compatible with Apostrophe will be permitted out of the box. For instance, because they are used relatively rarely, no special testing has been done for Wufoo or Infogram. You should test your site and configure custom policies accordingly.

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```
npm install @apostrophecms/security-headers
```

## Usage

Activate the `@apostrophecms/security-headers` module in the project's `app.js` file:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    '@apostrophecms/security-headers': {}
  }
});
```

The headers to be sent can be overriden by setting them as options to the module in the project-level `modules/@apostrophecms/security-headers/index.js` file:

```javascript
// in modules/@apostrophecms/security-headers/index.js
module.exports = {
  options: {
    'X-Frame-Options': 'DENY'
  }
};
```

You can also disable a header entirely by setting the option for it to `false`.

### Guide to configuring headers

Here are the headers that are sent by default, with their default values:

```javascript
module.exports = {
  // in modules/@apostrophecms/security-headers/index.js
  options: {
    // 1 year. Do not include subdomains as they could be unrelated sites
    'Strict-Transport-Security': 'max-age=31536000',
    // You may also set to DENY, however future Apostrophe modules may use
    // iframes to present previews etc.
    'X-Frame-Options': 'SAMEORIGIN',
    // If you have issues with broken images etc., make sure content type
    // configuration is correct for your production server
    'X-Content-Type-Options': 'nosniff',
    // Very new. Used to entirely disable browser features like geolocation per host.
    // Since we don't know what your site may need, we don't try to set this
    // header by default (false means "don't send the header")
    'Permissions-Policy': false,
    // Don't send a "Referer" (sp) header unless the new URL shares the same
    // origin. You can set this to `false` if you prefer cross-origin "Referer"
    // headers be sent. Apostrophe does not rely on them
    'Referrer-Policy': 'same-origin',
    // `true` means it should be computed according to the rules below.
    // You may also pass your own string, or `false` to not send this header.
    // The `policies` option and all of its sub-options are ignored unless
    // `Content-Security-Policy` is `true`.
    'Content-Security-Policy': true
  }
};
```

For more information about these security headers see the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers#security).

### Configuring the `Content-Security-Policy` header

The `Content-Security-Policy` header is more complex than the others. As described [in MDN](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy), this header is used to determine which hosts are permitted as a source for stylesheets, scripts, images and more. While you can certainly set a fixed value for it, the default response for it is the result of merging together options for individual use cases ("policies") as shown below. This makes it easier to think about what you need to allow for a particular purpose. The actual header sent contains all of the permissions required to satisfy all of the policies.

The default configuration is shown below. You do **not** have to copy and paste the entire default configuration, only the policies you wish to change. Any policy you specify explicitly at project level overrides all of the default settings shown below for that
policy. You may set one to `false` to completely disable it. You may also introduce entirely new policies.

Policies of the same type from different sub-options are merged, with the largest set of keywords and hosts enabled. This is done because browsers do not support more than one style-src policy, for example, but do support specifying several hosts.

Note the `HOSTS` wildcard which matches all expected hosts that Apostrophe is aware of, including `baseUrl` settings, CDN hosts and locale-specific hostnames.

```javascript
module.exports = {
  // in modules/@apostrophecms/security-headers/index.js
  options: {
    policies: {
      general: {
        'default-src': 'HOSTS',
        // Because it is necessary for some of the output of our rich text editor
        'style-src': "HOSTS 'unsafe-inline'",
        'script-src': 'HOSTS',
        'font-src': 'HOSTS',
        'img-src': 'HOSTS blob:',
        'frame-src': "'self'"
      },

      // Set this sub-option to false if you wish to forbid google fonts
      googleFonts: {
        'style-src': 'fonts.googleapis.com',
        'font-src': 'fonts.gstatic.com'
      },

      // Set this sub-option to false if you do not use the video widget
      oembed: {
        'frame-src': '*.youtube.com *.vimeo.com',
        'img-src': '*.ytimg.com'
      },

      // Set this sub-option to false if you do not wish to permit Google Analytics and
      // Google Adsense
      analytics: {
        'default-src': '*.google-analytics.com *.doubleclick.net',
        // Note that use of google tag manager by definition brings in scripts from
        // more third party sites and you will need to add policies for them
        'script-src': '*.google-analytics.com *.doubleclick.net *.googletagmanager.com',
      }
    }
  }
};
```

#### Inline style attributes are still allowed

Note that `style-src` is set by default to permit inline style attributes. This is currently necessary because the output of the tiptap rich text editor used in Apostrophe involves inline
styles in some cases.

#### Inline script tags are **not** allowed

Inline script tags (those without a `src`) are **not** allowed by the default policies shown above, as this is one of the primary benefits of using the `Content-Security-Policy` header. If you do choose to output an inline script tag, you may do so if you use the "nonce" template argument provided by this module, like this:

```
<script nonce="{{ nonce }}">
  // inline script code here
</script>
```

The `nonce` template variable is always available in Nunjucks templates when using this module. It is generated uniquely for each new page request.

The nonce mechanism ensures that the script tag was the intention of the developer and is not an XSS attack. However please note that you will lose the security benefits of this if you output other user-entered data inside the script tag without properly escaping it, for instance using the `| json` nunjucks filter.

Setting the nonce attribute on a DOM element has no ill effects when this module is not in use, so it is OK to set it in inline script tags output by npm modules intended for use with or without this module.

### Custom policies

You may add any number of custom policies. Any sub-option nested in your
`policies` option is treated just like the standard cases above and merged into
the final `Content-Security-Policy` header.

### Disabling standard policies

You may set any of the standard policy sub-options above to `false` to disable them.

### Hosts wildcard

Note that the `HOSTS` wildcard is automatically replaced with a list of hosts including any `baseUrl` host, localized hostnames for specific locales, CDN hosts from your uploadfs configuration, and `self`. Use of this wildcard is recommended as Apostrophe pushes assets to Amazon S3, CDNs, etc. when configured to do so, including scripts and stylesheets.

You may override the normal list of hosts for `HOSTS` by setting the `legitimateHosts` option to an array of strings. You can also extend or override the `legitimateHosts` method of this module at project level.

For example:

```javascript
module.exports = {
  // in modules/@apostrophecms/security-headers/index.js
  options: {
    legitimateHosts: [ 'mysite.com', 'www.mysite.com', 'surprise.mysite.com' ]
  }
};
```

---

### Overview (`packages/seo/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>SEO Tools for ApostropheCMS</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/seo/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

**Ensure your content gets found by search engines and AI systems** with comprehensive SEO management for ApostropheCMS. Essential meta fields, Google Analytics integration, automated `robots.txt` and `llms.txt` generation — everything you need to boost your search rankings, control AI training usage, and drive organic traffic.

## Why ApostropheCMS SEO Tools?

- **🎯 Complete SEO Control**: Essential meta fields for titles, descriptions, and canonical URLs
- **📊 Analytics Ready**: Built-in Google Analytics, Tag Manager, and Site Verification integration
- **🤖 Smart Automation**: Automatic robots.txt generation with granular control
- **⚡ Performance Optimization**: Critical font preloading improves Core Web Vitals scores
- **🔍 Search Engine Friendly**: Proper canonical linking prevents duplicate content issues
- **📈 Marketing Team Ready**: Easy-to-use interface for non-technical content creators
- **💰 E-commerce Ready**: Rich structured data for products, offers, and pricing
- **🤖 AI-Ready**: Automatic llms.txt generation for AI policy transparency (proposed standard for forward-thinking SEO)


## Compatibility

This version requires the latest ApostropheCMS. When adding this module to an existing project, run `npm update` to ensure all ApostropheCMS modules are up-to-date.

---

## TL;DR: Quick Setup

1. Install the module:

   ```bash
   npm install @apostrophecms/seo
   ```
2. Set your base URL (`APOS_BASE_URL`).
3. Enable Google Analytics or Tag Manager in `@apostrophecms/global`.
4. Optionally install `@apostrophecms/sitemap` for XML sitemap generation.
5. Configure `robots.txt` and `llms.txt` via global settings.
6. Choose schema types per page in the SEO tab.
7. Validate your structured data using [Google’s Rich Results Test](https://search.google.com/test/rich-results).

---

## Table of Contents

- [Why ApostropheCMS SEO Tools?](#why-apostrophecms-seo-tools)
- [TL;DR: Quick Setup](#tldr-quick-setup)
- [Table of Contents](#table-of-contents)
- [Installation](#installation)
- [Before You Start](#before-you-start)
  - [✅ Works Immediately (No Setup Required)](#-works-immediately-no-setup-required)
  - [⚙️ Requires Content Structure Setup](#️-requires-content-structure-setup)
- [Core Features](#core-features)
  - [Automatic SEO Fields](#automatic-seo-fields)
  - [Google Analytics \& Tag Manager](#google-analytics--tag-manager)
  - [Automated Robots.txt](#automated-robotstxt)
  - [AI Crawler Control (llms.txt)](#ai-crawler-control-llmstxt)
  - [Sitemap Integration](#sitemap-integration)
  - [Performance Optimization](#performance-optimization)
- [Essential Configuration](#essential-configuration)
  - [Setting the Base URL](#setting-the-base-url)
  - [Google Analytics Integration](#google-analytics-integration)
  - [Google Tag Manager Integration](#google-tag-manager-integration)
  - [Google Site Verification](#google-site-verification)
  - [Sitemap Installation](#sitemap-installation)
- [Structured Data \& Schema Types](#structured-data--schema-types)
  - [How It Works](#how-it-works)
  - [Choosing the Right Schema](#choosing-the-right-schema)
  - [Quick Schema Selection Guide](#quick-schema-selection-guide)
  - [Best Practices](#best-practices)
- [AI \& Search Strategy](#ai--search-strategy)
  - [Understanding Crawler Types](#understanding-crawler-types)
  - [Recommended Configuration for Most Sites](#recommended-configuration-for-most-sites)
  - [For Maximum AI Visibility](#for-maximum-ai-visibility)
  - [For Maximum Privacy/Protection](#for-maximum-privacyprotection)
  - [Understanding robots.txt vs llms.txt](#understanding-robotstxt-vs-llmstxt)
  - [Site Search Query Parameter](#site-search-query-parameter)
- [Advanced Configuration](#advanced-configuration)
  - [Disabling SEO Fields](#disabling-seo-fields)
  - [Setting Default Schema Types](#setting-default-schema-types)
  - [Canonical Link Configuration](#canonical-link-configuration)
  - [Pagination Support](#pagination-support)
  - [Custom 404 Tracking](#custom-404-tracking)
  - [Paywalled Content](#paywalled-content)
  - [Custom Field Mappings](#custom-field-mappings)
- [Implementation Guidelines for Developers](#implementation-guidelines-for-developers)
  - [Field Flexibility](#field-flexibility)
  - [Flexible Field Formats](#flexible-field-formats)
  - [Debug Mode](#debug-mode)
  - [Featured Images](#featured-images)
  - [Author Information](#author-information-1)
  - [URL Requirements](#url-requirements)
  - [Date Fields](#date-fields)
  - [Listing Pages (Item List)](#listing-pages-item-list)
  - [Summary: Key Document-Level Fields by Schema Type](#summary-key-document-level-fields-by-schema-type)
  - [Schema Types That Require No Developer Fields](#schema-types-that-require-no-developer-fields)
  - [Debugging Structured Data](#debugging-structured-data)
- [Extending the SEO Module with Custom JSON-LD Schemas](#extending-the-seo-module-with-custom-json-ld-schemas)
  - [1. Register a Custom Schema on `@apostrophecms/seo`](#1-register-a-custom-schema-on-apostrophecmsseo)
  - [2. Add the Type to the Schema Dropdown (`@apostrophecms/seo-fields-doc-type`)](#2-add-the-type-to-the-schema-dropdown-apostrophecmsseo-fields-doc-type)
  - [3. Add Fields on `@apostrophecms/doc-type`](#3-add-fields-on-apostrophecmsdoc-type)
- [Performance Optimization](#performance-optimization-1)
  - [Critical Font Preloading](#critical-font-preloading)
- [1. Place Your Fonts in a Module’s `public/` Directory](#1-place-your-fonts-in-a-modules-public-directory)
- [2. Configure the SEO Module to Preload Fonts](#2-configure-the-seo-module-to-preload-fonts)
  - [Mobile Optimization](#mobile-optimization)
- [Field Reference](#field-reference)
- [🚀 Ready for AI-Powered SEO?](#-ready-for-ai-powered-seo)
  - [✨ SEO Assistant Pro Features](#-seo-assistant-pro-features)
- [🏢 Managing Multiple Sites?](#-managing-multiple-sites)
  - [✨ Assembly Multisite Features](#-assembly-multisite-features)
- [Roadmap](#roadmap)

## Installation

```bash
npm install @apostrophecms/seo
```

Configure the module in your `app.js` file:

```javascript
import apostrophe from 'apostrophe';

apostrophe({
  root: import.meta,
  shortName: 'my-project',
  modules: {
    '@apostrophecms/seo': {}
  }
});
```

**Important:** For proper SEO functionality, you must also configure your site's base URL. See the [Essential Configuration](#essential-configuration) section below.

## Before You Start

This module provides SEO functionality at two levels:

### ✅ Works Immediately (No Setup Required)

These features work out-of-the-box with any ApostropheCMS site:

- **Essential meta tags**: Title, description, robots
- **Analytics**: Google Analytics, Tag Manager, Site Verification
- **Site control**: Automated robots.txt and llms.txt generation
- **Basic structured data**: WebPage and CollectionPage schemas

**You can start using these features right away** - just install the module and configure your global SEO settings.

### ⚙️ Requires Content Structure Setup

Advanced structured data types need [fields with specific names](#summary-required-fields-by-schema-type) in your content types:

- **Article, Review**: Work best with author information
- **Product, HowTo**: Benefit from featured images for richer results
- **Recipe**: **Requires** a featured image (Google requirement)
- **VideoObject**: **Requires** thumbnail and upload date (Google requirement)
- **JobPosting**: Complex schema with many required fields for Google for Jobs
- **Event, LocalBusiness**: Need address/location fields
- **FAQPage, QAPage**: Need question and answer content

> [!IMPORTANT]
> ### ⚠️ Important: Field Names Must Match Expected Names for Structured Data
>
> Some structured data types **require specific field names** so that the SEO module can automatically find the right values in your documents. If your content types use different field names, and you don’t configure them accordingly, your JSON-LD may be missing required properties and **fail validation in Google’s tools**.
>
> The module also supports **fallbacks and flexible formats** (e.g., string vs. relationship fields) for authors, images, descriptions, and dates.
>
> 👉 For the full list of supported field names, fallbacks, and recommended patterns, see **[Field Flexibility](#field-flexibility)**.
>
> You can also map existing field names to required field names using the [`seoFieldMappings`](#custom-field-mappings) option of the module.
>
> If you plan to rely heavily on structured data (especially for products, recipes, jobs, or video), it’s a good idea to:
>
> - Design your content types with these field names in mind, or
> - Refactor existing types to match, before enabling those schema types in production.

## Core Features

### Automatic SEO Fields



The module automatically adds an "SEO" tab to all page and piece editors containing:

- **Title Tag**: Custom titles for search results (falls back to page title)
- **Meta Description**: Compelling descriptions that appear in search results
- **Robots Meta Tag**: Control search engine indexing and following behavior
- **Canonical URLs**: Prevent duplicate content penalties
- **Schema Type Selection**: Choose the appropriate structured data type for your content

### Google Analytics & Tag Manager

Built-in integration with Google Analytics, Google Tag Manager, and Google Site Verification. Simply enable the options you need and add your tracking IDs through the global configuration interface.

**Supported integrations:**
- Google Analytics (GA4) tracking
- Google Tag Manager for advanced marketing campaigns
- Google Site Verification for Search Console

See [Essential Configuration](#essential-configuration) below for setup instructions.

### Automated Robots.txt

The module automatically provides a `/robots.txt` route with strategic control over both traditional search engines and AI crawlers. Configure through global settings with five control modes:

**Available Modes:**

1. **Allow All (Search + AI)** - Default open access for all crawlers
2. **Allow Search, Block AI Training** - Maintains search rankings while protecting content from AI training by AI agents that choose to respect this standard
3. **Selective AI Crawlers** - Granular control over individual AI crawlers that support this standard
4. **Block All** - Prevents all indexing
5. **Custom** - Write your own robots.txt content

> ⚠️ Make sure that if you block all indexing during development, make sure to change the policy when you launch your final site.

**Selective Mode Crawlers:**
For fine-grained control, use Selective mode to choose specific AI crawlers. These crawlers currently indicate they honor robots.txt directives.
- **GPTBot** (OpenAI ChatGPT training)
- **ChatGPT-User** (OpenAI real-time browsing)
- **Google-Extended** (Google AI training)
- **ClaudeBot** (Anthropic AI training)
- **Claude-User** (Anthropic real-time browsing)
- **PerplexityBot** (Perplexity AI)
- **CCBot** (Common Crawl datasets)
- **Applebot-Extended** (Apple Intelligence)
- **FacebookBot** (Meta AI)
- **anthropic-ai** (Anthropic general)

Traditional search engines (Googlebot, Bingbot) are always allowed unless using "Block All" mode.

> ### ⚠️ Important Note About Robots.txt Enforcement
> The `robots.txt` standard is a **voluntary convention**, not a security mechanism.
> While the crawlers listed here currently **state that they honor `robots.txt`**, real-world behavior can differ:
>
> - Some crawlers only respect `robots.txt` in certain contexts (e.g., indexing vs. real-time browsing).
> - User-agent policies may change over time.
> - New crawlers may appear that do not publicly disclose their behavior.
>
> ApostropheCMS provides fine-grained controls for compliant crawlers, but it **cannot guarantee enforcement** against bots that ignore `robots.txt` or do not implement the standard. We recommend periodically reviewing crawler policies to ensure ongoing compliance.


**Technical Notes:**
- A physical `robots.txt` file in your `public/` directory for a single-site project, or `sites/public` and `dashboard/public` directories for multisite will override these settings
- All modes preserve traditional search engine access (except "Block All")
- See [AI & Search Strategy](#ai--search-strategy) for detailed configuration guidance

**Related:** This module also provides automated [llms.txt generation](#ai-crawler-control-llmstxt) for policy communication.

> **Note:** A global `Disallow: /` in `robots.txt` may cause some AI crawlers to skip reading `llms.txt`, depending on their behavior, but the file remains publicly accessible.

### AI Crawler Control (llms.txt)

The module automatically provides an `/llms.txt` route to communicate your AI usage policies. This is **complementary** to `robots.txt`:

- **robots.txt**: Enforceable crawler access control (blocks/allows bots)
- **llms.txt**: Informational policy declaration (informs AI systems about usage terms)

> **⚠️ Important Note:** `llms.txt` is a proposed standard that is not yet widely adopted. As of this writing, most LLMs and AI systems do not respect or read `llms.txt` files. This feature is included for forward-thinking SEO strategies and may gain broader adoption in the future. For enforceable crawler control, rely on `robots.txt` settings.

**Configuration options:**

1. **Allow AI Crawling (Default)**: Generates a comprehensive `llms.txt` file that permits responsible AI crawling with site structure information

2. **Disallow AI Training**: States content should NOT be used for AI training datasets but permits real-time search and retrieval with attribution

3. **Custom Content**: Write your own `llms.txt` policies from scratch

4. **Disabled**: Returns 404 for `/llms.txt` requests

**Best Practice:** Combine both tools strategically:
- Use **robots.txt** (Allow Search, Block AI Training mode) to technically enforce access
- Use **llms.txt** (Disallow AI Training mode) to clearly communicate your policies
- This dual approach provides both technical enforcement and clear policy communication

**What's included in the generated file:**
- Site name and description
- AI training policy (based on your selection)
- Organization information
- Links to main pages with descriptions
- Available content types
- Technical details about structured data
- Sitemap reference (if @apostrophecms/sitemap is installed)

### Sitemap Integration

Works seamlessly with `@apostrophecms/sitemap` to generate XML sitemaps that help search engines discover and index your content. The sitemap is automatically referenced in the `/llms.txt` file for AI crawlers.

**Hreflang:** This module does not output `<link rel="alternate" hreflang="...">` tags. Generating them at render time requires fetching each locale's true URL, which is expensive. Google accepts hreflang via XML sitemap as an equivalent signal, and [`@apostrophecms/sitemap`](https://apostrophecms.com/extensions/sitemap) handles this without the per-request overhead.

### Performance Optimization

**Critical Font Preloading** - Automatically preload critical fonts to improve Core Web Vitals scores and SEO performance:
- Reduces Cumulative Layout Shift (CLS) by preventing font-loading jank
- Improves Largest Contentful Paint (LCP) with faster font rendering
- Optimizes First Contentful Paint (FCP) by eliminating render-blocking requests

Configure once in your `app.js` and the module handles the rest. See [Performance Optimization](#performance-optimization) for details.

## Essential Configuration

### Setting the Base URL

**This step is required** for proper canonical link generation and SEO performance. If using [ApostropheCMS hosting](https://apostrophecms.com/hosting), this is set automatically.

**Via environment variable (recommended):**
```bash
export APOS_BASE_URL=https://yoursite.com
```

**Via configuration file:**
```javascript
// data/local.js
export default {
  baseUrl: 'https://yoursite.com',
  modules: {
    // other module configuration
  }
};
```

**For multisite projects using ApostropheCMS Assembly:**
The base URL is automatically configured through the `baseUrlDomains` option. [Learn more about Assembly multisite hosting](https://apostrophecms.com/assembly).

### Google Analytics Integration

Enable Google Analytics tracking:

```javascript
import apostrophe from 'apostrophe';

apostrophe({
  root: import.meta,
  shortName: 'my-project',
  modules: {
    '@apostrophecms/seo': {},
    '@apostrophecms/global': {
      options: {
        seoGoogleAnalytics: true
      }
    }
  }
});
```

This adds a field in the global configuration for your Google Analytics Measurement ID (e.g., `G-XXXXXXXXXX`).

### Google Tag Manager Integration

For advanced tracking and marketing campaigns:

```javascript
import apostrophe from 'apostrophe';

apostrophe({
  root: import.meta,
  shortName: 'my-project',
  modules: {
    '@apostrophecms/seo': {},
    '@apostrophecms/global': {
      options: {
        seoGoogleTagManager: true
      }
    }
  }
});
```

Add your GTM container ID (e.g., `GTM-XXXXXXX`) in the global configuration.

### Google Site Verification

Verify site ownership for Google Search Console:

```javascript
import apostrophe from 'apostrophe';

apostrophe({
  root: import.meta,
  shortName: 'my-project',
  modules: {
    '@apostrophecms/seo': {},
    '@apostrophecms/global': {
      options: {
        seoGoogleVerification: true
      }
    }
  }
});
```

Enter your verification meta tag content from Google Search Console in the global settings.

### Sitemap Installation

> [!TIP]
> Installation of the sitemap module is optional, but highly recommended for better search rankings

Install the companion sitemap module for XML sitemap generation:

```bash
npm install @apostrophecms/sitemap
```

```javascript
import apostrophe from 'apostrophe';

apostrophe({
  root: import.meta,
  shortName: 'my-project',
  modules: {
    '@apostrophecms/seo': {},
    '@apostrophecms/sitemap': {}
  }
});
```

## Structured Data & Schema Types

This module generates rich structured data (JSON-LD) that helps search engines understand your content. All structured data is output in a single `<script type="application/ld+json">` tag with a `@graph` array for optimal performance.

### How It Works

The module automatically generates appropriate Schema.org markup based on the schema type you select in the SEO tab of your editor:

- **Sitewide schemas**: WebSite and Organization data from your Global settings appear on every page
- **Page-level schemas**: WebPage, CollectionPage, or your chosen primary entity type
- **Primary entities**: Article, Product, Event, Person, LocalBusiness, and more for detail pages
- **Item listings**: Automatic ItemList generation for index/listing pages

### Choosing the Right Schema

Select the schema type in the SEO tab of any page or piece editor.


### Quick Schema Selection Guide
See details for any schema below the table.

| Content Type | Recommended Schema |
|--------------|-------------------|
| Standard pages | WebPage |
| Blog index, Category pages | CollectionPage |
| Blog posts, Articles | Article |
| Product pages | Product |
| Single offers, service packages | Offer |
| Products with variants, tiered pricing | AggregateOffer |
| Event listings | Event |
| Author bios, Team pages | Person |
| Business locations | LocalBusiness |
| Job postings | JobPosting |
| Help/Support pages | FAQPage |
| Video pages | VideoObject |
| Tutorials, Guides | HowTo |
| Review articles | Review |
| Recipes | Recipe |
| Online courses | Course |


#### **Web Page**
Use for standard pages like About, Contact, or general information pages.

**Best for:** About pages, contact pages, general information pages, landing pages

---

#### **Collection Page**
Use for index and listing pages that display multiple items.

**Required fields:**
- Page title and description

**Features:**
- Automatically generates ItemList of visible items
- Toggle "Include ItemList in JSON-LD" to control ItemList output

**Best for:** Blog indexes, product catalogs, category pages, archives, search results

---

#### ItemList for Collection Pages

Collection pages (listing pages) can optionally include an **ItemList** schema in JSON-LD.
This describes the items displayed on the page (articles, products, events, etc.) and can improve how search engines understand category, listing, or archive pages.

You can enable or disable this using the **“Include ItemList in JSON-LD”** toggle in the SEO tab.

##### When you should enable ItemList
- The page is a true index/list of items
- Blog indexes, product category pages, news archives
- You want richer structured data for list/search pages

##### When you should NOT enable ItemList
- The page mixes unrelated content types
- The page is heavily personalized per user
- The listing is extremely large (hundreds+ items)
- The content shown changes frequently based on filters or user input
- It’s not actually a listing page (e.g., About, Contact)

ItemList content is generated automatically based on the items displayed by the page’s piece-page-type query (no developer configuration required).

---

#### **Article**
For blog posts, news articles, and editorial content.

**Required fields:**
- Title (from `seoTitle` or document title)
- Publication date

**Recommended fields:**
- Author
- Featured image
- Meta description

**Best for:** Blog posts, news articles, editorial content, press releases

---

#### **Product**
For e-commerce product pages with pricing and availability.

**Required fields:**
- Product name
- Price and currency

**Optional fields:**
- Brand, SKU, GTIN (barcode)
- Product condition (new, used, refurbished)
- Availability status
- Aggregate rating and review count
- Product description

**Best for:** E-commerce product pages, marketplace listings, service offerings with pricing

---

#### **Offer**
For single-price items, services, or standalone offers.

**Required fields:**
- Offer name
- Price and currency

**Optional fields:**
- Availability status (In Stock, Out of Stock, Pre-order, Discontinued, etc.)
- Valid date ranges (validFrom, priceValidUntil)
- Item condition (New, Used, Refurbished, Damaged)
- Seller information (defaults to your Organization)
- Offer URL
- Shipping details (rate, destination, delivery time)

**Best for:** 
- Individual service packages with fixed pricing
- Event tickets
- Subscription plans
- One-time purchase offers
- Limited-time deals

**SEO Impact:** Enables rich snippets showing pricing, availability, and seller info directly in search results. Improves visibility for Google Merchant Center and Shopping listings.

**Example use case:** A web design agency offering a "Starter Website Package" for $2,999 with a 30-day delivery time.

---

#### **Aggregate Offer**
For items with multiple price points, variants, or marketplace scenarios.

**Required fields:**
- Offer name
- Low price and high price
- Currency

**Optional fields:**
- Offer count (number of variants)
- Individual offers array (each with name, price, availability, URL)
- Common availability status
- Seller information

**Best for:**
- Products with size/color/material variants
- Marketplace listings from multiple sellers
- Tiered service packages (Basic, Pro, Enterprise)
- Hotel rooms with different rates
- Course offerings at different price levels
- Bulk pricing structures

**SEO Impact:** Shows price ranges in search results, helping users understand pricing options before clicking. Essential for marketplaces and products with variants.

**Example use case:** A SaaS product with Basic ($29/mo), Professional ($99/mo), and Enterprise ($299/mo) tiers.

---

#### **Event**
For concerts, webinars, conferences, and any scheduled events.

**Required fields:**
- Event name
- Start date

**Optional fields:**
- End date
- Location (name and address)
- Event description

**Best for:** Conferences, webinars, concerts, workshops, meetups, online events

---

#### **Person**
For author profiles, team member bios, and individual profiles.

**Required fields:**
- Person name

**Optional fields:**
- Job title
- Organization/employer
- Bio/description

**Best for:** Author pages, team member profiles, speaker bios, personal websites

---

#### **Local Business**
For brick-and-mortar businesses with physical locations.

**Required fields:**
- Business name

**Optional fields:**
- Address (street, city, state, zip, country)
- Phone number
- Opening hours
- Business description

**Best for:** Restaurants, retail stores, service providers, medical offices, salons

---

#### **Job Posting**
For job listings and career pages. Essential for appearing in Google for Jobs.

**Required fields:**
- Job title
- Expiration date
- Hiring organization name (falls back to global organization)
- Location (physical address or remote designation)

**Optional fields:**
- Employment type (full-time, part-time, contract, etc.)
- Salary information (range or fixed amount)
- Experience requirements (months of experience)
- Education requirements
- Skills, qualifications, responsibilities
- Benefits
- Industry and occupational category
- Work hours
- Direct apply toggle

**Best for:** Job boards, careers pages, recruitment sites, staffing agencies

**SEO impact:** Jobs appear in Google for Jobs search results with rich snippets showing salary, location, and employment type.

---

#### **FAQ Page**
For frequently asked questions pages.

**Required fields:**
- At least one question-and-answer pair

**How to use:**
1. Select **"FAQ Page"** as the schema type in the SEO tab.
2. In the **FAQ Details** section, add each question and its corresponding answer.
3. Each entry automatically generates structured data compliant with Google’s FAQPage schema.

**Best for:** Help centers, knowledge bases, product FAQ pages

**SEO impact:** Enables rich FAQ snippets in Google Search results, improving click-through rates.

#### **QA Page**
For question and answer pages where a single question has one or more answers (like Stack Overflow, forums, or community Q&A).

> [!TIP]
> The `FAQ` schema is valuable for SEO and AEO, but Google no longer shows rich search fragments from this structured data unless you are a governmental or recognized health site.

**Required fields:**
- Question title

**Optional fields:**
- Question details/body text
- Question author name
- Question date posted
- Question upvote count
- **Accepted Answer**: The answer marked as correct/most helpful
  - Answer text (required if providing accepted answer)
  - Answer author
  - Answer date
  - Answer upvote count
- **Other Answers**: Additional suggested answers
  - Each with text, author, date, and upvote count

**Best for:** Community forums, support forums, Q&A platforms, discussion boards, knowledge bases with user-contributed answers

**Difference from FAQ:**
- **FAQPage** is for curated, official FAQs written by your organization
- **QAPage** is for community-driven Q&A with voting, multiple answers, and user attribution

**SEO Impact:** Can appear in Google's Q&A rich results with voting counts, accepted answers highlighted, and author information. Helps establish expertise and community engagement.

**Example use cases:**
- Technical support forum: "How do I reset my password?" with 5 community answers
- Programming Q&A: "What's the difference between var and let in JavaScript?" with accepted answer
- Product support: Customer questions with manufacturer responses

**Best practices:**
- Always provide the accepted answer when one exists
- Include upvote counts to signal answer quality
- Add author names for credibility
- Use for pages with single questions only (not question listings)

**Note:** For question listing/index pages, use the `CollectionPage` schema instead.

#### **Video Object**
For video content pages, including educational videos and tutorials.

**Required fields:**
- Video name

**Optional fields:**
- Video description
- Upload date
- Duration (ISO 8601 format: "PT1M30S" for 1 minute 30 seconds)
- Thumbnail image (uses `_featuredImage` relationship if not specified)
- Content URL (direct video file)
- Embed URL (YouTube/Vimeo embed)

**Educational video fields:**
When "Is Educational Video" is checked, additional fields become available:
- **Educational Use**: How the video is used (assignment, professional development, continuing education, vocational training)
- **Learning Resource Type**: Type of educational content (lecture, tutorial, demonstration, presentation, exercise)

**Best for:** Video landing pages, video galleries, tutorial videos, webinar recordings, online courses, training materials

**SEO Impact:** Educational videos can appear in Google's learning-specific search features and video carousels with enhanced metadata.

**Example use cases:**
- Software tutorial: "How to Use Photoshop Layers" (learningResourceType: tutorial)
- University lecture: "Introduction to Calculus" (educationalUse: assignment)
- Professional training: "Project Management Fundamentals" (educationalUse: professional development)

#### **How To**
For step-by-step guides and tutorials.

**Required fields:**
- Guide name
- At least one step with name and instructions

**Optional fields:**
- Total time (ISO 8601 format: "PT30M" for 30 minutes)
- Supply list (materials needed)
- Tool list (tools required)
- Step images
- Guide description

**Best for:** DIY tutorials, cooking instructions, repair guides, software walkthroughs

**Example:** "How to Change a Tire," "How to Bake Sourdough Bread," "How to Install WordPress"

---

#### **Review**
For product reviews, service reviews, and editorial reviews.

**Required fields:**
- Item being reviewed (name)

**Optional fields:**
- Item type (Product, Book, Movie, Restaurant, Service)
- Rating (1-5 scale)
- Review body/text
- Author name
- Review date

**Best for:** Product review pages, service reviews, book reviews, restaurant reviews

---

#### **Recipe**
For cooking recipes and food content.

**Required fields:**
- Recipe name
- Ingredients list
- Cooking instructions

**Optional fields:**
- Author
- Prep time, cook time, total time (ISO 8601 format)
- Yield (servings)
- Recipe category (e.g., "Dessert," "Main Course")
- Cuisine type (e.g., "Italian," "Mexican")
- Nutrition information (calories, carbs, protein, fat)
- Aggregate rating and review count
- Recipe image

**Best for:** Food blogs, cooking websites, restaurant recipe pages

**Time format examples:**
- "PT30M" = 30 minutes
- "PT1H" = 1 hour
- "PT1H30M" = 1 hour 30 minutes

---

#### **Course**
For online courses and training programs.

**Required fields:**
- Course name
- Course description

**Optional fields:**
- Course provider (defaults to site organization)
- Course code (e.g., "CS101")
- Educational level (Beginner, Intermediate, Advanced)
- Price and currency
- Availability
- Aggregate rating and review count

**Best for:** Online learning platforms, training programs, educational institutions, certification courses

---

### Best Practices

**Test your markup**: Use [Google's Rich Results Test](https://search.google.com/test/rich-results) to validate your structured data

**For product catalogs:**
- Use **Product** schema on individual product detail pages
- Use **Offer** or **AggregateOffer** as the offer type within Product schema when appropriate
- Use **CollectionPage** with ItemList on category/listing pages

**For service businesses:**
- Use **Offer** schema for individual service packages
- Use **AggregateOffer** for tiered service offerings
- Combine with **LocalBusiness** for location-based services

**For marketplaces:**
- Use **AggregateOffer** to show price ranges across sellers
- Include individual offers array for each seller/variant
- Ensure seller information is populated for trust signals

**Pricing and Offers Tips:**
- Always include availability status for accurate search results
- Use priceValidUntil for time-limited offers
- Include shipping information for physical products
- Seller information automatically falls back to your global Organization settings

---

## AI & Search Strategy

Modern search and AI systems use different types of crawlers for different purposes. Understanding these differences helps you make informed decisions about your content's visibility and protection.

### Understanding Crawler Types

**Training Crawlers** (GPTBot, ClaudeBot, Google-Extended, CCBot):
- Build AI training datasets from your content
- Used to improve AI models
- Your content may be synthesized into AI responses without attribution

**Browsing Crawlers** (ChatGPT-User, Claude-User, PerplexityBot):
- Serve real-time user queries
- Typically provide attribution and links back to your site
- Drive referral traffic

**Traditional Search** (Googlebot, Bingbot):
- Power traditional search engines
- Include AI-enhanced features (Google AI Overview, Bing Chat)
- Essential for search rankings and organic traffic

### Recommended Configuration for Most Sites

For optimal search visibility while protecting intellectual property:

**robots.txt Settings:**
- Mode: **"Allow Search, Block AI Training"**

**llms.txt Settings:**
- Mode: **"Disallow AI Training"**

**Why This Works:**
- ✅ Traditional search engines continue normal indexing
- ✅ AI Overview and AI-powered search features remain functional  
- ✅ Real-time AI browsing for user queries still works
- ✅ Your content drives referral traffic from AI systems
- ❌ Your content is protected from AI training datasets
- ❌ No contribution to training commercial AI models

**Impact on Rankings:**
- **No negative impact** on Google Search rankings (confirmed by Google)
- Blocking Google-Extended does **not** affect Google Search
- AI training crawler access is completely separate from search indexing

### For Maximum AI Visibility

If you want your content widely used by AI systems for training and responses:

**robots.txt Settings:**
- Mode: **"Allow All (Search + AI)"**

**llms.txt Settings:**
- Mode: **"Allow AI Crawling"**

**Use this when:**
- You want maximum exposure in AI-generated content
- Your business model benefits from AI-driven traffic
- You're comfortable with your content training AI models
- You want to contribute to open AI datasets

### For Maximum Privacy/Protection

If you want to restrict most or all AI access:

**robots.txt Settings:**
- Mode: **"Selective AI Crawlers"**
- Check only: ChatGPT-User, Claude-User (optional - allows real-time queries)
- Or use **"Block All"** for complete restriction

**llms.txt Settings:**
- Mode: **"Disabled"**

**Use this when:**
- You have proprietary or competitive content
- Legal/compliance restrictions on AI training
- You want maximum control over content usage
- Privacy is a primary concern

### Understanding robots.txt vs llms.txt

Both tools work together but serve different purposes:

| Feature | robots.txt | llms.txt |
|---------|-----------|----------|
| **Purpose** | Enforceable crawler access control | Policy communication & transparency |
| **Technical** | Bots must respect (standard protocol) | Informational guidelines only |
| **Adoption** | Universal web standard since 1994 | **Proposed standard, limited adoption** |
| **Enforcement** | Technical blocking mechanism | **Voluntary compliance only** |
| **Controls** | Which bots can crawl your site | How content may be used if crawled |
| **Best for** | Technical access restrictions | Terms of use & AI transparency |
| **Required?** | Yes (web standard since 1994) | Optional (emerging standard) |
| **AI Support** | Most AI crawlers respect robots.txt | **Most AI systems do not read llms.txt** |
| **Example** | "Block GPTBot from accessing /api/*" | "Content may be used for search, not training" |

> **⚠️ Important:** While `llms.txt` represents forward-thinking SEO strategy, it should not be relied upon for actual crawler control. Use `robots.txt` for enforceable policies. The `llms.txt` file serves as a policy statement and may gain broader adoption over time.

**Recommended approach:** Use both together:
- **robots.txt** provides technical enforcement (works now)
- **llms.txt** clearly communicates your policies (may work in the future)

### Site Search Query Parameter

Configure the query parameter your site uses for internal search. This enables the `SearchAction` structured data in your site's WebSite schema.

**Configuration:**
Set this in your global SEO settings. Common values:
- `q` (most common) - for URLs like `/search?q=query`
- `search` - for URLs like `/search?search=query`
- `query` - for URLs like `/search?query=query`
- `s` (WordPress default) - for URLs like `/?s=query`

**Example in global settings:**
```json
"seoSearchQueryParam": "q"
```

**SEO Impact:**
This creates a SearchAction schema that:
- Helps search engines understand your site search
- May enable a "Search this site" box in Google results
- Improves your site's appearance as an authoritative source

**Note:** This should match whatever parameter your actual search functionality uses. Check your site's search URL to determine the correct value.

## Advanced Configuration

### Disabling SEO Fields

Disable SEO fields for specific page or piece types:

```javascript
// modules/my-piece-type/index.js
export default {
  extend: '@apostrophecms/piece-type',
  options: {
    seoFields: false
  }
};
```

The following modules disable SEO fields by default:
- `@apostrophecms/global`
- `@apostrophecms/user`
- `@apostrophecms/image`
- `@apostrophecms/image-tag`
- `@apostrophecms/file`
- `@apostrophecms/file-tag`


### Setting Default Schema Types

You can configure a default schema type for any piece or page type using the `seoSchemaType` option. When set, this type will be pre-selected and locked for all content of that type.

**Example configuration:**
```javascript
// modules/product/index.js
export default {
  extend: '@apostrophecms/piece-type',
  options: {
    label: 'Product',
    seoSchemaType: 'Product'  // Always use Product schema
  }
};
```

**Pre-configured defaults:**

The following ApostropheCMS extensions automatically set appropriate schema types:

- **`@apostrophecms/blog`**: Defaults to `Article` schema for blog posts
- **`@apostrophecms/event`**: Defaults to `Event` schema for events
- The pages for each of these modules defaults the `CollectionPage` schema

When a default is configured, the schema type selector becomes read-only in the editor UI, ensuring consistency across all content of that type. These can be overridden at project level.

**When to use defaults:**

- Content types with a clear, single schema purpose (products, events, recipes)
- Ensuring editors can't accidentally select the wrong schema type
- Maintaining consistency across large content collections
- Integration with specific Schema.org requirements (e.g., job boards must use JobPosting)

### Canonical Link Configuration

Configure canonical URL options for pieces by specifying which document types editors can reference:

```javascript
// modules/article/index.js
export default {
  extend: '@apostrophecms/piece-type',
  options: {
    label: 'Article',
    seoCanonicalTypes: [ '@apostrophecms/page', 'topic' ]
  }
};
```

This allows editors to designate another page or piece as the canonical source for search engines, helping prevent duplicate content penalties.

> **What are canonical links?** [As described on Moz.com](https://moz.com/learn/seo/canonicalization): "A canonical tag tells search engines which version of a URL you want to appear in search results." This prevents problems when identical content appears on multiple URLs.

### Pagination Support

The module **automatically** adds `rel="prev"` and `rel="next"` link tags for paginated content. No manual configuration required.

**Automatic detection works for:**

1. **Index pages** (piece-page-type listing pages):
   - Uses ApostropheCMS's built-in `req.data.currentPage` and `req.data.totalPages`
   - Automatically detects pagination from standard piece-page-type queries
   - Page 1 gets clean URLs (no `?page=1` query string)

2. **Show pages** (individual pieces with navigation):
   - Uses `req.data.next` and `req.data.previous` when configured
   - Works when you enable `next: true` and `previous: true` options on your piece-page-type

**Example piece-page-type with next/previous:**
```javascript
// modules/article-page/index.js
export default {
  extend: '@apostrophecms/piece-page-type',
  options: {
    // Enable automatic next/previous navigation
    next: true,
    previous: true
  }
};
```

**Manual override (backwards compatibility):**

If you need custom pagination logic, you can still manually set `req.data.pagination`:
```javascript
// In your route handler (only if you need custom behavior)
module.exports = {
  async index(req) {
    req.data.pagination = {
      currentPage: customPage,
      totalPages: customTotal,
      baseUrl: customBaseUrl
    };
    return {};
  }
};
```

This helps search engines understand pagination relationships and prevents duplicate content issues.

### Custom 404 Tracking

Track 404 errors in Google Analytics by adding this to your `notFound.html` template:

```nunjucks
{% block extraBody %}
  {{ super() }}
  {% include "@apostrophecms/seo:404.html" %}
{% endblock %}
```

This automatically sends 404 events when a tracking ID is configured, helping you identify broken links.


### Paywalled Content

If you mark content as paywalled, your templates must use consistent CSS classes or IDs to wrap premium content. The module needs to know which HTML element contains the paywalled content.

**How it works:**

1. Add a wrapper element around your paywalled content in your template
2. Configure the CSS selector in the SEO settings to match your wrapper

**Example template implementation:**

```nunjucks
{# views/show.html #}
<article>
  <h1>{{ data.piece.title }}</h1>
  
  {# Free preview content #}
  <div class="article-preview">
    {{ data.piece.excerpt }}
  </div>
  
  {# Paywalled content - note the class name #}
  <div class="paywall">
    {% if data.user %}
      {# Show full content to subscribers #}
      {{ data.piece.body }}
    {% else %}
      {# Show paywall message to non-subscribers #}
      <div class="paywall-notice">
        <p>Subscribe to read more...</p>
      </div>
    {% endif %}
  </div>
</article>
```

**Common CSS selector patterns:**

```css
/* By class (most common) */
.paywall
.premium-content
.members-only

/* By ID */
#paywalled-content

/* By data attribute */
[data-paywall="true"]

/* Multiple classes */
.article-body.premium
```

**In the SEO settings**, set the "Paywall CSS Selector" field to match your implementation (e.g., `.paywall`).

**Why this matters:** Google requires you to explicitly mark which parts of your page require payment. The CSS selector tells search engines exactly where the paywall boundary is, helping them show appropriate content previews without penalties.

### Custom Field Mappings

If your project uses different field names than the SEO module's defaults, you can configure custom field mappings to avoid refactoring existing content types:

```javascript
// app.js
modules: {
  '@apostrophecms/seo': {
    options: {
      seoFieldMappings: {
        author: 'authorName',           // Use authorName instead of author/_author
        image: 'heroImage',             // Use heroImage instead of _featuredImage
        description: 'summary',         // Use summary instead of description/excerpt
        publishedAt: 'publicationDate'  // Use publicationDate instead of publishedAt
      }
    }
  }
}
```

The module checks your custom field names first, then falls back to standard field names if the custom field is empty. This works with all field formats (strings, relationships, objects, and attachments), making it ideal for migrating from another CMS or maintaining project-specific naming conventions. Enable debug mode (`APOS_SEO_DEBUG=1`) to see which fields are being used.

> **Note:** Field mappings are global and apply to all content types. If different content types use different field names (e.g., articles use `authorName` but products use `createdBy`), use the `registerSchema()` method to create custom schema generators for those specific types instead.

## Implementation Guidelines for Developers

When using this SEO module, you have flexibility in how you structure your fields. The module supports multiple field formats through an intelligent fallback system. This section documents both the simple and advanced approaches you can take.

### Field Flexibility

The SEO module provides flexible field formats to accommodate different project needs. Whether you're building a simple blog or a complex application, you can choose the field structure that works best for your use case.

### Flexible Field Formats

#### Author Information

Provide author information in any of these formats:

**Simple string field (easiest):**
```javascript
fields: {
  add: {
    author: {
      type: 'string',
      label: 'Author Name',
      def: 'Editorial Team'
    }
  }
}
```

**Author relationship (full featured):**
```javascript
fields: {
  add: {
    _author: {
      type: 'relationship',
      withType: 'author',
      max: 1
    }
  }
}
```

**Automatic fallback:** When neither is provided, the module falls back to the user that last updated (if available).

---

#### Images

Images can be provided as:

**Simple object (for external images):**
```javascript
fields: {
  add: {
    featuredImage: {
      type: 'object',
      fields: {
        add: {
          url: { type: 'url', required: true },
          alt: { type: 'string' },
          width: { type: 'integer' },
          height: { type: 'integer' }
        }
      }
    }
  }
}
```

**ApostropheCMS image relationship (for uploaded images):**
```javascript
fields: {
  add: {
    _featuredImage: {
      type: 'relationship',
      withType: '@apostrophecms/image',
      max: 1
    }
  }
}
```

The module checks multiple field names: `_image`, `_featuredImage`, `attachment`, `featuredImage`, and `image`

---

#### Descriptions

Descriptions are automatically sourced from the first available field:

1. Schema-specific description (e.g., `product.description`)
2. `seoDescription` (SEO-optimized content)
3. `excerpt` (content preview)
4. `description` (general description)

This means you don't need to duplicate content across multiple fields.

---

#### Publication Dates

The module accepts multiple date field names:

- `publishedAt` (standard ApostropheCMS field)
- `publicationDate`
- `datePublished`
- Automatically falls back to `createdAt` if none are provided
```javascript
fields: {
  add: {
    publicationDate: {
      type: 'date',
      label: 'Publication Date'
    }
  }
}
```

---

### Debug Mode

Enable debug mode to see which fallback fields are being used:
```bash
export APOS_SEO_DEBUG=1
npm run dev
```

You'll see helpful log messages like:

```bash
[SEO] Author fallback used: document.author = "John Doe"
[SEO] Image fallback used: document.featuredImage
[SEO] Description fallback used: document.excerpt
```
---

### Featured Images

Several schema types rely on a `_featuredImage` relationship field being present on your document.

**Schema types that use featured images:**
- **Product** - Product image
- **Recipe** - Recipe photo
- **How To** - Guide illustration
- **Video Object** - Video thumbnail (falls back to featured image)

**Example implementation:**
```javascript
// modules/article/index.js
export default {
  extend: '@apostrophecms/piece-type',
  options: {
    label: 'Article'
  },
  fields: {
    add: {
      _featuredImage: {
        label: 'Featured Image',
        type: 'relationship',
        withType: '@apostrophecms/image',
        max: 1,
        required: true  // Make required if using Product or Recipe schemas
      },
    },
    group: {
      basics: {
        fields: ['title', '_featuredImage']
      }
    }
  }
};
```

**Note:** The field name **must be** `_featuredImage` (with the leading underscore) for the SEO module to find it automatically.

### Author Information

For **Article**, **Recipe**, and **Review** schemas, you can provide author information in multiple formats.

- A simple string field named `author` in the SEO schema
- A simple string field named `author` in the document schema
- A `relationship` field named `_author` that points to an “author-like” piece-type
  (for example: `author`, `person`, `staff-member`, etc.). The module reads the first relationship document’s `title` (preferred), then `name` to determine the author name.

**Resolution order:**

1. `schema.author` string (if present and non-empty)
2. `document.author` string
3. `_author` relationship: the first joined doc on `document._author`
4. `updatedBy` user on the document: `title`, then `name`

For full details on how author fields are resolved and mapped into structured data
(including fallbacks), see [Author Information](#author-information).

### URL Requirements

The `_url` property is automatically provided by ApostropheCMS for:
- All pages
- Pieces displayed through piece-page-types

No configuration required - the SEO module uses these URLs automatically for structured data.

### Date Fields

Several schema types use date information. The module looks for these fields in priority order:

1. **Publication dates:** `publishedAt`, then `createdAt`
2. **Modification dates:** `updatedAt`, then `createdAt`

**Best practice:** Add a `publishedAt` field to content types that use Article schema:

```javascript
fields: {
  add: {
    publishedAt: {
      label: 'Publication Date',
      type: 'date',
      def: null
    }
  }
}
```

### Listing Pages (Item List)

For **Collection Page** schema with Item List generation, the module automatically detects listing items from these request data properties:

- `req.data.pieces`
- `req.data.items`
- `req.data._pieces`
- `req.data.docs`

Each item must have:
- A URL: `_url` or `url` property
- A title: `seoTitle` or `title` property

**Standard piece-page-type index pages work automatically** without additional configuration.

### Summary: Key Document-Level Fields by Schema Type

Most schema types are driven primarily by their `seoJsonLd*` configuration fields  
(e.g., `seoJsonLdArticle`, `seoJsonLdProduct`, `seoJsonLdRecipe`).  
In addition, the SEO module can **reuse** certain document-level fields (outside the
`seoJsonLd*` objects) and, for some schema types, those fields are effectively
required for rich results.

The table below only lists **document-level fields** you may want to add to your
piece/page schemas. Fields that live *inside* the `seoJsonLd*` blocks (such as
`review.reviewRating`, `course.provider`, `job.baseSalary`, etc.) are documented
with each schema and are not repeated here.

| Schema Type | Suggested Document-level fields | Requirement level |
|------------------|------------------------------------------------------------------------------|-------------------|
| **Article** | **Author** → `author` (string) or `_author` (relationship)<br>**Dates** → `publishedAt` (or other mapped published date), `updatedAt` | Recommended for rich Article results (author + published date are strongly recommended) |
| **Product** | **Primary image** → `_featuredImage` (relationship), or a mapped image field used by the image fallback helper | Strongly recommended – module logs a debug warning if missing; many product rich results expect an image |
| **Recipe** | **Primary image** → `_featuredImage` (relationship) or mapped image field (resolved via image fallback helper)<br>**Author** → `author` or `_author`<br>**Dates** → `publishedAt` (or mapped) | Image is **required by the module** (no JSON-LD is emitted without one); author + published date are recommended for rich Recipe results |
| **VideoObject** | **Thumbnail fallback** → `_featuredImage` relationship (used if `seoJsonLdVideo._thumbnail` is missing)<br>**Dates** → `publishedAt` / `createdAt` (upload date fallback) | Thumbnail and upload date are **treated as required** by the module; `_featuredImage` is a practical requirement if you don’t always set `_thumbnail` |
| **HowTo** | **Top-level image** → `_featuredImage` relationship or mapped image field (via image fallback helper) | Optional in code but **strongly recommended** for rich HowTo results; step images are configured inside `seoJsonLdHowTo.steps` |
| **Review** | **Author** → `author` or `_author`<br>**Date** → `publishedAt` (or mapped) | Recommended – author and date are important for Review rich results |

> **Images and rich results:**
> The module enforces images for **Recipe** and **VideoObject** (no schema is output
> if none can be resolved). For **Product** and **HowTo**, images are not strictly
> required in code, but search engines commonly treat them as required for rich
> results. In practice, you should treat a `_featuredImage` relationship (or a
> custom-mapped image field) as required for those schema types.


### Schema Types That Require No Developer Fields

The following schema types do not depend on project-level fields. They are generated entirely from the SEO UI and built-in ApostropheCMS fields (title, URL, SEO tab configuration):

- **WebPage** – basic page metadata
- **CollectionPage** – ItemList is autogenerated from `req.data.pieces` / `items`
- **FAQPage** – uses fields in the `seoJsonLdFAQPage` UI group
- **QAPage** – uses fields in the `seoJsonLdQAPage` UI group

### Debugging Structured Data

Set the environment variable `APOS_SEO_DEBUG=1` to print JSON-LD generation diagnostics to your server logs during development. When enabled, any errors or malformed data encountered during schema generation will be logged to your server console along with the offending data payload.
This is particularly useful when testing new schema types or diagnosing missing fields in custom templates.

**Important:** Not all schema types show rich results in Google Search Console's URL Inspection Tool. The following schemas are valid and will be indexed, but may not appear in the rich results preview:

- **HowTo** - Valid schema, but not shown in URL Inspection Tool
- **QAPage** - Valid schema, but not shown in URL Inspection Tool
- **Learning Video** - Extension of VideoObject, shown as standard Video

Use the [Rich Results Test](https://search.google.com/test/rich-results) and [Schema Markup Validator](https://validator.schema.org/) for comprehensive testing of all schema types.

## Extending the SEO Module with Custom JSON-LD Schemas

You can add custom Schema.org types to the SEO module without modifying this package. At a high level, you will:

1. **Register** a new JSON-LD schema type on `@apostrophecms/seo`.
2. **Expose** that type in the schema type dropdown via `@apostrophecms/seo-fields-doc-type`.
3. **Add** the fields that power your schema on `@apostrophecms/doc-type`.

Apostrophe automatically merges project-level modules with the same name as core/pro modules, so you only need the right folder structure.

---

### 1. Register a Custom Schema on `@apostrophecms/seo`

Create a project-level module to register your custom Schema.org type. In this example, we’ll add a `Book` schema.

```js
// modules/@apostrophecms/seo/index.js
export default {
  init(self) {
    self.registerSchema('Book', (data) => {
      const { piece, page } = data;
      const doc = piece || page;
      const book = doc?.seoJsonLdBook;

      // Require a document and a title to emit the schema
      if (!doc || !book || !book.title?.trim()) {
        return null;
      }

      const schema = {
        '@type': 'Book',
        name: book.title
      };

      // URL / @id
      if (doc._url) {
        schema['@id'] = doc._url;
        schema.url = doc._url;
      }

      // Author
      if (book.author?.trim()) {
        schema.author = {
          '@type': 'Person',
          name: book.author
        };
      }

      // ISBN
      if (book.isbn?.trim()) {
        schema.isbn = book.isbn;
      }

      return schema;
    });
  }
};
```

The handler receives the same `data` object used to render SEO tags (`page`, `piece`, `global`, `req`, etc.). Return a JSON-LD object or `null` to skip output.

---

### 2. Add the Type to the Schema Dropdown (`@apostrophecms/seo-fields-doc-type`)

Next, make the new type available in the schema type selector. Use `extendMethods` on the `@apostrophecms/seo-fields-doc-type` module.

```js
// modules/@apostrophecms/seo-fields-doc-type/index.js
export default {
  extendMethods(self) {
    return {
      getSchemaTypeChoices(_super) {
        return function () {
          const baseChoices = _super();

          return [
            ...baseChoices,
            {
              label: 'Book',
              value: 'Book'
            }
          ];
        };
      }
    };
  }
};
```

This appends a `Book` option to the existing schema type choices. When editors select **Book** in the SEO tab, you can show Book-specific fields.

---

### 3. Add Fields on `@apostrophecms/doc-type`

Finally, define the fields that power the `Book` schema on `@apostrophecms/doc-type`. These fields will be available to all page and piece types.

```js
// modules/@apostrophecms/doc-type/index.js
export default {
  fields(self, options) {
    return {
      add: {
        seoJsonLdBook: {
          label: 'Book Details',
          type: 'object',
          help: 'Structured data fields for the Book schema.',
          if: {
            // Only show when the JSON-LD schema type is `Book`
            seoJsonLdType: 'Book'
          },
          fields: {
            add: {
              title: {
                label: 'Book Title',
                type: 'string',
                required: true
              },
              author: {
                label: 'Author',
                type: 'string'
              },
              isbn: {
                label: 'ISBN',
                type: 'string'
              }
            }
          }
        }
      },
      group: {
        seo: {
          // Add our field to the existing SEO group
          fields: [ 'seoJsonLdBook' ]
        }
      }
    };
  }
};
```

With these three pieces in place:

* Editors can select **Book** as a JSON-LD type in the SEO tab.
* A Book-specific fieldset appears when that type is selected.
* The SEO module outputs a valid `Book` JSON-LD object (including `isbn`) based on those fields.

## Performance Optimization

### Critical Font Preloading

Preload critical fonts to improve Core Web Vitals scores and SEO performance. Font loading directly impacts:
- **Cumulative Layout Shift (CLS)**: Prevents layout shift when custom fonts load
- **Largest Contentful Paint (LCP)**: Faster font loading improves render time
- **First Contentful Paint (FCP)**: Reduces render-blocking font requests

## 1. Place Your Fonts in a Module’s `public/` Directory

For cloud deployments (UploadFS → S3, GCS, etc.), fonts **must be stored inside a module**, for example:

```
modules/my-fonts/public/fonts/inter-variable.woff2
modules/my-fonts/public/fonts/geist-mono.woff
```

Apostrophe uploads assets only from module `public/` directories. These files become available at URLs like:

```
/modules/my-fonts/fonts/inter-variable.woff2
```

---
## 2. Configure the SEO Module to Preload Fonts
Configure critical fonts as a developer-level option in your `app.js`:
```javascript
import apostrophe from 'apostrophe';

apostrophe({
  root: import.meta,
  shortName: 'my-project',
  modules: {
    '@apostrophecms/seo': {
      options: {
        criticalFonts: [
          {
            url: '/modules/my-fonts/fonts/inter-variable.woff2',
            type: 'font/woff2'  // Optional, defaults to 'font/woff2'
          },
          {
            url: 'https://cdn.yoursite.com/fonts/geist-mono.woff',
            type: 'font/woff'
          }
        ]
      }
    }
  }
});
```

The module automatically generates `<link rel="preload">` tags for each configured font.
**Important:** Font preloading is complementary to your existing CSS - it doesn't replace it. You must still include your `@font-face` rules:

```css
/* Your existing CSS - keep this! */
@font-face {
  font-family: 'Inter';
  src: url('/modules/my-fonts/fonts/inter-variable.woff2') format('woff2');
  font-display: swap;
}
```
**How it works:**
- **Without preload**: Browser parses HTML → parses CSS → discovers font → starts download (delayed)
- **With preload**: Browser starts downloading font immediately → when CSS loads, font is already ready


The `crossorigin` attribute is automatically added for absolute URLs (CDN/external fonts) and omitted for relative URLs (self-hosted fonts).

**Where to store fonts:**

1. **Simple single-server deployments**: Place font files in `modules/my-fonts/public/fonts/` and reference them as `/modules/my-fonts/fonts/filename.woff2`
   - No CORS configuration needed for same-origin fonts
   - Works in both local and cloud/UploadFS deployments
   - Example: `{ url: '/modules/my-fonts/fonts/inter.woff2' }`

2. **CDN/S3**: Use full URLs with proper CORS headers configured on your CDN
   - Better caching and global performance
   - Requires CORS: `Access-Control-Allow-Origin: *`
   - Example: `{ url: 'https://cdn.yoursite.com/fonts/inter.woff2' }`

3. **Self-host Google Fonts instead of using their CDN**:
   - Google's CDN uses hashed URLs that make preloading impossible
   - Download Google Fonts and self-host them to enable preloading
   - Tools: [google-webfonts-helper](https://gwfh.mranftl.com/fonts) or [Google Fonts Helper](https://fonts.google.com/knowledge/using_type/self_hosting_web_fonts)
   - Once self-hosted, preload them like any other font

**Advanced options:**
```javascript
criticalFonts: [
  {
    url: '/fonts/local.woff2'
    // No crossorigin (relative URL)
  },
  {
    url: 'https://cdn.example.com/font.woff2'
    // Automatic crossorigin="anonymous" (absolute URL)
  },
  {
    url: 'https://cdn.example.com/font.woff2',
    crossorigin: false  // Explicitly disable crossorigin if needed
  },
  {
    url: 'https://private-cdn.example.com/font.woff2',
    crossorigin: 'use-credentials'  // For authenticated CDN requests
  }
]
```

**Best practices:**
- Only preload fonts used above the fold (typically 1-2 fonts maximum)
- Use `woff2` format for best compression (supported by all modern browsers)
- Ensure font files are actually available at the specified URLs before deployment
- Test with Google PageSpeed Insights to verify Core Web Vitals improvements
- Keep your existing `@font-face` CSS - preloading enhances it, doesn't replace it


**Example project structure:**
```
my-project/
├── public/
│   └── fonts/
│       ├── inter-variable.woff2
│       └── headings.woff2
└── modules/
    └── asset/
        └── ui/
            └── src/
                └── index.scss  # Reference fonts here with @font-face
```

### Mobile Optimization

The module automatically includes:
- Viewport meta tag for responsive design
- Optional theme-color meta tag for PWA compatibility
#### Theme Color for Mobile Browsers
Set a theme color for mobile browsers. Supports:
- **Single color mode** (one color for all)
- **Light/Dark mode** variants

Example configuration:
```json
{
  "mode": "lightDark",
  "light": "#ffffff",
  "dark": "#121212"
}
```

## Field Reference

|Name |Description  | Module Affected | Module Option |
--- | --- | --- | ---
|`seoTitle`|Title attribute for search results|`@apostrophecms/doc-type`|_Enabled by default_|
|`seoDescription`|Description for search results|`@apostrophecms/doc-type`|_Enabled by default_|
|`seoRobots`|Robots indexing behavior|`@apostrophecms/doc-type`|_Enabled by default_|
|`_seoCanonical`|[Canonical URL](https://moz.com/learn/seo/canonicalization) reference|`@apostrophecms/page-type`|_Enabled by default_|
|`seoGoogleTagManager`|Google Tag Manager Container ID|`@apostrophecms/global`|`seoGoogleTagManager: true`|
|`seoGoogleTrackingId`|Google Analytics Measurement ID|`@apostrophecms/global`|`seoGoogleAnalytics: true`|
|`seoGoogleVerificationId`|Google Site Verification ID|`@apostrophecms/global`|`seoGoogleVerification: true`|
|`seoJsonLdType`|Schema.org type for this document|`@apostrophecms/doc-type`|_Enabled by default_|
|`seoJsonLdProduct`|Product schema fields|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'Product'`|
|`seoJsonLdOffer`|Offer schema fields (price, availability, seller, shipping)|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'Offer'`|
|`seoJsonLdAggregateOffer`|AggregateOffer schema fields (price range, variants)|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'AggregateOffer'`|
|`seoJsonLdEvent`|Event schema fields|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'Event'`|
|`seoJsonLdPerson`|Person schema fields|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'Person'`|
|`seoJsonLdBusiness`|LocalBusiness schema fields|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'LocalBusiness'`|
|`seoJsonLdJobPosting`|JobPosting schema fields|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'JobPosting'`|
|`seoJsonLdHowTo`|How-To guide schema fields|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'HowTo'`|
|`seoJsonLdReview`|Review schema fields|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'Review'`|
|`seoJsonLdRecipe`|Recipe schema fields|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'Recipe'`|
|`seoJsonLdCourse`|Course schema fields|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'Course'`|
|`seoIncludeItemList`|Toggle ItemList in structured data|`@apostrophecms/doc-type`|Shown when `seoJsonLdType: 'CollectionPage'`|
|`seoSiteName`|Site name for WebSite schema|`@apostrophecms/global`|_Enabled by default_|
|`seoSiteDescription`|Site description for WebSite schema|`@apostrophecms/global`|_Enabled by default_|
|`seoSiteCanonicalUrl`|Base URL for structured data|`@apostrophecms/global`|_Enabled by default_|
|`seoJsonLdOrganization`|Organization schema settings|`@apostrophecms/global`|_Enabled by default_|

## 🚀 Ready for AI-Powered SEO?

**Want to supercharge your SEO workflow?** Create an account on Apostrophe Workspaces and upgrade to [**ApostropheCMS Pro**](https://app.apostrophecms.com/login). Get access to the [**SEO Assistant**](https://apostrophecms.com/extensions/seo-assistant) with AI-powered content optimization:

### ✨ SEO Assistant Pro Features
- **🤖 AI-Generated Meta Titles**: Compelling, keyword-optimized titles generated automatically
- **🔍 Smart Meta Descriptions**: AI-crafted descriptions that drive clicks
- **🎯 Content Analysis**: Get suggestions based on your actual page content
- **⚡ One-Click Optimization**: Generate, review, and apply SEO improvements instantly
- **🔄 Multiple Suggestions**: Try different approaches with regeneration options
- **✏️ Custom Prompts**: Fine-tune AI behavior for your brand voice

The SEO Assistant analyzes your page content and generates optimized meta titles and descriptions using advanced AI, making professional SEO accessible to content creators of all skill levels.

**[Contact us](https://apostrophecms.com/contact-us)** to learn more about ApostropheCMS Pro.

## 🏢 Managing Multiple Sites?

**Running multiple websites with shared content and branding?** Consider [**ApostropheCMS Assembly**](https://apostrophecms.com/assembly) for enterprise multisite management:

### ✨ Assembly Multisite Features
- **🗂️ Centralized Management**: Control multiple sites from a single dashboard
- **🚀 Shared Codebase**: Deploy updates across all sites simultaneously  
- **🌐 Multi-Domain Support**: Each site gets its own domain with automatic SSL
- **⚙️ Automatic SEO Configuration**: Base URLs and canonical links configured automatically
- **🎨 Per-Site Customization**: Individual themes, content, and settings per site
- **📊 Unified Analytics**: Track performance across your entire site network

Perfect for agencies, franchises, or organizations managing multiple branded websites.

**[Learn more about Assembly](https://apostrophecms.com/extensions/multisite-apostrophe-assembly)** or **[contact our team](https://apostrophecms.com/contact-us)**.

## Roadmap

| Feature | Status |
|---------|--------|
| SEO Meta fields for pages and pieces | ✅ Implemented |
| Google Analytics & Tag Manager integration | ✅ Implemented |
| Automated robots.txt generation | ✅ Implemented |
| Structured data (JSON-LD) for all major schema types | ✅ Implemented |
| Offer and AggregateOffer schemas | ✅ Implemented |
| SEO Assistant (AI-powered) | 🚀 Available in Pro |
| SEO Page Scanner | 🚧 Under development |

---

<div>
  <p>Made with ❤️ by the <a href="https://apostrophecms.com">ApostropheCMS</a> team. <strong>Found this useful? <a href="https://github.com/apostrophecms/seo">Give us a star on GitHub!</a> ⭐</strong>
  </p>
</div>

---

### Overview (`packages/sitemap/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>Sitemap generator for ApostropheCMS</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/blog/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

The Apostrophe Sitemap module generates XML sitemaps for websites powered by [ApostropheCMS](https://apostrophecms.com). The sitemap includes all of the pages on your site that are visible to the public, including "piece" content, such as events and blog posts.

A frequently updated and accurate XML sitemap allows search engines to index your content more quickly and spot new pages. The Sitemap module will maintain a cached sitemap to load quickly, but then automatically refresh after one hour (by default). This also prevents the sitemap from getting out-of-date, which would be *very bad* for SEO.

## Roadmap

| Feature                                                   | Status        |
| --------------------------------------------------------- | ------------- |
| Sitemap generation for single-locale websites             | ✅ Implemented |
| Module configuration to exclude certain doc types         | ✅ Implemented |
| Tasks to manually generate sitemap                        | ✅ Implemented |
| Text-style sitemap generation (for content strategy work) | 🚧 Planned     |
| Support for multiple locales (localization)               | ✅ Implemented |
| Output customization function                             | 🚧 Planned     |

## Installation

```bash
npm install @apostrophecms/sitemap
```

## Use

### Initialization

Configure `@apostrophecms/sitemap` in `app.js` as a project module.

```javascript
// app.js
require('apostrophe')({
  shortName: 'my-project',
  baseUrl: 'https://example.com',
  modules: {
    '@apostrophecms/sitemap': {}
  }
});
```

**Start the site** (with `node app` or your preferred command) and visit `http://localhost:3000/sitemap.xml` (in local development). You should now see any pages displayed in a sitemap as well as any pieces that have an associated piece page.

### Setting the `baseUrl`

It is important to configure a `baseUrl` for the project to properly display URLs. That can be done in the application configuration object as shown above. To support different domains in production and development environments, it can also be configured in a `data/local.js` file which should be ignored by version control. `data/local.js` will take precedence over `app.js`, so both can be used to support multiple environments as well.

```javascript
// data/local.js
module.exports = {
  baseUrl: 'http://localhost:3000'
};
```

### Options

All sitemap module options are configured in an `options` object.

```javascript
// modules/@apostrophecms/sitemap/index.js
module.exports = {
  // 👇 Module options
  options: {
    cacheLifetime: 1800,
    excludeTypes: [ 'exclusive-page', 'category' ]
  }
};
```

These can be added in the `app.js` configuration object for the module, but it is better practice to use a dedicated file for module configuration.

#### `cacheLifetime`

By default sitemaps are cached for one hour. You can change this by specifying the `cacheLifetime` option to this module, in seconds. It must be a number greater than zero.

**Tip:** To make entering the cache lifetime easier it can help to write it as a math expression, multiplying the desired number of minutes by sixty:

```javascript
cacheLifetime: 30 * 60 // or 1800 seconds
```

Keep in mind: Google and other search engines more than weekly, if that.Refreshing once every hour is usually more than often enough.

#### `excludeTypes`

If there are particular page types or piece content types that should *not* be in the sitemap, list them in an array as the `excludeType` option.

```javascript
excludeTypes: [ 'exclusive-page', 'category' ]
```

#### `piecesPerBatch` *(deprecated)*

This option is no longer used and will be ignored. It was previously used to control how many pieces were processed at a time when building the sitemap directly from database queries. The sitemap module now uses the centralized `getAllUrlMetadata` API instead.

### `perLocale`

If your project uses multiple locales and you want **each locale to have its own sitemap**, enable the `perLocale` option:

```js
// modules/@apostrophecms/sitemap/index.js
module.exports = {
  options: {
    perLocale: true
  }
};
```

This will:

* Generate separate sitemap files for each locale (e.g., `/sitemaps/en.xml`, `/sitemaps/es.xml`, etc.)
* Serve a sitemap index file at `/sitemaps/index.xml`
* Disable the default `/sitemap.xml` route (returns a 404)

> 💡 **Tip**
> If you're using multiple locales, enable `perLocale` to generate a separate sitemap for each one.

> ```
> Sitemap: https://example.com/sitemaps/index.xml
> ```

### Tasks

#### `print`

The `print` command will generate an up-to-date sitemap on demand and **print the sitemap into the console**. You can also pipe the output it as needed, to help generate a static file version. On the command line, run:

```bash
node app @apostrophecms/sitemap:print
```

#### `update-cache`

Use the `update-cache` task to force a cache update at any time. If the website is very large (multiple hundreds of URLs), running this task option with a cron job on the production server more often than the standard cache refresh can help ensure the cache is available when a search engine begins crawling the site.

```bash
node app @apostrophecms/sitemap:update-cache
```

#### `clear`

You can manually clear the cached sitemap at any time with the `clear` task. This will force a new sitemap to be generated on the next request to `/sitemap.xml`. On the command line, run:

```bash
node app @apostrophecms/sitemap:clear
```

### Telling search engines about the sitemap

Create a `public/robots.txt` file if you do not already have one and add a sitemap line. Here is a valid example for a site that doesn't have any other `robots.txt` rules:

```
Sitemap: https://example.com/sitemap.xml
```

### Troubleshooting

- If you already have a static `public/sitemap.xml` file, **that file will be shown at the `/sitemap.xml` URL path instead.** Remove it to let the module take over.
- Sitemaps are cached for one hour by default, so you won't see content changes instantly. See above about the `cacheLifetime` option, `clear` task, and `update-cache` task for ways to refresh the sitemap more frequently.

---

### Overview (`packages/sluggo/README.md`)

# sluggo

<a href="https://apostrophecms.com/"><img src="https://raw.githubusercontent.com/apostrophecms/sanitize-html/main/logos/logo-box-madefor.png" align="right" alt="Made for ApostropheCMS"/></a>

sluggo is a slug generator that:

* Understands Unicode
* Runs fast (much, much faster than a RegExp solution)
* Replaces all runs of punctuation (in any language), control characters, whitespace, etc. with single dashes, with no leading or trailing dashes
* Allows you to let one punctuation character through if you wish, such as a slash for pathnames
* Allows you to change the separator character
* Is small enough to include in your browser javascript (<10K), even with the Unicode data

## Installation

```bash
npm install sluggo
```

## Usage

```javascript
var sluggo = require('sluggo');

var s = sluggo('@ monkey\'s are elab؉؉orate fools##');
console.log(s);
```

Outputs:

```
monkey-s-are-elab-orate-fools
```

## Options

### separator

Change the string separator by passing a string (usually one character) to `separator`.

```javascript
const sluggo = require('sluggo');

const s = sluggo('monkey\'s are elaborate fools', {
  separator: ','
});
console.log(s);
```

Outputs:

```
monkey,s,are,elaborate,fools
```

### allow

Set a single-character string to allow in returned strings. Otherwise all punctuation characters are replaced by the separator.

```javascript
const sluggo = require('sluggo');

const s = sluggo('@ monkey\'s are elab؉؉orate fools##', {
  allow: '؉'
});
console.log(s);
```

Outputs:

```
monkey-s-are-elab؉؉orate-fools
```

## In the Browser

You just want `sluggo.js`. Add that file to your frontend javascript world.

Now you can call the `sluggo()` function anywhere.

You do NOT need `generator.js`, which we will use when the next version of Unicode comes out to update this module.

## About ApostropheCMS

sluggo was created at [P'unk Avenue](https://punkave.com) for use in [ApostropheCMS](https://apostrophecms.com), an open-source content management system built on Node.js. If you like sanitize-html you should definitely check out Apostrophe.

## Support

Feel free to [open issues on Github](http://github.com/apostrophecms/sluggo/issues).

## Changelog

### 1.0.0 - 2023-05-03
- Accepts an array of exceptions in the `allow` options property while still accepting a string. Declared stable.

### 0.3.1 - 2021-04-23
- Accepts the empty string as a legitimate value for `def`, as was always intended, rather than forcing `none` in that situation. If `def` is not set at all `none` is still the fallback default.

### 0.3.0 - 2020-01-27
- Updates package.json with new metadata
- Updates README.

### 0.2.0

Whoops, the classic apostrophe slugify method accepted `allow`, not `allowed`. We just released this today, so I've switched to `allow` in `sluggo` as well. However I did bump to 0.2.0 to remain faithful to the semver standard.

### 0.1.2

Converts to lowercase properly.

### 0.1.1

Packaged correctly to work in either node or the browser.

### 0.1.0

Initial release.

---

### Overview (`packages/stylelint-config-apostrophe/README.md`)

# stylelint-config-apostrophe

> An stylelint configuration for the Apostrophe team.

Extends `stylelint-config-standard`

This config also leverages the following plugins to fine tune our rules.

```json
"stylelint-order"
"stylelint-declaration-strict-value"
"stylelint-scss"
"stylelint-selector-bem-pattern"
```

## Installation

```bash
npm i stylelint-config-apostrophe --save-dev
```

## Package versions

Choose the appropriate for your application `stylelint-config-apostrophe` version based on the `stylelint` dependency introduced on a project level:

| Stylelint       | Apostrophe config |
| :-------------- | ----------------: |
| >= v13.8.x < 14 |            v1.x.x |
| v14.x.x         |            v2.x.x |
| v16.x.x         |            v2.x.x |

[Migrating to stylelint v14](https://stylelint.io/migration-guide/to-14/)

## Usage

If you've installed `stylelint-config-apostrophe` locally within your project, just set your `stylelint` config to:

```json
{
  "extends": "stylelint-config-apostrophe"
}
```

In order to also use the `stylelint-selector-bem-pattern` rules, you'll also need to configure this rule at a project level.

If using our build tools you can configure this as in your `webpack.config` by setting the `prefix` option of the `prefixer` setting. You may also use the `ignore` option to allow vendor prefixes to be allowed.

```js
prefixer: {
  prefix: 'my-project',
  ignore: [ /apos-/ ]
}
```

If you're not using our build tools, you may simply configure your rules in your `.stylelintrc` file per the instructions from the [`stylelint-selector-bem-pattern` plugin](https://github.com/simonsmith/stylelint-selector-bem-pattern#usage).

## Viewing differences between the standard configuration and ours

From this project's root, run

```
npm run compare
```

This will print a report in your terminal which shows which rules we not added to our config and which rules we have specifically modified.

All missing rules should be added to the `.stylelintrc.json` with a definition even if we agree with the standard. This ensures that we are aware of any new rules or changed definitions that are made to the standard and will allow us to lock down the rule definitions that we agree with.

---

### Overview (`packages/stylelint-no-mixed-decls/README.md`)

# @apostrophecms/stylelint-no-mixed-decls 

A Stylelint plugin that enforces Sass's `mixed-decls` rule — requiring declarations and nested rules to be ordered according to Sass's updated behavior.

> ⚠️ Since Sass `1.77.0+`, CSS blocks can no longer freely mix declarations and nested rules.  
> If you want to declare additional styles after nested rules, those declarations must be placed inside a `& { }` block.
>
> See: https://sass-lang.com/documentation/breaking-changes/mixed-decls/

## What this plugin does

- Prevents declarations appearing after nested rules within a CSS block unless they're wrapped in a `& { }` block.
- Handle `@include` statements that may contain nested rules.

## Installation

```bash
npm install @apostrophecms/stylelint-no-mixed-decls --save-dev
```

## Usage

Add it to your Stylelint configuration:

```js
{
  "plugins": [ "@apostrophecms/stylelint-no-mixed-decls" ],
  "rules": {
    "@apostrophecms/stylelint-no-mixed-decls": true
  }
}
```

You can set a `contain-nested` option to list mixins that are known to contain nested rules.  
This is an answer to the [limitations](#limitations) that this plugin cannot analyze mixin definitions from other files.

```js
{
  "plugins": [ "@apostrophecms/stylelint-no-mixed-decls" ],
  "rules": {
    "@apostrophecms/stylelint-no-mixed-decls": [
      true,
      {
        "contain-nested": [ "external-mixin-known-to-contain-nested-rules" ]
      }
    ] 
  }
}
```

## Example: Correct Usage

```scss
.foo {
  color: red;

  &--large {
    font-size: 24px;
  }

  & {
    font-weight: bold;
  }
}
```

```scss
@mixin foo {
  display: block;
  clear: both;
}

.foo {
  @include foo;
  @include external-mixin; // not known to contain nested rules
  color: red;
}
```

```scss
@mixin foo {
  display: block;
  clear: both;

  &--large {
    font-size: 24px;
  }
}

.foo {
  @include foo;

  & {
    color: red;
  }
}
```

```scss
@mixin foo {
  & {
    display: block;
    clear: both;
  }
}

.foo {
  font-weight: bold;

  & {
    color: red;
  }

  @include foo;
}
```

## Example: Incorrect Usage (will report)

```scss
.foo {
  color: red;

  &--large {
    font-size: 24px;
  }

  font-weight: bold; // ❌ Cannot mix declarations and nested rules. Group them together or wrap declarations in a nested "& { }" block. See https://sass-lang.com/documentation/breaking-changes/mixed-decls/
}
```

```scss
@mixin foo {
  display: block;
  clear: both;

  &--large {
    font-size: 24px;
  }
}

.foo {
  @include foo;
  color: red; // ❌ Cannot mix declarations and nested rules. Group them together or wrap declarations in a nested "& { }" block. See https://sass-lang.com/documentation/breaking-changes/mixed-decls/
}
```

```scss
@mixin foo {
  display: block; // ❌ Cannot mix declarations and nested rules. Group them together or wrap declarations in a nested "& { }" block. See https://sass-lang.com/documentation/breaking-changes/mixed-decls/
  clear: both; // ❌ Cannot mix declarations and nested rules. Group them together or wrap declarations in a nested "& { }" block. See https://sass-lang.com/documentation/breaking-changes/mixed-decls/
}

.foo {
  font-weight: bold;

  & {
    color: red;   
  }

  @include foo;
}
```

```scss
.foo {
  @include external-mixin-known-to-contain-nested-rules;

  color: red; // ❌ Cannot mix declarations and nested rules. Group them together or wrap declarations in a nested "& { }" block. See https://sass-lang.com/documentation/breaking-changes/mixed-decls/
}
```

## Why this matters

This plugin ensures your Sass code adheres to modern CSS nesting behavior,
prevents breaking builds on newer Sass versions,
and keeps your codebase cleanly structured.

## Limitations

This plugin only analyzes mixins defined within the same file being linted.
It does not resolve or inspect mixin definitions from other files,
even if those files are imported via Sass `@use` or `@import`.

As a result:

- If a mixin with the same name exists in multiple files with different contents
(some containing nested rules, others not), this plugin will only be aware of
the mixin definitions present in the file currently being linted.

- It will not detect nested rules within mixins defined elsewhere or included
from external files.

**Recommendation:**

To ensure accurate linting results, define critical mixins with nested rules consistently
or colocate them with the code that uses them.
Alternatively, wrap declarations following `@include` statements
in a nested `& { }` block as a safe default when unsure of the mixin's contents.

**Use `contain-nested` option:**

This option can be used to list mixins that are known to contain nested rules,
so that the plugin can treat them accordingly,
even if their definition is not present in the file they are used.

## Please contribute!

We welcome contributions! If you find a bug or something missing,
please open an issue or submit a pull request.

---

### Overview (`packages/svg-sprite/README.md`)

<div align="center">
  <img src="https://raw.githubusercontent.com/apostrophecms/apostrophe/main/logo.svg" alt="ApostropheCMS logo" width="80" height="80">

  <h1>SVG Sprites for ApostropheCMS</h1>
  <p>
    <a aria-label="Apostrophe logo" href="https://v3.docs.apostrophecms.org">
      <img src="https://img.shields.io/badge/MADE%20FOR%20ApostropheCMS-000000.svg?style=for-the-badge&logo=Apostrophe&labelColor=6516dd">
    </a>
    <a aria-label="Test status" href="https://github.com/apostrophecms/svg-sprite/actions">
      <img alt="GitHub Workflow Status (branch)" src="https://img.shields.io/github/workflow/status/apostrophecms/svg-sprite/Tests/main?label=Tests&labelColor=000000&style=for-the-badge">
    </a>
    <a aria-label="Join the community on Discord" href="http://chat.apostrophecms.org">
      <img alt="" src="https://img.shields.io/discord/517772094482677790?color=5865f2&label=Join%20the%20Discord&logo=discord&logoColor=fff&labelColor=000&style=for-the-badge&logoWidth=20">
    </a>
    <a aria-label="License" href="https://github.com/apostrophecms/svg-sprite/blob/main/LICENSE.md">
      <img alt="" src="https://img.shields.io/static/v1?style=for-the-badge&labelColor=000000&label=License&message=MIT&color=3DA639">
    </a>
  </p>
</div>

This module provides an Apostrophe piece type that manages and renders SVG sprites. Sprites can be imported from files in a website codebase or an external source via a URL.

SVG sprites must be generated by a separate process. The module does not provide functionality to build the sprite files. [See below](#sprite-file-markup) for sprite markup requirements.

## Installation

To install the module, use the command line to run this command in an Apostrophe project's root directory:

```
npm install @apostrophecms/svg-sprite
```

## Usage

Configure the SVG Sprite module in the `app.js` file:

```javascript
require('apostrophe')({
  shortName: 'my-project',
  modules: {
    '@apostrophecms/svg-sprite': {}
  }
});
```

The SVG Sprites module should then be configured in its own `index.js` file with information about the sprite maps. Sprite files should be registered in the `maps` option, set to an array of configuration objects.

```javascript
// modules/@apostrophecms/svg-sprite/index.js
module.exports = {
  options: {
    maps: [
      {
        label: 'Places Icons',
        name: 'places',
        file: 'svg/places.svg'
      },
      {
        label: 'Service Icons',
        name: 'services',
        file: 'svg/services.svg'
      }
    ]
  }
}
```

The configuration objects include:

- `label`: A clear label for the group of sprites.
- `name`: A string with no whitespace that is unique within the project.
- `file`: The location of the file. This may be a local file or a URL, as discussed below.

The sprites can be imported into Apostrophe as pieces by running the module's `import` task. This task will look for each registered sprite file and generate pieces (Apostrophe content) for each SVG. On the command line this task could be started from the project root with the following command:

```bash
node app @apostrophecms/svg-sprite:import
```

### Sprite file location options

There are three options for registering SVG file locations:

1. Use a partial file path to a specific file, e.g., `'svg/places.svg'`.
2. Use a partial file path with wild card symbols, e.g., `'svg/*-icons.svg'`. See the [Glob package documentation](https://www.npmjs.com/package/glob#glob-primer) for acceptable patterns.
3. Use a URL for an externally hosted file, e.g., `'http://myfiles.net/svg/icons.svg'`.

When using the partial file path options, the module will look for those files in its own `public` directory: `modules/@apostrophecms/svg-sprite/public/`. For example, `'svg/places.svg'` would reference a file at `modules/@apostrophecms/svg-sprite/public/svg/places.svg` in the code base.

### Sprite file markup

Sprite files use the [SVG `symbol` element](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/symbol) to include multiple SVG images within a single `svg` tag. See the [CSS-Tricks](https://css-tricks.com/svg-symbol-good-choice-icons/) guide for more information about how to construct and use these sprite files.

**Requirements for this module include:**
- Sprite maps must be formatted so that all `<symbol>...<symbol/>` elements are on the same node level. This simply means that `symbol` tags should not be nested within other `symbol` tags.
- `symbol` tags must have an `id` attribute, e.g., `<symbol id="bicycle">...</symbol>`.
- `symbol` tags can *optionally* have a `title` attribute that will be used as the imported piece's title field, e.g., `<symbol title="Bicycle icon">...</symbol>`

Here is an example of sprite file markup (with the `path` values abbeviated):

```html
<svg xmlns="http://www.w3.org/2000/svg">
	<symbol width="24" height="24" viewBox="0 0 24 24" id="coffee_cup" >
		<path d="..." />
	</symbol>
	<symbol width="24" height="24" viewBox="0 0 24 24" id="bicycle" >
		<path d="..." />
	</symbol>
</svg>
```

### Using SVG sprite pieces

The primary properties we use to reference individual SVG symbols are:
- `file`: The file path or URL to the full sprite map. This file still includes all symbols that were part of that original map file.
- `svgId`: The `id` property of the individual SVG symbol. We have to use this in combination with the file path to get a specific symbol.
- `map`: The `map` name property can be used to quickly find one of the sprite maps that were configured in the module's `maps` array.

HTML markup using an individual SVG symbol might look like the example below. The example uses `svgSprite` as a reference to the individual piece content. A project might reference sprite pieces using a relationship field, for example.

```django
<svg>
  <use xlink:href="{{ svgSprite.file }}#{{ svgSprite.id }}"></use>
</svg>
```

---

### Overview (`packages/uploadfs/README.md`)

# uploadfs

<a href="https://apostrophecms.com/"><img src="https://raw.github.com/apostrophecms/uploadfs/master/logos/logo-box-madefor.png" align="right" /></a>

uploadfs copies files to a web-accessible location and provides a consistent way to get the URLs that correspond to those files. uploadfs can also resize, crop and autorotate uploaded images. uploadfs includes S3-based, Azure-based, GCS-based and local filesystem-based backends and you may supply others. The API offers the same conveniences with both backends, avoiding the most frustrating features of each:

* Parent directories are created automatically as needed (like S3 and Azure)
* Content types are inferred from file extensions (like the filesystem)
* Files are by default marked as readable via the web (like a filesystem + web server)
* Images can be automatically scaled to multiple sizes
* Images can be cropped
* Images are automatically rotated if necessary for proper display on the web (i.e. iPhone photos with rotation hints are right side up)
* Image width, image height and correct file extension are made available to the developer
* Non-image files are also supported
* Web access to files can be disabled and reenabled
* GIF is supported, including animation, with full support for scaling and cropping
* On fire about minimizing file sizes for your resized images? You can plug in `imagemin` and compatible tools using the `postprocessors` option.

You can also remove a file if needed.

It is possible to copy a file back from uploadfs, but there is no API to retrieve information about files in uploadfs. This is intentional. *Constantly manipulating directory information is much slower in the cloud than on a local filesystem and you should not become reliant on it.* Your code should maintain its own database of file information if needed, for instance in a MongoDB collection. Copying the actual contents of the file back may occasionally be needed however and this is supported.

## Requirements

You need:

* A "normal" filesystem in which files stay put forever, *OR* Amazon S3, *OR* Microsoft Azure, *OR* Google Cloud Platform OR a willingness to write a backend for something else (look at `s3.js`, `azure.js` and `local.js` for examples; just supply an object with the same methods, you don't have to supply a factory function).

* Most modern macOS, Windows and Linux systems running Node.js >= 12.13.0 do not require any additional install or runtime dependencies. They will automatically use `sharp`, which is extremely fast. Systems not meeting these qualifications can still use this module if `imagemagick` is installed on the system. You can also write a backend for something else (look at `sharp.js` or `imagemagick.js` for examples); just supply an object with the same methods, you don't have to supply a factory function.

* If you need to use `imagemagick` and want faster GIF support: you'll need [gifsicle](https://www.lcdf.org/gifsicle/). It is an optional tool that processes large animated GIFs much faster. This package is not necessary with Sharp. Turn it on with the `gifsicle: true` option when calling `init`. Of course you must install `gifsicle` to use it. (Hint: your operating system probably has a package for it. Don't compile things.)

* A local filesystem in which files stay put at least during the current request, to hold temporary files for Sharp's conversions. This is no problem with Heroku and most other cloud servers. It's just long-term storage that needs to be in S3 or Azure for some of them.

> Note that Heroku includes Imagemagick. You can also install it with `apt-get install imagemagick` on Ubuntu servers. Homebrew can install `imagemagick` on Macs.

## API Overview

* The `init` method passes options to the backend and invokes a callback when the backend is ready.

* The optional `destroy(callback)` method releases any resources such as file descriptors and timeouts held by `uploadfs`.

* The `copyIn` method takes a local filename and copies it to a path in uploadfs. (Note that Express conveniently sets us up for this by dropping file uploads in a temporary local file for the duration of the request.)

* The `copyImageIn` method works like `copyIn`. In addition, it also copies in scaled versions of the image, corresponding to the sizes you specify when calling `init()`. Information about the image is returned in the second argument to the callback.

* If you wish to crop the image, pass an options object as the third parameter to `copyImageIn`. Set the `crop` property to an object with `top`, `left`, `width` and `height` properties, all specified in pixels. These coordinates are relative to the original image. **When you specify the `crop` property, both the "full size" image copied into uploadfs and any scaled images are cropped.** The uncropped original is NOT copied into uploadfs. If you want the uncropped original, be sure to copy it in separately. The `width` and `height` properties of the `info` object passed to your callback will be the cropped dimensions.

* The default JPEG quality setting for scaled-down versions of your image is `80`. This avoids unacceptably large file sizes for web deployment. You can adjust this via the `scaledJpegQuality` option, either when initializing uploadfs or when calling `copyImageIn`.

* If you do not wish to always use the same set of image sizes, you may pass a `sizes` property as part of the options object when calling `copyImageIn`.

* The `copyOut` method takes a path in uploadfs and a local filename and copies the file back from uploadfs to the local filesystem. This should be used only rarely. Heavy reliance on this method sets you up for poor performance in S3 and Azure. However it may be necessary at times, for instance when you want to crop an image differently later. *Heavy reliance on copyOut is a recipe for bad S3 and/or Azure performance. Use it only for occasional operations like cropping.*

* The `streamOut` method takes a path in uploadfs and returns a readable stream. This should be used only rarely. Heavy reliance on this method sets you up for poor performance in S3 and Azure. However it may be necessary at times, for instance when permissions must be checked on a request-by-request basis in a proxy route. **This method, which is not required for normal use in ApostropheCMS, is currently implemented only in the `local` and `s3` storage backends.** Contributions for Azure and GCS are welcome.

* The `remove` method removes a file from uploadfs.

* The `getUrl` method returns the URL to which you should append uploadfs paths to fetch them with a web browser.

* The `disable` method shuts off web access to a file. Depending on the storage backend it may also block the `copyOut` method, so you should be sure to call `enable` before attempting any further access to the file.

* The `enable` method restores web access to a file.

* The `getImageSize` method returns the currently configured image sizes.

* The `identifyLocalImage` method provides direct access to the `uploadfs` functionality for determining the extension, width, height and orientation of images. Normally `copyIn` does everything you need in one step, but this method is occasionally useful for migration purposes.

The `destroy` method releases any resources such as file descriptors or timeouts that may be held by the backends, and then invokes its callback. Its use is optional, but command line Node apps might never exit without it.

## Working Example

For a complete, very simple and short working example in which a user uploads a profile photo, see `sample.js`.

Here's the interesting bit. Note that we do not supply an extension for the final image file, because we want to have Sharp figure that out for us.

```javascript
app.post('/', multipartMiddleware, function(req, res) {
  uploadfs.copyImageIn(req.files.photo.path, '/profiles/me', function(e, info) {
    if (e) {
      res.send('An error occurred: ' + e);
    } else {
      res.send('<h1>All is well. Here is the image in three sizes plus the original.</h1>' +
        '<div><img src="' + uploadfs.getUrl() + info.basePath + '.small.' + info.extension + '" /></div>' +
        '<div><img src="' + uploadfs.getUrl() + info.basePath + '.medium.' + info.extension + '" /></div>' +
        '<div><img src="' + uploadfs.getUrl() + info.basePath + '.large.' + info.extension + '" /></div>' +
        '<div><img src="' + uploadfs.getUrl() + info.basePath + '.' + info.extension + '" /></div>');
    }
  });
});
```

Note the use of `uploadfs.getUrl()` to determine the URL of the uploaded image. **Use this method consistently and your code will find the file in the right place regardless of the backend chosen.**

## Retrieving Information About Images

When you successfully copy an image into uploadfs with copyImageIn, the second argument to your callback has the following useful properties:

`width` (already rotated for the web if necessary, as with iPhone photos)

`height` (already rotated for the web if necessary, as with iPhone photos)

`originalWidth` (not rotated)

`originalHeight` (not rotated)

`extension` (`gif`,`jpg`, `webp` or `png`)

You should record these properties in your own database if you need access to them later.

**When cropping, the uncropped size of the original image is not returned by uploadfs. It is assumed that if you are cropping you already know what the original dimensions were.**

The same information is available via `identifyLocalImage` if you want to examine a local file before handing it off to `copyImageIn`.

## Removing Files

Here's how to remove a file:

```javascript
uploadfs.remove('/profiles/me.jpg', function(e) { ... });
```

## Disabling Access To Files

This call shuts off **web access** to a file:

```javascript
uploadfs.disable('/profiles/me.jpg', function(e) { ... });
```

And this call restores it:

```javascript
uploadfs.enable('/profiles/me.jpg', function(e) { ... });
```

*Depending on the backend, `disable` may also block the copyOut method*, so be sure to call `enable` before attempting any further access to the file.

*With the local storage backend, `disable` uses permissions `000` by default.* This is a big hassle if you want to be able to easily use rsync to move the files outside of `uploadfs`. **As an alternative, you can set the `disabledFileKey` option to a random string.** If you do this, uploadfs will *rename* disabled files based on an HMAC digest of the filename and the `disabledFileKey`. This is secure from the webserver's point of view, **as long as your webserver is not configured to display automatic directory listings of files**. But from your local file system's point of view, the file is still completely accessible. And that makes it a lot easier to use `rsync`.

*With the `azure` storage backend, you MUST set `disabledFileKey`.* This is because Azure provides no way to alter the permissions of a single blob (file). Our only option is to copy the blob to a new, cryptographically unguessable name and remove the old one while it is "disabled," then reverse the operation when it is enabled again.

For your convenience in the event you should lose your database, the filenames generated still begin with the original filename. The presence of a cryptographically un-guessable part is enough to make them secure.

Those using `local` storage can change their minds about using `disabledFileKey`. use `uploadfs.migrateToDisabledFileKey(callback)` to migrate your existing disabled files to this approach, and `uploadfs.migrateFromDisabledFileKey(callback)` to migrate back. Before calling the former, add the option to your configuration. Before calling the latter, remove it.

## Configuration Options

Here are the options we pass to `init()` in `sample.js`. Note that we define the image sizes we want the `copyImageIn` function to produce. No image will be wider or taller than the limits specified. The aspect ratio is always maintained, so one axis will often be smaller than the limits specified. Here's a hint: specify the width you really want, and the maximum height you can put up with. That way only obnoxiously tall images will get a smaller width, as a safeguard.
```javascript
{
  storage: 'local',
  // Optional. If not specified, Sharp will be used with automatic
  // fallback to Imagemagick.
  image: 'sharp',
  // Options are 'sharp' and 'imagemagick', or a custom image
  // processing backend
  uploadsPath: __dirname + '/public/uploads',
  uploadsUrl: 'http://localhost:3000' + uploadsLocalUrl,
  // Required if you use copyImageIn
  // Temporary files are made here and later automatically removed
  tempPath: __dirname + '/temp',
  imageSizes: [
    {
      name: 'small',
      width: 320,
      height: 320
    },
    {
      name: 'medium',
      width: 640,
      height: 640
    },
    {
      name: 'large',
      width: 1140,
      height: 1140
    }
  ],
  // Render up to 4 image sizes at once. Note this means 4 at once per call
  // to copyImageIn. There is currently no built-in throttling of multiple calls to
  // copyImageIn
  parallel: 4,
  // Optional. See "disabling access to files," above
  // disabledFileKey: 'this should be a unique, random string'
}
```

Here is an equivalent configuration for S3:

```javascript
{
  storage: 's3',
  // Add an arbitrary S3 compatible endpoint
  endpoint: 's3-compatible-endpoint.com',
  // Get your credentials at aws.amazon.com
  secret: 'xxx',
  key: 'xxx',
  // You need to create your bucket first before using it here
  // Go to aws.amazon.com
  bucket: 'getyourownbucketplease',
  // For read-after-write consistency in the US East region.
  // You could also use any other region name except us-standard
  region: 'external-1',
  // Required if you use copyImageIn, or use Azure at all
  tempPath: __dirname + '/temp',
  imageSizes: [
    {
      name: 'small',
      width: 320,
      height: 320
    },
    {
      name: 'medium',
      width: 640,
      height: 640
    },
    {
      name: 'large',
      width: 1140,
      height: 1140
    }
  ],
  // Render up to 4 image sizes at once. Note this means 4 at once per call
  // to copyImageIn. There is currently no built-in throttling of multiple calls to
  // copyImageIn
  parallel: 4
}
```

And, an equivalent configuration for Azure:

```javascript
{
  storage: 'azure',
  account: 'storageAccountName',
  container: 'storageContainerName',
  // If set to true, uploadfs will consider `key` to be a
  // SAS token. Otherwise, it will be considered an access key.
  // Default is false.
  sas: false,
  key: 'accessKeyOrSASToken',
  disabledFileKey: 'a random string of your choosing',
  // Always required for Azure
  tempPath: __dirname + '/temp',
  // by default we gzip encode EVERYTHING except for a short list of excpetions, found in defaultGzipBlacklist.js
  // if for some reason you want to enable gzip encoding for one of these types, you can
  // you can also add types to ignore when gzipping
  gzipEncoding: {
    'jpg': true,
    'rando': false
  },
  imageSizes: [
    {
      name: 'small',
      width: 320,
      height: 320
    },
    {
      name: 'medium',
      width: 640,
      height: 640
    },
    {
      name: 'large',
      width: 1140,
      height: 1140
    }
  ],
  // Render up to 4 image sizes at once. Note this means 4 at once per call
  // to copyImageIn. There is currently no built-in throttling of multiple calls to
  // copyImageIn
  parallel: 4
}
```

With Azure you may optionally replicate the content across a cluster:

```javascript
{
  storage: 'azure',
  replicateClusters: [
    {
      account: 'storageAccountName1',
      container: 'storageContainerName1',
      key: 'accessKey1'
    },
    {
      account: 'storageAccountName2',
      container: 'storageContainerName2',
      sas: true,
      key: 'sharedAccessSignature'
    }
  ],
  ...
}
```

And, an equivalent configuration for Google Cloud Storage:

```javascript
{
      storage: 'gcs',
      // Go to the Google Cloud Console, select your project and select the Storage item on the left side of the screen to find / create your bucket. Put your bucket name here.
      bucket: 'getyourownbucketplease',
      // Select your region
      region: 'us-west-2',
      // Required if you use copyImageIn, or use Azure at all
      tempPath: __dirname + '/temp',
      imageSizes: [
        {
          name: 'small',
          width: 320,
          height: 320
        },
        {
          name: 'medium',
          width: 640,
          height: 640
        },
        {
          name: 'large',
          width: 1140,
          height: 1140
        }
      ],
      // Render up to 4 image sizes at once. Note this means 4 at once per call
      // to copyImageIn. There is currently no built-in throttling of multiple calls to
      // copyImageIn
      parallel: 4
}
```
Note that GCS assumes the presence of a service account file and an environment variable of `GOOGLE_APPLICATION_CREDENTIALS` set pointing to this file. For example:
```sh
export GOOGLE_APPLICATION_CREDENTIALS=./projectname-f7f5e919aa79.json
```

In the above example, the file named `projectname-f7f5e919aa79.json` is sitting in the root of the module

For more information, see [Creating and Managing Service Accounts](https://cloud.google.com/iam/docs/creating-managing-service-account-keys) at cloud.google.com.

> When using Google Cloud Storage, you **must enable object ACLs for the
bucket**. Otherwise you will get this error: "cannot use ACL API to set object policy when object policies are disabled." You have 90 days to do this after first creating a bucket, otherwise you will need to use a new bucket for uploadfs.

## Less Frequently Used Options

* If you are using the `local` backend (files on your server's drive), you might not like that when `disable` is called, the permissions of a file are set to `000` (no one has access). We suggest using the `disabledFileKey` option to completely avoid this issue. However, if you wish, you can pass the `disablePermissions` option. As usual with Unix permissions, this is an OCTAL NUMBER, not a decimal one. Octal constants have been deprecated, so in modern JavaScript it is best to write it like this:
* 
```javascript
// Only the owner can read. This is handy if
// your proxy server serves static files for you and
// shares a group but does not run as the same user
disablePermissions: parseInt("0400", 8)
```

You can also change the permissions set when `enable` is invoked via `enablePermissions`. Keep in mind that `enable()` is not invoked for a brand new file (it receives the default permissions). You might choose to write:

```javascript
// Only the owner and group can read.
enablePermissions: parseInt("0440", 8)
```

* In backends like sharp or imagemagick that support it, even the "original" is rotated for you if it is not oriented "top left," as with some iPhone photos. This is necessary for the original to be of any use on the web. But it does modify the original. So if you really don't want this, you can set the `orientOriginals` option to `false`.

* It is possible to pass your own custom storage module instead of `local` or `s3`. Follow `local.js` or `s3.js` as a model, and specify your backend like this:
* 
```javascript
storage: require('mystorage.js')
```

* You may specify an alternate image processing backend via the `image` option. Two backends, `sharp` and `imagemagick`, are built in. You may also supply an object instead of a string to use your own image processor. Just follow the existing `sharp.js` file as a model.

* In backends like Google Cloud Storage and S3, uploadfs finesses the path so that paths with a leading slash like `/foo/bar.txt` behave reasonably and a double slash never appears in the URL. For Apostrophe this is a requirement. However, if you have your heart set on the double slashes, you can set the `strictPaths` option to `true`.

* uploadfs writes its own diagnostics to the console. If your application has a logging pipeline of its own, pass any console-shaped object as the `logger` option and they are delivered to it instead:

```javascript
logger: myLogger // an object with log, debug, info, warn and error methods
```

Any method you do not provide falls back to the console, except `log`, which falls back to your `info` when you have one. So a logger that offers only `info` and `error` — pino, for instance — still receives everything it can express.

## Extra features for S3: caching, HTTPS, CDNs, permissions, and No Gzip Content Types

By default, when users fetch files from S3 via the web, the browser is instructed to cache them for 24 hours. This is reasonable, but you can change that cache lifetime by specifying the `cachingTime` option, in seconds:

```javascript
  // 60*60*24*7 = 1 Week
  // Images are delivered with cache-control-header
  cachingTime: 604800
```

S3 file delivery can be set to use the HTTPS protocol with the `https` option. This is essentially necessary if used on a site that uses the secure protocol.

```javascript
  https: true
```

Also, if you are using a CDN such as cloudfront that automatically mirrors the contents of your S3 bucket, you can specify that CDN so that the `getUrl` method of `uploadfs` returns the CDN's URL rather than a direct URL to Amazon S3 or Azure:

```javascript
  cdn: {
    enabled: true,
    url: 'http://myAwesomeCDN'
  }
```

Note that specifying a CDN in this way does not in any way activate that CDN for you. It just tells `uploadfs` to return a different result from `getUrl`. The rest is up to you. More CDN-related options may be added in the future.

If you want to make your S3 bucket private and serve content through the Amazon CloudFront service, you need to set the objects' access control levels (ACL) in the bucket to `private`. By default, the `bucketObjectsACL` option sets the object ACL to `public-read`. You need to change this option to `private` to block public access. Additionally, follow the [documentation](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-restricting-access-to-s3.html) to ensure your bucket is set up with an Origin Access Control correctly, otherwise CloudFront will not be able to access it.

There is also a list which contains content types that should not be gzipped for faster delivery from s3. Note that gzip content delivery is completely transparent to the end user and supported by all browsers, so the only types that should be excluded are those that are already compressed (i.e. a waste of CPU to unzip) unless there is an issue with the gzip feature in a particular s3-compatible backend.

You can override this default list by setting the `noGzipContentTypes` option: 

```javascript
  // Don't gzip jpeg and zip files, but gzip everything else (override default list)
  noGzipContentTypes: ['image/jpeg', 'application/zip'] 
```

Alternatively you can just extend the standard list of types not to be gzipped by setting `addNoGzipContentTypes`:

```javascript
  // Additionally don't gzip pdf files (append to default list)
  addNoGzipContentTypes: ['application/pdf'] 
```

## Important Concerns With S3

Since 2015, files uploaded to S3 are immediately available in all AWS regions ("read after write consistency"). However, also be aware that no matter what region you choose, updates of an existing file or deletions of a file still won't always be instantly seen everywhere, even if you don't use the `us-standard` region. To avoid this problem, it is best to change filenames when uploading updated versions.

In `sample.js` we configure Express to actually serve the uploaded files when using the local backend. When using the s3 backend, you don't need to do this, because your files are served from S3. S3 URLs look like this:

```html
https://yourbucketname.s3.amazonaws.com/your/path/to/something.jpg
```

But your code doesn't need to worry about that. If you use `uploadfs.getUrl()` consistently, code written with one backend will migrate easily to the other.

It's up to you to create an Amazon S3 bucket and obtain your secret and key. See sample.js for details.

S3 support is based on the official AWS SDK.

## Applying a prefix to paths regardless of storage layer

If you are running several Apostrophe sites that must share an S3 bucket, you'll notice
that their uploads are jumbled together in a single `/attachments` "folder." With
the local storage method, you can address this by specifying an `uploadsPath` that
includes a different prefix for each site, but for S3 or Azure there was previously no good
solution.

Starting with version 1.11.0, you can specify a `prefix` option no matter what the
storage backend is. When you do, `uploadfs` will automatically prepend it to
all uploadfs paths that you pass to it. In addition, the `getUrl` method will
include it as well. So you can use this technique to separate files from several
sites even if they share a bucket in S3 or Azure.

**An important exception:** if you have configured the `cdn` option, `uploadfs` assumes that your cdn's `url` subproperty points to the right place for this individual site. This is necessary because CDNs may have prefix features of their own which remap the URL.

## Postprocessing images: extra compression, watermarking, etc.

It is possible to configure `uploadfs` to run a postprocessor such as `imagemin` on every custom-sized image that it generates. This is intended for file size optimization tools like `imagemin`.

Here is an example based on the `imagemin` documentation:

```javascript
const imagemin = require('imagemin');
const imageminJpegtran = require('imagemin-jpegtran');
const imageminPngquant = require('imagemin-pngquant');

uploadfs.init({
  storage: 'local',
  image: 'sharp',
  tempPath: __dirname + '/temp',
  imageSizes: [
    {
      name: 'small',
      width: 320,
      height: 320
    },
    {
      name: 'medium',
      width: 640,
      height: 640
    }
  ],
  postprocessors: [
    {
      postprocessor: imagemin,
      extensions: [ 'gif', 'jpg', 'png' ],
      options: {
        plugins: [
          imageminJpegtran(),
          imageminPngquant({quality: '65-80'})
        ]
      }
    }
  ]
});
```

A file will not be passed to a postprocessor unless it is configured for the file's true extension as determined by the image backend (`gif`, `jpg`, `png` etc., never `GIF` or `JPEG`).

The above code will invoke `imagemin` like this:

```javascript
imagemin([ '/temp/folder/file1-small.jpg', '/temp/folder/file2-medium.jpg', ... ], '/temp/folder', {
  plugins: [
    imageminJpegtran(),
    imageminPngquant({quality: '65-80'})
  ]
}).then(function() {
  // All finished
}).catch(function() {
  // An error occurred
});
```

You may write and use other postprocessors, as long as they expect to be called the same way.

> Note that the second argument is always the folder that contains all of the files in the first argument's array. `uploadfs` expects your postprocessor to be able to update the files "in place." All of the files in the first argument will have the same extension.

If your postprocessor expects four arguments, uploadfs will pass a callback, rather than expecting a promise to be returned.

## Participating in development

### Running the unit tests

If you wish to run the unit tests of this module, you will need to copy the various `-sample.js` files to `.js` and edit them to match your own credentials and buckets for the various services. In addition, you will need to download your credentials `.json` file for Google Cloud Services and place it in `gcs-credentials-uploadfstest.json`. *None of these steps are needed unless you are running our module's unit tests, which only makes sense if you are contributing to further development.*

## About P'unk Avenue and Apostrophe

`uploadfs` was created at [P'unk Avenue](https://punkave.com) for use in many projects built with Apostrophe, an open-source content management system built on node.js. Appy isn't mandatory for Apostrophe and vice versa, but they play very well together. If you like `uploadfs` you should definitely [check out apostrophecms.com](https://apostrophecms.com). Also be sure to visit us on [github](http://github.com/apostrophecms).

## Support

Feel free to open issues on [github](http://github.com/apostrophecms/uploadfs).

<a href="https://punkave.com/"><img src="https://raw.github.com/apostrophecms/uploadfs/master/logos/logo-box-builtby.png" /></a>

---

--- METRICS ---
- Files Extracted: 50
- Estimated Token Budget: ~125122 tokens
- Canonical Reference: https://codewiki.google/github.com/apostrophecms/apostrophe
