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:
# 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 devYour new ApostropheCMS site will be available at http://localhost:3000 with a powerful admin interface at /login.
Prefer to Go Headless?
Get started with Astro integration - the easiest way to build headless sites while keeping visual editing:
- Apollo Starter Kit (Astro) - Production-ready foundation with beautiful design system and rich content features
- Essentials Starter Kit (Astro) - Minimal, clean foundation for building custom designs from scratch
Both starter kits provide headless CMS power with in-context editing, letting content creators edit directly on the live site while you build with modern frontend tools. Our Astro integration handles all the content fetching automaticallyβno REST API calls to write.
Desire a different frontend framework? Use our REST APIs with React, Vue, Svelte, or any other framework:
- REST API Documentation - Complete API reference
- Headless CMS Guide - Integration walkthrough for any 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.
- Contribution Guide - How to contribute code, documentation, and feedback
- Good First Issues - Perfect starting points for new contributors
Pro Features
For teams and organizations requiring additional features:
- π Advanced User Management - Granular permissions, user groups, and access controls
- π Automated Translation - AI-powered translation with DeepL, Google Translate, and Azure
- π Analytics & SEO - Built-in SEO optimization and content analytics
- β‘ Performance Optimization - Advanced caching, CDN integration, and performance monitoring
- π’ Multisite Management - Manage multiple sites from a single dashboard with shared resources
- πΌ Professional Support - Dedicated support, training, and consultation services
Explore all the pro extensions 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
- Configuration
- Usage
- Provider Configuration Options
- Mix and Match Providers
- Bundled Providers
- Provider Metadata
- Custom AI Providers
- API Endpoints
- π Ready for Enterprise AI Features?
- Need Help?
- License
Installation
To install the module, use the command line to run this command in an Apostrophe project's root directory:
npm install @apostrophecms/ai-helperAll 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:
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.jsconfiguration.
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.apiKeyorimageProviderOptions.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:
// 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):
textYou 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:
- Per-area prompt
- Global prompt
- Default guardrails
Global Configuration (in app.js):
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:
// 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 AIappendSystemPrompt- Whentrue, appends your prompt to the default guardrails. Whenfalse, replaces them entirely.
Tip: Set
appendSystemPrompt: trueto 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:
// 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:
export APOS_AI_HELPER_MOCK=trueWhen 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
- Add an image widget to your page
- Click "Browse" to open the media manager
- Click the π€ robot button in the upper right
- Enter a description and click "Generate"
- Review the generated image(s) (1 is the default)
- 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.
- In a rich text widget, press
/at the start of a line - Choose "Generate Text"
- Enter your prompt (be specific!)
- 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
'@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
'@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
'@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:
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
imageSizeparameter (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
imageAspectRatioparameter (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:
- Registration - Providers register their factory function and metadata (capabilities, label)
- 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:
aiHelper.registerProvider('bedrock', {
factory: bedrockFactory,
label: 'AWS Bedrock',
capabilities: {
text: true,
image: false,
imageVariation: false
}
});The factory function will be called with:
apos- ApostropheCMS instancetextOpts- Options fromtextProviderOptionsimageOpts- Options fromimageProviderOptions
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_MOCKis 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:
{
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:
[
{
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
Errorobjects - 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.mdin 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):
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?
- Documentation: ApostropheCMS Documentation
- Community Support: Join our Discord
- Professional Support: Contact us for dedicated support packages
- Issues & Feedback: GitHub Issues - Your feedback helps shape future AI features!
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:
npm install @apostrophecms/anchorsUsage
Configure the anchor modules in the app.js file:
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.
// modules/my-banner-widget/index.js
export default {
options: {
anchorAttribute: 'data-anchor'
}
};In this example the wrapping div element would look like this:
<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.
// 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.
// 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
- About Astro
- Bringing ApostropheCMS and Astro together
- Installation
- Security
- Configuration (Astro)
- Options
- Mapping Apostrophe templates to Astro components
- Mapping Apostrophe widgets to Astro components
- Creating the
[...slug.astro]component and fetching Apostrophe data - Creating Astro page components
- Creating Astro widget components
- Accessing image and URLs
- What to change in your Apostrophe project
- Starting up your combined project
- Logging in
- Redirections
- 404 Not Found
- Reserved routes
- What about widget players?
aposSetQueryParameter: working with query parameters- Other helper functions
- What about Vue, React, SvelteJS, etc.?
- A note on production use
- Static builds
- Output and logging
- Debugging
- Enabling the
render-areaoption to ApostropheCMS REST APIs - Enabling the
@apostrophecms/layout-widgetin an existing project - Conclusion
- Acknowledgements
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:
An Astro project. This is where you write your templates and frontend code.
An Apostrophe project. This is where you define your page types, widget types
and other content types with their schemas and other customizations.
This kind of dual-project CMS integration is typical for Astro.
The best way to keep everything consistent is to build these in frontend and backend subdirectories of the same git repository.
To get you started quickly, we recommend one of our official Astro starter kits:
- apostrophecms/starter-kit-astro-essentials is best for a clean start with as little extra code as possible.
- apostrophecms/astro-public-demo is a full-fledged project with a blog, custom widgets, and other nice touches. It can also be loaded with the same data as found in our online public demo
π‘ 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:
cd my-astro-project
npm install @apostrophecms/apostrophe-astroAstro 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 deprecatedlib/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.
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.noExternalentry for@apostrophecms/apostrophe-astroto 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. |
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.
// 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 asrc/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.
// 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/imagedo 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-widgetneeds 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:
---
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 anypiece: the piece document when on a "show page" for a piece page typepieces: an array of pieces when on an "index page" for a piece page typeuser: information about the currently logged-in userglobal: the ApostropheCMS global document e.g. global settings, editable global
headers and footers, etc.query: thereq.queryobject, 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 endstartBody: at the very beginning of the `` - this is not part of the refresh zone in edit modebeforeMain: at the very beginning of the main body zone - part of the refresh zone in edit modemain: the inner part of the main body zone - part of the refresh zone in edit modeafterMain: at the very end of the main body zone - part of the refresh zone in edit modeendBody: 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 Apostrophetitle: this will go in the<title>HTML taglangwhich will be set in the<html>langattributebodyClass: this will be added in theclassattribute 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 thesrc/templates/HomePage.astro file mentioned above.
We do not recommend placing these in
src/pagesbecause their names are not
routes and Astro should not try to compile them as routes. Place them insrc/templatesinstead.src/pagesshould only contain the[...slug.astro]file.
As an example, let's take a look at a simple home page template:
---
// 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. TheAposField 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.
---
// 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:
// 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 astitle. 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 arestring 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 forrender-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:
---
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 updateyour project to the latest version ofapostrophe. - You'll need to set the
APOS_EXTERNAL_FRONT_KEYenvironment 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:
cd my-astro-project
npm install
export APOS_EXTERNAL_FRONT_KEY=your-secret-goes-here
npm run devIn an adjacent terminal, start your Apostrophe project:
cd my-apostrophe-project
npm install
export APOS_EXTERNAL_FRONT_KEY=your-secret-goes-here
npm run devFor 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:
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/loginand/[locale]/loginfor 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.
---
// 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:
---
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:
- Take a URL and return a new URL with a certain query parameter set
to a new value. - Remove a query parameter completely by passing the empty string as
a value, or by passingnullorundefined.
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 constructionslugifyβ the same slug rules Apostrophe usesstylesAttributes,stylesElementsβ render the styles system's output for a document or widgetgetAttachmentUrl,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 backendgetAposHost,isStaticBuildβ resolved integration stategetAllStaticPaths,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:
---
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 thestaticBuild option and its environment variables. Both steps need APOS_HOST andAPOS_EXTERNAL_FRONT_KEY to be set at build time and are skipped silently if either is missing.
[!TIP]
If your project setssecurity.allowedDomainsinastro.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 undernode_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-configandvirtual: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:
// 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
// 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
propsthat 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:
---
// 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
- Add
@apostrophecms/layoutto 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:
- Create a
backend/modules/@apostrophecms/layout-column-widget/index.jsfile. - Add the following code:
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.
- Open the
frontend/src/widgets/index.jsfile. - Import the
layoutandlayout-columnwidgets
import LayoutWidget from '@apostrophecms/apostrophe-astro/widgets/LayoutWidget.astro';
import LayoutColumnWidget from '@apostrophecms/apostrophe-astro/widgets/LayoutColumnWidget.astro';- Map the components in the
widgetComponentsobject
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:
# 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 devYour new ApostropheCMS site will be available at http://localhost:3000 with a powerful admin interface at /login.
Prefer to Go Headless?
Get started with Astro integration - the easiest way to build headless sites while keeping visual editing:
- Apollo Starter Kit (Astro) - Production-ready foundation with beautiful design system and rich content features
- [Essentials Starter Kit (Astro)](git clone https://github.com/apostrophecms/starter-kit-astro-essentials) - Minimal, clean foundation for building custom designs from scratch
Both starter kits provide headless CMS power with in-context editing, letting content creators edit directly on the live site while you build with modern frontend tools. Our Astro integration handles all the content fetching automaticallyβno REST API calls to write.
Desire a different frontend framework? Use our REST APIs with React, Vue, Svelte, or any other framework:
- REST API Documentation - Complete API reference
- Headless CMS Guide - Integration walkthrough for any 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.
- Contribution Guide - How to contribute code, documentation, and feedback
- Good First Issues - Perfect starting points for new contributors
Pro Features
For teams and organizations requiring additional features:
- π Advanced User Management - Granular permissions, user groups, and access controls
- π Automated Translation - AI-powered translation with DeepL, Google Translate, and Azure
- π Analytics & SEO - Built-in SEO optimization and content analytics
- β‘ Performance Optimization - Advanced caching, CDN integration, and performance monitoring
- π’ Multisite Management - Manage multiple sites from a single dashboard with shared resources
- πΌ Professional Support - Dedicated support, training, and consultation services
Explore all the pro extensions 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:
- Install @apostrophecms/openapi-generator in your Apostrophe project
- Run the generator from your project directory
- 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:
# Install dependencies
npm install
# Open interactive documentation
npm run docs:openThis opens a browser interface where you can browse endpoints, view schemas, and test API calls.
Note: You will have to have an ApostropheCMS project running before testing the endpoints.
Authenticate for Testing
You can authenticate in Swagger UI using either an API key or bearer token:
Option 1: API Key (Recommended)
The simplest method - requires one-time setup in your project:
- Add an API key to your ApostropheCMS project in
modules/@apostrophecms/express/index.js:
export default {
options: {
apiKeys: {
myTestKey: {
role: 'admin'
}
}
}
};In Swagger UI: Click "Authorize" β scroll to "ApiKeyAuth" β enter
myTestKeyTest away: Execute requests directly from the documentation
Option 2: Bearer Token
No project configuration needed - use your existing login credentials:
- Generate a token: In Swagger UI, find the
POST /@apostrophecms/login/loginendpoint and execute it with:
{
"username": "your-username",
"password": "your-password"
}Copy the token: From the response, copy only the token value (not the full JSON).
- Example: if response is
{"token": "abc123xyz"}, copy onlyabc123xyz
- Example: if response is
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:
- Start with the core spec as your foundation
- Manually add your custom endpoints following the same patterns used in the core spec
- Share the spec with your team as the contract between frontend and backend
- 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:
# 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:4010The 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):
# TypeScript/JavaScript
npm run generate:typescript
# Python
npm run generate:python
# PHP
npm run generate:phpThe 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
# Build the SDK
cd examples/typescript
npm install && npm run build
# Install in your project
npm install /path/to/examples/typescriptBasic usage:
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 specificationexamples/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:
npm run validate # Check OpenAPI structure
npm run lint # Lint with Spectral
npm test # Run both checksThe 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
# 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:
- Fork the repository and create a feature branch
- Make changes to the OpenAPI specification
- Run
npm testto validate your changes - 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
- Discord Community - Get help from other developers
- GitHub Issues - Report bugs or request features
- Professional Support - Enterprise support and consulting
Versioning
- Specification Version: Follows semantic versioning (current:
1.0.0) - API Compatibility: The
x-apostrophe.cmsVersionfield 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:
npm install @apostrophecms/blogUsage
Configure the blog modules in your app.js file:
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:
// modules/@apostrophecms/page/index.js
export default {
options: {
types: [
{
name: '@apostrophecms/home-page',
label: 'Home'
},
{
name: '@apostrophecms/blog-page',
label: 'Blog'
}
]
}
};Customizing Templates
The included templates (index.html, show.html, filters.html) are starting points that demonstrate the available data. Override them in your project to implement your own styling and layout:
modules/
βββ @apostrophecms/
β βββ blog-page/
β βββ views/
β βββ index.html # Blog listing page
β βββ show.html # Individual blog post
β βββ filters.html # Date filtering controlsDate-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
// 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:
// modules/news-blog-page/index.js - page type for news articles
export default {
extend: '@apostrophecms/blog-page'
};Custom Templates for Blog Types
Each blog type can have its own templates. Create them in the corresponding page module:
modules/
βββ news-blog-page/
β βββ views/
β βββ index.html # News listing page
β βββ show.html # Individual news article
β βββ filters.html # Custom filters for news
βββ @apostrophecms/
βββ blog-page/
βββ views/
βββ index.html # Default blog listing
βββ show.html # Default blog postThis 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:
node app jump sideways --foo --bar=whee --super-cool=totallyResponse:
{
_: [ "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
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:
const argv = require('boring')({
end: true
});
console.log(argv);Now, when you run this command:
node app hello --pretty -- --boringYou will get:
{
_: [ 'hello', '--boring' ],
pretty: true
}What you don't get with boring
Single hyphens: nope
There is no support for old-fashioned "single-hyphen" options, like:
-x 50Or:
-hYou 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.
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:
- Stop starting new iterator callbacks.
- Wait for any outstanding iterator callbacks to finish.
- 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
nextandnextObjectmethods. - 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
broadbandto 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.
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:
npm install @apostrophecms/cache-redisUsage
Configure the Redis cache module in the app.js file:
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.
// 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. |
await apos.cache.get(namespace, key)
await apos.cache.set(namespace, key, value)Example:
// 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.
// modules/@apostrophecms/cache/index.js
module.exports = {
options: {
prefix: false
}
};To customize the prefix, set the prefix option to the desired string value.
// 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:
npm create apostrophe@latestSee 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
npm install @apostrophecms/cliTo view the available commands in a given context, execute the apos command with no arguments:
aposNote: 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:
apos createIt 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:
npm create apostrophe@latest -- --unattended \
--project-name=my-site --password=secret --telemetry=offTelemetry
Telemetry is opt-in and anonymous. If you have the CLI installed, you can manage your preference through it:
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 sentThese 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:
# "-widgets" will automatically be appended to the end of your module name
apos add widget fancy-buttonNote: 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.
// 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.
apos add widget tabs --playerCreate 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:
apos add piece vegetableThen 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.
apos add piece vegetable --pageCreate 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:
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.
npm create apostrophe@latestThat runs the interactive flow. A few supporting commands:
# 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 -- --helpEverything after
--is forwarded to the installer; npm swallows args without it.
Run-- --helpany 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, noprocess.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.
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 }
(plusfailStage/errorCodeon 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'screatedelegate calls, so
Commander keeps ownership of--help.runTelemetryCommand({ sub }, deps?)βPromise<number>. Handles thestatus | on | off | previewsubcommands; used by theapos telemetrydelegate.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 onflips it on and
generates a stable anonymous id (UUID v4) the first time;telemetry offkeeps
the id so opting back in resumes the same identity. - Kill switch:
APOS_TELEMETRY=0disables telemetry entirely, regardless of
stored consent.
Payload
Exactly one event is emitted per install attempt: install_success orinstall_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_successorinstall_fail.cliVersion- version of the installer.nodeVersion- the Node release running the install (e.g.24.4.1).platform- the OS family, fromprocess.platform(e.g.darwin,linux,win32).kitId- the starter kit chosen (e.g.apostrophe-demo).dbChoice-sqlite,mongodb, orpostgres.packageManager-npm,pnpm,yarn, orunknown(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), orpreflightfor 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-gitisn't installed.git_spawn_failed-gitfailed to launch (not a missing binary).git_clone_failed-git cloneexited non-zero.
scaffold
missing_scaffold_file- the clone is missingapp.jsorpackage.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 innode_modulesafter 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-nodeisn't installed.node_spawn_failed-nodefailed to launch.admin_user_failed- creating the admin user failed.
Starter-kit sample-data manifest
The seed_* codes above all come from the sample-data step, which runs only for*-demo-data kits. By default it pulls a fixed pair of archives (a database dump
and an uploads bundle) baked into the installer. A kit can override that by
shipping a manifest at:
<project>/.apostrophe/sample-data.jsonIf 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.
{
"db": {
"url": "https://static.apostrophecms.com/public-demo/starter-database.jsonl.zip",
"sha256": "862fc4b380675e0eaade8447f6a137807ce54b54acbbee79636e581e0c5f29b1"
},
"uploads": {
"url": "https://static.apostrophecms.com/public-demo/starter-uploads.zip",
"sha256": "b1a70f3372f8c897fd934abbe711cc1a8b0aeaf7d38b165890dd96939ab62f58"
}
}License
MIT
Overview (`packages/csv-to-zone-file/README.md`)
Produces a zone file suitable for import into Amazon Route 53. If your goal is
to actually use it in a standalone DNS server, you'll want to add
an SOA (Start Of Authority) record to the output manually.
Run:
npm install csv-to-zone-file
csv-to-zone-file < myfile.csv > myfile.zoneThe 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
mongodb://localhost:27017/mydb
mongodb+srv://user:[email protected]/mydbStandard MongoDB connection strings. The MongoDB adapter is a thin wrapper around the existing driver.
PostgreSQL
postgres://localhost:5432/mydbSingle-database mode. All collections are stored as tables in the public schema.
SQLite
sqlite:///path/to/database.dbFile-based SQLite databases using better-sqlite3. The parent directory is
created if it does not exist.
Unlike the others, this is not really a URL: everything after sqlite:// is a
filesystem path, taken exactly as written. That matters, because
filesystem paths are not URLs β they are not percent-encoded, they may contain
characters a URL parser treats as significant, and on Windows they begin with a
drive letter that a URL parser reads as host:port. So do not percent-encode
the path. A space is a space:
sqlite:///Users/jane/My Project/data/site.db%20 here means a literal percent, two, zero β not a space. Both absolute and
relative paths work, and on Windows both separators do:
sqlite:///var/lib/mysite/data.db absolute (POSIX)
sqlite://data/site.db relative to the working directory
sqlite://C:\Users\jane\site\data\site.db absolute (Windows)
sqlite://C:/Users/jane/site/data/site.db absolute (Windows, forward slashes)
sqlite:///C:/Users/jane/site/data/site.db absolute (Windows, file:// spelling)In-memory databases (sqlite://:memory:) are not supported β the adapter
assumes a persistent store suitable for hosting an ApostropheCMS site.
Multi-Schema PostgreSQL (multipostgres)
multipostgres://localhost:5432/shareddb-tenant1Designed 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
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):
updateOnewith$renameorupsert: trueupdateOnewith$push,$pull, or$addToSetusing non-scalar valuesupdateMany,findOneAndUpdate, andreplaceOne
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.
npm install @apostrophecms/emulate-mongo-3-driverconst mongo = require('@apostrophecms/emulate-mongo-3-driver');
// Use it here as if it were the 3.x driverGoals
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:
{
"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:
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:
// 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-standardthat 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 fromeslint-config-standard. Also copies over missing eslint rules with the configurations fromeslint-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
quotesrule to enforce single quotes. This should change to an error in the next major version. - 3.1.0: Adds a warning for the
curlyandbrace-stylerules to avoid single line blocks. Alsoobject-curly-newlineandobject-property-newlinerules to have similar treatment for objects. Adds the changelog versioning guidelines. - 3.0.0: Adds a warning for the
no-varrule. - 2.0.2: packaging issue, no changes.
- 2.0.1: use
import/no-extraneous-dependenciesto detectrequirecalls 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:
npm install @apostrophecms/eventUsage
Configure the event modules in the app.js file:
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:
// 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:
// 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.
// 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
npm install express-cache-on-demandExample
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.sendFileand related methods - It does not work and is not suitable anyway for routes that
pipecontent intores - It shouldn't be registered globally before the
express.staticmiddleware
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.methodisGETorHEAD.req.useris falsy.req.sessionis 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.
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.sessionobject). Previously areturn falsewas missing, resulting in the possibility of a cached result going to a user with a different session. varhas 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:
npm install @apostrophecms/faviconUsage
Configure the @apostrophecms/favicon module in the app.js file:
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
npm install @apostrophecms/font-sizeThen enable it in app.js:
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:
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 afontSize
attribute to the sharedtextStylemark and providessetFontSize/unsetFontSizecommands. - 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-htmlallowlists so that
`` survives sanitization β only pixel values
are permitted, and only where thesizetool 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
npm install @apostrophecms/form-submission-googleUsage
Initialization
Add this module along with the other Apostrophe Form modules in app.js to instantiate it.
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
- Create a project in the Google Cloud Platform API console. Enable the Google Sheets API on the project in the API Library.
- Create an API service account in the Google API Console service.
- Save the credentials JSON file provided with the new service account. You may not be able to download this again.
- Add the credentials file to the
modules/@apostrophecms/formdirectory in your Apostrophe project ascredentials.json.
- Note: We do not recommend committing this file to version control if your code is public. You should add it to the
.gitignorefile (for Git) and put it directly on your production server. Alternately you can provide the credentials as JSON in an environment variable namedGOOGLE_APPLICATION_CREDENTIALS.
- 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.
- 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.
// 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:
// 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
- Usage
- Configuration
- Handling Submissions
- reCAPTCHA Integration
- Styling
- Field-Specific Options
- Custom Field Validation
- Use Cases
- π Ready for More?
Installation
npm install @apostrophecms/formUsage
Module Configuration
Configure the form modules in your app.js file:
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:
// 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:
- Create a form: Click "Forms" in the admin bar and create a new form (e.g., "Contact Form")
- Build the form: Add field widgets (text fields, email, checkboxes, etc.) and configure options
- Configure submission handling: Set up email notifications and confirmation messages in the "After-Submission" tab
- 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:
// 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
formWidgetsoption 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/formmodule to create a second form piece-type with its ownformWidgetsconfiguration. However, without additional controls, all editors can use both form types. Use@apostrophecms-pro/advanced-permissionto 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:
// 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:
// modules/@apostrophecms/form/index.js
export default {
options: {
emailSubmissions: false
}
};π§ Email Configuration: To send form submissions via email, you must first configure the
@apostrophecms/emailmodule. 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:
// 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):
// 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.
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.
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
// 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:
// 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:
// 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:
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
ApostropheCMS attachment module (
modules/@apostrophecms/attachment/index.js):javascriptexport 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.
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 } } }; } };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:
// 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:
- Assigning the original function to a variable
- Creating a new function that uses the original one, adds functionality, and returns an identically structured response
- Assigning the new function to the original function property
Example: Minimum Word Count Validation
// 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
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:
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:
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.
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:
// 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:
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:
npm install @apostrophecms/import-export-xlsxUsage
Configure the module in the app.js file:
require('apostrophe')({
shortName: 'my-project',
modules: {
'@apostrophecms/import-export': {},
'@apostrophecms/import-export-xlsx': {}
}
});Please note that
aposis not passed to theinputandoutputfunctions of the format, as they should remain agnostic of apostrophe and only deal with retrieving and writing the documents from/to the.xlsxfile.
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:
npm install @apostrophecms/import-exportUsage
Configure the module in the app.js file:
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 acontact-pagemodule. 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.
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.
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:
export default {
options: {
importLimits: {
// Maximum number of files and directories in the archive
maxEntries: 100000,
// Maximum total size of the extracted files, in bytes.
// No absolute maximum by default
maxExtractedSize: null,
// Maximum total size of the extracted files, as a multiple of the
// size of the archive. Up to 64MB may always be extracted.
// Set to `false` to disable this check
maxCompressionRatio: 100
}
}
};Exports made by this module stay well within the defaults, because media files barely compress. The defaults mostly stop "compression bombs", small archives that would expand into huge amounts of data. If your server has limited disk space, also set maxExtractedSize.
Importing documents from another locale
Regardless of the original locale, imported documents are imported into the current locale. You will be prompted to confirm if the locale is different and you do not have, or are not opting to use, the automatic translation module.
If multiple locales are set up, the user will be prompted to choose between canceling the import or proceeding with it, unless the user has elected to translate the content.
Automatic translation
If the @apostrophecms-pro/automatic-translation module is installed, the import modal will offer a "Translate" checkbox. When selected, the module will automatically translate the imported documents to the current site's locale using the configured translation service.
When the "Translate" checkbox is selected, the confirm modal letting the user choose between aborting on continuing the import when the docs locale is different from the site, will be suppressed and the import will proceed without any further user interaction.
Importing as drafts only
The import modal includes a checkbox labeled "Import all documents as drafts" that, when selected, imports the published version of documents in draft status only. This feature allows users to review and make modifications to imported documents before they are published.
By default, this checkbox can be set to selected by enabling the importDraftsOnlyDefault: true option in this module.
If a document in the import file has no published version, it will still be imported. Content types that do not have separate drafts will be imported normally.
Updating existing pieces using CSV format
You can also update existing pieces via this module.
To do that, you will need one (and only one) key column in your file. This column's name must be exactly the name of the existing field that uniquely identifies each row as an update of a specific existing piece, followed by :key.
For instance, if you need to change the usernames of users in bulk, you might prepare a CSV file like this:
username:key,username
bobsmith,bob.smith
janedoe,jane.doeThe 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):
// 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.
// lib/formats/index.js
const ods = require('./ods');
module.exports = {
// ...
ods
};For formats with attachment files:
If you want to add a format that includes attachment files such as an archive, you can enable the includeAttachments option and utilize extra arguments provided in the input and output functions.
Advanced example:
/* Detailed source-code truncated for AI context efficiency. */Add formats via a separate module
You might want to scope one or multiple formats in another module for several reasons:
- The formats rely on a dependency that is not hosted on NPM (which is the case with @apostrophecms/import-export-xlsx)
- 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:
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
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:
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-DDMM/DD/YYYYMM/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:0016:30161pm2:37am2:37:12am2PM (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:
npm install @apostrophecms/login-hcaptchaUsage
Instantiate the hCaptcha login module in the app.js file:
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.
// 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.
// 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):
// 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:
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:
npm install @apostrophecms/login-recaptchaUsage
Instantiate the reCAPTCHA login module in the app.js file:
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.
// 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):
// 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:
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:
npm install @apostrophecms/login-totpUsage
Instantiate the TOTP login module in the app.js file:
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.
// 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/loginmodule. The@apostrophecms/login-totpmodule 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):
// modules/@apostrophecms/login/index.js
module.exports = {
options: {
direction: 'ltr'
}
};Resetting TOTP when a user loses their device
If a user loses their device, an admin can edit the appropriate user via the admin bar. Select "Yes" for the "Reset TOTP" field and save the user.
If an admin user loses their own device, they can reset TOTP via a command line task. Pass the username as the sole argument:
node app @apostrophecms/user:reset-totp username-goes-hereOnce 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
# 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-snapshotCommand line usage
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 --eraseIncluding and excluding collections
With both commands, if you want to exclude certain certain collections, just add:
--exclude=name1,name2Filtering individual documents
With the mongodb-snapshot-write command only, you can specify a MongoDB query to
filter the documents for any collection. For example:
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
// 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'
}
}
}});
}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
/* 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:
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.
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:
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:
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:
`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:
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:
// 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) { ... }
};// 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
nestedModuleSubdirsoption totrue. - Modules can now be found nested beneath your
localModulesfolder, 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
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.
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:
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:
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:
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.
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:
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.
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:
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.
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.
// "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
npm install @apostrophecms/open-graphUse
Configure @apostrophecms/open-graph in app.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
require('apostrophe')({
shortName: 'mysite',
baseUrl: 'https://mysite.com',
modules: {
// other module configurations
}
});As part of an environment configuration in `data/local.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.
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?
- Requirements
- Installation
- Quick Start
- How It Works
- Commands & Usage
- Client SDK Generation
- Authentication
- Configuration
- Development Workflow
- Contributing
- Community & Support
Why ApostropheCMS OpenAPI Generator?
- π― Your Complete API: Documents YOUR content types, custom pieces, and routesβnot just core CMS
- π Zero Configuration: Works instantly with any ApostropheCMS 4+ project
- π Total Discovery: Automatically finds all routes, schemas, and Pro module endpoints
- π± Production-Ready SDKs: Generate enterprise-grade clients in TypeScript, Python, PHP, and 20+ other languages
- β‘ Live Documentation: Interactive testing environment for your specific API endpoints
- π οΈ DevOps Ready: Built for modern development workflows and CI/CD pipelines
- π§ Extensible: Custom field mappers and route filtering for project-specific needs
Requirements
- ApostropheCMS 4.0 or higher
- Node.js 22+
- For SDK generation:
- Java 8+ (required for npx or global usage)
- Optional: global installation of
@openapitools/openapi-generator-clifor faster repeat runs (usesnpxby default) - Optional: Docker (if you prefer not to install Java). The generator will use the official
openapitools/openapi-generator-cliimage, which is pulled automatically the first time you run it.
Installation
npm install @apostrophecms/openapi-generatorQuick Start
- Add to your ApostropheCMS project:
Configure the module in yourapp.jsfile:
import apostrophe from 'apostrophe';
apostrophe({
root: import.meta,
shortName: 'my-project',
modules: {
'@apostrophecms/openapi-generator': {}
}
});- Generate your OpenAPI spec:
node app openapi-generator:generate- View interactive documentation:
node app openapi-generator:docs --open- Generate a client SDK:
# Generate TypeScript client
node app openapi-generator:generateSDK typescript- Use your generated SDK:
# 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 buildHow It Works
The generator uses a three-step process:
- Route Discovery: Scans ApostropheCMS's route registry to find all registered endpoints
- Schema Discovery: Examines module schemas to understand your content types
- 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
# 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.yamlDevelopment & Testing
# 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 --verboseFile 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.
# 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.yamlDocumentation
# Serve interactive documentation
node app openapi-generator:docs
node app openapi-generator:docs --openClient 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:
NPX (default)
- Runs
npx @openapitools/openapi-generator-cliautomatically - Downloads the generator each time you use it
- Works out of the box with Java 8+ installed
- Runs
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
- Install once with:
Docker (optional) β no Java required
- Uses the official
openapitools/openapi-generator-cliDocker image - Great if you prefer not to install Java locally
- Requires Docker installed and running
- Uses the official
Check your Java installation (needed for npx or global):
java -versionIf not installed, get OpenJDK 8+ from your package manager or OpenJDK.
Quick SDK Generation
# 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 phpAdvanced SDK Options
Use any OpenAPI Generator language:
# 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 kotlinWhat'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/awaitwith 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
- Copy to your project:
# 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/- Install and build:
# 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- Use in your application:
TypeScript/JavaScript Example:
TypeScript/JavaScript Example:
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
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
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
const config = new Configuration({
basePath: 'http://localhost:3000/api/v1',
apiKey: 'your-api-key-here'
});Bearer Tokens
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:
const config = new Configuration({
basePath: 'http://localhost:3000/api/v1',
withCredentials: true // Include session cookies
});Authentication Priority:
- API key (query string): pass
apiKeyintoConfiguration({ apiKey: 'β¦' }) - Bearer token: pass
accessTokenintoConfiguration({ accessToken: 'β¦' }) - Session/cookie: enable
withCredentialsand run in an environment that includes the session cookie
Security
All generated endpoints include appropriate security schemes:
security:
- ApiKeyAuth: []
- BearerAuth: []
- SessionAuth: []
- {} # Allows unauthenticated access as fallbackThis provides multiple authentication options while maintaining compatibility with the ApostropheCMS authentication systems.
Configuration
The generator works without configuration, but you can customize its behavior:
// 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:
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 onlyPOST /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:
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
# Regenerate docs as you add custom fields/pieces
node app openapi-generator:generate
node app openapi-generator:docs --open # Test your new endpoints2. Frontend Integration
# 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 build3. CI/CD Integration
# Add to your deployment pipeline
node app openapi-generator:validate # Ensure spec is valid
node app openapi-generator:generateSDK typescript --output=../frontend/src/api4. API Testing & Validation
# Mock server for development
npm install @stoplight/prism-cli
prism mock openapi/apostrophecms-openapi.yaml
# Mock server runs at http://localhost:4010Contributing
We welcome contributions to improve the generator:
- Fork the repository and create a feature branch
- Make your changes and add tests
- Run tests to ensure everything works
- Submit a pull request with a clear description
Please ensure all changes:
- Follow existing code patterns
- Include appropriate tests
- Update documentation if needed
- Work with the latest ApostropheCMS version
Community & Support
- Discord Community - Get help from other developers
- GitHub Issues - Report bugs or request features
- Documentation - Comprehensive guides and API reference
- Professional Support - Enterprise support and consulting
π Ready for Enterprise Features?
Need advanced API capabilities for your development workflow? This free OpenAPI generator works great with ApostropheCMS Pro extensions to document enterprise-grade endpoints:
π **ApostropheCMS Pro Features**
- π€ SEO Assistant - AI-powered content optimization with dedicated API endpoints
- π Document Versions - Complete revision management and rollback APIs
- π Advanced Permissions - Granular access control with user group management
- π Automatic Translation - Instant multilingual content generation with AI-powered translations
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:
npm install @apostrophecms/passport-bridge
# Example: Google OAuth (many other providers available)
npm install --save passport-google-oauth20Most 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:
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:
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
createoption, users must actually exist in
Apostrophe with the same username or email address, depending on thematchoption. 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:
// 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
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:
// 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:
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
verifyfunction 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-clientnpm install @apostrophecms/passport-bridge- Add
@apostrophecms/passport-bridge: {}to yourmodulessection inapp.js - Set the
OIDC_ISSUER,OIDC_CLIENT_ID, andOIDC_CLIENT_SECRETenvironment variables
Note that OIDC_ISSUER should be the URL of an identity provider that supports OIDC discovery, which most
OIDC providers do.
Now configure the passport bridge module:
/* Detailed source-code truncated for AI context efficiency. */Adding login links for a traditional ApostropheCMS project
The easiest way to enable login is to use the loginLinks async component in your template:
{% component "@apostrophecms/passport-bridge:loginLinks" %}This component will output links that attempt to bring the user back to the same page after login, and to keep them in the same locale even if your site has separate hostnames configured for separate locales.
You can override this template's markup by copying views/loginLinks.html from this npm module to your project-level modules/@apostrophecms/passport-bridge/views folder.
You can also determine the login URLs by invoking the @apostrophecms/passport-bridge:list-urls task, however this method does not give you a way to preserve the current URL or redirect back to the current locale's hostname.
Using login links with headless ApostropheCMS and an Astro Frontend
When using @apostrophecms/passport-bridge with an Astro frontend (via @apostrophecms/apostrophe-astro), 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:
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:
// 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.
node app @apostrophecms/passport-bridge:listUrlsThis 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_IDandGOOGLE_CLIENT_SECRET
Handling Authentication State
After successful authentication, users will be redirected to your Astro frontend. You can check authentication state by making requests to your ApostropheCMS backend's session endpoints or by implementing additional API routes to expose user information.
Error Handling
The failureRedirect configuration will send users to your specified error page (e.g., /login?error=oauth_failed) where you can display appropriate error messages based on URL parameters.
Right-to-left websites
The error page shown when a login attempt is rejected follows the text direction of the current locale. Set the direction option of the @apostrophecms/login module to ltr or rtl to force a direction for it, as for the login page itself.
Configuring your identity provider
What is my oauth callback URL?
Many strategies require an oauth callback URL. To discover those, run this command line task to print the URLs for login, and for the oauth callback URLs:
node app @apostrophecms/passport-bridge:listUrlsYou'll see something like:
These are the login URLs you may wish to link users to:
/auth/gitlab/login
These are the callback URLs you may need to configure on sites:
http://localhost:3000/auth/gitlab/callbackβ οΈ You can use a URL like http://localhost:3000 for testing but in production you must use your production URL. Most identity providers will reject a URL beginning with http: or an IP address, except for http://localhost:3000 which is often accepted for testing purposes only.
Where do I get my `clientID`, `clientSecret`, etc.?
You get these from the identity provider, usually by adding an "app" to your profile or developer console. In the case of Google you will need to create an application in the Google API console and authorize it to perform oauth logins. 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.
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
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:
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 bothaccessToken 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:
// 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:
// 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 a401status 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.
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):
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:
The user logs in normally to their Apostrophe account.
Await
requestConnectionto 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.
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-githubuses
the strategy namegithub. You can find it in the source of the strategy module
you are using and it is usually your first guess as well.
The user receives the email and follows the link provided.
The user is redirected to authorize access to their
githubaccount (in this example).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 theiruserpiece asgithubId(in the case of the github strategy;
substitute the appropriate strategy name), and their tokens are available as described
earlier ifretainSessionToken: trueis 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-levelmodules/@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:
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:
// 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:
.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:
.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:
@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:
@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
npm install postcss-viewport-to-container-toggleGetting started
Webpack
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
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:bodydebug: If set totrue, will output debug information. Default:falsetransform: A function that will be called for each media query, allowing to modify its params when creating thecontainer.
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:
npm install random-wordsExamples:
/* Detailed source-code truncated for AI context efficiency. */Overview (`packages/redirect/README.md`)
Installation
First make sure you have an Apostrophe project!
Then:
npm install @apostrophecms/redirectConfiguration
In app.js, add the module to your configuration:
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
// 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.
// 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:
// 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.
// Other modules, then...
'@apostrophecms/redirect': {
options: {
caseInsensitive: true
}
}When this option is enabled:
- New and edited redirects have their "Old URL" stored in lowercase automatically.
- A migration runs to lowercase the "Old URL" of any existing redirects.
- Incoming request URLs are lowercased before being matched against stored redirects (the original casing of the request is still used when substituting a wildcard match into a destination URL).
β οΈ This option does not support "mixing" case-sensitive and case-insensitive redirects. Enabling it lowercases all existing "Old URL" values, and this migration is not reversible. If you later disable caseInsensitive, your pre-existing redirects will only match lowercase request URLs, since their stored "Old URL" values remain lowercase. If this matters to you, you would need to manually recreate the affected redirects with their original mixed-case "Old URL" values.
Usage
While logged in as an admin, click the "Redirects" button. A list of redirects appears, initially empty. Add as many redirects as you like. The "from" URL must begin with a /. The "to" URL may be anything and need not be on your site. The "description" field is for your own convenience.
Matching the query string
By default a redirect includes any query string (the ? and whatever follows it, up to but not including any #) on incoming requests when matching for redirection.
You can toggle the "ignore query string when matching" option in a redirect definition to ignore query strings on incoming requests and only match on the base URL path. A redirect that does not use this option will always match first, so you can match various specific query strings and then have a fallback rule for other cases.
Matching wildcards
Normally, aside from the query string, only an exact match is honored. If you wish to redirect an entire subdirectory, you may use a * immediately after a /, for example:
/auto/*This will redirect all URLs beginning with /auto/ to your destination, like this:
/auto/hyundai -> /fr/auto
/auto/hyundai/ioniq-5 -> /fr/autoβ οΈ Note that this discards the rest of the URL. If this is not what you want, see below for ways to avoid it.
Exact matches always win
If you have a redirect rule with no *, it will always beat a redirect rule with an *.
For instance, if you have rules like this:
/auto/gm/bolt -> /en/auto/gm/bolt
/auto/* -> /fr/autoThen actual redirects will play out like this:
/auto/gm/bolt -> /en/auto/gm/bolt
/auto/toyota/tercel -> /fr/autoCapturing wildcards
If you are creating a "URL" redirect, and not an "Internal Page" one, you may also "capture" and reuse the rest of the URL by including the * . This is useful when the rest of the URL is still correct.
For instance, if your "Old URL" is:
/auto/*And you select "URL" as the redirect type and type the following in the "URL" field:
/fr/auto/*Then redirects will occur like this:
/auto/hyundai -> /fr/auto/hyundai
/auto/hyundai/ioniq-5 -> /fr/auto/hyundai/ioniq-5Creating fallbacks with multiple wildcard rules
Note that if you have two redirect rules involving an * and one is longer than the other, the longer match always wins.
For example, if your redirects are set up like this:
/auto/hyundai/* -> /en/car/hyundai/*
/auto/* -> /fr/auto/*Then redirects will play out like this:
/auto/mercedes -> /fr/auto/mercedes
/auto/mercedes/w123 -> /fr/auto/mercedes/w123
/auto/hyundai -> /en/car/hyundai
/auto/hyundai/ioniq-5 -> /en/car/hyundai/ioniq-5Safety 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:
// 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:
// 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:
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 (& --> &, < --> <, and so on). Additionally, in attribute values, quotation marks are escaped as well (" --> ").
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.
npm install -D @types/sanitize-htmlIf esModuleInterop=true is not set in your tsconfig.json file, you have to import it with:
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:
npm install sanitize-htmlor
yarn add sanitize-htmlThe 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:
import sanitizeHtml from 'sanitize-html';
const html = "<strong>hello world</strong>";
console.log(sanitizeHtml(html));
console.log(sanitizeHtml("<img src=x onerror=alert('img') />"));
console.log(sanitizeHtml("console.log('hello world')"));
console.log(sanitizeHtml("<script>alert('hello world')</script>"));Node (Recommended)
Install module from console:
npm install sanitize-htmlImport the module:
// In ES modules
import sanitizeHtml from 'sanitize-html';
// Or in CommonJS
const sanitizeHtml = require('sanitize-html');Use it in your JavaScript app:
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:
// Allow only a super restricted set of tags and attributes
const clean = sanitizeHtml(dirty, {
allowedTags: [ 'b', 'i', 'em', 'strong', 'a' ],
allowedAttributes: {
'a': [ 'href' ]
},
allowedIframeHostnames: ['www.youtube.com']
});Boom!
Default options
/* Detailed source-code truncated for AI context efficiency. */Common use cases
"I like your set but I want to add one more tag. Is there a convenient way?"
Sure:
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:
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 [].
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.
nonBooleanAttributes: ['*']"What if I don't want to allow *any* tags?"
Also simple! Set allowedTags to [] and allowedAttributes to {}.
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":
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.
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:
allowedAttributes: {
a: [ 'href', 'data-*' ]
}Also you can use the * as name for a tag, to allow listed attributes to be valid for any tag:
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.
// 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:
allowedClasses: {
'code': [ 'language-*', 'lang-*' ],
'*': [ 'fancy', 'simple' ]
}Furthermore, regular expressions are supported too:
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.
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.
enforceHtmlBoundary: truehtmlparser2 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.
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):
const clean = sanitizeHtml(dirty, {
transformTags: {
'ol': 'ul',
}
});The most advanced usage:
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:
const clean = sanitizeHtml(dirty, {
transformTags: {
'ol': sanitizeHtml.simpleTransform('ul', {class: 'foo'}),
}
});The simpleTransform helper method has 3 parameters:
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:
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:
<a href="http://somelink.com"></a>To a link with anchor text:
<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:
<a href="page.html"></a>We can do that with the following filter:
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:
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 themediaTagsvariable insrc/index.jsfor 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.
<p>some text...</p>We can do that with the following filter:
sanitizeHtml(
'<p>some text...</p>',
{
textFilter: function(text, tagName) {
if (['a'].indexOf(tagName) > -1) return //Skip anchor tags
return text.replace(/\.\.\./, '…');
}
}
);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.:
allowedIframeHostnames: ['www.youtube.com', 'player.vimeo.com'],
allowedIframeDomains: ['zoom.us']You may also specify whether or not to allow relative URLs as iframe sources.
allowIframeRelativeUrls: trueNote 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:
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:
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
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
// 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
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
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:
[ 'http', 'https', 'ftp', 'mailto' ]You can override this if you want to:
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:
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:
allowProtocolRelative: falseSVG 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:
<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:
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 andstyle 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:
disallowedTagsMode: 'escape'This will transform <disallowed>content</disallowed> to <disallowed>content</disallowed>
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.
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.
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:
disallowedTagsMode: `recursiveEscape`This will transform <disallowed>helloworld</disallowed> to <disallowed>hello<p>world</p></disallowed>
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.
preserveEscapedAttributes: trueIgnore style attribute contents
Instead of discarding faulty style attributes, you can allow them by disabling the parsing of style attributes:
parseStyleAttributes: falseThis 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:
nestingLimit: 6This 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:
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:
const allowedTags = [ 'b' ];
let addSpace = false;
const sanitizedHtml = sanitizeHtml(
'There should be<div><p>spaces</p></div>between <b>these</b> words.',
{
allowedTags,
onOpenTag: (tagName, attribs) => {
addSpace = !allowedTags.includes(tagName);
},
onCloseTag: (tagName, isImplied) => {
addSpace = !allowedTags.includes(tagName);
},
textFilter: (text) => {
if (addSpace) {
addSpace = false;
return ' ' + text;
}
return text;
}
}
);In this example, we are setting a flag when a tag that will be removed has been opened or closed. Then we use the textFilter to modify the text to include spaces. The example should produce:
There should be spaces between <b>these</b> words.This is a simplified example that is not meant to be production-ready. For your specific case, you may want to keep track of currently open tags, using the open and close events to push and pop items on the stack, or only insert spaces next to a subset of disallowed tags.
About ApostropheCMS
sanitize-html was created at P'unk Avenue 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:
npm install @apostrophecms/scheduled-publishingUsage
Configure the Scheduled Publishing module in the app.js file:
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:
node app @apostrophecms/scheduled-publishing:updateTypically 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:
# 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
stagecoachutility for deployment, don't forget/currentat 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:
npm install @apostrophecms/security-headersUsage
Activate the @apostrophecms/security-headers module in the project's app.js file:
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:
// 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:
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.
module.exports = {
// in modules/@apostrophecms/security-headers/index.js
options: {
policies: {
general: {
'default-src': 'HOSTS',
// Because it is necessary for some of the output of our rich text editor
'style-src': "HOSTS 'unsafe-inline'",
'script-src': 'HOSTS',
'font-src': 'HOSTS',
'img-src': 'HOSTS blob:',
'frame-src': "'self'"
},
// Set this sub-option to false if you wish to forbid google fonts
googleFonts: {
'style-src': 'fonts.googleapis.com',
'font-src': 'fonts.gstatic.com'
},
// Set this sub-option to false if you do not use the video widget
oembed: {
'frame-src': '*.youtube.com *.vimeo.com',
'img-src': '*.ytimg.com'
},
// Set this sub-option to false if you do not wish to permit Google Analytics and
// Google Adsense
analytics: {
'default-src': '*.google-analytics.com *.doubleclick.net',
// Note that use of google tag manager by definition brings in scripts from
// more third party sites and you will need to add policies for them
'script-src': '*.google-analytics.com *.doubleclick.net *.googletagmanager.com',
}
}
}
};Inline style attributes are still allowed
Note that style-src is set by default to permit inline style attributes. This is currently necessary because the output of the tiptap rich text editor used in Apostrophe involves inline
styles in some cases.
Inline script tags are **not** allowed
Inline script tags (those without a src) are not allowed by the default policies shown above, as this is one of the primary benefits of using the Content-Security-Policy header. If you do choose to output an inline script tag, you may do so if you use the "nonce" template argument provided by this module, like this:
<script nonce="{{ nonce }}">
// inline script code here
</script>The nonce template variable is always available in Nunjucks templates when using this module. It is generated uniquely for each new page request.
The nonce mechanism ensures that the script tag was the intention of the developer and is not an XSS attack. However please note that you will lose the security benefits of this if you output other user-entered data inside the script tag without properly escaping it, for instance using the | json nunjucks filter.
Setting the nonce attribute on a DOM element has no ill effects when this module is not in use, so it is OK to set it in inline script tags output by npm modules intended for use with or without this module.
Custom policies
You may add any number of custom policies. Any sub-option nested in yourpolicies 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:
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
Install the module:
bashnpm install @apostrophecms/seoSet your base URL (
APOS_BASE_URL).Enable Google Analytics or Tag Manager in
@apostrophecms/global.Optionally install
@apostrophecms/sitemapfor XML sitemap generation.Configure
robots.txtandllms.txtvia global settings.Choose schema types per page in the SEO tab.
Validate your structured data using Googleβs Rich Results Test.
Table of Contents
- Why ApostropheCMS SEO Tools?
- TL;DR: Quick Setup
- Table of Contents
- Installation
- Before You Start
- Core Features
- Essential Configuration
- Structured Data & Schema Types
- AI & Search Strategy
- Advanced Configuration
- Implementation Guidelines for Developers
- Extending the SEO Module with Custom JSON-LD Schemas
- Performance Optimization
- 1. Place Your Fonts in a Moduleβs
public/Directory - 2. Configure the SEO Module to Preload Fonts
- Field Reference
- π Ready for AI-Powered SEO?
- π’ Managing Multiple Sites?
- Roadmap
Installation
npm install @apostrophecms/seoConfigure the module in your app.js file:
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
seoFieldMappingsoption 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:
- Allow All (Search + AI) - Default open access for all crawlers
- Allow Search, Block AI Training - Maintains search rankings while protecting content from AI training by AI agents that choose to respect this standard
- Selective AI Crawlers - Granular control over individual AI crawlers that support this standard
- Block All - Prevents all indexing
- 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.txtstandard is a voluntary convention, not a security mechanism.
While the crawlers listed here currently state that they honorrobots.txt, real-world behavior can differ:
- Some crawlers only respect
robots.txtin 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.txtor do not implement the standard. We recommend periodically reviewing crawler policies to ensure ongoing compliance.
Technical Notes:
- A physical
robots.txtfile in yourpublic/directory for a single-site project, orsites/publicanddashboard/publicdirectories 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: /inrobots.txtmay cause some AI crawlers to skip readingllms.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.txtis a proposed standard that is not yet widely adopted. As of this writing, most LLMs and AI systems do not respect or readllms.txtfiles. This feature is included for forward-thinking SEO strategies and may gain broader adoption in the future. For enforceable crawler control, rely onrobots.txtsettings.
Configuration options:
Allow AI Crawling (Default): Generates a comprehensive
llms.txtfile that permits responsible AI crawling with site structure informationDisallow AI Training: States content should NOT be used for AI training datasets but permits real-time search and retrieval with attribution
Custom Content: Write your own
llms.txtpolicies from scratchDisabled: Returns 404 for
/llms.txtrequests
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):
export APOS_BASE_URL=https://yoursite.comVia configuration file:
// 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:
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:
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:
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:
npm install @apostrophecms/sitemapimport 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
seoTitleor 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:
- Select "FAQ Page" as the schema type in the SEO tab.
- In the FAQ Details section, add each question and its corresponding answer.
- 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]
TheFAQschema 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
_featuredImagerelationship 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
Recommended Configuration for Most Sites
For optimal search visibility while protecting intellectual property:
robots.txt Settings:
- Mode: "Allow Search, Block AI Training"
llms.txt Settings:
- Mode: "Disallow AI Training"
Why This Works:
- β Traditional search engines continue normal indexing
- β AI Overview and AI-powered search features remain functional
- β Real-time AI browsing for user queries still works
- β Your content drives referral traffic from AI systems
- β Your content is protected from AI training datasets
- β No contribution to training commercial AI models
Impact on Rankings:
- No negative impact on Google Search rankings (confirmed by Google)
- Blocking Google-Extended does not affect Google Search
- AI training crawler access is completely separate from search indexing
For Maximum AI Visibility
If you want your content widely used by AI systems for training and responses:
robots.txt Settings:
- Mode: "Allow All (Search + AI)"
llms.txt Settings:
- Mode: "Allow AI Crawling"
Use this when:
- You want maximum exposure in AI-generated content
- Your business model benefits from AI-driven traffic
- You're comfortable with your content training AI models
- You want to contribute to open AI datasets
For Maximum Privacy/Protection
If you want to restrict most or all AI access:
robots.txt Settings:
- Mode: "Selective AI Crawlers"
- Check only: ChatGPT-User, Claude-User (optional - allows real-time queries)
- Or use "Block All" for complete restriction
llms.txt Settings:
- Mode: "Disabled"
Use this when:
- You have proprietary or competitive content
- Legal/compliance restrictions on AI training
- You want maximum control over content usage
- Privacy is a primary concern
Understanding robots.txt vs llms.txt
Both tools work together but serve different purposes:
| Feature | robots.txt | llms.txt |
|---|---|---|
| Purpose | Enforceable crawler access control | Policy communication & transparency |
| Technical | Bots must respect (standard protocol) | Informational guidelines only |
| Adoption | Universal web standard since 1994 | Proposed standard, limited adoption |
| Enforcement | Technical blocking mechanism | Voluntary compliance only |
| Controls | Which bots can crawl your site | How content may be used if crawled |
| Best for | Technical access restrictions | Terms of use & AI transparency |
| Required? | Yes (web standard since 1994) | Optional (emerging standard) |
| AI Support | Most AI crawlers respect robots.txt | Most AI systems do not read llms.txt |
| Example | "Block GPTBot from accessing /api/*" | "Content may be used for search, not training" |
β οΈ Important: While
llms.txtrepresents forward-thinking SEO strategy, it should not be relied upon for actual crawler control. Userobots.txtfor enforceable policies. Thellms.txtfile 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=querysearch- for URLs like/search?search=queryquery- for URLs like/search?query=querys(WordPress default) - for URLs like/?s=query
Example in global settings:
"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:
// 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:
// 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 toArticleschema for blog posts@apostrophecms/event: Defaults toEventschema for events- The pages for each of these modules defaults the
CollectionPageschema
When a default is configured, the schema type selector becomes read-only in the editor UI, ensuring consistency across all content of that type. These can be overridden at project level.
When to use defaults:
- Content types with a clear, single schema purpose (products, events, recipes)
- Ensuring editors can't accidentally select the wrong schema type
- Maintaining consistency across large content collections
- Integration with specific Schema.org requirements (e.g., job boards must use JobPosting)
Canonical Link Configuration
Configure canonical URL options for pieces by specifying which document types editors can reference:
// 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:
Index pages (piece-page-type listing pages):
- Uses ApostropheCMS's built-in
req.data.currentPageandreq.data.totalPages - Automatically detects pagination from standard piece-page-type queries
- Page 1 gets clean URLs (no
?page=1query string)
- Uses ApostropheCMS's built-in
Show pages (individual pieces with navigation):
- Uses
req.data.nextandreq.data.previouswhen configured - Works when you enable
next: trueandprevious: trueoptions on your piece-page-type
- Uses
Example piece-page-type with next/previous:
// 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:
// 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:
{% 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:
- Add a wrapper element around your paywalled content in your template
- Configure the CSS selector in the SEO settings to match your wrapper
Example template implementation:
{# 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:
/* By class (most common) */
.paywall
.premium-content
.members-only
/* By ID */
#paywalled-content
/* By data attribute */
[data-paywall="true"]
/* Multiple classes */
.article-body.premiumIn 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:
// 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
authorNamebut products usecreatedBy), use theregisterSchema()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):
fields: {
add: {
author: {
type: 'string',
label: 'Author Name',
def: 'Editorial Team'
}
}
}Author relationship (full featured):
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):
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):
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:
- Schema-specific description (e.g.,
product.description) seoDescription(SEO-optimized content)excerpt(content preview)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)publicationDatedatePublished- Automatically falls back to
createdAtif none are provided
fields: {
add: {
publicationDate: {
type: 'date',
label: 'Publication Date'
}
}
}Debug Mode
Enable debug mode to see which fallback fields are being used:
export APOS_SEO_DEBUG=1
npm run devYou'll see helpful log messages like:
[SEO] Author fallback used: document.author = "John Doe"
[SEO] Image fallback used: document.featuredImage
[SEO] Description fallback used: document.excerptFeatured Images
Several schema types rely on a _featuredImage relationship field being present on your document.
Schema types that use featured images:
- Product - Product image
- Recipe - Recipe photo
- How To - Guide illustration
- Video Object - Video thumbnail (falls back to featured image)
Example implementation:
// 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
authorin the SEO schema - A simple string field named
authorin the document schema - A
relationshipfield named_authorthat points to an βauthor-likeβ piece-type
(for example:author,person,staff-member, etc.). The module reads the first relationship documentβstitle(preferred), thennameto determine the author name.
Resolution order:
schema.authorstring (if present and non-empty)document.authorstring_authorrelationship: the first joined doc ondocument._authorupdatedByuser on the document:title, thenname
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:
- Publication dates:
publishedAt, thencreatedAt - Modification dates:
updatedAt, thencreatedAt
Best practice: Add a publishedAt field to content types that use Article schema:
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.piecesreq.data.itemsreq.data._piecesreq.data.docs
Each item must have:
- A URL:
_urlorurlproperty - A title:
seoTitleortitleproperty
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 theseoJsonLd* 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 asreview.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_featuredImagerelationship (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
seoJsonLdFAQPageUI group - QAPage β uses fields in the
seoJsonLdQAPageUI 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:
- Register a new JSON-LD schema type on
@apostrophecms/seo. - Expose that type in the schema type dropdown via
@apostrophecms/seo-fields-doc-type. - 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.
// 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.
// 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.
// 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
BookJSON-LD object (includingisbn) based on those fields.
Performance Optimization
Critical Font Preloading
Preload critical fonts to improve Core Web Vitals scores and SEO performance. Font loading directly impacts:
- Cumulative Layout Shift (CLS): Prevents layout shift when custom fonts load
- Largest Contentful Paint (LCP): Faster font loading improves render time
- First Contentful Paint (FCP): Reduces render-blocking font requests
1. Place Your Fonts in a Moduleβs `public/` Directory
For cloud deployments (UploadFS β S3, GCS, etc.), fonts must be stored inside a module, for example:
modules/my-fonts/public/fonts/inter-variable.woff2
modules/my-fonts/public/fonts/geist-mono.woffApostrophe uploads assets only from module public/ directories. These files become available at URLs like:
/modules/my-fonts/fonts/inter-variable.woff22. Configure the SEO Module to Preload Fonts
Configure critical fonts as a developer-level option in your app.js:
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:
/* 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:
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' }
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' }
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:
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
woff2format 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-faceCSS - preloading enhances it, doesn't replace it
Example project structure:
my-project/
βββ public/
β βββ fonts/
β βββ inter-variable.woff2
β βββ headings.woff2
βββ modules/
βββ asset/
βββ ui/
βββ src/
βββ index.scss # Reference fonts here with @font-faceMobile 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:
{
"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
npm install @apostrophecms/sitemapUse
Initialization
Configure @apostrophecms/sitemap in app.js as a project module.
// 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.
// data/local.js
module.exports = {
baseUrl: 'http://localhost:3000'
};Options
All sitemap module options are configured in an options object.
// 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:
cacheLifetime: 30 * 60 // or 1800 secondsKeep 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.
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:
// 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.xmlroute (returns a 404)
π‘ Tip
If you're using multiple locales, enableperLocaleto generate a separate sitemap for each one.
textSitemap: 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:
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.
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:
node app @apostrophecms/sitemap:clearTelling search engines about the sitemap
Create a public/robots.txt file if you do not already have one and add a sitemap line. Here is a valid example for a site that doesn't have any other robots.txt rules:
Sitemap: https://example.com/sitemap.xmlTroubleshooting
- If you already have a static
public/sitemap.xmlfile, that file will be shown at the/sitemap.xmlURL 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
cacheLifetimeoption,cleartask, andupdate-cachetask 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
npm install sluggoUsage
var sluggo = require('sluggo');
var s = sluggo('@ monkey\'s are elabΨΨorate fools##');
console.log(s);Outputs:
monkey-s-are-elab-orate-foolsOptions
separator
Change the string separator by passing a string (usually one character) to separator.
const sluggo = require('sluggo');
const s = sluggo('monkey\'s are elaborate fools', {
separator: ','
});
console.log(s);Outputs:
monkey,s,are,elaborate,foolsallow
Set a single-character string to allow in returned strings. Otherwise all punctuation characters are replaced by the separator.
const sluggo = require('sluggo');
const s = sluggo('@ monkey\'s are elabΨΨorate fools##', {
allow: 'Ψ'
});
console.log(s);Outputs:
monkey-s-are-elabΨΨorate-foolsIn 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
allowoptions 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 forcingnonein that situation. Ifdefis not set at allnoneis 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.
"stylelint-order"
"stylelint-declaration-strict-value"
"stylelint-scss"
"stylelint-selector-bem-pattern"Installation
npm i stylelint-config-apostrophe --save-devPackage 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 |
Usage
If you've installed stylelint-config-apostrophe locally within your project, just set your stylelint config to:
{
"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.
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
npm run compareThis 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
@includestatements that may contain nested rules.
Installation
npm install @apostrophecms/stylelint-no-mixed-decls --save-devUsage
Add it to your Stylelint configuration:
{
"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.
{
"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
.foo {
color: red;
&--large {
font-size: 24px;
}
& {
font-weight: bold;
}
}@mixin foo {
display: block;
clear: both;
}
.foo {
@include foo;
@include external-mixin; // not known to contain nested rules
color: red;
}@mixin foo {
display: block;
clear: both;
&--large {
font-size: 24px;
}
}
.foo {
@include foo;
& {
color: red;
}
}@mixin foo {
& {
display: block;
clear: both;
}
}
.foo {
font-weight: bold;
& {
color: red;
}
@include foo;
}Example: Incorrect Usage (will report)
.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/
}@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/
}@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;
}.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:
npm install @apostrophecms/svg-spriteUsage
Configure the SVG Sprite module in the app.js file:
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.
// 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:
node app @apostrophecms/svg-sprite:importSprite file location options
There are three options for registering SVG file locations:
- Use a partial file path to a specific file, e.g.,
'svg/places.svg'. - Use a partial file path with wild card symbols, e.g.,
'svg/*-icons.svg'. See the Glob package documentation for acceptable patterns. - 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 thatsymboltags should not be nested within othersymboltags. symboltags must have anidattribute, e.g.,<symbol id="bicycle">...</symbol>.symboltags can optionally have atitleattribute 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):
<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: Theidproperty of the individual SVG symbol. We have to use this in combination with the file path to get a specific symbol.map: Themapname property can be used to quickly find one of the sprite maps that were configured in the module'smapsarray.
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.
<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
imageminand compatible tools using thepostprocessorsoption.
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.jsandlocal.jsfor 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 ifimagemagickis installed on the system. You can also write a backend for something else (look atsharp.jsorimagemagick.jsfor examples); just supply an object with the same methods, you don't have to supply a factory function.If you need to use
imagemagickand 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 thegifsicle: trueoption when callinginit. Of course you must installgifsicleto 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 imagemagickon Ubuntu servers. Homebrew can installimagemagickon Macs.
API Overview
The
initmethod 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 byuploadfs.The
copyInmethod 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
copyImageInmethod works likecopyIn. In addition, it also copies in scaled versions of the image, corresponding to the sizes you specify when callinginit(). 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 thecropproperty to an object withtop,left,widthandheightproperties, all specified in pixels. These coordinates are relative to the original image. When you specify thecropproperty, 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. Thewidthandheightproperties of theinfoobject 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 thescaledJpegQualityoption, either when initializing uploadfs or when callingcopyImageIn.If you do not wish to always use the same set of image sizes, you may pass a
sizesproperty as part of the options object when callingcopyImageIn.The
copyOutmethod 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
streamOutmethod 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 thelocalands3storage backends. Contributions for Azure and GCS are welcome.The
removemethod removes a file from uploadfs.The
getUrlmethod returns the URL to which you should append uploadfs paths to fetch them with a web browser.The
disablemethod shuts off web access to a file. Depending on the storage backend it may also block thecopyOutmethod, so you should be sure to callenablebefore attempting any further access to the file.The
enablemethod restores web access to a file.The
getImageSizemethod returns the currently configured image sizes.The
identifyLocalImagemethod provides direct access to theuploadfsfunctionality for determining the extension, width, height and orientation of images. NormallycopyIndoes 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.
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:
uploadfs.remove('/profiles/me.jpg', function(e) { ... });Disabling Access To Files
This call shuts off web access to a file:
uploadfs.disable('/profiles/me.jpg', function(e) { ... });And this call restores it:
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.
{
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:
{
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:
{
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:
{
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:
{
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:
export GOOGLE_APPLICATION_CREDENTIALS=./projectname-f7f5e919aa79.jsonIn 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
localbackend (files on your server's drive), you might not like that whendisableis called, the permissions of a file are set to000(no one has access). We suggest using thedisabledFileKeyoption to completely avoid this issue. However, if you wish, you can pass thedisablePermissionsoption. 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:
// 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:
// 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
orientOriginalsoption tofalse.It is possible to pass your own custom storage module instead of
localors3. Followlocal.jsors3.jsas a model, and specify your backend like this:
storage: require('mystorage.js')You may specify an alternate image processing backend via the
imageoption. Two backends,sharpandimagemagick, are built in. You may also supply an object instead of a string to use your own image processor. Just follow the existingsharp.jsfile 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.txtbehave 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 thestrictPathsoption totrue.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
loggeroption and they are delivered to it instead:
logger: myLogger // an object with log, debug, info, warn and error methodsAny 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:
// 60*60*24*7 = 1 Week
// Images are delivered with cache-control-header
cachingTime: 604800S3 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.
https: trueAlso, 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:
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:
// 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:
// 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:
https://yourbucketname.s3.amazonaws.com/your/path/to/something.jpgBut 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:
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:
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.
uploadfsexpects 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.