apostrophe Documentation

A full-featured, open-source content management framework built with Node.js that empowers organizations by combining in-context editing and headless...

4,652 stars JavaScript JavaScript 50 files Β· ~125,207 tokens #apostrophe#cms#cms-framework#jamstack#javascript#node#node-js#nodejs
RAW Doc

Technical Documentation: apostrophecms/apostrophe

ℹ️ Provenance: Multi-Tier Ingestion Β· CodeWiki Reference

Overview (`README.md`)

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 for version management
MongoDB 6.0+ MongoDB Atlas (cloud) or local install
npm 10.x+ Included with Node.js

See our setup guides 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:

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:

Hosting & Deployment

Choose ApostropheCMS hosting for turnkey solutions with optimized performance and dedicated support, or deploy to any platform where Node.js runs.

Built With Modern Tech

  • Node.js - JavaScript runtime for server-side development
  • MongoDB - 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 - Get help, share projects, and connect with other users
  • GitHub Discussions - Feature requests, technical discussions, and community support
  • Documentation - 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.

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 and sign up for a Pro license in our self-service Apostrophe Workspaces, or contact us to learn about licensing and support options.

License

ApostropheCMS is open source software licensed under the MIT License. This means you're free to use, modify, and distribute it for both personal and commercial projects.



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

We have a quick list of common questions to get you started engaging with this project in
our documentation


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

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

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):

text
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 to learn about Pro licensing and enterprise AI capabilities.


Need Help?

License

MIT


Made with ❀️ by the ApostropheCMS team. Found this useful? Give us a star on GitHub! ⭐


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

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.

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.


Made with ❀️ by the ApostropheCMS team. Found this useful? Give us a star on GitHub! ⭐


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

Astro integration for ApostropheCMS

This module integrates ApostropheCMS into your Astro 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 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:

πŸ’‘ 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 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 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 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.
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), 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.

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

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

Importing these from their old lib/ paths still works but is deprecated; see the upgrade guide 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.

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

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.

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';
  1. 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`)

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 for version management
MongoDB 6.0+ MongoDB Atlas (cloud) or local install
npm 10.x+ Included with Node.js

See our setup guides 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:

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:

Hosting & Deployment

Choose ApostropheCMS hosting for turnkey solutions with optimized performance and dedicated support, or deploy to any platform where Node.js runs.

Built With Modern Tech

  • Node.js - JavaScript runtime for server-side development
  • MongoDB - 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 - Get help, share projects, and connect with other users
  • GitHub Discussions - Feature requests, technical discussions, and community support
  • Documentation - 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.

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 and sign up for a Pro license in our self-service Apostrophe Workspaces, or contact us to learn about licensing and support options.

License

ApostropheCMS is open source software licensed under the MIT License. This means you're free to use, modify, and distribute it for both personal and commercial projects.



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

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.

For Your Own Project

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

  1. Install @apostrophecms/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:

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'
      }
    }
  }
};
  1. In Swagger UI: Click "Authorize" β†’ scroll to "ApiKeyAuth" β†’ enter myTestKey

  2. 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"
}
  1. Copy the token: From the response, copy only the token value (not the full JSON).

    • Example: if response is {"token": "abc123xyz"}, copy only abc123xyz
  2. 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 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 for linting.


SDK Generation Details

This repository uses OpenAPI Generator 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

Support & Community


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


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

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:

text
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 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 and query builder 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:

text
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 ⭐


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

Boring

A command line argument parser without pirates

What you get with Boring

Input:

text
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:

text
-x 50

Or:

text
-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.

Support

Feel free to open issues on github.


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

broadband

=========

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, 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 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.

Support

Feel free to open issues on github.

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

"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.

About P'unk Avenue and Apostrophe

cache-on-demand was created at P'unk Avenue 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.

Support

Feel free to open issues on github.

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 module.

CHANGES IN 0.1.0

Initial release. With shiny unit tests, of course.


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

This module enhances the core caching module, @apostrophecms/cache, to use Redis 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:

text
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:[email protected]:6380'
    }
  }
};

By default, the client will look for a Redis server running on localhost port 6379. See all the client configuration options 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 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 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 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.


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:

text
<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:

text
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. 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

text
mongodb://localhost:27017/mydb
mongodb+srv://user:[email protected]/mydb

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

PostgreSQL

text
postgres://localhost:5432/mydb

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

SQLite

text
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:

text
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:

text
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)

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

Designed for use with the ApostropheCMS multisite 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 β€” client.db(name), listing collections, dropping databases
  • Collection Methods β€” CRUD operations, cursors, and bulk writes
  • Query Operators β€” filtering documents with comparison, logical, element, and array operators
  • Update Operators β€” modifying documents with $set, $inc, $push, and more
  • Indexes β€” creating and managing indexes, including numeric and date types
  • Aggregation β€” pipeline stages and group accumulators
  • Dump and Restore β€” 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,
v5,
v6
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.
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,
v5,
v6
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`)

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:

text
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 and query builder 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 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

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

Install

text
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 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 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.

Support

Feel free to open issues on github.

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

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

text
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 ``, 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
    `` survives sanitization β€” only pixel values
    are permitted, and only where the size tool is enabled.

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

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. Enable the Google Sheets API on the project in the API Library.
  2. Create an API service account 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.
  1. 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.
  2. 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 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). 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`)

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

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

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 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 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 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 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 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 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 or contact our team to learn about Pro licensing and access enterprise features that enhance form management, compliance, and team collaboration.


Made with ❀️ by the ApostropheCMS team. Found this useful? Give us a star on GitHub! ⭐


Overview (`packages/i18n-static/README.md`)

i18n-static

Edit translations of static text with i18next

  1. Installation
  2. Configuration
  3. Behavior
  4. Import and export

This module makes it possible to edit the translations of static text strings found in templates 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 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.

1 Installation [⬆](#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': {},
  }

2 Configuration [⬆](#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).

3 Behavior [⬆](#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.

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.

4 Import and export [⬆](#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 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:

text
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.

The export feature is available in the context menu when items are selected for import.

The piece-type-exporter plugin 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`)

