pulse-js-framework 1.7.31 → 1.7.32

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/cli/build.js CHANGED
@@ -7,9 +7,13 @@
7
7
  import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, copyFileSync } from 'fs';
8
8
  import { join, extname, relative, dirname } from 'path';
9
9
  import { compile } from '../compiler/index.js';
10
+ import { preprocessStylesSync, isSassAvailable, getSassVersion } from '../compiler/preprocessor.js';
10
11
  import { log } from './logger.js';
11
12
  import { createTimer, createProgressBar, formatDuration, createSpinner } from './utils/cli-ui.js';
12
13
 
14
+ // SASS availability (checked once at build start)
15
+ let sassAvailable = false;
16
+
13
17
  /**
14
18
  * Build project for production
15
19
  */
@@ -34,6 +38,13 @@ export async function buildProject(args) {
34
38
 
35
39
  log.info('Building with Pulse compiler...\n');
36
40
 
41
+ // Check for SASS availability
42
+ sassAvailable = isSassAvailable();
43
+ if (sassAvailable) {
44
+ const version = getSassVersion();
45
+ log.info(` SASS support enabled (sass ${version || 'unknown'})`);
46
+ }
47
+
37
48
  // Create output directory
38
49
  if (!existsSync(outDir)) {
39
50
  mkdirSync(outDir, { recursive: true });
@@ -140,8 +151,30 @@ function processDirectory(srcDir, outDir, progress = null) {
140
151
  });
141
152
 
142
153
  if (result.success) {
154
+ let code = result.code;
155
+
156
+ // Preprocess SASS/SCSS in style blocks if sass is available
157
+ if (sassAvailable) {
158
+ const stylesMatch = code.match(/const styles = `([\s\S]*?)`;/);
159
+ if (stylesMatch) {
160
+ try {
161
+ const preprocessed = preprocessStylesSync(stylesMatch[1], {
162
+ filename: srcPath,
163
+ loadPaths: [dirname(srcPath)],
164
+ compressed: true
165
+ });
166
+
167
+ if (preprocessed.wasSass) {
168
+ code = code.replace(stylesMatch[0], `const styles = \`${preprocessed.css}\`;`);
169
+ }
170
+ } catch (sassError) {
171
+ log.warn(` SASS warning in ${file}: ${sassError.message}`);
172
+ }
173
+ }
174
+ }
175
+
143
176
  const outPath = join(outDir, file.replace('.pulse', '.js'));
144
- writeFileSync(outPath, result.code);
177
+ writeFileSync(outPath, code);
145
178
  } else {
146
179
  log.error(` Error compiling ${file}:`);
147
180
  for (const error of result.errors) {
package/cli/dev.js CHANGED
@@ -6,10 +6,14 @@
6
6
 
7
7
  import { createServer } from 'http';
8
8
  import { readFileSync, existsSync, statSync, watch } from 'fs';
9
- import { join, extname, resolve } from 'path';
9
+ import { join, extname, resolve, dirname } from 'path';
10
10
  import { compile } from '../compiler/index.js';
11
+ import { preprocessStylesSync, isSassAvailable, getSassVersion } from '../compiler/preprocessor.js';
11
12
  import { log } from './logger.js';
12
13
 
14
+ // SASS availability (checked once at server start)
15
+ let sassAvailable = false;
16
+
13
17
  const MIME_TYPES = {
14
18
  '.html': 'text/html',
15
19
  '.js': 'application/javascript',
@@ -87,6 +91,13 @@ export async function startDevServer(args) {
87
91
  // Vite not available, use built-in server
88
92
  }
89
93
 
94
+ // Check for SASS availability
95
+ sassAvailable = isSassAvailable();
96
+ if (sassAvailable) {
97
+ const version = getSassVersion();
98
+ log.info(`SASS support enabled (sass ${version || 'unknown'})`);
99
+ }
100
+
90
101
  // Built-in development server
91
102
  const server = createServer(async (req, res) => {
92
103
  const url = new URL(req.url, `http://localhost:${port}`);
@@ -128,11 +139,31 @@ export async function startDevServer(args) {
128
139
  });
129
140
 
130
141
  if (result.success) {
142
+ let code = result.code;
143
+
144
+ // Preprocess SASS/SCSS if available
145
+ if (sassAvailable) {
146
+ const stylesMatch = code.match(/const styles = `([\s\S]*?)`;/);
147
+ if (stylesMatch) {
148
+ try {
149
+ const preprocessed = preprocessStylesSync(stylesMatch[1], {
150
+ filename: filePath,
151
+ loadPaths: [dirname(filePath)]
152
+ });
153
+ if (preprocessed.wasSass) {
154
+ code = code.replace(stylesMatch[0], `const styles = \`${preprocessed.css}\`;`);
155
+ }
156
+ } catch (sassError) {
157
+ console.warn(`[Pulse] SASS warning: ${sassError.message}`);
158
+ }
159
+ }
160
+ }
161
+
131
162
  res.writeHead(200, {
132
163
  'Content-Type': 'application/javascript',
133
164
  'Cache-Control': 'no-cache, no-store, must-revalidate'
134
165
  });
135
- res.end(result.code);
166
+ res.end(code);
136
167
  } else {
137
168
  const errorDetails = result.errors.map(e => `${e.message} at line ${e.line || '?'}:${e.column || '?'}`).join('\n');
138
169
  console.error(`[Pulse] Compilation error in ${filePath}:`, result.errors);
@@ -173,11 +204,31 @@ export async function startDevServer(args) {
173
204
  });
174
205
 
175
206
  if (result.success) {
207
+ let code = result.code;
208
+
209
+ // Preprocess SASS/SCSS if available
210
+ if (sassAvailable) {
211
+ const stylesMatch = code.match(/const styles = `([\s\S]*?)`;/);
212
+ if (stylesMatch) {
213
+ try {
214
+ const preprocessed = preprocessStylesSync(stylesMatch[1], {
215
+ filename: pulseFilePath,
216
+ loadPaths: [dirname(pulseFilePath)]
217
+ });
218
+ if (preprocessed.wasSass) {
219
+ code = code.replace(stylesMatch[0], `const styles = \`${preprocessed.css}\`;`);
220
+ }
221
+ } catch (sassError) {
222
+ console.warn(`[Pulse] SASS warning: ${sassError.message}`);
223
+ }
224
+ }
225
+ }
226
+
176
227
  res.writeHead(200, {
177
228
  'Content-Type': 'application/javascript',
178
229
  'Cache-Control': 'no-cache, no-store, must-revalidate'
179
230
  });
180
- res.end(result.code);
231
+ res.end(code);
181
232
  } else {
182
233
  res.writeHead(500, { 'Content-Type': 'text/plain' });
183
234
  res.end(`Compilation error: ${result.errors.map(e => e.message).join('\n')}`);
@@ -0,0 +1,819 @@
1
+ /**
2
+ * CSS Preprocessor Support
3
+ *
4
+ * Provides optional SASS/SCSS, LESS, and Stylus support without requiring them as direct dependencies.
5
+ * If the user has `sass`, `less`, or `stylus` installed in their project, they will be automatically detected
6
+ * and used to compile SCSS/LESS/Stylus syntax in style blocks.
7
+ *
8
+ * @module pulse-js-framework/compiler/preprocessor
9
+ */
10
+
11
+ import { createRequire } from 'module';
12
+
13
+ // Cache for the sass module (null = not checked, false = not available, object = sass module)
14
+ let sassModule = null;
15
+
16
+ // Cache for the less module (null = not checked, false = not available, object = less module)
17
+ let lessModule = null;
18
+
19
+ // Cache for the stylus module (null = not checked, false = not available, object = stylus module)
20
+ let stylusModule = null;
21
+
22
+ /**
23
+ * Patterns that indicate SASS/SCSS syntax
24
+ */
25
+ const SASS_PATTERNS = [
26
+ /\$[\w-]+\s*:/, // Variables: $primary-color:
27
+ /@mixin\s+[\w-]+/, // Mixins: @mixin button-styles
28
+ /@include\s+[\w-]+/, // Include: @include button-styles
29
+ /@extend\s+[.%][\w-]+/, // Extend: @extend .class or @extend %placeholder
30
+ /@function\s+[\w-]+/, // Functions: @function calculate
31
+ /@use\s+['"][^'"]+['"]/, // Use: @use 'module'
32
+ /@forward\s+['"][^'"]+['"]/, // Forward: @forward 'module'
33
+ /%[\w-]+\s*\{/, // Placeholder selectors: %button { }
34
+ /#\{.*\}/, // Interpolation: #{$variable}
35
+ /@if\s+/, // Control: @if
36
+ /@else\s*/, // Control: @else
37
+ /@for\s+\$/, // Loop: @for $i
38
+ /@each\s+\$/, // Loop: @each $item
39
+ /@while\s+/, // Loop: @while
40
+ /@debug\s+/, // Debug: @debug
41
+ /@warn\s+/, // Warn: @warn
42
+ /@error\s+/, // Error: @error
43
+ ];
44
+
45
+ /**
46
+ * Patterns that indicate LESS syntax
47
+ */
48
+ const LESS_PATTERNS = [
49
+ /@[\w-]+\s*:/, // Variables: @primary-color:
50
+ /\.[\w-]+\s*\([^)]*\)\s*\{/, // Mixins: .button-styles() { }
51
+ /\.[\w-]+\s*>\s*\([^)]*\)\s*\{/, // Parametric mixins: .button-styles > () { }
52
+ /\.[\w-]+\([^)]*\);/, // Mixin calls: .button-styles();
53
+ /&:extend\(/, // Extend: &:extend(.class)
54
+ /@\{[\w-]+\}/, // Interpolation: @{variable}
55
+ /when\s*\(/, // Guards: when (@a > 0)
56
+ /\.[\w-]+\(\)\s*when/, // Guarded mixins
57
+ /@import\s+\(.*\)\s+['"]/, // Import options: @import (less) "file"
58
+ /@plugin\s+['"][^'"]+['"]/, // Plugin: @plugin "plugin-name"
59
+ ];
60
+
61
+ /**
62
+ * Patterns that indicate Stylus syntax
63
+ */
64
+ const STYLUS_PATTERNS = [
65
+ /^[\w-]+\s*=/m, // Variables without $ or @: primary-color = #333
66
+ /^\s*[\w-]+\([^)]*\)$/m, // Mixins without braces: button-style()
67
+ /^\s*[\w-]+$/m, // Mixin calls without parens or semicolon
68
+ /\{\s*\$[\w-]+\s*\}/, // Interpolation: {$variable}
69
+ /^\s*if\s+/m, // Conditionals: if condition
70
+ /^\s*unless\s+/m, // Unless: unless condition
71
+ /^\s*for\s+[\w-]+\s+in\s+/m, // Loops: for item in items
72
+ /^\s*@css\s+\{/m, // Literal CSS blocks: @css { }
73
+ /^\s*@extends?\s+/m, // Extend: @extend or @extends
74
+ /^\s*\+[\w-]+/m, // Mixin calls with +: +button-style
75
+ /arguments/, // Stylus arguments variable
76
+ /^[\w-]+\s*\?=/m, // Conditional assignment: var ?= value
77
+ ];
78
+
79
+ /**
80
+ * Patterns that indicate EITHER SASS or LESS syntax
81
+ * These are shared between both preprocessors
82
+ */
83
+ const SHARED_PATTERNS = [
84
+ /&\s*\{/, // Parent selector nesting
85
+ /&:[\w-]+/, // Parent pseudo-class: &:hover
86
+ /&\.[\w-]+/, // Parent class: &.active
87
+ ];
88
+
89
+ /**
90
+ * Check if CSS contains exclusively SASS/SCSS-specific syntax
91
+ * (excludes shared patterns that could be LESS or Stylus)
92
+ * @param {string} css - CSS string to check
93
+ * @returns {boolean} True if SASS syntax is detected
94
+ */
95
+ function hasSassSpecificSyntax(css) {
96
+ return SASS_PATTERNS.some(pattern => pattern.test(css));
97
+ }
98
+
99
+ /**
100
+ * Check if CSS contains exclusively LESS-specific syntax
101
+ * (excludes shared patterns that could be SASS or Stylus)
102
+ * @param {string} css - CSS string to check
103
+ * @returns {boolean} True if LESS syntax is detected
104
+ */
105
+ function hasLessSpecificSyntax(css) {
106
+ return LESS_PATTERNS.some(pattern => pattern.test(css));
107
+ }
108
+
109
+ /**
110
+ * Check if CSS contains exclusively Stylus-specific syntax
111
+ * @param {string} css - CSS string to check
112
+ * @returns {boolean} True if Stylus syntax is detected
113
+ */
114
+ function hasStylusSpecificSyntax(css) {
115
+ return STYLUS_PATTERNS.some(pattern => pattern.test(css));
116
+ }
117
+
118
+ /**
119
+ * Check if CSS contains SASS/SCSS-specific syntax
120
+ * @param {string} css - CSS string to check
121
+ * @returns {boolean} True if SASS syntax is detected
122
+ */
123
+ export function hasSassSyntax(css) {
124
+ return hasSassSpecificSyntax(css);
125
+ }
126
+
127
+ /**
128
+ * Check if CSS contains LESS-specific syntax
129
+ * @param {string} css - CSS string to check
130
+ * @returns {boolean} True if LESS syntax is detected
131
+ */
132
+ export function hasLessSyntax(css) {
133
+ return hasLessSpecificSyntax(css);
134
+ }
135
+
136
+ /**
137
+ * Check if CSS contains Stylus-specific syntax
138
+ * @param {string} css - CSS string to check
139
+ * @returns {boolean} True if Stylus syntax is detected
140
+ */
141
+ export function hasStylusSyntax(css) {
142
+ return hasStylusSpecificSyntax(css);
143
+ }
144
+
145
+ /**
146
+ * Detect which preprocessor the CSS is using
147
+ * @param {string} css - CSS string to check
148
+ * @returns {'sass'|'less'|'stylus'|'none'} Detected preprocessor
149
+ */
150
+ export function detectPreprocessor(css) {
151
+ const hasSass = hasSassSpecificSyntax(css);
152
+ const hasLess = hasLessSpecificSyntax(css);
153
+ const hasStylus = hasStylusSpecificSyntax(css);
154
+
155
+ // Priority order: SASS > LESS > Stylus (based on popularity)
156
+ if (hasSass) return 'sass';
157
+ if (hasLess) return 'less';
158
+ if (hasStylus) return 'stylus';
159
+
160
+ return 'none';
161
+ }
162
+
163
+ /**
164
+ * Try to load the sass module from the user's project (sync version)
165
+ * Uses require() for compatibility with sync contexts
166
+ * @returns {object|false} The sass module or false if not available
167
+ */
168
+ export function tryLoadSassSync() {
169
+ // Return cached result if already checked
170
+ if (sassModule !== null) {
171
+ return sassModule;
172
+ }
173
+
174
+ try {
175
+ // Use createRequire to load sass synchronously from the user's project
176
+ const require = createRequire(import.meta.url);
177
+ sassModule = require('sass');
178
+ return sassModule;
179
+ } catch {
180
+ // sass not installed in user's project
181
+ sassModule = false;
182
+ return false;
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Try to load the sass module from the user's project (async version)
188
+ * @returns {Promise<object|false>} The sass module or false if not available
189
+ */
190
+ export async function tryLoadSass() {
191
+ // Return cached result if already checked
192
+ if (sassModule !== null) {
193
+ return sassModule;
194
+ }
195
+
196
+ try {
197
+ // Try to import sass from the user's project
198
+ sassModule = await import('sass');
199
+ return sassModule;
200
+ } catch {
201
+ // Fall back to sync require
202
+ return tryLoadSassSync();
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Compile SASS/SCSS to CSS (sync version)
208
+ * @param {string} scss - SCSS source code
209
+ * @param {object} options - Compilation options
210
+ * @param {string} [options.filename] - Source filename for error messages
211
+ * @param {string[]} [options.loadPaths] - Paths to search for @use/@import
212
+ * @param {boolean} [options.sourceMap] - Generate source map
213
+ * @param {boolean} [options.compressed] - Compress output
214
+ * @returns {{css: string, sourceMap?: object}|null} Compiled CSS or null if sass unavailable
215
+ */
216
+ export function compileSassSync(scss, options = {}) {
217
+ const sass = tryLoadSassSync();
218
+
219
+ if (!sass) {
220
+ return null;
221
+ }
222
+
223
+ try {
224
+ const result = sass.compileString(scss, {
225
+ syntax: 'scss',
226
+ loadPaths: options.loadPaths || [],
227
+ sourceMap: options.sourceMap || false,
228
+ sourceMapIncludeSources: true,
229
+ style: options.compressed ? 'compressed' : 'expanded',
230
+ // Silence deprecation warnings for @import
231
+ silenceDeprecations: ['import'],
232
+ });
233
+
234
+ return {
235
+ css: result.css,
236
+ sourceMap: result.sourceMap || null
237
+ };
238
+ } catch (error) {
239
+ // Re-throw with better context
240
+ const sassError = new Error(`SASS compilation error: ${error.message}`);
241
+ sassError.line = error.span?.start?.line;
242
+ sassError.column = error.span?.start?.column;
243
+ sassError.file = options.filename;
244
+ sassError.cause = error;
245
+ throw sassError;
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Compile SASS/SCSS to CSS (async version)
251
+ * @param {string} scss - SCSS source code
252
+ * @param {object} options - Compilation options
253
+ * @returns {Promise<{css: string, sourceMap?: object}|null>} Compiled CSS or null if sass unavailable
254
+ */
255
+ export async function compileSass(scss, options = {}) {
256
+ const sass = await tryLoadSass();
257
+
258
+ if (!sass) {
259
+ return null;
260
+ }
261
+
262
+ try {
263
+ // Use compileStringAsync if available (dart-sass)
264
+ const compileFunc = sass.compileStringAsync || sass.compileString;
265
+ const result = await Promise.resolve(compileFunc.call(sass, scss, {
266
+ syntax: 'scss',
267
+ loadPaths: options.loadPaths || [],
268
+ sourceMap: options.sourceMap || false,
269
+ sourceMapIncludeSources: true,
270
+ style: options.compressed ? 'compressed' : 'expanded',
271
+ silenceDeprecations: ['import'],
272
+ }));
273
+
274
+ return {
275
+ css: result.css,
276
+ sourceMap: result.sourceMap || null
277
+ };
278
+ } catch (error) {
279
+ const sassError = new Error(`SASS compilation error: ${error.message}`);
280
+ sassError.line = error.span?.start?.line;
281
+ sassError.column = error.span?.start?.column;
282
+ sassError.file = options.filename;
283
+ sassError.cause = error;
284
+ throw sassError;
285
+ }
286
+ }
287
+
288
+ // Note: preprocessStylesSync and preprocessStyles are defined later in the file
289
+ // with support for both SASS and LESS auto-detection
290
+
291
+ /**
292
+ * Check if sass package is available in user's project
293
+ * @returns {boolean}
294
+ */
295
+ export function isSassAvailable() {
296
+ const sass = tryLoadSassSync();
297
+ return sass !== false;
298
+ }
299
+
300
+ /**
301
+ * Check if sass package is available (async)
302
+ * @returns {Promise<boolean>}
303
+ */
304
+ export async function isSassAvailableAsync() {
305
+ const sass = await tryLoadSass();
306
+ return sass !== false;
307
+ }
308
+
309
+ /**
310
+ * Get sass version if available
311
+ * @returns {string|null}
312
+ */
313
+ export function getSassVersion() {
314
+ const sass = tryLoadSassSync();
315
+ if (sass && sass.info) {
316
+ // sass.info is a string like "dart-sass\t1.77.0"
317
+ const match = sass.info.match(/(\d+\.\d+\.\d+)/);
318
+ return match ? match[1] : null;
319
+ }
320
+ return null;
321
+ }
322
+
323
+ /**
324
+ * Reset the cached sass module (for testing)
325
+ */
326
+ export function resetSassCache() {
327
+ sassModule = null;
328
+ }
329
+
330
+ // ===== LESS SUPPORT =====
331
+
332
+ /**
333
+ * Try to load the less module from the user's project (sync version)
334
+ * @returns {object|false} The less module or false if not available
335
+ */
336
+ export function tryLoadLessSync() {
337
+ // Return cached result if already checked
338
+ if (lessModule !== null) {
339
+ return lessModule;
340
+ }
341
+
342
+ try {
343
+ const require = createRequire(import.meta.url);
344
+ lessModule = require('less');
345
+ return lessModule;
346
+ } catch {
347
+ // less not installed in user's project
348
+ lessModule = false;
349
+ return false;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Try to load the less module from the user's project (async version)
355
+ * @returns {Promise<object|false>} The less module or false if not available
356
+ */
357
+ export async function tryLoadLess() {
358
+ // Return cached result if already checked
359
+ if (lessModule !== null) {
360
+ return lessModule;
361
+ }
362
+
363
+ try {
364
+ lessModule = await import('less');
365
+ return lessModule;
366
+ } catch {
367
+ // Fall back to sync require
368
+ return tryLoadLessSync();
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Compile LESS to CSS (async - LESS is async-only)
374
+ * @param {string} less - LESS source code
375
+ * @param {object} options - Compilation options
376
+ * @param {string} [options.filename] - Source filename for error messages
377
+ * @param {string[]} [options.loadPaths] - Paths to search for @import
378
+ * @param {boolean} [options.sourceMap] - Generate source map
379
+ * @param {boolean} [options.compressed] - Compress output
380
+ * @returns {Promise<{css: string, sourceMap?: object}|null>} Compiled CSS or null if less unavailable
381
+ */
382
+ export async function compileLess(less, options = {}) {
383
+ const lessModule = await tryLoadLess();
384
+
385
+ if (!lessModule) {
386
+ return null;
387
+ }
388
+
389
+ try {
390
+ const result = await lessModule.render(less, {
391
+ filename: options.filename || 'input.less',
392
+ paths: options.loadPaths || [],
393
+ sourceMap: options.sourceMap ? {} : undefined,
394
+ compress: options.compressed || false,
395
+ strictMath: false, // Allow math without parentheses
396
+ strictUnits: false,
397
+ });
398
+
399
+ return {
400
+ css: result.css,
401
+ sourceMap: result.map ? JSON.parse(result.map) : null
402
+ };
403
+ } catch (error) {
404
+ // Re-throw with better context
405
+ const lessError = new Error(`LESS compilation error: ${error.message}`);
406
+ lessError.line = error.line;
407
+ lessError.column = error.column;
408
+ lessError.file = options.filename || error.filename;
409
+ lessError.cause = error;
410
+ throw lessError;
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Compile LESS to CSS (sync version - NOT RECOMMENDED)
416
+ * Note: LESS is fundamentally async. This function returns null in sync contexts.
417
+ * Use compileLess() (async) instead for proper LESS compilation.
418
+ * @param {string} _less - LESS source code (unused in sync context)
419
+ * @param {object} _options - Compilation options (unused in sync context)
420
+ * @returns {{css: string, sourceMap?: object}|null} Compiled CSS or null (LESS requires async)
421
+ */
422
+ export function compileLessSync(_less, _options = {}) {
423
+ const lessModule = tryLoadLessSync();
424
+
425
+ if (!lessModule) {
426
+ return null;
427
+ }
428
+
429
+ // LESS is fundamentally async and has no true sync API
430
+ // Return null to indicate compilation not possible in sync context
431
+ // The preprocessStylesSync will fall back to returning original CSS
432
+ console.warn('[Pulse] LESS compilation requires async context. Use preprocessStyles() instead of preprocessStylesSync()');
433
+ return null;
434
+ }
435
+
436
+ /**
437
+ * Check if less package is available in user's project
438
+ * @returns {boolean}
439
+ */
440
+ export function isLessAvailable() {
441
+ const less = tryLoadLessSync();
442
+ return less !== false;
443
+ }
444
+
445
+ /**
446
+ * Check if less package is available (async)
447
+ * @returns {Promise<boolean>}
448
+ */
449
+ export async function isLessAvailableAsync() {
450
+ const less = await tryLoadLess();
451
+ return less !== false;
452
+ }
453
+
454
+ /**
455
+ * Get less version if available
456
+ * @returns {string|null}
457
+ */
458
+ export function getLessVersion() {
459
+ const less = tryLoadLessSync();
460
+ if (less && less.version) {
461
+ return Array.isArray(less.version) ? less.version.join('.') : less.version;
462
+ }
463
+ return null;
464
+ }
465
+
466
+ /**
467
+ * Reset the cached less module (for testing)
468
+ */
469
+ export function resetLessCache() {
470
+ lessModule = null;
471
+ }
472
+
473
+ // ===== STYLUS SUPPORT =====
474
+
475
+ /**
476
+ * Try to load the stylus module from the user's project (sync version)
477
+ * @returns {object|false} The stylus module or false if not available
478
+ */
479
+ export function tryLoadStylusSync() {
480
+ // Return cached result if already checked
481
+ if (stylusModule !== null) {
482
+ return stylusModule;
483
+ }
484
+
485
+ try {
486
+ const require = createRequire(import.meta.url);
487
+ stylusModule = require('stylus');
488
+ return stylusModule;
489
+ } catch {
490
+ // stylus not installed in user's project
491
+ stylusModule = false;
492
+ return false;
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Try to load the stylus module from the user's project (async version)
498
+ * @returns {Promise<object|false>} The stylus module or false if not available
499
+ */
500
+ export async function tryLoadStylus() {
501
+ // Return cached result if already checked
502
+ if (stylusModule !== null) {
503
+ return stylusModule;
504
+ }
505
+
506
+ try {
507
+ stylusModule = await import('stylus');
508
+ return stylusModule;
509
+ } catch {
510
+ // Fall back to sync require
511
+ return tryLoadStylusSync();
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Compile Stylus to CSS (async)
517
+ * @param {string} stylus - Stylus source code
518
+ * @param {object} options - Compilation options
519
+ * @param {string} [options.filename] - Source filename for error messages
520
+ * @param {string[]} [options.loadPaths] - Paths to search for @import/@require
521
+ * @param {boolean} [options.sourceMap] - Generate source map
522
+ * @param {boolean} [options.compressed] - Compress output
523
+ * @returns {Promise<{css: string, sourceMap?: object}|null>} Compiled CSS or null if stylus unavailable
524
+ */
525
+ export async function compileStylus(stylus, options = {}) {
526
+ const stylusModule = await tryLoadStylus();
527
+
528
+ if (!stylusModule) {
529
+ return null;
530
+ }
531
+
532
+ return new Promise((resolve, reject) => {
533
+ try {
534
+ const renderer = stylusModule(stylus)
535
+ .set('filename', options.filename || 'input.styl')
536
+ .set('compress', options.compressed || false);
537
+
538
+ // Add load paths
539
+ if (options.loadPaths && options.loadPaths.length > 0) {
540
+ options.loadPaths.forEach(path => renderer.include(path));
541
+ }
542
+
543
+ // Enable source maps if requested
544
+ if (options.sourceMap) {
545
+ renderer.set('sourcemap', {});
546
+ }
547
+
548
+ renderer.render((err, css) => {
549
+ if (err) {
550
+ const stylusError = new Error(`Stylus compilation error: ${err.message}`);
551
+ stylusError.line = err.line;
552
+ stylusError.column = err.column;
553
+ stylusError.file = options.filename || err.filename;
554
+ stylusError.cause = err;
555
+ reject(stylusError);
556
+ } else {
557
+ resolve({
558
+ css,
559
+ sourceMap: renderer.sourcemap || null
560
+ });
561
+ }
562
+ });
563
+ } catch (error) {
564
+ const stylusError = new Error(`Stylus compilation error: ${error.message}`);
565
+ stylusError.file = options.filename;
566
+ stylusError.cause = error;
567
+ reject(stylusError);
568
+ }
569
+ });
570
+ }
571
+
572
+ /**
573
+ * Compile Stylus to CSS (sync version)
574
+ * @param {string} stylus - Stylus source code
575
+ * @param {object} options - Compilation options
576
+ * @param {string} [options.filename] - Source filename for error messages
577
+ * @param {string[]} [options.loadPaths] - Paths to search for @import/@require
578
+ * @param {boolean} [options.sourceMap] - Generate source map
579
+ * @param {boolean} [options.compressed] - Compress output
580
+ * @returns {{css: string, sourceMap?: object}|null} Compiled CSS or null if stylus unavailable
581
+ */
582
+ export function compileStylusSync(stylus, options = {}) {
583
+ const stylusModule = tryLoadStylusSync();
584
+
585
+ if (!stylusModule) {
586
+ return null;
587
+ }
588
+
589
+ try {
590
+ const renderer = stylusModule(stylus)
591
+ .set('filename', options.filename || 'input.styl')
592
+ .set('compress', options.compressed || false);
593
+
594
+ // Add load paths
595
+ if (options.loadPaths && options.loadPaths.length > 0) {
596
+ options.loadPaths.forEach(path => renderer.include(path));
597
+ }
598
+
599
+ // Enable source maps if requested
600
+ if (options.sourceMap) {
601
+ renderer.set('sourcemap', {});
602
+ }
603
+
604
+ const css = renderer.render();
605
+
606
+ return {
607
+ css,
608
+ sourceMap: renderer.sourcemap || null
609
+ };
610
+ } catch (error) {
611
+ // Re-throw with better context
612
+ const stylusError = new Error(`Stylus compilation error: ${error.message}`);
613
+ stylusError.line = error.line;
614
+ stylusError.column = error.column;
615
+ stylusError.file = options.filename || error.filename;
616
+ stylusError.cause = error;
617
+ throw stylusError;
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Check if stylus package is available in user's project
623
+ * @returns {boolean}
624
+ */
625
+ export function isStylusAvailable() {
626
+ const stylus = tryLoadStylusSync();
627
+ return stylus !== false;
628
+ }
629
+
630
+ /**
631
+ * Check if stylus package is available (async)
632
+ * @returns {Promise<boolean>}
633
+ */
634
+ export async function isStylusAvailableAsync() {
635
+ const stylus = await tryLoadStylus();
636
+ return stylus !== false;
637
+ }
638
+
639
+ /**
640
+ * Get stylus version if available
641
+ * @returns {string|null}
642
+ */
643
+ export function getStylusVersion() {
644
+ const stylus = tryLoadStylusSync();
645
+ if (stylus && stylus.version) {
646
+ return stylus.version;
647
+ }
648
+ return null;
649
+ }
650
+
651
+ /**
652
+ * Reset the cached stylus module (for testing)
653
+ */
654
+ export function resetStylusCache() {
655
+ stylusModule = null;
656
+ }
657
+
658
+ /**
659
+ * Preprocess CSS - auto-detect and compile SASS, LESS, or Stylus if detected (sync)
660
+ * Falls back to returning original CSS if preprocessor is not available
661
+ * @param {string} css - CSS, SCSS, LESS, or Stylus source
662
+ * @param {object} options - Options
663
+ * @param {string} [options.filename] - Source filename
664
+ * @param {string[]} [options.loadPaths] - Paths for @use/@import/@require
665
+ * @param {boolean} [options.forceCompile] - Compile even if no syntax detected
666
+ * @param {'auto'|'sass'|'less'|'stylus'} [options.preprocessor] - Force specific preprocessor
667
+ * @returns {{css: string, preprocessor: 'sass'|'less'|'stylus'|'none', sourceMap?: object}}
668
+ */
669
+ export function preprocessStylesSync(css, options = {}) {
670
+ // Determine which preprocessor to use
671
+ let preprocessor = options.preprocessor || 'auto';
672
+
673
+ if (preprocessor === 'auto') {
674
+ preprocessor = detectPreprocessor(css);
675
+ }
676
+
677
+ // Try SASS compilation
678
+ if (preprocessor === 'sass') {
679
+ const result = compileSassSync(css, options);
680
+ if (result) {
681
+ return {
682
+ css: result.css,
683
+ preprocessor: 'sass',
684
+ sourceMap: result.sourceMap
685
+ };
686
+ }
687
+ }
688
+
689
+ // Try LESS compilation
690
+ if (preprocessor === 'less') {
691
+ const result = compileLessSync(css, options);
692
+ if (result) {
693
+ return {
694
+ css: result.css,
695
+ preprocessor: 'less',
696
+ sourceMap: result.sourceMap
697
+ };
698
+ }
699
+ }
700
+
701
+ // Try Stylus compilation
702
+ if (preprocessor === 'stylus') {
703
+ const result = compileStylusSync(css, options);
704
+ if (result) {
705
+ return {
706
+ css: result.css,
707
+ preprocessor: 'stylus',
708
+ sourceMap: result.sourceMap
709
+ };
710
+ }
711
+ }
712
+
713
+ // No preprocessor detected or available
714
+ return { css, preprocessor: 'none' };
715
+ }
716
+
717
+ /**
718
+ * Preprocess CSS - auto-detect and compile SASS, LESS, or Stylus if detected (async)
719
+ * @param {string} css - CSS, SCSS, LESS, or Stylus source
720
+ * @param {object} options - Options
721
+ * @returns {Promise<{css: string, preprocessor: 'sass'|'less'|'stylus'|'none', sourceMap?: object}>}
722
+ */
723
+ export async function preprocessStyles(css, options = {}) {
724
+ // Determine which preprocessor to use
725
+ let preprocessor = options.preprocessor || 'auto';
726
+
727
+ if (preprocessor === 'auto') {
728
+ preprocessor = detectPreprocessor(css);
729
+ }
730
+
731
+ // Try SASS compilation
732
+ if (preprocessor === 'sass') {
733
+ const result = await compileSass(css, options);
734
+ if (result) {
735
+ return {
736
+ css: result.css,
737
+ preprocessor: 'sass',
738
+ sourceMap: result.sourceMap
739
+ };
740
+ }
741
+ }
742
+
743
+ // Try LESS compilation
744
+ if (preprocessor === 'less') {
745
+ const result = await compileLess(css, options);
746
+ if (result) {
747
+ return {
748
+ css: result.css,
749
+ preprocessor: 'less',
750
+ sourceMap: result.sourceMap
751
+ };
752
+ }
753
+ }
754
+
755
+ // Try Stylus compilation
756
+ if (preprocessor === 'stylus') {
757
+ const result = await compileStylus(css, options);
758
+ if (result) {
759
+ return {
760
+ css: result.css,
761
+ preprocessor: 'stylus',
762
+ sourceMap: result.sourceMap
763
+ };
764
+ }
765
+ }
766
+
767
+ // No preprocessor detected or available
768
+ return { css, preprocessor: 'none' };
769
+ }
770
+
771
+ /**
772
+ * Reset all preprocessor caches (for testing)
773
+ */
774
+ export function resetPreprocessorCaches() {
775
+ resetSassCache();
776
+ resetLessCache();
777
+ resetStylusCache();
778
+ }
779
+
780
+ export default {
781
+ // SASS
782
+ hasSassSyntax,
783
+ tryLoadSass,
784
+ tryLoadSassSync,
785
+ compileSass,
786
+ compileSassSync,
787
+ isSassAvailable,
788
+ isSassAvailableAsync,
789
+ getSassVersion,
790
+ resetSassCache,
791
+
792
+ // LESS
793
+ hasLessSyntax,
794
+ tryLoadLess,
795
+ tryLoadLessSync,
796
+ compileLess,
797
+ compileLessSync,
798
+ isLessAvailable,
799
+ isLessAvailableAsync,
800
+ getLessVersion,
801
+ resetLessCache,
802
+
803
+ // Stylus
804
+ hasStylusSyntax,
805
+ tryLoadStylus,
806
+ tryLoadStylusSync,
807
+ compileStylus,
808
+ compileStylusSync,
809
+ isStylusAvailable,
810
+ isStylusAvailableAsync,
811
+ getStylusVersion,
812
+ resetStylusCache,
813
+
814
+ // Auto-detect
815
+ detectPreprocessor,
816
+ preprocessStyles,
817
+ preprocessStylesSync,
818
+ resetPreprocessorCaches
819
+ };
@@ -4,11 +4,17 @@
4
4
  * Enables .pulse file support in Vite projects
5
5
  * Extracts CSS to virtual .css modules so Vite's CSS pipeline handles them
6
6
  * (prevents JS minifier from corrupting CSS in template literals)
7
+ *
8
+ * SASS/SCSS Support:
9
+ * - If `sass` is installed in the user's project, SCSS syntax in style blocks
10
+ * is automatically compiled before being passed to Vite's CSS pipeline
11
+ * - No configuration needed - just install sass: `npm install -D sass`
7
12
  */
8
13
 
9
14
  import { compile } from '../compiler/index.js';
10
15
  import { existsSync } from 'fs';
11
16
  import { resolve, dirname } from 'path';
17
+ import { preprocessStylesSync, isSassAvailable, getSassVersion } from '../compiler/preprocessor.js';
12
18
 
13
19
  // Virtual module ID for extracted CSS (uses .css extension so Vite treats it as CSS)
14
20
  const VIRTUAL_CSS_SUFFIX = '.pulse.css';
@@ -19,16 +25,37 @@ const VIRTUAL_CSS_SUFFIX = '.pulse.css';
19
25
  export default function pulsePlugin(options = {}) {
20
26
  const {
21
27
  exclude = /node_modules/,
22
- sourceMap = true
28
+ sourceMap = true,
29
+ // SASS options
30
+ sass: sassOptions = {}
23
31
  } = options;
24
32
 
25
33
  // Store extracted CSS for each .pulse module
26
34
  const cssMap = new Map();
27
35
 
36
+ // Check for sass availability once at startup
37
+ let sassAvailable = false;
38
+ let sassVersion = null;
39
+
28
40
  return {
29
41
  name: 'vite-plugin-pulse',
30
42
  enforce: 'pre',
31
43
 
44
+ /**
45
+ * Log sass availability on build start
46
+ */
47
+ buildStart() {
48
+ // Clear CSS map on new build
49
+ cssMap.clear();
50
+
51
+ // Check sass availability
52
+ sassAvailable = isSassAvailable();
53
+ if (sassAvailable) {
54
+ sassVersion = getSassVersion();
55
+ console.log(`[Pulse] SASS support enabled (sass ${sassVersion || 'unknown'})`);
56
+ }
57
+ },
58
+
32
59
  /**
33
60
  * Resolve .pulse files and virtual CSS modules
34
61
  */
@@ -107,9 +134,27 @@ export default function pulsePlugin(options = {}) {
107
134
  // Extract CSS from compiled output and move to virtual CSS module
108
135
  const stylesMatch = outputCode.match(/const styles = `([\s\S]*?)`;/);
109
136
  if (stylesMatch) {
110
- const css = stylesMatch[1];
137
+ let css = stylesMatch[1];
111
138
  const virtualCssId = id + '.css';
112
139
 
140
+ // Preprocess SASS/SCSS if detected and sass is available
141
+ if (sassAvailable) {
142
+ try {
143
+ const preprocessed = preprocessStylesSync(css, {
144
+ filename: id,
145
+ loadPaths: [dirname(id), ...(sassOptions.loadPaths || [])],
146
+ compressed: sassOptions.compressed || false
147
+ });
148
+
149
+ if (preprocessed.wasSass) {
150
+ css = preprocessed.css;
151
+ }
152
+ } catch (sassError) {
153
+ this.warn(`SASS compilation warning in ${id}: ${sassError.message}`);
154
+ // Continue with original CSS if SASS fails
155
+ }
156
+ }
157
+
113
158
  // Store CSS for the virtual module loader
114
159
  cssMap.set(id, css);
115
160
 
@@ -168,21 +213,21 @@ export default function pulsePlugin(options = {}) {
168
213
  },
169
214
 
170
215
  /**
171
- * Configure dev server
216
+ * Configure dev server - log sass status on start
172
217
  */
173
218
  configureServer(server) {
219
+ // Check sass on server start if not already checked
220
+ if (!sassAvailable) {
221
+ sassAvailable = isSassAvailable();
222
+ if (sassAvailable) {
223
+ sassVersion = getSassVersion();
224
+ console.log(`[Pulse] SASS support enabled (sass ${sassVersion || 'unknown'})`);
225
+ }
226
+ }
227
+
174
228
  server.middlewares.use((_req, _res, next) => {
175
- // Add any custom middleware here
176
229
  next();
177
230
  });
178
- },
179
-
180
- /**
181
- * Build hooks
182
- */
183
- buildStart() {
184
- // Clear CSS map on new build
185
- cssMap.clear();
186
231
  }
187
232
  };
188
233
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.7.31",
3
+ "version": "1.7.32",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -88,6 +88,7 @@
88
88
  "./compiler/lexer": "./compiler/lexer.js",
89
89
  "./compiler/parser": "./compiler/parser.js",
90
90
  "./compiler/transformer": "./compiler/transformer.js",
91
+ "./compiler/preprocessor": "./compiler/preprocessor.js",
91
92
  "./core/errors": "./runtime/errors.js",
92
93
  "./vite": {
93
94
  "types": "./types/index.d.ts",
@@ -109,10 +110,11 @@
109
110
  "LICENSE"
110
111
  ],
111
112
  "scripts": {
112
- "test": "npm run test:compiler && npm run test:sourcemap && npm run test:css-parsing && npm run test:pulse && npm run test:dom && npm run test:dom-element && npm run test:dom-list && npm run test:dom-conditional && npm run test:dom-lifecycle && npm run test:dom-selector && npm run test:dom-adapter && npm run test:dom-advanced && npm run test:enhanced-mock-adapter && npm run test:router && npm run test:store && npm run test:context && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:cli && npm run test:cli-ui && npm run test:cli-create && npm run test:lru-cache && npm run test:utils && npm run test:utils-coverage && npm run test:docs && npm run test:docs-nav && npm run test:async && npm run test:async-coverage && npm run test:form && npm run test:http && npm run test:devtools && npm run test:native && npm run test:a11y && npm run test:a11y-enhanced && npm run test:logger && npm run test:logger-prod && npm run test:errors && npm run test:security && npm run test:websocket && npm run test:graphql && npm run test:graphql-coverage && npm run test:doctor && npm run test:scaffold && npm run test:test-runner && npm run test:build && npm run test:integration && npm run test:context-stress && npm run test:form-edge-cases && npm run test:graphql-subscriptions && npm run test:http-edge-cases && npm run test:integration-advanced && npm run test:websocket-stress && npm run test:ssr && npm run test:ssr-hydrator",
113
+ "test": "npm run test:compiler && npm run test:sourcemap && npm run test:css-parsing && npm run test:preprocessor && npm run test:pulse && npm run test:dom && npm run test:dom-element && npm run test:dom-list && npm run test:dom-conditional && npm run test:dom-lifecycle && npm run test:dom-selector && npm run test:dom-adapter && npm run test:dom-advanced && npm run test:enhanced-mock-adapter && npm run test:router && npm run test:store && npm run test:context && npm run test:hmr && npm run test:lint && npm run test:format && npm run test:analyze && npm run test:cli && npm run test:cli-ui && npm run test:cli-create && npm run test:lru-cache && npm run test:utils && npm run test:utils-coverage && npm run test:docs && npm run test:docs-nav && npm run test:async && npm run test:async-coverage && npm run test:form && npm run test:http && npm run test:devtools && npm run test:native && npm run test:a11y && npm run test:a11y-enhanced && npm run test:logger && npm run test:logger-prod && npm run test:errors && npm run test:security && npm run test:websocket && npm run test:graphql && npm run test:graphql-coverage && npm run test:doctor && npm run test:scaffold && npm run test:test-runner && npm run test:build && npm run test:integration && npm run test:context-stress && npm run test:form-edge-cases && npm run test:graphql-subscriptions && npm run test:http-edge-cases && npm run test:integration-advanced && npm run test:websocket-stress && npm run test:ssr && npm run test:ssr-hydrator",
113
114
  "test:compiler": "node test/compiler.test.js",
114
115
  "test:sourcemap": "node test/sourcemap.test.js",
115
116
  "test:css-parsing": "node test/css-parsing.test.js",
117
+ "test:preprocessor": "node test/preprocessor.test.js",
116
118
  "test:pulse": "node test/pulse.test.js",
117
119
  "test:dom": "node test/dom.test.js",
118
120
  "test:dom-element": "node test/dom-element.test.js",