@elenajs/bundler 0.0.2

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ariel Salminen https://arielsalminen.com
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,87 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { create, ts } from "@custom-elements-manifest/analyzer";
4
+ import globby from "globby";
5
+ import { customElementJsxPlugin as elenaJsxPlugin } from "custom-element-jsx-integration";
6
+ import { elenaDefinePlugin } from "./plugins/cem-define.js";
7
+ import { elenaTagPlugin } from "./plugins/cem-tag.js";
8
+ import { elenaTypeScriptPlugin } from "./plugins/cem-typescript.js";
9
+ import { color } from "./utils/color.js";
10
+
11
+ /**
12
+ * Returns the CEM config object for the given Elena config. Useful for advanced
13
+ * users who still call the CEM CLI with a thin `elena.config.js` wrapper.
14
+ *
15
+ * @param {import("./utils/load-config.js").ElenaConfig} [options]
16
+ * @returns {object} CEM config object
17
+ */
18
+ export function createCemConfig(options = {}) {
19
+ const src = options.input ?? "src";
20
+ const outdir = options.output?.dir ?? "dist";
21
+ const extraPlugins = options.analyze?.plugins ?? [];
22
+
23
+ return {
24
+ outdir,
25
+ watch: false,
26
+ dev: false,
27
+ globs: [`${src}/**/*.js`],
28
+ exclude: [`${src}/**/*.ts`, "**/*.test.js", "node_modules"],
29
+ plugins: [
30
+ elenaDefinePlugin(),
31
+ elenaTagPlugin("status"),
32
+ elenaTagPlugin("displayName"),
33
+ elenaJsxPlugin({ outdir, fileName: "custom-elements.d.ts" }),
34
+ elenaTypeScriptPlugin({ outdir }),
35
+ ...extraPlugins,
36
+ ],
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Runs the CEM analyzer programmatically using `create()` from
42
+ * `@custom-elements-manifest/analyzer`, writes the manifest to disk.
43
+ *
44
+ * @param {import("./utils/load-config.js").ElenaConfig} config
45
+ * @param {string} [cwd]
46
+ * @returns {Promise<void>}
47
+ */
48
+ export async function runCemAnalyze(config, cwd = process.cwd()) {
49
+ const src = config.input ?? "src";
50
+ const outdir = config.output?.dir ?? "dist";
51
+ const extraPlugins = config.analyze?.plugins ?? [];
52
+
53
+ console.log(` `);
54
+ console.log(color(`░█ [ELENA]: Analyzing the build output...`));
55
+ console.log(color(`░█ [ELENA]: Generating Custom Elements Manifest...`));
56
+ console.log(color(`░█ [ELENA]: Generating TypeScript types...`));
57
+ console.log(` `);
58
+
59
+ const globs = await globby(
60
+ [`${src}/**/*.js`, `!${src}/**/*.ts`, "!**/*.test.js", "!node_modules"],
61
+ { cwd }
62
+ );
63
+
64
+ const modules = globs.map(glob => {
65
+ const fullPath = resolve(cwd, glob);
66
+ const source = readFileSync(fullPath).toString();
67
+ return ts.createSourceFile(glob, source, ts.ScriptTarget.ES2015, true);
68
+ });
69
+
70
+ const plugins = [
71
+ elenaDefinePlugin(),
72
+ elenaTagPlugin("status"),
73
+ elenaTagPlugin("displayName"),
74
+ elenaJsxPlugin({ outdir, fileName: "custom-elements.d.ts" }),
75
+ elenaTypeScriptPlugin({ outdir: join(cwd, outdir) }),
76
+ ...extraPlugins,
77
+ ];
78
+
79
+ const manifest = create({ modules, plugins, context: { dev: false } });
80
+
81
+ const outPath = join(cwd, outdir);
82
+ if (!existsSync(outPath)) {
83
+ mkdirSync(outPath, { recursive: true });
84
+ }
85
+
86
+ writeFileSync(join(outPath, "custom-elements.json"), `${JSON.stringify(manifest, null, 2)}\n`);
87
+ }
package/dist/cli.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { loadConfig } from "./utils/load-config.js";
4
+ import { runRollupBuild } from "./rollup-build.js";
5
+ import { runCemAnalyze } from "./cem-analyze.js";
6
+ import { color } from "./utils/color.js";
7
+
8
+ const BANNER = `
9
+ ██████████ ████
10
+ ░░███░░░░░█░░███
11
+ ░███ █ ░ ░███ ██████ ████████ ██████
12
+ ░██████ ░███ ███░░███░░███░░███ ░░░░░███
13
+ ░███░░█ ░███ ░███████ ░███ ░███ ███████
14
+ ░███ ░ █ ░███ ░███░░░ ░███ ░███ ███░░███
15
+ ██████████ █████░░██████ ████ █████░░████████
16
+ ░░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░ ░░░░░ ░░░░░░░░
17
+ `;
18
+
19
+ const [, , command = "build"] = process.argv;
20
+
21
+ async function main() {
22
+ if (command !== "build") {
23
+ console.error(`Unknown command: ${command}. Usage: elena [build]`);
24
+ process.exit(1);
25
+ }
26
+
27
+ console.log(color(BANNER));
28
+
29
+ const config = await loadConfig(process.cwd());
30
+
31
+ await runRollupBuild(config);
32
+ await runCemAnalyze(config);
33
+ }
34
+
35
+ main().catch(err => {
36
+ console.error(err);
37
+ process.exit(1);
38
+ });
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createRollupConfig, runRollupBuild } from "./rollup-build.js";
2
+ export { createCemConfig, runCemAnalyze } from "./cem-analyze.js";
3
+ export { cssBundlePlugin } from "./plugins/css-bundle.js";
4
+ export { elenaDefinePlugin } from "./plugins/cem-define.js";
5
+ export { elenaTagPlugin } from "./plugins/cem-tag.js";
6
+ export { elenaTypeScriptPlugin } from "./plugins/cem-typescript.js";
@@ -0,0 +1,93 @@
1
+ /**
2
+ * CEM analyzer plugin that reads `tagName` from the options object passed to
3
+ * `Elena(superClass, { tagName: "...", ... })` and registers the element in the manifest.
4
+ *
5
+ * Supports both inline objects and variable references:
6
+ * - `Elena(HTMLElement, { tagName: "elena-button", ... })`
7
+ * - `const options = { tagName: "elena-button", ... }; Elena(HTMLElement, options)`
8
+ *
9
+ * @returns {import("@custom-elements-manifest/analyzer").Plugin}
10
+ */
11
+ export function elenaDefinePlugin() {
12
+ /** @type {Map<string, string>} variable name → tag name */
13
+ const optionsMap = new Map();
14
+
15
+ return {
16
+ name: "define-element",
17
+
18
+ analyzePhase({ ts, node, moduleDoc }) {
19
+ // Collect variable declarations: const options = { tagName: "...", ... }
20
+ if (
21
+ ts.isVariableDeclaration(node) &&
22
+ node.initializer &&
23
+ ts.isObjectLiteralExpression(node.initializer)
24
+ ) {
25
+ const varName = node.name.getText();
26
+ const tagNameProp = node.initializer.properties.find(
27
+ p => ts.isPropertyAssignment(p) && p.name.getText() === "tagName"
28
+ );
29
+ if (tagNameProp && ts.isStringLiteral(tagNameProp.initializer)) {
30
+ optionsMap.set(varName, tagNameProp.initializer.text);
31
+ }
32
+ return;
33
+ }
34
+
35
+ if (!ts.isClassDeclaration(node)) {
36
+ return;
37
+ }
38
+
39
+ const className = node.name?.getText();
40
+ if (!className) {
41
+ return;
42
+ }
43
+
44
+ const heritageClause = node.heritageClauses?.[0];
45
+ if (!heritageClause) {
46
+ return;
47
+ }
48
+
49
+ for (const type of heritageClause.types) {
50
+ const expr = type.expression;
51
+ if (!ts.isCallExpression(expr) || expr.expression.getText() !== "Elena") {
52
+ continue;
53
+ }
54
+
55
+ const optionsArg = expr.arguments[1];
56
+ if (!optionsArg) {
57
+ continue;
58
+ }
59
+
60
+ let tagName;
61
+
62
+ if (ts.isObjectLiteralExpression(optionsArg)) {
63
+ // Inline: Elena(HTMLElement, { tagName: "...", ... })
64
+ const tagNameProp = optionsArg.properties.find(
65
+ p => ts.isPropertyAssignment(p) && p.name.getText() === "tagName"
66
+ );
67
+ if (tagNameProp && ts.isStringLiteral(tagNameProp.initializer)) {
68
+ tagName = tagNameProp.initializer.text;
69
+ }
70
+ } else if (ts.isIdentifier(optionsArg)) {
71
+ // Variable reference: Elena(HTMLElement, options)
72
+ tagName = optionsMap.get(optionsArg.getText());
73
+ }
74
+
75
+ if (!tagName) {
76
+ continue;
77
+ }
78
+
79
+ const declaration = moduleDoc.declarations?.find(d => d.name === className);
80
+ if (declaration) {
81
+ declaration.tagName = tagName;
82
+ }
83
+
84
+ moduleDoc.exports ??= [];
85
+ moduleDoc.exports.push({
86
+ kind: "custom-element-definition",
87
+ name: tagName,
88
+ declaration: { name: className, module: moduleDoc.path },
89
+ });
90
+ }
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Returns the CEM class declaration and resolved tag name for a class node,
3
+ * or `undefined` if the node is not a registered custom element.
4
+ *
5
+ * @param {Partial<import("@custom-elements-manifest/analyzer").JavaScriptModule>} moduleDoc
6
+ * @param {import("typescript").ClassDeclaration} node
7
+ * @returns {{ class: import("@custom-elements-manifest/analyzer").Declaration; tagName: string } | undefined}
8
+ */
9
+ function getElenaElementDetails(moduleDoc, node) {
10
+ const declaration = moduleDoc.declarations?.find(d => d.name === node.name?.getText());
11
+ if (!declaration) {
12
+ return undefined;
13
+ }
14
+
15
+ const customElement = moduleDoc.exports?.find(
16
+ e => e.kind === "js" && e.declaration.name === declaration.name
17
+ );
18
+
19
+ if (!customElement) {
20
+ return undefined;
21
+ }
22
+
23
+ return { class: declaration, tagName: customElement.name };
24
+ }
25
+
26
+ /**
27
+ * CEM analyzer plugin that reads a non-standard JSDoc tag from each custom element class
28
+ * and writes its value onto the CEM class declaration under the same key.
29
+ *
30
+ * Used for Elena-specific tags like `@status` and `@displayName`.
31
+ *
32
+ * @see https://custom-elements-manifest.open-wc.org/analyzer/plugins/authoring/#example-plugin
33
+ * @param {string} tagName - The JSDoc tag name to extract (e.g. `"status"`, `"displayName"`).
34
+ * @returns {import("@custom-elements-manifest/analyzer").Plugin}
35
+ */
36
+ export function elenaTagPlugin(tagName) {
37
+ return {
38
+ name: `${tagName}-tag`,
39
+
40
+ analyzePhase({ ts, node, moduleDoc }) {
41
+ if (!ts.isClassDeclaration(node)) {
42
+ return;
43
+ }
44
+
45
+ const details = getElenaElementDetails(moduleDoc, node);
46
+ if (!details) {
47
+ return;
48
+ }
49
+
50
+ const [tag] = ts.getAllJSDocTags(node, t => t.tagName.getText() === tagName);
51
+ details.class[tagName] = tag && ts.getTextOfJSDocComment(tag.comment);
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,56 @@
1
+ import { writeFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ /**
5
+ * CEM analyzer plugin that generates a per-component `.d.ts` file for each
6
+ * custom element in the manifest.
7
+ *
8
+ * This lets TypeScript resolve sub-path imports like
9
+ * `@elenajs/components/dist/button.js` without manual declaration files.
10
+ *
11
+ * @param {{ outdir?: string }} options
12
+ * @returns {import("@custom-elements-manifest/analyzer").Plugin}
13
+ */
14
+ export function elenaTypeScriptPlugin({ outdir = "dist" } = {}) {
15
+ return {
16
+ name: "per-component-dts",
17
+
18
+ packageLinkPhase({ customElementsManifest }) {
19
+ for (const mod of customElementsManifest.modules) {
20
+ const ceDecl = mod.declarations?.find(d => d.tagName);
21
+ if (!ceDecl) {
22
+ continue;
23
+ }
24
+
25
+ const fields = (ceDecl.members ?? []).filter(m => m.kind === "field" && !m.static);
26
+ const events = ceDecl.events ?? [];
27
+
28
+ const fieldLines = fields.map(f => {
29
+ const lines = [];
30
+ if (f.description) {
31
+ lines.push(` /** ${f.description} */`);
32
+ }
33
+ lines.push(` ${f.name}?: ${f.type?.text ?? "string"};`);
34
+ return lines.join("\n");
35
+ });
36
+
37
+ const eventLines = events.map(e => {
38
+ const lines = [];
39
+ if (e.description) {
40
+ lines.push(` /** ${e.description} */`);
41
+ }
42
+ lines.push(` on${e.name}?: (e: CustomEvent<never>) => void;`);
43
+ return lines.join("\n");
44
+ });
45
+
46
+ const members = [...fieldLines, ...eventLines].join("\n");
47
+ const body = members ? `\n${members}\n` : "";
48
+ const propsType = `${ceDecl.name}Props`;
49
+ const content = `export type { ${propsType} } from './custom-elements.js';\n\ndeclare class ${ceDecl.name} extends HTMLElement {${body}}\n\nexport default ${ceDecl.name};\n`;
50
+
51
+ const fileName = mod.path.split("/").pop().replace(".js", ".d.ts");
52
+ writeFileSync(join(outdir, fileName), content);
53
+ }
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,37 @@
1
+ import { readFileSync, readdirSync } from "fs";
2
+ import { color } from "../utils/color.js";
3
+
4
+ /**
5
+ * Rollup plugin that concatenates and minifies all CSS files from `srcDir` into a
6
+ * single bundle asset emitted to the output directory.
7
+ *
8
+ * @param {string} srcDir - Source directory to scan for `.css` files (e.g. `"src"`).
9
+ * @param {string} fileName - Output filename relative to the output dir (e.g. `"bundle.css"`).
10
+ * @returns {import("rollup").Plugin}
11
+ */
12
+ export function cssBundlePlugin(srcDir, fileName) {
13
+ return {
14
+ name: "css-bundle",
15
+ generateBundle() {
16
+ const cssFiles = readdirSync(srcDir, { recursive: true })
17
+ .filter(f => f.endsWith(".css"))
18
+ .map(f => `${srcDir}/${f}`);
19
+
20
+ const source = cssFiles
21
+ .map(f => readFileSync(f, "utf8"))
22
+ .join("\n")
23
+ // Strip block comments
24
+ .replace(/\/\*[\s\S]*?\*\//g, "")
25
+ // Collapse whitespace runs into a single space
26
+ .replace(/\s+/g, " ")
27
+ // Remove spaces around structural characters
28
+ .replace(/\s*([{}:;,>~+])\s*/g, "$1")
29
+ // Drop trailing semicolons before closing brace
30
+ .replace(/;}/g, "}")
31
+ .trim();
32
+
33
+ this.emitFile({ type: "asset", fileName, source });
34
+ console.log(color(`░█ [ELENA]: Generating and minifying CSS bundle...`));
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,177 @@
1
+ import { readdirSync } from "fs";
2
+ import { rollup } from "rollup";
3
+ import resolve from "@rollup/plugin-node-resolve";
4
+ import terser from "@rollup/plugin-terser";
5
+ import copy from "rollup-plugin-copy";
6
+ import minifyHtmlLiterals from "rollup-plugin-minify-html-literals-v3";
7
+ import summary from "rollup-plugin-summary";
8
+ import { cssBundlePlugin } from "./plugins/css-bundle.js";
9
+ import { color } from "./utils/color.js";
10
+
11
+ const TREESHAKE = {
12
+ moduleSideEffects: false,
13
+ propertyReadSideEffects: false,
14
+ };
15
+
16
+ function onwarn(warning, warn) {
17
+ if (warning.code === "UNUSED_EXTERNAL_IMPORT") {
18
+ return;
19
+ }
20
+ warn(warning);
21
+ }
22
+
23
+ /**
24
+ * Builds the plugin list for a single Rollup build target.
25
+ *
26
+ * @param {{ src: string; outdir: string; hasSummary: boolean; includeCssBundle: boolean; extraPlugins?: import("rollup").Plugin[] }} opts
27
+ * @returns {import("rollup").Plugin[]}
28
+ */
29
+ function buildPlugins({ src, outdir, hasSummary, includeCssBundle, extraPlugins = [] }) {
30
+ const plugins = [
31
+ resolve({ extensions: [".js", ".css"] }),
32
+ minifyHtmlLiterals({
33
+ options: {
34
+ // Elena components use this.template`...` rather than html`...`
35
+ shouldMinify: template => template.parts.some(({ text }) => /<[a-z]/i.test(text)),
36
+ },
37
+ }),
38
+ terser({
39
+ ecma: 2020,
40
+ module: true,
41
+ }),
42
+ copy({
43
+ targets: [{ src: `${src}/**/*.css`, dest: outdir }],
44
+ flatten: true,
45
+ }),
46
+ ];
47
+
48
+ if (includeCssBundle) {
49
+ plugins.push(cssBundlePlugin(src, "bundle.css"));
50
+ }
51
+
52
+ // User-provided plugins are appended after built-ins, before summary.
53
+ plugins.push(...extraPlugins);
54
+
55
+ if (hasSummary) {
56
+ plugins.push(summary({ showBrotliSize: true, showGzippedSize: true }));
57
+ }
58
+
59
+ return plugins;
60
+ }
61
+
62
+ /**
63
+ * Returns the Rollup config array for the given Elena config. Useful for advanced
64
+ * users who want to call `rollup -c` with a thin wrapper config file.
65
+ *
66
+ * @param {import("./utils/load-config.js").ElenaConfig} [options]
67
+ * @returns {import("rollup").RollupOptions[]}
68
+ */
69
+ export function createRollupConfig(options = {}) {
70
+ const src = options.input ?? "src";
71
+ const outdir = options.output?.dir ?? "dist";
72
+ const format = options.output?.format ?? "esm";
73
+ const sourcemap = options.output?.sourcemap ?? true;
74
+ const bundle = options.bundle !== undefined ? options.bundle : "src/index.js";
75
+ const extraPlugins = options.plugins ?? [];
76
+
77
+ const entries = readdirSync(src, { recursive: true })
78
+ .filter(f => f.endsWith(".js"))
79
+ .map(f => `${src}/${f}`);
80
+
81
+ const configs = [
82
+ {
83
+ input: entries,
84
+ plugins: buildPlugins({
85
+ src,
86
+ outdir,
87
+ hasSummary: false,
88
+ includeCssBundle: true,
89
+ extraPlugins,
90
+ }),
91
+ output: { format, sourcemap, dir: outdir },
92
+ preserveEntrySignatures: "strict",
93
+ treeshake: TREESHAKE,
94
+ onwarn,
95
+ },
96
+ ];
97
+
98
+ if (bundle) {
99
+ configs.push({
100
+ input: bundle,
101
+ plugins: buildPlugins({
102
+ src,
103
+ outdir,
104
+ hasSummary: true,
105
+ includeCssBundle: false,
106
+ extraPlugins,
107
+ }),
108
+ output: { format, sourcemap, file: `${outdir}/bundle.js` },
109
+ preserveEntrySignatures: "strict",
110
+ treeshake: TREESHAKE,
111
+ onwarn,
112
+ });
113
+ }
114
+
115
+ return configs;
116
+ }
117
+
118
+ /**
119
+ * Runs both Rollup build targets programmatically using the Rollup Node.js API.
120
+ *
121
+ * Note: the `output` key inside `RollupOptions` is ignored by the `rollup()` API —
122
+ * output options must be passed separately to `build.write()`.
123
+ *
124
+ * @param {import("./utils/load-config.js").ElenaConfig} config
125
+ * @returns {Promise<void>}
126
+ */
127
+ export async function runRollupBuild(config) {
128
+ const src = config.input ?? "src";
129
+ const outdir = config.output?.dir ?? "dist";
130
+ const format = config.output?.format ?? "esm";
131
+ const sourcemap = config.output?.sourcemap ?? true;
132
+ const bundle = config.bundle !== undefined ? config.bundle : "src/index.js";
133
+ const extraPlugins = config.plugins ?? [];
134
+
135
+ const entries = readdirSync(src, { recursive: true })
136
+ .filter(f => f.endsWith(".js"))
137
+ .map(f => `${src}/${f}`);
138
+
139
+ // Build 1: individual modules + CSS bundle
140
+ console.log(color(`░█ [ELENA]: Building Progressive Web Components...`));
141
+ console.log(` `);
142
+ for (const entry of entries) {
143
+ console.log(color(`░█ [ELENA]: Generating and minifying ${entry}...`));
144
+ }
145
+
146
+ const build1 = await rollup({
147
+ input: entries,
148
+ plugins: buildPlugins({ src, outdir, hasSummary: false, includeCssBundle: true, extraPlugins }),
149
+ preserveEntrySignatures: "strict",
150
+ treeshake: TREESHAKE,
151
+ onwarn,
152
+ });
153
+ await build1.write({ format, sourcemap, dir: outdir });
154
+ await build1.close();
155
+
156
+ // Build 2: single-file JS bundle (optional)
157
+ if (bundle) {
158
+ console.log(color(`░█ [ELENA]: Generating and minifying JS bundle...`));
159
+ console.log(` `);
160
+
161
+ const build2 = await rollup({
162
+ input: bundle,
163
+ plugins: buildPlugins({
164
+ src,
165
+ outdir,
166
+ hasSummary: true,
167
+ includeCssBundle: false,
168
+ extraPlugins,
169
+ }),
170
+ preserveEntrySignatures: "strict",
171
+ treeshake: TREESHAKE,
172
+ onwarn,
173
+ });
174
+ await build2.write({ format, sourcemap, file: `${outdir}/bundle.js` });
175
+ await build2.close();
176
+ }
177
+ }
@@ -0,0 +1,2 @@
1
+ /** Applies the Elena brand color (#f19c77) to a string using ANSI true color escape codes. */
2
+ export const color = str => `\x1b[38;2;241;156;119m${str}\x1b[0m`;
@@ -0,0 +1,57 @@
1
+ import { pathToFileURL } from "url";
2
+ import { resolve } from "path";
3
+ import { existsSync } from "fs";
4
+
5
+ /**
6
+ * @typedef {object} ElenaOutputConfig
7
+ * @property {string} [dir] Output directory for individual modules, CSS, and CEM artifacts.
8
+ * @property {string} [format] Rollup output format (default: `"esm"`).
9
+ * @property {boolean} [sourcemap] Whether to emit sourcemaps (default: `true`).
10
+ */
11
+
12
+ /**
13
+ * @typedef {object} ElenaAnalyzeConfig
14
+ * @property {import("@custom-elements-manifest/analyzer").Plugin[]} [plugins]
15
+ * Additional CEM analyzer plugins appended after Elena's built-in set.
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} ElenaConfig
20
+ * @property {string} [input] Source directory scanned for .js and .css files.
21
+ * @property {ElenaOutputConfig} [output] Rollup output options.
22
+ * @property {string|false} [bundle] Entry for the single-file bundle; `false` to disable.
23
+ * @property {import("rollup").Plugin[]} [plugins] Additional Rollup plugins appended after built-ins.
24
+ * @property {ElenaAnalyzeConfig} [analyze] CEM analysis options.
25
+ */
26
+
27
+ /** @type {Required<ElenaConfig>} */
28
+ const DEFAULTS = {
29
+ input: "src",
30
+ output: { dir: "dist", format: "esm", sourcemap: true },
31
+ bundle: "src/index.js",
32
+ plugins: [],
33
+ analyze: { plugins: [] },
34
+ };
35
+
36
+ /**
37
+ * Loads the Elena config from `elena.config.mjs` or `elena.config.js` in `cwd`,
38
+ * falling back to defaults if no config file is found.
39
+ *
40
+ * @param {string} [cwd]
41
+ * @returns {Promise<Required<ElenaConfig>>}
42
+ */
43
+ export async function loadConfig(cwd = process.cwd()) {
44
+ for (const name of ["elena.config.mjs", "elena.config.js"]) {
45
+ const configPath = resolve(cwd, name);
46
+ if (!existsSync(configPath)) continue;
47
+ const mod = await import(pathToFileURL(configPath).href);
48
+ const user = mod.default ?? {};
49
+ return {
50
+ ...DEFAULTS,
51
+ ...user,
52
+ output: { ...DEFAULTS.output, ...user.output },
53
+ analyze: { ...DEFAULTS.analyze, ...user.analyze },
54
+ };
55
+ }
56
+ return { ...DEFAULTS };
57
+ }
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@elenajs/bundler",
3
+ "version": "0.0.2",
4
+ "description": "Bundler for Elena component libraries: Rollup + CEM analyzer in one CLI command.",
5
+ "author": "Ariel Salminen <info@arielsalminen.com>",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "type": "module",
10
+ "bin": {
11
+ "elena": "./src/cli.js"
12
+ },
13
+ "exports": {
14
+ ".": "./dist/index.js",
15
+ "./rollup": "./dist/rollup-build.js",
16
+ "./cem": "./dist/cem-analyze.js",
17
+ "./plugins/css-bundle": "./dist/plugins/css-bundle.js",
18
+ "./plugins/cem-define": "./dist/plugins/cem-define.js",
19
+ "./plugins/cem-tag": "./dist/plugins/cem-tag.js",
20
+ "./plugins/cem-typescript": "./dist/plugins/cem-typescript.js"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src"
25
+ ],
26
+ "engines": {
27
+ "node": ">= 20"
28
+ },
29
+ "scripts": {
30
+ "build": "node build.mjs",
31
+ "format": "prettier --write \"src/**/*.js\" \"test/**/*.mjs\" \"*.mjs\"",
32
+ "test": "vitest run"
33
+ },
34
+ "dependencies": {
35
+ "@custom-elements-manifest/analyzer": "0.11.0",
36
+ "@rollup/plugin-node-resolve": "16.0.3",
37
+ "@rollup/plugin-terser": "0.4.4",
38
+ "custom-element-jsx-integration": "1.6.0",
39
+ "globby": "11.0.4",
40
+ "rollup": "4.57.1",
41
+ "rollup-plugin-copy": "3.5.0",
42
+ "rollup-plugin-minify-html-literals-v3": "^1.3.4",
43
+ "rollup-plugin-summary": "3.0.1"
44
+ },
45
+ "devDependencies": {
46
+ "vitest": "4.0.18"
47
+ },
48
+ "gitHead": "640bc98adce4748a037120e319507bb8efcb721c"
49
+ }
@@ -0,0 +1,87 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { create, ts } from "@custom-elements-manifest/analyzer";
4
+ import globby from "globby";
5
+ import { customElementJsxPlugin as elenaJsxPlugin } from "custom-element-jsx-integration";
6
+ import { elenaDefinePlugin } from "./plugins/cem-define.js";
7
+ import { elenaTagPlugin } from "./plugins/cem-tag.js";
8
+ import { elenaTypeScriptPlugin } from "./plugins/cem-typescript.js";
9
+ import { color } from "./utils/color.js";
10
+
11
+ /**
12
+ * Returns the CEM config object for the given Elena config. Useful for advanced
13
+ * users who still call the CEM CLI with a thin `elena.config.js` wrapper.
14
+ *
15
+ * @param {import("./utils/load-config.js").ElenaConfig} [options]
16
+ * @returns {object} CEM config object
17
+ */
18
+ export function createCemConfig(options = {}) {
19
+ const src = options.input ?? "src";
20
+ const outdir = options.output?.dir ?? "dist";
21
+ const extraPlugins = options.analyze?.plugins ?? [];
22
+
23
+ return {
24
+ outdir,
25
+ watch: false,
26
+ dev: false,
27
+ globs: [`${src}/**/*.js`],
28
+ exclude: [`${src}/**/*.ts`, "**/*.test.js", "node_modules"],
29
+ plugins: [
30
+ elenaDefinePlugin(),
31
+ elenaTagPlugin("status"),
32
+ elenaTagPlugin("displayName"),
33
+ elenaJsxPlugin({ outdir, fileName: "custom-elements.d.ts" }),
34
+ elenaTypeScriptPlugin({ outdir }),
35
+ ...extraPlugins,
36
+ ],
37
+ };
38
+ }
39
+
40
+ /**
41
+ * Runs the CEM analyzer programmatically using `create()` from
42
+ * `@custom-elements-manifest/analyzer`, writes the manifest to disk.
43
+ *
44
+ * @param {import("./utils/load-config.js").ElenaConfig} config
45
+ * @param {string} [cwd]
46
+ * @returns {Promise<void>}
47
+ */
48
+ export async function runCemAnalyze(config, cwd = process.cwd()) {
49
+ const src = config.input ?? "src";
50
+ const outdir = config.output?.dir ?? "dist";
51
+ const extraPlugins = config.analyze?.plugins ?? [];
52
+
53
+ console.log(` `);
54
+ console.log(color(`░█ [ELENA]: Analyzing the build output...`));
55
+ console.log(color(`░█ [ELENA]: Generating Custom Elements Manifest...`));
56
+ console.log(color(`░█ [ELENA]: Generating TypeScript types...`));
57
+ console.log(` `);
58
+
59
+ const globs = await globby(
60
+ [`${src}/**/*.js`, `!${src}/**/*.ts`, "!**/*.test.js", "!node_modules"],
61
+ { cwd }
62
+ );
63
+
64
+ const modules = globs.map(glob => {
65
+ const fullPath = resolve(cwd, glob);
66
+ const source = readFileSync(fullPath).toString();
67
+ return ts.createSourceFile(glob, source, ts.ScriptTarget.ES2015, true);
68
+ });
69
+
70
+ const plugins = [
71
+ elenaDefinePlugin(),
72
+ elenaTagPlugin("status"),
73
+ elenaTagPlugin("displayName"),
74
+ elenaJsxPlugin({ outdir, fileName: "custom-elements.d.ts" }),
75
+ elenaTypeScriptPlugin({ outdir: join(cwd, outdir) }),
76
+ ...extraPlugins,
77
+ ];
78
+
79
+ const manifest = create({ modules, plugins, context: { dev: false } });
80
+
81
+ const outPath = join(cwd, outdir);
82
+ if (!existsSync(outPath)) {
83
+ mkdirSync(outPath, { recursive: true });
84
+ }
85
+
86
+ writeFileSync(join(outPath, "custom-elements.json"), `${JSON.stringify(manifest, null, 2)}\n`);
87
+ }
package/src/cli.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { loadConfig } from "./utils/load-config.js";
4
+ import { runRollupBuild } from "./rollup-build.js";
5
+ import { runCemAnalyze } from "./cem-analyze.js";
6
+ import { color } from "./utils/color.js";
7
+
8
+ const BANNER = `
9
+ ██████████ ████
10
+ ░░███░░░░░█░░███
11
+ ░███ █ ░ ░███ ██████ ████████ ██████
12
+ ░██████ ░███ ███░░███░░███░░███ ░░░░░███
13
+ ░███░░█ ░███ ░███████ ░███ ░███ ███████
14
+ ░███ ░ █ ░███ ░███░░░ ░███ ░███ ███░░███
15
+ ██████████ █████░░██████ ████ █████░░████████
16
+ ░░░░░░░░░░ ░░░░░ ░░░░░░ ░░░░ ░░░░░ ░░░░░░░░
17
+ `;
18
+
19
+ const [, , command = "build"] = process.argv;
20
+
21
+ async function main() {
22
+ if (command !== "build") {
23
+ console.error(`Unknown command: ${command}. Usage: elena [build]`);
24
+ process.exit(1);
25
+ }
26
+
27
+ console.log(color(BANNER));
28
+
29
+ const config = await loadConfig(process.cwd());
30
+
31
+ await runRollupBuild(config);
32
+ await runCemAnalyze(config);
33
+ }
34
+
35
+ main().catch(err => {
36
+ console.error(err);
37
+ process.exit(1);
38
+ });
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createRollupConfig, runRollupBuild } from "./rollup-build.js";
2
+ export { createCemConfig, runCemAnalyze } from "./cem-analyze.js";
3
+ export { cssBundlePlugin } from "./plugins/css-bundle.js";
4
+ export { elenaDefinePlugin } from "./plugins/cem-define.js";
5
+ export { elenaTagPlugin } from "./plugins/cem-tag.js";
6
+ export { elenaTypeScriptPlugin } from "./plugins/cem-typescript.js";
@@ -0,0 +1,93 @@
1
+ /**
2
+ * CEM analyzer plugin that reads `tagName` from the options object passed to
3
+ * `Elena(superClass, { tagName: "...", ... })` and registers the element in the manifest.
4
+ *
5
+ * Supports both inline objects and variable references:
6
+ * - `Elena(HTMLElement, { tagName: "elena-button", ... })`
7
+ * - `const options = { tagName: "elena-button", ... }; Elena(HTMLElement, options)`
8
+ *
9
+ * @returns {import("@custom-elements-manifest/analyzer").Plugin}
10
+ */
11
+ export function elenaDefinePlugin() {
12
+ /** @type {Map<string, string>} variable name → tag name */
13
+ const optionsMap = new Map();
14
+
15
+ return {
16
+ name: "define-element",
17
+
18
+ analyzePhase({ ts, node, moduleDoc }) {
19
+ // Collect variable declarations: const options = { tagName: "...", ... }
20
+ if (
21
+ ts.isVariableDeclaration(node) &&
22
+ node.initializer &&
23
+ ts.isObjectLiteralExpression(node.initializer)
24
+ ) {
25
+ const varName = node.name.getText();
26
+ const tagNameProp = node.initializer.properties.find(
27
+ p => ts.isPropertyAssignment(p) && p.name.getText() === "tagName"
28
+ );
29
+ if (tagNameProp && ts.isStringLiteral(tagNameProp.initializer)) {
30
+ optionsMap.set(varName, tagNameProp.initializer.text);
31
+ }
32
+ return;
33
+ }
34
+
35
+ if (!ts.isClassDeclaration(node)) {
36
+ return;
37
+ }
38
+
39
+ const className = node.name?.getText();
40
+ if (!className) {
41
+ return;
42
+ }
43
+
44
+ const heritageClause = node.heritageClauses?.[0];
45
+ if (!heritageClause) {
46
+ return;
47
+ }
48
+
49
+ for (const type of heritageClause.types) {
50
+ const expr = type.expression;
51
+ if (!ts.isCallExpression(expr) || expr.expression.getText() !== "Elena") {
52
+ continue;
53
+ }
54
+
55
+ const optionsArg = expr.arguments[1];
56
+ if (!optionsArg) {
57
+ continue;
58
+ }
59
+
60
+ let tagName;
61
+
62
+ if (ts.isObjectLiteralExpression(optionsArg)) {
63
+ // Inline: Elena(HTMLElement, { tagName: "...", ... })
64
+ const tagNameProp = optionsArg.properties.find(
65
+ p => ts.isPropertyAssignment(p) && p.name.getText() === "tagName"
66
+ );
67
+ if (tagNameProp && ts.isStringLiteral(tagNameProp.initializer)) {
68
+ tagName = tagNameProp.initializer.text;
69
+ }
70
+ } else if (ts.isIdentifier(optionsArg)) {
71
+ // Variable reference: Elena(HTMLElement, options)
72
+ tagName = optionsMap.get(optionsArg.getText());
73
+ }
74
+
75
+ if (!tagName) {
76
+ continue;
77
+ }
78
+
79
+ const declaration = moduleDoc.declarations?.find(d => d.name === className);
80
+ if (declaration) {
81
+ declaration.tagName = tagName;
82
+ }
83
+
84
+ moduleDoc.exports ??= [];
85
+ moduleDoc.exports.push({
86
+ kind: "custom-element-definition",
87
+ name: tagName,
88
+ declaration: { name: className, module: moduleDoc.path },
89
+ });
90
+ }
91
+ },
92
+ };
93
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Returns the CEM class declaration and resolved tag name for a class node,
3
+ * or `undefined` if the node is not a registered custom element.
4
+ *
5
+ * @param {Partial<import("@custom-elements-manifest/analyzer").JavaScriptModule>} moduleDoc
6
+ * @param {import("typescript").ClassDeclaration} node
7
+ * @returns {{ class: import("@custom-elements-manifest/analyzer").Declaration; tagName: string } | undefined}
8
+ */
9
+ function getElenaElementDetails(moduleDoc, node) {
10
+ const declaration = moduleDoc.declarations?.find(d => d.name === node.name?.getText());
11
+ if (!declaration) {
12
+ return undefined;
13
+ }
14
+
15
+ const customElement = moduleDoc.exports?.find(
16
+ e => e.kind === "js" && e.declaration.name === declaration.name
17
+ );
18
+
19
+ if (!customElement) {
20
+ return undefined;
21
+ }
22
+
23
+ return { class: declaration, tagName: customElement.name };
24
+ }
25
+
26
+ /**
27
+ * CEM analyzer plugin that reads a non-standard JSDoc tag from each custom element class
28
+ * and writes its value onto the CEM class declaration under the same key.
29
+ *
30
+ * Used for Elena-specific tags like `@status` and `@displayName`.
31
+ *
32
+ * @see https://custom-elements-manifest.open-wc.org/analyzer/plugins/authoring/#example-plugin
33
+ * @param {string} tagName - The JSDoc tag name to extract (e.g. `"status"`, `"displayName"`).
34
+ * @returns {import("@custom-elements-manifest/analyzer").Plugin}
35
+ */
36
+ export function elenaTagPlugin(tagName) {
37
+ return {
38
+ name: `${tagName}-tag`,
39
+
40
+ analyzePhase({ ts, node, moduleDoc }) {
41
+ if (!ts.isClassDeclaration(node)) {
42
+ return;
43
+ }
44
+
45
+ const details = getElenaElementDetails(moduleDoc, node);
46
+ if (!details) {
47
+ return;
48
+ }
49
+
50
+ const [tag] = ts.getAllJSDocTags(node, t => t.tagName.getText() === tagName);
51
+ details.class[tagName] = tag && ts.getTextOfJSDocComment(tag.comment);
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,56 @@
1
+ import { writeFileSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ /**
5
+ * CEM analyzer plugin that generates a per-component `.d.ts` file for each
6
+ * custom element in the manifest.
7
+ *
8
+ * This lets TypeScript resolve sub-path imports like
9
+ * `@elenajs/components/dist/button.js` without manual declaration files.
10
+ *
11
+ * @param {{ outdir?: string }} options
12
+ * @returns {import("@custom-elements-manifest/analyzer").Plugin}
13
+ */
14
+ export function elenaTypeScriptPlugin({ outdir = "dist" } = {}) {
15
+ return {
16
+ name: "per-component-dts",
17
+
18
+ packageLinkPhase({ customElementsManifest }) {
19
+ for (const mod of customElementsManifest.modules) {
20
+ const ceDecl = mod.declarations?.find(d => d.tagName);
21
+ if (!ceDecl) {
22
+ continue;
23
+ }
24
+
25
+ const fields = (ceDecl.members ?? []).filter(m => m.kind === "field" && !m.static);
26
+ const events = ceDecl.events ?? [];
27
+
28
+ const fieldLines = fields.map(f => {
29
+ const lines = [];
30
+ if (f.description) {
31
+ lines.push(` /** ${f.description} */`);
32
+ }
33
+ lines.push(` ${f.name}?: ${f.type?.text ?? "string"};`);
34
+ return lines.join("\n");
35
+ });
36
+
37
+ const eventLines = events.map(e => {
38
+ const lines = [];
39
+ if (e.description) {
40
+ lines.push(` /** ${e.description} */`);
41
+ }
42
+ lines.push(` on${e.name}?: (e: CustomEvent<never>) => void;`);
43
+ return lines.join("\n");
44
+ });
45
+
46
+ const members = [...fieldLines, ...eventLines].join("\n");
47
+ const body = members ? `\n${members}\n` : "";
48
+ const propsType = `${ceDecl.name}Props`;
49
+ const content = `export type { ${propsType} } from './custom-elements.js';\n\ndeclare class ${ceDecl.name} extends HTMLElement {${body}}\n\nexport default ${ceDecl.name};\n`;
50
+
51
+ const fileName = mod.path.split("/").pop().replace(".js", ".d.ts");
52
+ writeFileSync(join(outdir, fileName), content);
53
+ }
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,37 @@
1
+ import { readFileSync, readdirSync } from "fs";
2
+ import { color } from "../utils/color.js";
3
+
4
+ /**
5
+ * Rollup plugin that concatenates and minifies all CSS files from `srcDir` into a
6
+ * single bundle asset emitted to the output directory.
7
+ *
8
+ * @param {string} srcDir - Source directory to scan for `.css` files (e.g. `"src"`).
9
+ * @param {string} fileName - Output filename relative to the output dir (e.g. `"bundle.css"`).
10
+ * @returns {import("rollup").Plugin}
11
+ */
12
+ export function cssBundlePlugin(srcDir, fileName) {
13
+ return {
14
+ name: "css-bundle",
15
+ generateBundle() {
16
+ const cssFiles = readdirSync(srcDir, { recursive: true })
17
+ .filter(f => f.endsWith(".css"))
18
+ .map(f => `${srcDir}/${f}`);
19
+
20
+ const source = cssFiles
21
+ .map(f => readFileSync(f, "utf8"))
22
+ .join("\n")
23
+ // Strip block comments
24
+ .replace(/\/\*[\s\S]*?\*\//g, "")
25
+ // Collapse whitespace runs into a single space
26
+ .replace(/\s+/g, " ")
27
+ // Remove spaces around structural characters
28
+ .replace(/\s*([{}:;,>~+])\s*/g, "$1")
29
+ // Drop trailing semicolons before closing brace
30
+ .replace(/;}/g, "}")
31
+ .trim();
32
+
33
+ this.emitFile({ type: "asset", fileName, source });
34
+ console.log(color(`░█ [ELENA]: Generating and minifying CSS bundle...`));
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,177 @@
1
+ import { readdirSync } from "fs";
2
+ import { rollup } from "rollup";
3
+ import resolve from "@rollup/plugin-node-resolve";
4
+ import terser from "@rollup/plugin-terser";
5
+ import copy from "rollup-plugin-copy";
6
+ import minifyHtmlLiterals from "rollup-plugin-minify-html-literals-v3";
7
+ import summary from "rollup-plugin-summary";
8
+ import { cssBundlePlugin } from "./plugins/css-bundle.js";
9
+ import { color } from "./utils/color.js";
10
+
11
+ const TREESHAKE = {
12
+ moduleSideEffects: false,
13
+ propertyReadSideEffects: false,
14
+ };
15
+
16
+ function onwarn(warning, warn) {
17
+ if (warning.code === "UNUSED_EXTERNAL_IMPORT") {
18
+ return;
19
+ }
20
+ warn(warning);
21
+ }
22
+
23
+ /**
24
+ * Builds the plugin list for a single Rollup build target.
25
+ *
26
+ * @param {{ src: string; outdir: string; hasSummary: boolean; includeCssBundle: boolean; extraPlugins?: import("rollup").Plugin[] }} opts
27
+ * @returns {import("rollup").Plugin[]}
28
+ */
29
+ function buildPlugins({ src, outdir, hasSummary, includeCssBundle, extraPlugins = [] }) {
30
+ const plugins = [
31
+ resolve({ extensions: [".js", ".css"] }),
32
+ minifyHtmlLiterals({
33
+ options: {
34
+ // Elena components use this.template`...` rather than html`...`
35
+ shouldMinify: template => template.parts.some(({ text }) => /<[a-z]/i.test(text)),
36
+ },
37
+ }),
38
+ terser({
39
+ ecma: 2020,
40
+ module: true,
41
+ }),
42
+ copy({
43
+ targets: [{ src: `${src}/**/*.css`, dest: outdir }],
44
+ flatten: true,
45
+ }),
46
+ ];
47
+
48
+ if (includeCssBundle) {
49
+ plugins.push(cssBundlePlugin(src, "bundle.css"));
50
+ }
51
+
52
+ // User-provided plugins are appended after built-ins, before summary.
53
+ plugins.push(...extraPlugins);
54
+
55
+ if (hasSummary) {
56
+ plugins.push(summary({ showBrotliSize: true, showGzippedSize: true }));
57
+ }
58
+
59
+ return plugins;
60
+ }
61
+
62
+ /**
63
+ * Returns the Rollup config array for the given Elena config. Useful for advanced
64
+ * users who want to call `rollup -c` with a thin wrapper config file.
65
+ *
66
+ * @param {import("./utils/load-config.js").ElenaConfig} [options]
67
+ * @returns {import("rollup").RollupOptions[]}
68
+ */
69
+ export function createRollupConfig(options = {}) {
70
+ const src = options.input ?? "src";
71
+ const outdir = options.output?.dir ?? "dist";
72
+ const format = options.output?.format ?? "esm";
73
+ const sourcemap = options.output?.sourcemap ?? true;
74
+ const bundle = options.bundle !== undefined ? options.bundle : "src/index.js";
75
+ const extraPlugins = options.plugins ?? [];
76
+
77
+ const entries = readdirSync(src, { recursive: true })
78
+ .filter(f => f.endsWith(".js"))
79
+ .map(f => `${src}/${f}`);
80
+
81
+ const configs = [
82
+ {
83
+ input: entries,
84
+ plugins: buildPlugins({
85
+ src,
86
+ outdir,
87
+ hasSummary: false,
88
+ includeCssBundle: true,
89
+ extraPlugins,
90
+ }),
91
+ output: { format, sourcemap, dir: outdir },
92
+ preserveEntrySignatures: "strict",
93
+ treeshake: TREESHAKE,
94
+ onwarn,
95
+ },
96
+ ];
97
+
98
+ if (bundle) {
99
+ configs.push({
100
+ input: bundle,
101
+ plugins: buildPlugins({
102
+ src,
103
+ outdir,
104
+ hasSummary: true,
105
+ includeCssBundle: false,
106
+ extraPlugins,
107
+ }),
108
+ output: { format, sourcemap, file: `${outdir}/bundle.js` },
109
+ preserveEntrySignatures: "strict",
110
+ treeshake: TREESHAKE,
111
+ onwarn,
112
+ });
113
+ }
114
+
115
+ return configs;
116
+ }
117
+
118
+ /**
119
+ * Runs both Rollup build targets programmatically using the Rollup Node.js API.
120
+ *
121
+ * Note: the `output` key inside `RollupOptions` is ignored by the `rollup()` API —
122
+ * output options must be passed separately to `build.write()`.
123
+ *
124
+ * @param {import("./utils/load-config.js").ElenaConfig} config
125
+ * @returns {Promise<void>}
126
+ */
127
+ export async function runRollupBuild(config) {
128
+ const src = config.input ?? "src";
129
+ const outdir = config.output?.dir ?? "dist";
130
+ const format = config.output?.format ?? "esm";
131
+ const sourcemap = config.output?.sourcemap ?? true;
132
+ const bundle = config.bundle !== undefined ? config.bundle : "src/index.js";
133
+ const extraPlugins = config.plugins ?? [];
134
+
135
+ const entries = readdirSync(src, { recursive: true })
136
+ .filter(f => f.endsWith(".js"))
137
+ .map(f => `${src}/${f}`);
138
+
139
+ // Build 1: individual modules + CSS bundle
140
+ console.log(color(`░█ [ELENA]: Building Progressive Web Components...`));
141
+ console.log(` `);
142
+ for (const entry of entries) {
143
+ console.log(color(`░█ [ELENA]: Generating and minifying ${entry}...`));
144
+ }
145
+
146
+ const build1 = await rollup({
147
+ input: entries,
148
+ plugins: buildPlugins({ src, outdir, hasSummary: false, includeCssBundle: true, extraPlugins }),
149
+ preserveEntrySignatures: "strict",
150
+ treeshake: TREESHAKE,
151
+ onwarn,
152
+ });
153
+ await build1.write({ format, sourcemap, dir: outdir });
154
+ await build1.close();
155
+
156
+ // Build 2: single-file JS bundle (optional)
157
+ if (bundle) {
158
+ console.log(color(`░█ [ELENA]: Generating and minifying JS bundle...`));
159
+ console.log(` `);
160
+
161
+ const build2 = await rollup({
162
+ input: bundle,
163
+ plugins: buildPlugins({
164
+ src,
165
+ outdir,
166
+ hasSummary: true,
167
+ includeCssBundle: false,
168
+ extraPlugins,
169
+ }),
170
+ preserveEntrySignatures: "strict",
171
+ treeshake: TREESHAKE,
172
+ onwarn,
173
+ });
174
+ await build2.write({ format, sourcemap, file: `${outdir}/bundle.js` });
175
+ await build2.close();
176
+ }
177
+ }
@@ -0,0 +1,2 @@
1
+ /** Applies the Elena brand color (#f19c77) to a string using ANSI true color escape codes. */
2
+ export const color = str => `\x1b[38;2;241;156;119m${str}\x1b[0m`;
@@ -0,0 +1,57 @@
1
+ import { pathToFileURL } from "url";
2
+ import { resolve } from "path";
3
+ import { existsSync } from "fs";
4
+
5
+ /**
6
+ * @typedef {object} ElenaOutputConfig
7
+ * @property {string} [dir] Output directory for individual modules, CSS, and CEM artifacts.
8
+ * @property {string} [format] Rollup output format (default: `"esm"`).
9
+ * @property {boolean} [sourcemap] Whether to emit sourcemaps (default: `true`).
10
+ */
11
+
12
+ /**
13
+ * @typedef {object} ElenaAnalyzeConfig
14
+ * @property {import("@custom-elements-manifest/analyzer").Plugin[]} [plugins]
15
+ * Additional CEM analyzer plugins appended after Elena's built-in set.
16
+ */
17
+
18
+ /**
19
+ * @typedef {object} ElenaConfig
20
+ * @property {string} [input] Source directory scanned for .js and .css files.
21
+ * @property {ElenaOutputConfig} [output] Rollup output options.
22
+ * @property {string|false} [bundle] Entry for the single-file bundle; `false` to disable.
23
+ * @property {import("rollup").Plugin[]} [plugins] Additional Rollup plugins appended after built-ins.
24
+ * @property {ElenaAnalyzeConfig} [analyze] CEM analysis options.
25
+ */
26
+
27
+ /** @type {Required<ElenaConfig>} */
28
+ const DEFAULTS = {
29
+ input: "src",
30
+ output: { dir: "dist", format: "esm", sourcemap: true },
31
+ bundle: "src/index.js",
32
+ plugins: [],
33
+ analyze: { plugins: [] },
34
+ };
35
+
36
+ /**
37
+ * Loads the Elena config from `elena.config.mjs` or `elena.config.js` in `cwd`,
38
+ * falling back to defaults if no config file is found.
39
+ *
40
+ * @param {string} [cwd]
41
+ * @returns {Promise<Required<ElenaConfig>>}
42
+ */
43
+ export async function loadConfig(cwd = process.cwd()) {
44
+ for (const name of ["elena.config.mjs", "elena.config.js"]) {
45
+ const configPath = resolve(cwd, name);
46
+ if (!existsSync(configPath)) continue;
47
+ const mod = await import(pathToFileURL(configPath).href);
48
+ const user = mod.default ?? {};
49
+ return {
50
+ ...DEFAULTS,
51
+ ...user,
52
+ output: { ...DEFAULTS.output, ...user.output },
53
+ analyze: { ...DEFAULTS.analyze, ...user.analyze },
54
+ };
55
+ }
56
+ return { ...DEFAULTS };
57
+ }