This module improves @apostrophecms/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. It now depends on @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 should be installed and instantiated.

Please refer to the module's README before continuing.

Installation

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

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

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:

text
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.


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.


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.


Templates

If you have the Template Library Pro 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.


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. If you have the optional @apostrophecms-pro/automatic-translation extension 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:

text
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:

text
/* 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)
  • 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 sanitization module for the people. Built for use in the ApostropheCMS, 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. Also be sure to visit us on github.

Support

Feel free to open issues on github.


Overview (`packages/login-hcaptcha/README.md`)

This login verification module adds a hCaptcha 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:

text
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 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). 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 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`)

This login verification module adds a reCAPTCHA 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:

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

This login verification module adds a TOTP (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:

text
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:

text
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:

text
--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, 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`)

moog-require

moog-require provides powerful module subclassing for server-side development. It extends the features of 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

text
/* 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, 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

oembetter is a modern oembed 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 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 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.


Overview (`packages/open-graph/README.md`)

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

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?

  • 🎯 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': {}
  }
});
  1. Generate your OpenAPI spec:
bash
node app openapi-generator:generate
  1. View interactive documentation:
bash
node app openapi-generator:docs --open
  1. Generate a client SDK:
bash
# Generate TypeScript client
node app openapi-generator:generateSDK typescript
  1. 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.

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 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/
  1. 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
  1. 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


πŸ’Ž Ready for Enterprise Features?

Need advanced API capabilities for your development workflow? This free OpenAPI generator works great with ApostropheCMS Pro extensions to document enterprise-grade endpoints:

πŸš€ **ApostropheCMS Pro Features**

Create an account on Apostrophe Workspaces and upgrade to ApostropheCMS Pro or contact our team to learn more about ApostropheCMS Pro licensing and unlock enterprise-grade API endpoints that will enhance your documentation and development capabilities.


Made with ❀️ by the ApostropheCMS team. Found this useful? Give us a star on GitHub! ⭐


Overview (`packages/passport-bridge/README.md`)

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 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 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 module, but there are several challenges:

  • Because of the way it is exported, the strategy cannot simply be required 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:

text
/* Detailed source-code truncated for AI context efficiency. */

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.

When using @apostrophecms/passport-bridge with an Astro frontend (via @apostrophecms/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:

text
node app @apostrophecms/passport-bridge:listUrls

You'll see something like:

text
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. 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.
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.

  1. The user receives the email and follows the link provided.

  2. The user is redirected to authorize access to their github account (in this example).

  3. 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 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 for information how to change this.


Made with ❀️ by the ApostropheCMS team. Found this useful? Give us a star on GitHub! ⭐


Overview (`packages/postcss-viewport-to-container-toggle/README.md`)

postcss-viewport-to-container-toggle

A plugin for 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.

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

text
npm install random-words

Examples:

text
/* Detailed source-code truncated for AI context efficiency. */

Overview (`packages/redirect/README.md`)

Installation

First make sure you have an Apostrophe project!

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:

text
/auto/*

This will redirect all URLs beginning with /auto/ to your destination, like this:

text
/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:

text
/auto/gm/bolt -> /en/auto/gm/bolt
/auto/* -> /fr/auto

Then actual redirects will play out like this:

text
/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:

text
/auto/*

And you select "URL" as the redirect type and type the following in the "URL" field:

text
/fr/auto/*

Then redirects will occur like this:

text
/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:

text
/auto/hyundai/* -> /en/car/hyundai/*
/auto/* -> /fr/auto/*

Then redirects will play out like this:

text
/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

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

text
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>"));

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

text
/* 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 `````` 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 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>helloworld</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 content to content instead of stripping it: content

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, 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. foobar) or explicit (e.g. foobar).

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:

text
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 for use in ApostropheCMS, 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.


Overview (`packages/scheduled-publishing/README.md`)

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:

text
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.

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

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:

text
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.

Configuring the `Content-Security-Policy` header

The Content-Security-Policy header is more complex than the others. As described in MDN, 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:

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

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.


Table of Contents

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

You can also map existing field names to required field names using the seoFieldMappings 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 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 for detailed configuration guidance

Related: This module also provides automated llms.txt generation 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 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 for details.

Essential Configuration

Setting the Base URL

This step is required for proper canonical link generation and SEO performance. If using ApostropheCMS 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.

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

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)

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: "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

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.

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)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)Author β†’ author or _authorDates β†’ 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)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 _authorDate β†’ 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 and Schema Markup Validator 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:

text
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:

text
/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 or Google Fonts Helper
    • 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:

text
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 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. Get access to the 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 to learn more about ApostropheCMS Pro.

🏒 Managing Multiple Sites?

Running multiple websites with shared content and branding? Consider ApostropheCMS 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 or contact our team.

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

Made with ❀️ by the ApostropheCMS team. Found this useful? Give us a star on GitHub! ⭐


Overview (`packages/sitemap/README.md`)

The Apostrophe Sitemap module generates XML sitemaps for websites powered by ApostropheCMS. 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.

text
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:

text
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

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:

text
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:

text
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:

text
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 for use in ApostropheCMS, 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.

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

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.

Viewing differences between the standard configuration and ours

From this project's root, run

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

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 for sprite markup requirements.

Installation

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

text
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 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 to include multiple SVG images within a single svg tag. See the CSS-Tricks 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

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. 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 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 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 Apo