@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.
@@ -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
+ }