metalsmith-bundled-components
v0.6.0
Published
A Metalsmith plugin that discovers, orders, and bundles CSS and JavaScript files from component-based architectures using esbuild
Maintainers
Readme
metalsmith-bundled-components
A Metalsmith plugin that automatically discovers and bundles CSS and JavaScript files from component-based architectures using esbuild
This Metalsmith plugin is under active development. The API is stable, but breaking changes may occur before reaching 1.0.0.
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
- Tree shaking - Removes unused code for smaller bundles
- Convention over configuration - Sensible defaults with minimal required setup
- ESM and CommonJS support:
- ESM:
import bundledComponents from 'metalsmith-bundled-components' - CommonJS:
const bundledComponents = require('metalsmith-bundled-components')
- ESM:
Installation
npm install metalsmith-bundled-componentsUsage
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 } |
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",
"enum": ["primary", "secondary", "ghost", "none"]
}
}
}
}
}
}
}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"]).
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;
});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 using mocha with c8 for code 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.
