@eleventy-plugin-themer/build-vite 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +161 -0
- package/index.mjs +260 -0
- package/package.json +63 -0
- package/plugins/auto-import.mjs +82 -0
- package/plugins/critical-css.mjs +31 -0
- package/plugins/feature-serve.mjs +51 -0
- package/plugins/index.mjs +15 -0
- package/plugins/minify-html.mjs +66 -0
- package/plugins/preserve-non-html.mjs +55 -0
- package/plugins/prism-theme.mjs +61 -0
- package/plugins/purge-css.mjs +64 -0
- package/plugins/validate-links.mjs +164 -0
- package/postcss.mjs +113 -0
- package/theme-config.mjs +245 -0
- package/utils/constants.mjs +23 -0
- package/utils/features.mjs +98 -0
- package/utils/file-processor.mjs +122 -0
- package/utils/integration-check.mjs +146 -0
- package/utils/merge-arrays.mjs +34 -0
- package/utils/merge-config.mjs +99 -0
- package/utils/plugin-orchestrator.mjs +74 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
|
|
3
|
+
import { minify } from 'html-minifier-terser';
|
|
4
|
+
|
|
5
|
+
import { processFiles } from '../utils/file-processor.mjs';
|
|
6
|
+
import { GLOB_PATTERNS } from '../utils/constants.mjs';
|
|
7
|
+
|
|
8
|
+
export async function minifyHTML(outputDir, userOptions = {}) {
|
|
9
|
+
// outputDir is guaranteed to be provided by the orchestrator
|
|
10
|
+
const defaultOptions = {
|
|
11
|
+
collapseBooleanAttributes: true,
|
|
12
|
+
collapseWhitespace: true,
|
|
13
|
+
conservativeCollapse: false,
|
|
14
|
+
decodeEntities: true,
|
|
15
|
+
html5: true,
|
|
16
|
+
includeAutoGeneratedTags: false,
|
|
17
|
+
minifyCSS: true,
|
|
18
|
+
minifyJS: true,
|
|
19
|
+
preserveLineBreaks: false,
|
|
20
|
+
preventAttributesEscaping: true,
|
|
21
|
+
removeAttributeQuotes: true,
|
|
22
|
+
removeComments: true,
|
|
23
|
+
removeEmptyAttributes: true,
|
|
24
|
+
removeOptionalTags: false,
|
|
25
|
+
removeRedundantAttributes: true,
|
|
26
|
+
removeScriptTypeAttributes: true,
|
|
27
|
+
removeStyleLinkTypeAttributes: true,
|
|
28
|
+
sortAttributes: true,
|
|
29
|
+
sortClassName: true,
|
|
30
|
+
useShortDoctype: true,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const options = { ...defaultOptions, ...userOptions };
|
|
34
|
+
|
|
35
|
+
return processFiles({
|
|
36
|
+
pattern: GLOB_PATTERNS.html(outputDir),
|
|
37
|
+
outputDir,
|
|
38
|
+
taskName: 'HTML Minification',
|
|
39
|
+
errorTip:
|
|
40
|
+
'Check if HTML is valid and properly formed. Invalid HTML can cause minification to fail.',
|
|
41
|
+
processor: async (file) => {
|
|
42
|
+
const html = await fs.readFile(file, 'utf-8');
|
|
43
|
+
const originalSize = Buffer.byteLength(html, 'utf8');
|
|
44
|
+
const minified = await minify(html, options);
|
|
45
|
+
const minifiedSize = Buffer.byteLength(minified, 'utf8');
|
|
46
|
+
|
|
47
|
+
await fs.writeFile(file, minified);
|
|
48
|
+
|
|
49
|
+
const savings = ((1 - minifiedSize / originalSize) * 100).toFixed(1);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
message: ` (${savings}% smaller)`,
|
|
53
|
+
stats: { originalSize, minifiedSize },
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
calculateStats: (results) => {
|
|
57
|
+
const totalOriginal = results.reduce((sum, r) => sum + r.stats.originalSize, 0);
|
|
58
|
+
const totalMinified = results.reduce((sum, r) => sum + r.stats.minifiedSize, 0);
|
|
59
|
+
const totalSavings = ((1 - totalMinified / totalOriginal) * 100).toFixed(1);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
'total reduction': `${totalSavings}% (${(totalOriginal / 1024).toFixed(1)} KB ā ${(totalMinified / 1024).toFixed(1)} KB)`,
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preserve Non-HTML Files
|
|
3
|
+
* Copies non-HTML files (XML, TXT, XSL) from Vite temp folder to output
|
|
4
|
+
* Ensures feed files and other static assets are included in build
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
|
|
10
|
+
import { glob } from 'glob';
|
|
11
|
+
import { logger } from '@eleventy-plugin-themer/core/logger';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Copy non-HTML files from .11ty-vite to _site
|
|
15
|
+
* @param {string} outputDir - Output directory (default: '_site')
|
|
16
|
+
* @param {Object} options - Configuration options
|
|
17
|
+
* @param {string} options.temp - Vite temp directory (required)
|
|
18
|
+
* @param {string[]} options.extensions - File extensions to preserve (default: [])
|
|
19
|
+
*/
|
|
20
|
+
export async function preserveNonHtmlFiles(outputDir, options = {}) {
|
|
21
|
+
const { temp: tempDir, extensions = [] } = options;
|
|
22
|
+
|
|
23
|
+
if (!tempDir) {
|
|
24
|
+
logger.warn('ā ļø preserveNonHtmlFiles: tempDir option is required');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (extensions.length === 0) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
logger.info('\nš Preserving non-HTML files...\n');
|
|
33
|
+
|
|
34
|
+
const pattern = `${tempDir}/**/*.{${extensions.join(',')}}`;
|
|
35
|
+
const files = await glob(pattern);
|
|
36
|
+
|
|
37
|
+
if (files.length === 0) {
|
|
38
|
+
logger.info(' No non-HTML files to preserve\n');
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let copiedCount = 0;
|
|
43
|
+
|
|
44
|
+
for (const file of files) {
|
|
45
|
+
const dest = path.join(outputDir, path.relative(tempDir, file));
|
|
46
|
+
await fs.mkdir(path.dirname(dest), { recursive: true });
|
|
47
|
+
await fs.copyFile(file, dest);
|
|
48
|
+
copiedCount++;
|
|
49
|
+
|
|
50
|
+
const relativePath = path.relative(outputDir, dest);
|
|
51
|
+
logger.info(` ā ${relativePath}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
logger.info(`\nā
Preserved ${copiedCount} non-HTML file(s)\n`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite plugin for config-driven PrismJS theme loading
|
|
3
|
+
*
|
|
4
|
+
* Provides a virtual module `virtual:prism-theme` that resolves to
|
|
5
|
+
* the configured PrismJS theme CSS and optional diff-highlight plugin.
|
|
6
|
+
*
|
|
7
|
+
* Theme is configured via theme.json `config.codeHighlighting.prismTheme`.
|
|
8
|
+
* Users override in their `theme.config.mjs`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const VIRTUAL_MODULE_ID = 'virtual:prism-theme';
|
|
12
|
+
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Valid PrismJS theme names (bundled with prismjs package)
|
|
16
|
+
*/
|
|
17
|
+
const VALID_THEMES = new Set([
|
|
18
|
+
'prism',
|
|
19
|
+
'prism-coy',
|
|
20
|
+
'prism-dark',
|
|
21
|
+
'prism-funky',
|
|
22
|
+
'prism-okaidia',
|
|
23
|
+
'prism-solarizedlight',
|
|
24
|
+
'prism-tomorrow',
|
|
25
|
+
'prism-twilight',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {Object} options
|
|
30
|
+
* @param {string} [options.prismTheme='prism-tomorrow'] - PrismJS theme name (without .css)
|
|
31
|
+
* @param {boolean} [options.diffHighlight=true] - Include diff-highlight plugin CSS
|
|
32
|
+
* @returns {import('vite').Plugin}
|
|
33
|
+
*/
|
|
34
|
+
export function prismThemePlugin(options = {}) {
|
|
35
|
+
const { prismTheme = 'prism-tomorrow', diffHighlight = true } = options;
|
|
36
|
+
|
|
37
|
+
if (!VALID_THEMES.has(prismTheme)) {
|
|
38
|
+
const available = [...VALID_THEMES].join(', ');
|
|
39
|
+
throw new Error(`[prism-theme] Invalid theme "${prismTheme}". Available themes: ${available}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
name: 'eleventy-themes-prism-theme',
|
|
44
|
+
|
|
45
|
+
resolveId(id) {
|
|
46
|
+
if (id === VIRTUAL_MODULE_ID) {
|
|
47
|
+
return RESOLVED_VIRTUAL_MODULE_ID;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
load(id) {
|
|
52
|
+
if (id === RESOLVED_VIRTUAL_MODULE_ID) {
|
|
53
|
+
let code = `import 'prismjs/themes/${prismTheme}.css';\n`;
|
|
54
|
+
if (diffHighlight) {
|
|
55
|
+
code += `import 'prismjs/plugins/diff-highlight/prism-diff-highlight.css';\n`;
|
|
56
|
+
}
|
|
57
|
+
return code;
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
|
|
3
|
+
import { PurgeCSS } from 'purgecss';
|
|
4
|
+
|
|
5
|
+
import { processFiles } from '../utils/file-processor.mjs';
|
|
6
|
+
import { GLOB_PATTERNS } from '../utils/constants.mjs';
|
|
7
|
+
|
|
8
|
+
export async function purgeCSSFiles(outputDir, options = {}) {
|
|
9
|
+
const { safelist: extraSafelist = {} } = options;
|
|
10
|
+
|
|
11
|
+
// Default safelist ā generic patterns the build plugin always preserves
|
|
12
|
+
// Theme-specific patterns belong in theme.json under build.purgeCSS.safelist
|
|
13
|
+
const defaultSafelist = {
|
|
14
|
+
standard: [/^is-/, /^has-/, /^js-/, /^page-/],
|
|
15
|
+
deep: [],
|
|
16
|
+
greedy: [],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Merge theme/user safelist with defaults (strings become RegExp)
|
|
20
|
+
const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
21
|
+
const toRegExp = (v) => (v instanceof RegExp ? v : new RegExp(escapeRegExp(v)));
|
|
22
|
+
const mergedSafelist = {
|
|
23
|
+
standard: [...defaultSafelist.standard, ...(extraSafelist.standard || []).map(toRegExp)],
|
|
24
|
+
deep: [...defaultSafelist.deep, ...(extraSafelist.deep || []).map(toRegExp)],
|
|
25
|
+
greedy: [...defaultSafelist.greedy, ...(extraSafelist.greedy || []).map(toRegExp)],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// outputDir is guaranteed to be provided by the orchestrator
|
|
29
|
+
return processFiles({
|
|
30
|
+
pattern: GLOB_PATTERNS.css(outputDir),
|
|
31
|
+
outputDir,
|
|
32
|
+
taskName: 'PurgeCSS',
|
|
33
|
+
processor: async (file) => {
|
|
34
|
+
const stat = await fs.stat(file);
|
|
35
|
+
const originalSize = stat.size;
|
|
36
|
+
|
|
37
|
+
const results = await new PurgeCSS().purge({
|
|
38
|
+
content: [GLOB_PATTERNS.html(outputDir)],
|
|
39
|
+
css: [file],
|
|
40
|
+
safelist: mergedSafelist,
|
|
41
|
+
defaultExtractor: (content) => {
|
|
42
|
+
const matches = content.match(/[^<>"'`\s]*[^<>"'`\s:]/g) || [];
|
|
43
|
+
return matches;
|
|
44
|
+
},
|
|
45
|
+
keyframes: true,
|
|
46
|
+
fontFace: true,
|
|
47
|
+
variables: true,
|
|
48
|
+
rejected: false,
|
|
49
|
+
rejectedCss: false,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await fs.writeFile(file, results[0].css);
|
|
53
|
+
|
|
54
|
+
const newStat = await fs.stat(file);
|
|
55
|
+
const newSize = newStat.size;
|
|
56
|
+
const reduction = ((1 - newSize / originalSize) * 100).toFixed(1);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
message: ` (${reduction}% smaller)`,
|
|
60
|
+
stats: { originalSize, newSize, reduction },
|
|
61
|
+
};
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link Validation
|
|
3
|
+
* Validates internal links and images after build
|
|
4
|
+
* Catches broken links before deployment
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import fs from 'fs/promises';
|
|
9
|
+
import { existsSync } from 'fs';
|
|
10
|
+
|
|
11
|
+
import { glob } from 'glob';
|
|
12
|
+
import { parse } from 'node-html-parser';
|
|
13
|
+
import { logger } from '@eleventy-plugin-themer/core/logger';
|
|
14
|
+
|
|
15
|
+
import { GLOB_PATTERNS } from '../utils/constants.mjs';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Check resources (links or images) in parsed HTML for broken references
|
|
19
|
+
*
|
|
20
|
+
* @param {Object} root - Parsed HTML root from node-html-parser
|
|
21
|
+
* @param {Object} options
|
|
22
|
+
* @param {string} options.selector - CSS selector (e.g., 'a[href]', 'img[src]')
|
|
23
|
+
* @param {string} options.attribute - Attribute to check (e.g., 'href', 'src')
|
|
24
|
+
* @param {string[]} options.skipPrefixes - URL prefixes to skip
|
|
25
|
+
* @param {string} options.errorType - Error type label (e.g., 'broken-link', 'missing-image')
|
|
26
|
+
* @param {string} options.errorPrefix - Error message prefix
|
|
27
|
+
* @param {string} options.outputDir - Build output directory
|
|
28
|
+
* @param {string} options.baseDir - Directory of the HTML file
|
|
29
|
+
* @param {string} options.relativePath - Relative path of the HTML file
|
|
30
|
+
* @returns {{ count: number, errors: Array }}
|
|
31
|
+
*/
|
|
32
|
+
function checkResources(root, options) {
|
|
33
|
+
const {
|
|
34
|
+
selector,
|
|
35
|
+
attribute,
|
|
36
|
+
skipPrefixes,
|
|
37
|
+
errorType,
|
|
38
|
+
errorPrefix,
|
|
39
|
+
outputDir,
|
|
40
|
+
baseDir,
|
|
41
|
+
relativePath,
|
|
42
|
+
} = options;
|
|
43
|
+
|
|
44
|
+
const elements = root.querySelectorAll(selector);
|
|
45
|
+
const errors = [];
|
|
46
|
+
let count = 0;
|
|
47
|
+
|
|
48
|
+
for (const el of elements) {
|
|
49
|
+
const value = el.getAttribute(attribute);
|
|
50
|
+
if (!value || skipPrefixes.some((prefix) => value.startsWith(prefix))) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
count++;
|
|
55
|
+
|
|
56
|
+
const cleanValue = value.split('#')[0].split('?')[0];
|
|
57
|
+
|
|
58
|
+
let targetPath;
|
|
59
|
+
if (cleanValue.startsWith('/')) {
|
|
60
|
+
targetPath = path.join(outputDir, cleanValue);
|
|
61
|
+
} else {
|
|
62
|
+
targetPath = path.join(baseDir, cleanValue);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fileExists = existsSync(targetPath);
|
|
66
|
+
const indexExists = !fileExists && existsSync(path.join(targetPath, 'index.html'));
|
|
67
|
+
|
|
68
|
+
if (!fileExists && !indexExists) {
|
|
69
|
+
errors.push({
|
|
70
|
+
file: relativePath,
|
|
71
|
+
type: errorType,
|
|
72
|
+
target: value,
|
|
73
|
+
message: `${errorPrefix}: ${value}`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { count, errors };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const LINK_SKIP_PREFIXES = ['http://', 'https://', 'mailto:', 'tel:', '#'];
|
|
82
|
+
const IMAGE_SKIP_PREFIXES = ['http://', 'https://', 'data:'];
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate links and images in built HTML.
|
|
86
|
+
* Throws an error if validation fails.
|
|
87
|
+
* @param {string} outputDir - Output directory to validate
|
|
88
|
+
* @param {Object} options - Validation options (currently unused but reserved)
|
|
89
|
+
*/
|
|
90
|
+
export async function validateLinks(outputDir, _options = {}) {
|
|
91
|
+
logger.info('\nš Validating links and images...\n');
|
|
92
|
+
|
|
93
|
+
const htmlFiles = await glob(GLOB_PATTERNS.html(outputDir));
|
|
94
|
+
|
|
95
|
+
const errors = [];
|
|
96
|
+
let totalLinks = 0;
|
|
97
|
+
let totalImages = 0;
|
|
98
|
+
|
|
99
|
+
for (const htmlFile of htmlFiles) {
|
|
100
|
+
try {
|
|
101
|
+
const html = await fs.readFile(htmlFile, 'utf-8');
|
|
102
|
+
const root = parse(html);
|
|
103
|
+
|
|
104
|
+
const relativePath = path.relative(outputDir, htmlFile);
|
|
105
|
+
const baseDir = path.dirname(htmlFile);
|
|
106
|
+
const common = { outputDir, baseDir, relativePath };
|
|
107
|
+
|
|
108
|
+
const links = checkResources(root, {
|
|
109
|
+
...common,
|
|
110
|
+
selector: 'a[href]',
|
|
111
|
+
attribute: 'href',
|
|
112
|
+
skipPrefixes: LINK_SKIP_PREFIXES,
|
|
113
|
+
errorType: 'broken-link',
|
|
114
|
+
errorPrefix: 'Broken internal link',
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const images = checkResources(root, {
|
|
118
|
+
...common,
|
|
119
|
+
selector: 'img[src]',
|
|
120
|
+
attribute: 'src',
|
|
121
|
+
skipPrefixes: IMAGE_SKIP_PREFIXES,
|
|
122
|
+
errorType: 'missing-image',
|
|
123
|
+
errorPrefix: 'Missing image',
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
totalLinks += links.count;
|
|
127
|
+
totalImages += images.count;
|
|
128
|
+
errors.push(...links.errors, ...images.errors);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
errors.push({
|
|
131
|
+
file: path.relative(outputDir, htmlFile),
|
|
132
|
+
type: 'parse-error',
|
|
133
|
+
message: `Failed to parse HTML: ${error.message}`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Report results
|
|
139
|
+
if (errors.length > 0) {
|
|
140
|
+
logger.error(`ā Link validation failed: ${errors.length} errors found\n`);
|
|
141
|
+
|
|
142
|
+
const grouped = {
|
|
143
|
+
'broken-link': { icon: 'š', label: 'Broken Links' },
|
|
144
|
+
'missing-image': { icon: 'š¼ļø ', label: 'Missing Images' },
|
|
145
|
+
'parse-error': { icon: 'ā ļø ', label: 'Parse Errors' },
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
for (const [type, { icon, label }] of Object.entries(grouped)) {
|
|
149
|
+
const items = errors.filter((e) => e.type === type);
|
|
150
|
+
if (items.length === 0) continue;
|
|
151
|
+
|
|
152
|
+
logger.error(`\n${icon} ${label} (${items.length}):`);
|
|
153
|
+
items.forEach(({ file, target, message }) => {
|
|
154
|
+
logger.error(` ${file}${target ? ` ā ${target}` : `: ${message}`}`);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
logger.error('\nš” Tip: Fix broken links and missing images before deployment\n');
|
|
159
|
+
|
|
160
|
+
throw new Error(`Link validation failed with ${errors.length} error(s). Fix issues above.`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
logger.info(`ā
Link validation passed: ${totalLinks} links, ${totalImages} images\n`);
|
|
164
|
+
}
|
package/postcss.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme-aware PostCSS preset for consumer `postcss.config.mjs` files.
|
|
3
|
+
*
|
|
4
|
+
* Themes can declare a `build.postcss.plugins` array in `theme.json` listing
|
|
5
|
+
* preferred PostCSS plugin packages (with options). Consumers call
|
|
6
|
+
* `createPostcssConfig({ themeMetadata, userPlugins })` from their own
|
|
7
|
+
* `postcss.config.mjs` to get a ready-to-export config that defers to theme
|
|
8
|
+
* defaults but lets the user append project-specific plugins.
|
|
9
|
+
*
|
|
10
|
+
* Example `theme.json`:
|
|
11
|
+
* "build": {
|
|
12
|
+
* "postcss": {
|
|
13
|
+
* "plugins": [
|
|
14
|
+
* { "package": "postcss-preset-env", "options": { "stage": 3 } },
|
|
15
|
+
* {
|
|
16
|
+
* "package": "cssnano",
|
|
17
|
+
* "options": { "preset": "default" },
|
|
18
|
+
* "production": true
|
|
19
|
+
* }
|
|
20
|
+
* ]
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
*
|
|
24
|
+
* Each plugin entry:
|
|
25
|
+
* - `package` (string, required): the npm package name to dynamic-import.
|
|
26
|
+
* - `options` (object, optional): passed to the plugin factory.
|
|
27
|
+
* - `production` (boolean, optional): if `true`, only loaded when
|
|
28
|
+
* `process.env.NODE_ENV === 'production'`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { createRequire } from 'module';
|
|
32
|
+
import path from 'path';
|
|
33
|
+
import { pathToFileURL } from 'url';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* NOTE: PostCSS config files are loaded synchronously at module-import time,
|
|
37
|
+
* before Eleventy's async config function runs ā so a consumer's
|
|
38
|
+
* `postcss.config.mjs` cannot read the themer context off `eleventyConfig`
|
|
39
|
+
* (it doesn't exist yet). It therefore re-reads `theme.json` via
|
|
40
|
+
* `resolveThemeMetadata`. The cost is one static-file read per process; if
|
|
41
|
+
* the same Node process also instantiates `createThemerProject` from
|
|
42
|
+
* `eleventy.config.mjs`, `resolveThemeMetadata`'s module-level cache
|
|
43
|
+
* collapses both calls to a single disk read.
|
|
44
|
+
*
|
|
45
|
+
* @param {Object} args
|
|
46
|
+
* @param {Object} args.themeMetadata - Output of `resolveThemeMetadata`.
|
|
47
|
+
* @param {string} [args.projectRoot] - Project root used for resolving
|
|
48
|
+
* theme-declared plugin packages. Defaults to `process.cwd()` (which is
|
|
49
|
+
* correct when called from a consumer's `postcss.config.mjs`).
|
|
50
|
+
* @param {Array} [args.userPlugins] - Already-instantiated PostCSS plugins to
|
|
51
|
+
* append after the theme defaults. Use this to add project-specific
|
|
52
|
+
* plugins or to override theme plugins (PostCSS evaluates in order).
|
|
53
|
+
* @param {boolean} [args.production] - Override env detection (defaults to
|
|
54
|
+
* `process.env.NODE_ENV === 'production'`).
|
|
55
|
+
* @returns {Promise<{ plugins: Array }>} A PostCSS config object.
|
|
56
|
+
*/
|
|
57
|
+
export async function createPostcssConfig({
|
|
58
|
+
themeMetadata,
|
|
59
|
+
projectRoot,
|
|
60
|
+
userPlugins = [],
|
|
61
|
+
production,
|
|
62
|
+
} = {}) {
|
|
63
|
+
if (!themeMetadata) {
|
|
64
|
+
throw new Error('createPostcssConfig: themeMetadata is required');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const isProduction =
|
|
68
|
+
typeof production === 'boolean' ? production : process.env.NODE_ENV === 'production';
|
|
69
|
+
|
|
70
|
+
const declared = themeMetadata?.build?.postcss?.plugins;
|
|
71
|
+
const themePluginEntries = Array.isArray(declared) ? declared : [];
|
|
72
|
+
if (themePluginEntries.length === 0) {
|
|
73
|
+
return { plugins: [...userPlugins] };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Resolve packages relative to the consumer's project root rather than this
|
|
77
|
+
// build-vite file. Plugin packages are dependencies of the consumer, not of
|
|
78
|
+
// build-vite.
|
|
79
|
+
const cwd = projectRoot || process.cwd();
|
|
80
|
+
const requireFromProject = createRequire(path.join(cwd, 'package.json'));
|
|
81
|
+
|
|
82
|
+
const themePlugins = [];
|
|
83
|
+
for (const entry of themePluginEntries) {
|
|
84
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
85
|
+
const { package: pkgName, options, production: prodOnly } = entry;
|
|
86
|
+
if (!pkgName || typeof pkgName !== 'string') continue;
|
|
87
|
+
if (prodOnly && !isProduction) continue;
|
|
88
|
+
|
|
89
|
+
let resolvedPath;
|
|
90
|
+
try {
|
|
91
|
+
resolvedPath = requireFromProject.resolve(pkgName);
|
|
92
|
+
} catch (cause) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`createPostcssConfig: PostCSS plugin "${pkgName}" declared by the theme is not ` +
|
|
95
|
+
`installed in the consumer project. Add it to your dependencies.`,
|
|
96
|
+
{ cause },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const mod = await import(pathToFileURL(resolvedPath).href);
|
|
101
|
+
const factory = mod.default ?? mod;
|
|
102
|
+
if (typeof factory !== 'function') {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`createPostcssConfig: PostCSS plugin "${pkgName}" did not export a callable default.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
themePlugins.push(options !== undefined ? factory(options) : factory());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
plugins: [...themePlugins, ...userPlugins],
|
|
112
|
+
};
|
|
113
|
+
}
|