metalsmith-bundled-components
v1.4.0
Published
A Metalsmith plugin that discovers, orders, and bundles CSS and JavaScript files from component-based architectures using esbuild
Downloads
667
Maintainers
Readme
metalsmith-bundled-components
A Metalsmith plugin that automatically discovers and bundles CSS and JavaScript files from component-based architectures using esbuild
Features
- Automatic component discovery - Scans directories for components and their assets
- Requirement validation - Validates that component requirements exist (no complex dependency ordering)
- esbuild-powered bundling - Modern, fast bundling with tree shaking and minification
- CSS @import resolution - Automatically resolves @import statements in main CSS files
- Complete minification - All CSS and JS (main + components) properly minified in production
- Main entry points - Bundle your main CSS/JS files alongside components
- PostCSS integration - PostCSS support via esbuild plugins
- Simple, predictable ordering - Main entries → base components → sections (filesystem order)
- Component validation - Validates component properties to prevent silent failures
- Editor schema emit - Optionally emits a composed field schema per section for external editors/form generators
- Tree shaking - Removes unused code for smaller bundles
- Convention over configuration - Sensible defaults with minimal required setup
Installation
npm install metalsmith-bundled-componentsThis plugin is published as ESM only and requires Node.js 22 or newer. CommonJS consumers should pin to the 0.10.x line.
Usage
Pass metalsmith-bundled-components to metalsmith.use:
Basic Usage
import Metalsmith from 'metalsmith';
import bundledComponents from 'metalsmith-bundled-components';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
Metalsmith(__dirname)
.use(bundledComponents()) // default options
.build((err) => {
if (err) throw err;
});With Custom Component Paths
import Metalsmith from 'metalsmith';
import bundledComponents from 'metalsmith-bundled-components';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
Metalsmith(__dirname)
.use(
bundledComponents({
basePath: 'components/base',
sectionsPath: 'components/sections',
cssDest: 'assets/bundle.css',
jsDest: 'assets/bundle.js'
})
)
.build((err) => {
if (err) throw err;
});With Main Entry Points (New!)
import Metalsmith from 'metalsmith';
import bundledComponents from 'metalsmith-bundled-components';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
Metalsmith(__dirname)
.use(
bundledComponents({
// Bundle main app files along with components
mainCSSEntry: 'src/styles/main.css',
mainJSEntry: 'src/scripts/main.js',
// Component paths
basePath: 'components/base',
sectionsPath: 'components/sections'
})
)
.build((err) => {
if (err) throw err;
});Real-World Example with PostCSS Processing
import Metalsmith from 'metalsmith';
import bundledComponents from 'metalsmith-bundled-components';
import autoprefixer from 'autoprefixer';
import cssnano from 'cssnano';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
Metalsmith(__dirname)
.use(
bundledComponents({
basePath: 'lib/layouts/components/_partials',
sectionsPath: 'lib/layouts/components/sections',
postcss: {
enabled: true,
plugins: [autoprefixer(), cssnano({ preset: 'default' })],
options: {
// Additional PostCSS options if needed
}
}
})
)
.build((err) => {
if (err) throw err;
});This configuration:
- Uses the default component paths in the
lib/layoutsdirectory structure - Enables PostCSS processing
- Applies autoprefixer to add vendor prefixes for better browser compatibility
- Minifies the CSS output using cssnano with default settings
The resulting bundled CSS will be properly ordered by dependencies, prefixed for browser compatibility, and minified for production use.
Options
| Option | Description | Type | Default |
| -------------- | -------------------------------------------------------- | --------- | --------------------------------------------------------- |
| basePath | Path to base/atomic components directory | String | 'lib/layouts/components/_partials' |
| sectionsPath | Path to section/composite components directory | String | 'lib/layouts/components/sections' |
| layoutsPath | Path to layouts directory for scanning template includes | String | 'lib/layouts' |
| cssDest | Destination path for bundled CSS | String | 'assets/main.css' |
| jsDest | Destination path for bundled JavaScript | String | 'assets/main.js' |
| mainCSSEntry | Main CSS entry point (design tokens, base styles) | String | 'lib/assets/main.css' |
| mainJSEntry | Main JS entry point (app initialization code) | String | 'lib/assets/main.js' |
| minifyOutput | Enable esbuild minification for production builds | Boolean | false |
| postcss | PostCSS configuration (enabled, plugins, options) | Object | { enabled: false, plugins: [], options: {} } |
| validation | Section validation configuration | Object | { enabled: true, strict: false, reportAllErrors: true } |
| schema | Editor schema emit configuration | Object | { enabled: false, dest: 'assets/components-schema.json' } |
| layers | Cascade layer wrapping and site override pickup | Object | { enabled: false, order: ['tokens', 'base', 'components', 'site'], componentsLayer: 'components', siteLayer: 'site', overridesPath: 'lib/overrides' } |
Cascade Layers and Site Overrides
A site that installs a component and then wants it to look different has two bad options: edit the canon file, which makes the next update a merge, or out-specify it from site CSS, which turns styling into a specificity contest. Cascade layers remove the contest.
With layers.enabled, each component's CSS is wrapped in its own sublayer at concat time, and the site's overrides are appended in a later layer:
.use(
bundledComponents({
layers: { enabled: true }
})
)The bundle then looks like this:
@layer tokens, base, components, site;
/* main.css, exactly as authored */
@layer components.hero {
.hero { ... } /* canon component CSS, untouched on disk */
}
@layer site.hero {
.hero { --hero-gap: var(--space-l); } /* lib/overrides/hero/hero.css */
}Anything in site beats anything in components, whatever its specificity and wherever it sits in the file. A one-class override wins over a canon rule with three, so overrides stay small and canon component directories stay pristine and re-installable.
Overrides live at <overridesPath>/<name>/<name>.css, mirroring how components themselves are laid out. They are picked up only for components the build actually uses, so a site that overrides nothing ships nothing extra, and an override for an unused component costs nothing.
Sublayer names keep devtools attribution readable: you can see that a rule came from components.hero rather than from an anonymous blob of concatenated CSS.
Only assembled CSS is wrapped. The main entry is hand-authored and passes through exactly as written, which is where a site declares its own @layer blocks if it wants them. A component stylesheet that begins with @import or @charset is also left unwrapped, since neither is valid inside a layer block; it keeps working, it just does not participate in layer precedence.
The order array is emitted as the bundle's @layer statement, so precedence comes from configuration rather than from whichever component happened to be concatenated first.
Off by default in 1.x, because turning it on changes which rules win. Expect to spend a little time on rules that previously won by accident.
Component Structure
The plugin expects components to be organized in a specific structure:
lib/
└─ layouts/
├─ components/
│ ├─ _partials/ # Atomic/base components
│ │ ├─ button/
│ │ │ ├─ button.njk
│ │ │ ├─ button.css
│ │ │ ├─ button.js
│ │ │ └─ manifest.json (optional)
│ │ └─ image/
│ │ ├─ image.njk
│ │ └─ image.css
│ └─ sections/ # Composite components
│ ├─ banner/
│ │ ├─ banner.njk
│ │ ├─ banner.css
│ │ ├─ banner.js
│ │ └─ manifest.json
│ └─ media/
│ ├─ media.njk
│ ├─ media.css
│ └─ manifest.json
└─ pages/
├─ default.njk
└─ home.njkComponent Manifest
Each component can include an optional manifest.json file:
{
"name": "banner",
"type": "section",
"description": "banner section with background image",
"styles": ["banner.css", "banner-responsive.css"],
"scripts": ["banner.js"],
"requires": ["button", "image"]
}If no manifest file is present, the plugin will auto-generate one based on the component name:
- It will look for
<component-name>.cssand<component-name>.jsfiles - Requirements must be explicitly defined in a manifest file if component depends on others
Section Validation
The plugin includes validation capabilities to catch common configuration errors in your frontmatter/YAML that would otherwise result in "silent failures" - where the site builds successfully but renders incorrectly.
Common Problems Solved
- Type coercion issues:
isAnimated: "false"(string) always evaluates totruein templates - Invalid enum values:
buttonStyle: "blue"when CSS only supportsprimary,secondary,ghost - Misspelled properties:
titleTag: "header"instead of valid HTML heading tags
Manifest with Validation Rules
Add a validation object to your component's manifest.json:
{
"name": "hero",
"type": "section",
"styles": ["hero.css"],
"scripts": [],
"requires": ["button", "image"],
"validation": {
"required": ["sectionType"],
"properties": {
"sectionType": {
"type": "string",
"const": "hero"
},
"isReverse": {
"type": "boolean"
},
"containerFields.isAnimated": {
"type": "boolean"
},
"containerFields.background.imageScreen": {
"type": "string",
"enum": ["light", "dark", "none"]
},
"text.titleTag": {
"type": "string",
"enum": ["h1", "h2", "h3", "h4", "h5", "h6"]
},
"ctas": {
"type": "array",
"items": {
"properties": {
"isButton": {
"type": "boolean"
},
"buttonStyle": {
"type": "string",
"pattern": "^(primary|secondary|ghost|none)( small)?$"
}
}
}
}
}
}
}Validation Features
Type Validation: Ensure fields are actual booleans, strings, numbers, or arrays - not string representations.
Enum Validation: Restrict values to predefined options (e.g., titleTag: ["h1", "h2", "h3"]).
Pattern Validation: Match values against a regex pattern. Use this instead of enum when values support compound forms, e.g., buttonStyle accepts a base style optionally followed by small:
"buttonStyle": {
"type": "string",
"pattern": "^(primary|secondary|tertiary|inverted)( small)?$"
}This accepts "primary", "tertiary small", "inverted small", etc.
Nested Properties: Use dot notation for nested validation (containerFields.isAnimated).
Array Items: Validate properties within array elements.
Helpful Error Messages: Get error messages with file context and helpful tips.
Error Message Example
❌ Section Validation Errors:
Section 0 (hero) in src/index.md:
- containerFields.isAnimated: expected boolean, got string "false"
- text.titleTag: "header" is invalid. Must be one of: h1, h2, h3, h4, h5, h6
- ctas[0].buttonStyle: "blue" is invalid. Must be one of: primary, secondary, ghost, none
Tip: String "false" evaluates to true in templates. Use boolean false instead.Validation Configuration
Configure validation behavior in plugin options:
Metalsmith(__dirname)
.use(
bundledComponents({
validation: {
enabled: true, // Enable/disable validation
strict: false, // Fail build on errors vs warnings only
reportAllErrors: true // Report all errors vs stop on first
}
})
)
.build((err) => {
if (err) throw err;
});Editor Schema
For external editors or form generators that author section content, the plugin can emit a single JSON artifact describing every section's fields. The plugin already discovers all components and follows their requires graph; with schema.enabled, it surfaces the composed result so the editor never has to re-implement component composition.
Enable it in plugin options:
Metalsmith(__dirname)
.use(
bundledComponents({
schema: {
enabled: true, // off by default
dest: 'assets/components-schema.json' // where the artifact is written
}
})
)
.build((err) => {
if (err) throw err;
});The artifact is a map from section name to its fully resolved, nested field tree. It is built from all section components, not the tree-shaken set, so an editor always sees the full authoring palette regardless of what a given build uses.
The fields block
Add a fields block to a component's manifest.json alongside validation. It mirrors the data shape your templates render:
- A node with a string
widgetis a leaf field. Recognized widgets includetext,markdown,select,checkbox, andimage(an editor may define more). A leaf may also carrylabel,default,help, and the constraint keysenum,required, andtype. - An array of objects uses
widget: "array"with anitemsfield tree describing one entry. - Any other object is a group; its nesting becomes the field's path in the frontmatter.
{
"name": "text",
"type": "partial",
"fields": {
"title": { "widget": "text", "label": "Title", "default": "" },
"titleTag": { "widget": "select", "label": "Title level", "enum": ["h1", "h2", "h3"], "default": "h2" },
"prose": { "widget": "markdown", "label": "Body", "default": "" }
}
}Composition: $use and $extends
So that shared field groups are defined once on a partial and reused, composition has two forms:
$useinserts a partial's fields under a named key. Sibling keys on the same node deep-merge over the inherited fields, letting a section override one inherited field without redefining the group. A partial whose entirefieldsblock is a single field (for example actaspartial that is onewidget: "array"field) also resolves through$use.$extendsspreads one or more partials' fields into the current level instead of nesting them. Use it for fields that live at the section root and are shared by every section.
{
"name": "banner",
"type": "section",
"requires": ["ctas", "text", "image", "commons"],
"fields": {
"isReverse": { "widget": "checkbox", "label": "Reverse layout", "default": false },
"text": { "$use": "text" },
"image": { "$use": "image" },
"ctas": { "$use": "ctas" },
"$extends": ["commons"]
}
}Here text, image, and ctas are inserted under their keys, while the shared commons fields (such as a containerFields wrapper) are spread onto the section root. $use and $extends targets should also appear in requires. Unknown references and reference cycles throw a build error; a $extends target that resolves to a single leaf field (rather than a group) also throws.
Components without a fields block are simply omitted from the schema, so you can migrate components to the format incrementally. That holds for references too: a section whose $use/$extends target has no fields block yet is left out of the schema and reported on stdout, rather than failing the build. A site partway through migration gets a partial schema that fills in as its components are updated. A component that needs a fields block only as a shared composition source (referenced by other components via $use/$extends) but is not an authorable section itself can set "abstract": true to stay out of the emitted schema while remaining available for composition.
Additional PostCSS Examples
Adding Custom Media Queries Support
import Metalsmith from 'metalsmith';
import bundledComponents from 'metalsmith-bundled-components';
import autoprefixer from 'autoprefixer';
import cssnano from 'cssnano';
import postcssCustomMedia from 'postcss-custom-media';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
Metalsmith(__dirname)
.use(
bundledComponents({
postcss: {
enabled: true,
plugins: [postcssCustomMedia(), autoprefixer(), cssnano({ preset: 'default' })]
}
})
)
.build((err) => {
if (err) throw err;
});Adding Nested Rules Support
import Metalsmith from 'metalsmith';
import bundledComponents from 'metalsmith-bundled-components';
import postcssNested from 'postcss-nested';
import autoprefixer from 'autoprefixer';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
Metalsmith(__dirname)
.use(
bundledComponents({
postcss: {
enabled: true,
plugins: [postcssNested(), autoprefixer()]
}
})
)
.build((err) => {
if (err) throw err;
});CSS Processing & @import Resolution
The plugin provides CSS processing with automatic @import resolution:
How CSS Processing Works
- Concatenation: Main CSS entry + all component CSS files are combined
- Temp Directory Setup: Combined CSS and @import dependencies copied to temporary directory
- @import Resolution: esbuild processes the combined CSS to resolve all @import statements
- Minification: When
minifyOutput: true, all CSS (main + components) is minified together - Output: Final processed CSS written to build directory
- Cleanup: Temporary files automatically cleaned up
@import Support
Your main CSS file can use @import statements with the following supported directory structure:
/* main.css */
@import './styles/_design-tokens.css';
@import './styles/_base.css';
@import './_utilities.css'; /* Files in same directory */
/* Your main application styles */
body {
font-family: var(--font-primary);
line-height: var(--line-height);
}Expected Directory Structure:
src/assets/
├── main.css /* Main CSS entry point */
├── _utilities.css /* CSS files in same directory */
└── styles/ /* Subdirectory for @imports */
├── _design-tokens.css
├── _base.css
└── _components.cssThe plugin automatically:
- ✅ Copies imported files to temp directory preserving relative paths
- ✅ Resolves @import statements using esbuild bundling
- ✅ Combines with component CSS for a single output file
- ✅ Applies minification to the entire combined CSS when enabled
Production Minification
When minifyOutput: true is set:
Metalsmith(__dirname).use(
bundledComponents({
mainCSSEntry: 'lib/assets/main.css',
minifyOutput: process.env.NODE_ENV === 'production' // Enable in production
})
);Result: All CSS (main entry + imported files + component styles) is fully minified into a single optimized file.
Test Coverage
This plugin is tested with Node's native node:test runner and --experimental-test-coverage.
Debug
To enable debug logs, set the DEBUG environment variable to metalsmith-bundled-components*:
metalsmith.env('DEBUG', 'metalsmith-bundled-components*');Alternatively, you can set DEBUG to metalsmith:* to debug all Metalsmith plugins.
CLI Usage
To use this plugin with the Metalsmith CLI, add metalsmith-bundled-components to the plugins key in your metalsmith.json file:
{
"plugins": [
{
"metalsmith-bundled-components": {
"basePath": "lib/layouts/components/_partials",
"sectionsPath": "lib/layouts/components/sections",
"postcss": {
"enabled": true,
"plugins": ["autoprefixer", "cssnano"]
}
}
}
]
}License
MIT
Development transparency
Portions of this project were developed with the assistance of AI tools including Claude and Claude Code. These tools were used to:
- Generate or refactor code
- Assist with documentation
- Troubleshoot bugs and explore alternative approaches
All AI-assisted code has been reviewed and tested to ensure it meets project standards. See the included CLAUDE.md and PROMPT-TEMPLATE.md files for more details.
