@stati/core 1.13.0 → 1.15.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.
@@ -1,9 +1,11 @@
1
1
  /**
2
2
  * TypeScript compilation utilities using esbuild.
3
3
  * Provides functions for compiling TypeScript files and watching for changes.
4
+ * Supports multiple bundles with per-page targeting.
4
5
  * @module core/utils/typescript
5
6
  */
6
7
  import * as esbuild from 'esbuild';
8
+ import { type CompiledBundleInfo } from './bundle-matching.utils.js';
7
9
  import type { TypeScriptConfig } from '../../types/config.js';
8
10
  import type { Logger } from '../../types/logging.js';
9
11
  /**
@@ -21,61 +23,69 @@ export interface CompileOptions {
21
23
  /** Logger instance for output */
22
24
  logger: Logger;
23
25
  }
24
- /**
25
- * Result of TypeScript compilation.
26
- */
27
- export interface CompileResult {
28
- /** The generated bundle filename (e.g., 'bundle-a1b2c3d4.js'), or undefined if compilation was skipped */
29
- bundleFilename?: string;
30
- }
31
26
  /**
32
27
  * Options for TypeScript file watcher.
28
+ * Note: Watcher is always in development mode (no hash, no minify, with sourcemaps).
33
29
  */
34
- export interface WatchOptions extends CompileOptions {
35
- /** Callback invoked when files are recompiled */
36
- onRebuild: () => void;
30
+ export interface WatchOptions {
31
+ /** Root directory of the project */
32
+ projectRoot: string;
33
+ /** TypeScript configuration */
34
+ config: TypeScriptConfig;
35
+ /** Output directory (defaults to 'dist') */
36
+ outDir?: string;
37
+ /** Logger instance for output */
38
+ logger: Logger;
39
+ /** Callback invoked when files are recompiled, receives results and compile time in ms */
40
+ onRebuild: (results: CompiledBundleInfo[], compileTimeMs: number) => void;
37
41
  }
38
42
  /**
39
43
  * Compile TypeScript files using esbuild.
40
- * Returns the generated bundle filename for template injection.
44
+ * Supports multiple bundles with parallel compilation.
41
45
  *
42
46
  * @param options - Compilation options
43
- * @returns The compilation result with bundle filename
47
+ * @returns Array of compilation results for each bundle
44
48
  *
45
49
  * @example
46
50
  * ```typescript
47
- * const result = await compileTypeScript({
51
+ * const results = await compileTypeScript({
48
52
  * projectRoot: process.cwd(),
49
- * config: { enabled: true },
53
+ * config: {
54
+ * enabled: true,
55
+ * bundles: [
56
+ * { entryPoint: 'core.ts', bundleName: 'core' },
57
+ * { entryPoint: 'docs.ts', bundleName: 'docs', include: ['/docs/**'] }
58
+ * ]
59
+ * },
50
60
  * mode: 'production',
51
61
  * logger: console,
52
62
  * });
53
- * console.log(result.bundleFilename); // 'bundle-a1b2c3d4.js'
63
+ * console.log(results[0].bundlePath); // '/_assets/core-a1b2c3d4.js'
54
64
  * ```
55
65
  */
56
- export declare function compileTypeScript(options: CompileOptions): Promise<CompileResult>;
66
+ export declare function compileTypeScript(options: CompileOptions): Promise<CompiledBundleInfo[]>;
57
67
  /**
58
68
  * Create an esbuild watch context for development.
59
69
  * Watches for TypeScript file changes and recompiles automatically.
70
+ * Supports multiple bundles with selective recompilation.
60
71
  *
61
72
  * @param options - Watch options including rebuild callback
62
- * @returns The esbuild build context for cleanup
73
+ * @returns Array of esbuild build contexts for cleanup
63
74
  *
64
75
  * @example
65
76
  * ```typescript
66
- * const watcher = await createTypeScriptWatcher({
77
+ * const watchers = await createTypeScriptWatcher({
67
78
  * projectRoot: process.cwd(),
68
79
  * config: { enabled: true },
69
- * mode: 'development',
70
80
  * logger: console,
71
- * onRebuild: () => console.log('Rebuilt!'),
81
+ * onRebuild: (results, compileTimeMs) => console.log(`Rebuilt ${results.length} bundles in ${compileTimeMs}ms`),
72
82
  * });
73
83
  *
74
84
  * // Later, cleanup:
75
- * await watcher.dispose();
85
+ * await Promise.all(watchers.map(w => w.dispose()));
76
86
  * ```
77
87
  */
78
- export declare function createTypeScriptWatcher(options: WatchOptions): Promise<esbuild.BuildContext>;
88
+ export declare function createTypeScriptWatcher(options: WatchOptions): Promise<esbuild.BuildContext[]>;
79
89
  /**
80
90
  * Compile stati.config.ts to a temporary JS file for import.
81
91
  * Returns the path to the compiled file.
@@ -98,20 +108,32 @@ export declare function compileStatiConfig(configPath: string): Promise<string>;
98
108
  */
99
109
  export declare function cleanupCompiledConfig(compiledPath: string): Promise<void>;
100
110
  /**
101
- * Auto-inject TypeScript bundle script tag into HTML before </body>.
102
- * Similar to SEO auto-injection, this adds the script tag automatically
111
+ * Validates a bundle path for safety against XSS attacks.
112
+ * Only allows safe ASCII characters: alphanumeric, hyphens, underscores, dots, and forward slashes.
113
+ * Must start with / and end with .js, no encoded characters or unicode allowed.
114
+ *
115
+ * @param bundlePath - The bundle path to validate
116
+ * @returns true if the path is safe, false otherwise
117
+ *
118
+ * @internal
119
+ */
120
+ export declare function isValidBundlePath(bundlePath: string): boolean;
121
+ /**
122
+ * Auto-inject TypeScript bundle script tags into HTML before </body>.
123
+ * Similar to SEO auto-injection, this adds script tags automatically
103
124
  * so users don't need to modify their templates.
125
+ * Supports multiple bundles - injects all matched bundles in order.
104
126
  *
105
127
  * @param html - Rendered HTML content
106
- * @param bundlePath - Path to the compiled bundle (e.g., '/_assets/bundle-a1b2c3d4.js')
107
- * @returns HTML with injected script tag
128
+ * @param bundlePaths - Array of paths to compiled bundles (e.g., ['/_assets/core.js', '/_assets/docs.js'])
129
+ * @returns HTML with injected script tags
108
130
  *
109
131
  * @example
110
132
  * ```typescript
111
133
  * const html = '<html><body>Content</body></html>';
112
- * const enhanced = autoInjectBundle(html, '/_assets/bundle.js');
113
- * // Returns: '<html><body>Content\n <script type="module" src="/_assets/bundle.js"></script>\n</body></html>'
134
+ * const enhanced = autoInjectBundles(html, ['/_assets/core.js', '/_assets/docs.js']);
135
+ * // Returns HTML with both script tags injected before </body>
114
136
  * ```
115
137
  */
116
- export declare function autoInjectBundle(html: string, bundlePath: string): string;
138
+ export declare function autoInjectBundles(html: string, bundlePaths: string[]): string;
117
139
  //# sourceMappingURL=typescript.utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"typescript.utils.d.ts","sourceRoot":"","sources":["../../../src/core/utils/typescript.utils.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAInC,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,uBAAuB,CAAC;AAC9D,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAQrD;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,oCAAoC;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,+BAA+B;IAC/B,MAAM,EAAE,gBAAgB,CAAC;IACzB,4CAA4C;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uBAAuB;IACvB,IAAI,EAAE,aAAa,GAAG,YAAY,CAAC;IACnC,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,0GAA0G;IAC1G,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;GAEG;AACH,MAAM,WAAW,YAAa,SAAQ,cAAc;IAClD,iDAAiD;IACjD,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB;AAiCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,aAAa,CAAC,CA8CvF;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,YAAY,GACpB,OAAO,CAAC,OAAO,CAAC,YAAY,CAAC,CAiD/B;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAgB5E;AAED;;;;GAIG;AACH,wBAAsB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM/E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAgCzE"}
1
+ {"version":3,"file":"typescript.utils.d.ts","sourceRoot":"","sources":["../../../src/core/utils/typescript.utils.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,OAAO,MAAM,SAAS,CAAC;AAInC,OAAO,EAA6B,KAAK,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAChG,OAAO,KAAK,EAAE,gBAAgB,EAAgB,MAAM,uBAAuB,CAAC;AAC5E,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,wBAAwB,CAAC;AAQrD;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,oCAAoC;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,+BAA+B;IAC/B,MAAM,EAAE,gBAAgB,CAAC;IACzB,4CAA4C;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uBAAuB;IACvB,IAAI,EAAE,aAAa,GAAG,YAAY,CAAC;IACnC,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,oCAAoC;IACpC,WAAW,EAAE,MAAM,CAAC;IACpB,+BAA+B;IAC/B,MAAM,EAAE,gBAAgB,CAAC;IACzB,4CAA4C;IAC5C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,0FAA0F;IAC1F,SAAS,EAAE,CAAC,OAAO,EAAE,kBAAkB,EAAE,EAAE,aAAa,EAAE,MAAM,KAAK,IAAI,CAAC;CAC3E;AAmGD;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,cAAc,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAuC9F;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,uBAAuB,CAC3C,OAAO,EAAE,YAAY,GACpB,OAAO,CAAC,OAAO,CAAC,YAAY,EAAE,CAAC,CAqFjC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAsB,kBAAkB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAgB5E;AAED;;;;GAIG;AACH,wBAAsB,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAM/E;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAqC7D;AAiBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,EAAE,GAAG,MAAM,CA8B7E"}
@@ -1,21 +1,26 @@
1
1
  /**
2
2
  * TypeScript compilation utilities using esbuild.
3
3
  * Provides functions for compiling TypeScript files and watching for changes.
4
+ * Supports multiple bundles with per-page targeting.
4
5
  * @module core/utils/typescript
5
6
  */
6
7
  import * as esbuild from 'esbuild';
7
8
  import * as path from 'node:path';
8
9
  import * as fs from 'node:fs/promises';
9
10
  import { pathExists } from './fs.utils.js';
10
- import { DEFAULT_TS_SRC_DIR, DEFAULT_TS_OUT_DIR, DEFAULT_TS_ENTRY_POINT, DEFAULT_TS_BUNDLE_NAME, } from '../../constants.js';
11
+ import { validateUniqueBundleNames } from './bundle-matching.utils.js';
12
+ import { DEFAULT_TS_SRC_DIR, DEFAULT_TS_OUT_DIR, DEFAULT_BUNDLES, DEFAULT_OUT_DIR, } from '../../constants.js';
13
+ /**
14
+ * Resolves TypeScript config with defaults based on mode.
15
+ * @internal
16
+ */
11
17
  function resolveConfig(config, mode) {
12
18
  const isProduction = mode === 'production';
13
19
  return {
14
20
  enabled: config.enabled,
15
21
  srcDir: config.srcDir ?? DEFAULT_TS_SRC_DIR,
16
22
  outDir: config.outDir ?? DEFAULT_TS_OUT_DIR,
17
- entryPoint: config.entryPoint ?? DEFAULT_TS_ENTRY_POINT,
18
- bundleName: config.bundleName ?? DEFAULT_TS_BUNDLE_NAME,
23
+ bundles: config.bundles ?? [...DEFAULT_BUNDLES],
19
24
  // hash/minify: always false in dev (config ignored), configurable in prod (default true)
20
25
  hash: isProduction && (config.hash ?? true),
21
26
  minify: isProduction && (config.minify ?? true),
@@ -24,130 +29,196 @@ function resolveConfig(config, mode) {
24
29
  };
25
30
  }
26
31
  /**
27
- * Compile TypeScript files using esbuild.
28
- * Returns the generated bundle filename for template injection.
29
- *
30
- * @param options - Compilation options
31
- * @returns The compilation result with bundle filename
32
- *
33
- * @example
34
- * ```typescript
35
- * const result = await compileTypeScript({
36
- * projectRoot: process.cwd(),
37
- * config: { enabled: true },
38
- * mode: 'production',
39
- * logger: console,
40
- * });
41
- * console.log(result.bundleFilename); // 'bundle-a1b2c3d4.js'
42
- * ```
32
+ * Compile a single bundle using esbuild.
33
+ * @internal
43
34
  */
44
- export async function compileTypeScript(options) {
45
- const { projectRoot, config, mode, logger, outDir: globalOutDir } = options;
46
- const resolved = resolveConfig(config, mode);
47
- const entryPath = path.join(projectRoot, resolved.srcDir, resolved.entryPoint);
48
- // Output to configured build output directory (default: dist)
49
- const outDir = path.join(projectRoot, globalOutDir || 'dist', resolved.outDir);
35
+ async function compileSingleBundle(bundleConfig, resolvedConfig, projectRoot, globalOutDir, logger) {
36
+ const entryPath = path.join(projectRoot, resolvedConfig.srcDir, bundleConfig.entryPoint);
37
+ const outDir = path.join(projectRoot, globalOutDir, resolvedConfig.outDir);
50
38
  // Validate entry point exists
51
39
  if (!(await pathExists(entryPath))) {
52
- logger.warning(`TypeScript entry point not found: ${entryPath}`);
53
- logger.warning('Skipping TypeScript compilation.');
54
- return {};
40
+ logger.warning(`TypeScript entry point not found: ${entryPath} (bundle: ${bundleConfig.bundleName})`);
41
+ return null;
55
42
  }
56
- logger.info('Compiling TypeScript...');
57
43
  try {
58
44
  const result = await esbuild.build({
59
45
  entryPoints: [entryPath],
60
46
  bundle: true,
61
47
  outdir: outDir,
62
- entryNames: resolved.hash ? `${resolved.bundleName}-[hash]` : resolved.bundleName,
63
- minify: resolved.minify,
64
- sourcemap: resolved.sourceMaps,
48
+ entryNames: resolvedConfig.hash
49
+ ? `${bundleConfig.bundleName}-[hash]`
50
+ : bundleConfig.bundleName,
51
+ minify: resolvedConfig.minify,
52
+ sourcemap: resolvedConfig.sourceMaps,
65
53
  target: 'es2022',
66
54
  format: 'esm',
67
55
  platform: 'browser',
68
56
  logLevel: 'silent',
69
57
  metafile: true,
70
58
  });
71
- // Extract the generated filename for template injection
59
+ // Extract the generated filename for this bundle
72
60
  const outputs = Object.keys(result.metafile?.outputs ?? {});
73
61
  const bundleFile = outputs.find((f) => f.endsWith('.js'));
74
- const bundleFilename = bundleFile ? path.basename(bundleFile) : `${resolved.bundleName}.js`;
75
- logger.success(`TypeScript compiled: ${bundleFilename}`);
76
- return { bundleFilename };
62
+ const bundleFilename = bundleFile ? path.basename(bundleFile) : `${bundleConfig.bundleName}.js`;
63
+ // Construct the path relative to site root
64
+ const bundlePath = path.posix.join('/', resolvedConfig.outDir, bundleFilename);
65
+ return {
66
+ config: bundleConfig,
67
+ filename: bundleFilename,
68
+ path: bundlePath,
69
+ };
77
70
  }
78
71
  catch (error) {
79
72
  if (error instanceof Error) {
80
- logger.error(`TypeScript compilation failed: ${error.message}`);
73
+ logger.error(`Bundle '${bundleConfig.bundleName}' compilation failed: ${error.message}`);
81
74
  }
82
75
  throw error;
83
76
  }
84
77
  }
78
+ /**
79
+ * Compile TypeScript files using esbuild.
80
+ * Supports multiple bundles with parallel compilation.
81
+ *
82
+ * @param options - Compilation options
83
+ * @returns Array of compilation results for each bundle
84
+ *
85
+ * @example
86
+ * ```typescript
87
+ * const results = await compileTypeScript({
88
+ * projectRoot: process.cwd(),
89
+ * config: {
90
+ * enabled: true,
91
+ * bundles: [
92
+ * { entryPoint: 'core.ts', bundleName: 'core' },
93
+ * { entryPoint: 'docs.ts', bundleName: 'docs', include: ['/docs/**'] }
94
+ * ]
95
+ * },
96
+ * mode: 'production',
97
+ * logger: console,
98
+ * });
99
+ * console.log(results[0].bundlePath); // '/_assets/core-a1b2c3d4.js'
100
+ * ```
101
+ */
102
+ export async function compileTypeScript(options) {
103
+ const { projectRoot, config, mode, logger, outDir: globalOutDir } = options;
104
+ const resolved = resolveConfig(config, mode);
105
+ const outputDir = globalOutDir || DEFAULT_OUT_DIR;
106
+ // Handle empty bundles array
107
+ if (resolved.bundles.length === 0) {
108
+ logger.warning('TypeScript is enabled but no bundles are configured. Add bundles to your stati.config.ts or disable TypeScript.');
109
+ return [];
110
+ }
111
+ // Validate unique bundle names early - fail fast with clear error message
112
+ validateUniqueBundleNames(resolved.bundles);
113
+ logger.info('');
114
+ logger.info(`Compiling TypeScript (${resolved.bundles.length} bundle${resolved.bundles.length > 1 ? 's' : ''})...`);
115
+ // Compile all bundles in parallel
116
+ const compilationPromises = resolved.bundles.map((bundleConfig) => compileSingleBundle(bundleConfig, resolved, projectRoot, outputDir, logger));
117
+ const results = await Promise.all(compilationPromises);
118
+ // Filter out null results (skipped bundles) and collect successful ones
119
+ const successfulResults = results.filter((r) => r !== null);
120
+ if (successfulResults.length > 0) {
121
+ const bundleNames = successfulResults.map((r) => r.filename).join(', ');
122
+ logger.success(`TypeScript compiled: ${bundleNames}`);
123
+ }
124
+ else if (resolved.bundles.length > 0) {
125
+ logger.warning('No TypeScript bundles were compiled (all entry points missing).');
126
+ }
127
+ return successfulResults;
128
+ }
85
129
  /**
86
130
  * Create an esbuild watch context for development.
87
131
  * Watches for TypeScript file changes and recompiles automatically.
132
+ * Supports multiple bundles with selective recompilation.
88
133
  *
89
134
  * @param options - Watch options including rebuild callback
90
- * @returns The esbuild build context for cleanup
135
+ * @returns Array of esbuild build contexts for cleanup
91
136
  *
92
137
  * @example
93
138
  * ```typescript
94
- * const watcher = await createTypeScriptWatcher({
139
+ * const watchers = await createTypeScriptWatcher({
95
140
  * projectRoot: process.cwd(),
96
141
  * config: { enabled: true },
97
- * mode: 'development',
98
142
  * logger: console,
99
- * onRebuild: () => console.log('Rebuilt!'),
143
+ * onRebuild: (results, compileTimeMs) => console.log(`Rebuilt ${results.length} bundles in ${compileTimeMs}ms`),
100
144
  * });
101
145
  *
102
146
  * // Later, cleanup:
103
- * await watcher.dispose();
147
+ * await Promise.all(watchers.map(w => w.dispose()));
104
148
  * ```
105
149
  */
106
150
  export async function createTypeScriptWatcher(options) {
107
- const { projectRoot, config, mode, logger, onRebuild, outDir: globalOutDir } = options;
108
- const resolved = resolveConfig(config, mode);
109
- const entryPath = path.join(projectRoot, resolved.srcDir, resolved.entryPoint);
110
- // Output to configured build output directory (default: dist)
111
- const outDir = path.join(projectRoot, globalOutDir || 'dist', resolved.outDir);
112
- // Validate entry point exists
113
- if (!(await pathExists(entryPath))) {
114
- logger.warning(`TypeScript entry point not found: ${entryPath}`);
115
- throw new Error(`Entry point not found: ${entryPath}`);
116
- }
117
- const context = await esbuild.context({
118
- entryPoints: [entryPath],
119
- bundle: true,
120
- outdir: outDir,
121
- entryNames: resolved.bundleName, // Stable filename in dev mode (no hash)
122
- minify: resolved.minify,
123
- sourcemap: resolved.sourceMaps,
124
- target: 'es2022',
125
- format: 'esm',
126
- platform: 'browser',
127
- logLevel: 'silent',
128
- plugins: [
129
- {
130
- name: 'stati-rebuild-notify',
131
- setup(build) {
132
- build.onEnd((result) => {
133
- if (result.errors.length > 0) {
134
- result.errors.forEach((err) => {
135
- logger.error(`TypeScript error: ${err.text}`);
136
- });
137
- }
138
- else {
139
- logger.info('TypeScript recompiled.');
140
- onRebuild();
141
- }
142
- });
151
+ const { projectRoot, config, logger, onRebuild, outDir: globalOutDir } = options;
152
+ const resolved = resolveConfig(config, 'development');
153
+ const outputDir = globalOutDir || DEFAULT_OUT_DIR;
154
+ const outDir = path.join(projectRoot, outputDir, resolved.outDir);
155
+ const contexts = [];
156
+ const latestResults = new Map();
157
+ for (const bundleConfig of resolved.bundles) {
158
+ const entryPath = path.join(projectRoot, resolved.srcDir, bundleConfig.entryPoint);
159
+ // Validate entry point exists
160
+ if (!(await pathExists(entryPath))) {
161
+ logger.warning(`TypeScript entry point not found: ${entryPath} (bundle: ${bundleConfig.bundleName})`);
162
+ continue;
163
+ }
164
+ const context = await esbuild.context({
165
+ entryPoints: [entryPath],
166
+ bundle: true,
167
+ outdir: outDir,
168
+ entryNames: bundleConfig.bundleName, // Stable filename in dev mode (no hash)
169
+ minify: false, // Dev mode: never minify for fast rebuilds
170
+ sourcemap: true, // Dev mode: always enable for debugging
171
+ target: 'es2022',
172
+ format: 'esm',
173
+ platform: 'browser',
174
+ logLevel: 'silent',
175
+ metafile: true,
176
+ plugins: [
177
+ {
178
+ name: 'stati-rebuild-notify',
179
+ setup(build) {
180
+ let startTime;
181
+ build.onStart(() => {
182
+ startTime = Date.now();
183
+ });
184
+ build.onEnd((result) => {
185
+ const compileTime = Date.now() - startTime;
186
+ if (result.errors.length > 0) {
187
+ result.errors.forEach((err) => {
188
+ logger.error(`TypeScript error in '${bundleConfig.bundleName}': ${err.text}`);
189
+ });
190
+ }
191
+ else {
192
+ // Extract the generated filename
193
+ const outputs = Object.keys(result.metafile?.outputs ?? {});
194
+ const bundleFile = outputs.find((f) => f.endsWith('.js'));
195
+ const bundleFilename = bundleFile
196
+ ? path.basename(bundleFile)
197
+ : `${bundleConfig.bundleName}.js`;
198
+ const bundlePath = path.posix.join('/', resolved.outDir, bundleFilename);
199
+ const bundleResult = {
200
+ config: bundleConfig,
201
+ filename: bundleFilename,
202
+ path: bundlePath,
203
+ };
204
+ latestResults.set(bundleConfig.bundleName, bundleResult);
205
+ logger.info(`TypeScript '${bundleConfig.bundleName}' recompiled.`);
206
+ // Notify with all current results and compile time
207
+ onRebuild(Array.from(latestResults.values()), compileTime);
208
+ }
209
+ });
210
+ },
143
211
  },
144
- },
145
- ],
146
- });
147
- // Start watching
148
- await context.watch();
149
- logger.info(`Watching TypeScript files in ${resolved.srcDir}/`);
150
- return context;
212
+ ],
213
+ });
214
+ // Start watching
215
+ await context.watch();
216
+ contexts.push(context);
217
+ }
218
+ if (contexts.length > 0) {
219
+ logger.info(`Watching TypeScript files in ${resolved.srcDir}/ (${contexts.length} bundle${contexts.length > 1 ? 's' : ''})`);
220
+ }
221
+ return contexts;
151
222
  }
152
223
  /**
153
224
  * Compile stati.config.ts to a temporary JS file for import.
@@ -192,35 +263,86 @@ export async function cleanupCompiledConfig(compiledPath) {
192
263
  }
193
264
  }
194
265
  /**
195
- * Auto-inject TypeScript bundle script tag into HTML before </body>.
196
- * Similar to SEO auto-injection, this adds the script tag automatically
266
+ * Validates a bundle path for safety against XSS attacks.
267
+ * Only allows safe ASCII characters: alphanumeric, hyphens, underscores, dots, and forward slashes.
268
+ * Must start with / and end with .js, no encoded characters or unicode allowed.
269
+ *
270
+ * @param bundlePath - The bundle path to validate
271
+ * @returns true if the path is safe, false otherwise
272
+ *
273
+ * @internal
274
+ */
275
+ export function isValidBundlePath(bundlePath) {
276
+ // Reject non-string or empty paths
277
+ if (typeof bundlePath !== 'string' || bundlePath.length === 0) {
278
+ return false;
279
+ }
280
+ // Reject paths with null bytes (can bypass security checks)
281
+ if (bundlePath.includes('\0')) {
282
+ return false;
283
+ }
284
+ // Reject paths with control characters or non-ASCII characters
285
+ // Check that all characters are in printable ASCII range (32-126)
286
+ for (let i = 0; i < bundlePath.length; i++) {
287
+ const charCode = bundlePath.charCodeAt(i);
288
+ if (charCode < 32 || charCode > 126) {
289
+ return false;
290
+ }
291
+ }
292
+ // Reject URL-encoded characters (e.g., %20, %3C) and unicode escapes (e.g., \u003C)
293
+ if (/%[0-9a-fA-F]{2}/.test(bundlePath) || /\\u[0-9a-fA-F]{4}/.test(bundlePath)) {
294
+ return false;
295
+ }
296
+ // Reject HTML entities (e.g., &lt; &#60; &#x3C;)
297
+ if (/&#?[a-zA-Z0-9]+;/.test(bundlePath)) {
298
+ return false;
299
+ }
300
+ // Reject path traversal attempts
301
+ if (bundlePath.includes('..') || bundlePath.includes('//')) {
302
+ return false;
303
+ }
304
+ // Validate against strict safe pattern
305
+ const safePathPattern = /^\/[a-zA-Z0-9_\-./]+\.js$/;
306
+ return safePathPattern.test(bundlePath);
307
+ }
308
+ /**
309
+ * Checks if a bundle script tag is already present in the HTML.
310
+ *
311
+ * @param html - The HTML content to check
312
+ * @param bundlePath - The bundle path to look for
313
+ * @returns true if the bundle is already included, false otherwise
314
+ *
315
+ * @internal
316
+ */
317
+ function isBundleAlreadyIncluded(html, bundlePath) {
318
+ const escapedPath = bundlePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
319
+ const scriptTagPattern = new RegExp(`<script[^>]*\\ssrc=["']${escapedPath}["'][^>]*>`, 'i');
320
+ return scriptTagPattern.test(html);
321
+ }
322
+ /**
323
+ * Auto-inject TypeScript bundle script tags into HTML before </body>.
324
+ * Similar to SEO auto-injection, this adds script tags automatically
197
325
  * so users don't need to modify their templates.
326
+ * Supports multiple bundles - injects all matched bundles in order.
198
327
  *
199
328
  * @param html - Rendered HTML content
200
- * @param bundlePath - Path to the compiled bundle (e.g., '/_assets/bundle-a1b2c3d4.js')
201
- * @returns HTML with injected script tag
329
+ * @param bundlePaths - Array of paths to compiled bundles (e.g., ['/_assets/core.js', '/_assets/docs.js'])
330
+ * @returns HTML with injected script tags
202
331
  *
203
332
  * @example
204
333
  * ```typescript
205
334
  * const html = '<html><body>Content</body></html>';
206
- * const enhanced = autoInjectBundle(html, '/_assets/bundle.js');
207
- * // Returns: '<html><body>Content\n <script type="module" src="/_assets/bundle.js"></script>\n</body></html>'
335
+ * const enhanced = autoInjectBundles(html, ['/_assets/core.js', '/_assets/docs.js']);
336
+ * // Returns HTML with both script tags injected before </body>
208
337
  * ```
209
338
  */
210
- export function autoInjectBundle(html, bundlePath) {
211
- if (!bundlePath) {
212
- return html;
213
- }
214
- // Sanitize bundlePath to prevent XSS attacks
215
- // Only allow safe characters: alphanumeric, hyphens, underscores, dots, and forward slashes
216
- // Must start with / and end with .js
217
- const safePathPattern = /^\/[\w\-./]+\.js$/;
218
- if (!safePathPattern.test(bundlePath)) {
219
- // Invalid path format, skip injection to prevent potential XSS
339
+ export function autoInjectBundles(html, bundlePaths) {
340
+ if (!bundlePaths || bundlePaths.length === 0) {
220
341
  return html;
221
342
  }
222
- // Check if the bundle is already included (avoid duplicate injection)
223
- if (html.includes(bundlePath)) {
343
+ // Filter paths: must be valid and not already included in HTML
344
+ const validPaths = bundlePaths.filter((bundlePath) => isValidBundlePath(bundlePath) && !isBundleAlreadyIncluded(html, bundlePath));
345
+ if (validPaths.length === 0) {
224
346
  return html;
225
347
  }
226
348
  // Find </body> tag (case-insensitive)
@@ -231,7 +353,9 @@ export function autoInjectBundle(html, bundlePath) {
231
353
  const bodyClosePos = bodyCloseMatch.index;
232
354
  const before = html.substring(0, bodyClosePos);
233
355
  const after = html.substring(bodyClosePos);
234
- // Inject script tag before </body> with proper indentation
235
- const scriptTag = ` <script type="module" src="${bundlePath}"></script>\n`;
236
- return `${before}${scriptTag}${after}`;
356
+ // Inject all script tags before </body>
357
+ const scriptTags = validPaths
358
+ .map((bundlePath) => `<script type="module" src="${bundlePath}"></script>`)
359
+ .join('\n');
360
+ return `${before}${scriptTags}\n${after}`;
237
361
  }
package/dist/index.d.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  * await build({ clean: true });
20
20
  * ```
21
21
  */
22
- export type { StatiConfig, PageModel, FrontMatter, BuildContext, PageContext, BuildHooks, NavNode, ISGConfig, AgingRule, BuildStats, } from './types/index.js';
22
+ export type { StatiConfig, PageModel, FrontMatter, BuildContext, PageContext, BuildHooks, NavNode, ISGConfig, AgingRule, BuildStats, BundleConfig, } from './types/index.js';
23
23
  export type { SEOMetadata, SEOConfig, SEOContext, SEOValidationResult, SEOTagType, RobotsConfig, OpenGraphConfig, OpenGraphImage, OpenGraphArticle, TwitterCardConfig, AuthorConfig, } from './types/index.js';
24
24
  export type { SitemapConfig, SitemapEntry, SitemapGenerationResult, ChangeFrequency, } from './types/index.js';
25
25
  export type { RSSConfig, RSSFeedConfig, RSSGenerationResult } from './types/index.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,YAAY,EACV,WAAW,EACX,SAAS,EACT,WAAW,EACX,YAAY,EACZ,WAAW,EACX,UAAU,EACV,OAAO,EACP,SAAS,EACT,SAAS,EACT,UAAU,GACX,MAAM,kBAAkB,CAAC;AAG1B,YAAY,EACV,WAAW,EACX,SAAS,EACT,UAAU,EACV,mBAAmB,EACnB,UAAU,EACV,YAAY,EACZ,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,iBAAiB,EACjB,YAAY,GACb,MAAM,kBAAkB,CAAC;AAE1B,YAAY,EACV,aAAa,EACb,YAAY,EACZ,uBAAuB,EACvB,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAG1B,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAGtF,YAAY,EACV,YAAY,EACZ,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,KAAK,EAAE,eAAe,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAG1F,YAAY,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EACL,mBAAmB,EACnB,WAAW,EACX,qBAAqB,EACrB,uBAAuB,EACvB,eAAe,EACf,oBAAoB,EACpB,kBAAkB,EAClB,uBAAuB,EACvB,iBAAiB,EACjB,2BAA2B,EAC3B,UAAU,EACV,qBAAqB,EACrB,mBAAmB,EACnB,qBAAqB,EACrB,gBAAgB,EAChB,kBAAkB,EAClB,UAAU,EACV,aAAa,EACb,gBAAgB,GACjB,MAAM,gBAAgB,CAAC;AAGxB,YAAY,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,iBAAiB,EACjB,qBAAqB,GACtB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,WAAW,CAE7D"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,YAAY,EACV,WAAW,EACX,SAAS,EACT,WAAW,EACX,YAAY,EACZ,WAAW,EACX,UAAU,EACV,OAAO,EACP,SAAS,EACT,SAAS,EACT,UAAU,EACV,YAAY,GACb,MAAM,kBAAkB,CAAC;AAG1B,YAAY,EACV,WAAW,EACX,SAAS,EACT,UAAU,EACV,mBAAmB,EACnB,UAAU,EACV,YAAY,EACZ,eAAe,EACf,cAAc,EACd,gBAAgB,EAChB,iBAAiB,EACjB,YAAY,GACb,MAAM,kBAAkB,CAAC;AAE1B,YAAY,EACV,aAAa,EACb,YAAY,EACZ,uBAAuB,EACvB,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAG1B,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAC;AAGtF,YAAY,EACV,YAAY,EACZ,gBAAgB,EAChB,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,KAAK,EAAE,eAAe,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAG1F,YAAY,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AACxD,OAAO,EACL,mBAAmB,EACnB,WAAW,EACX,qBAAqB,EACrB,uBAAuB,EACvB,eAAe,EACf,oBAAoB,EACpB,kBAAkB,EAClB,uBAAuB,EACvB,iBAAiB,EACjB,2BAA2B,EAC3B,UAAU,EACV,qBAAqB,EACrB,mBAAmB,EACnB,qBAAqB,EACrB,gBAAgB,EAChB,kBAAkB,EAClB,UAAU,EACV,aAAa,EACb,gBAAgB,GACjB,MAAM,gBAAgB,CAAC;AAGxB,YAAY,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAC1D,OAAO,EACL,eAAe,EACf,gBAAgB,EAChB,iBAAiB,EACjB,qBAAqB,GACtB,MAAM,gBAAgB,CAAC;AAGxB,OAAO,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAC;AAChD,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,WAAW,GAAG,WAAW,CAE7D"}