@lass-lang/core 0.0.1
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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/dist/constants.d.ts +32 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +32 -0
- package/dist/constants.js.map +1 -0
- package/dist/context-tracker.d.ts +46 -0
- package/dist/context-tracker.d.ts.map +1 -0
- package/dist/context-tracker.js +76 -0
- package/dist/context-tracker.js.map +1 -0
- package/dist/errors.d.ts +80 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +81 -0
- package/dist/errors.js.map +1 -0
- package/dist/helpers.d.ts +24 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +32 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +82 -0
- package/dist/index.js.map +1 -0
- package/dist/scanner.d.ts +326 -0
- package/dist/scanner.d.ts.map +1 -0
- package/dist/scanner.js +815 -0
- package/dist/scanner.js.map +1 -0
- package/dist/scope-tracker.d.ts +160 -0
- package/dist/scope-tracker.d.ts.map +1 -0
- package/dist/scope-tracker.js +376 -0
- package/dist/scope-tracker.js.map +1 -0
- package/dist/transpiler.d.ts +153 -0
- package/dist/transpiler.d.ts.map +1 -0
- package/dist/transpiler.js +650 -0
- package/dist/transpiler.js.map +1 -0
- package/dist/types.d.ts +55 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -0
package/dist/scanner.js
ADDED
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-pass text scanner for Lass language.
|
|
3
|
+
*
|
|
4
|
+
* The scanner processes input in a single pass. It does NOT parse CSS - it
|
|
5
|
+
* only scans for Lass symbols within CSS text.
|
|
6
|
+
*
|
|
7
|
+
* Story 1.4: Passthrough-only
|
|
8
|
+
* Story 2.1: Zone detection (--- separator)
|
|
9
|
+
*/
|
|
10
|
+
import { LassTranspileError, ErrorCategory } from './errors.js';
|
|
11
|
+
import { createContextState, updateContextState, isInProtectedContext } from './context-tracker.js';
|
|
12
|
+
/**
|
|
13
|
+
* Single-pass text scanner for Lass language.
|
|
14
|
+
*
|
|
15
|
+
* Story 1.4: Passthrough mode - returns input unchanged
|
|
16
|
+
* Story 2.1: Zone detection - finds --- separator, splits into preamble/CSS
|
|
17
|
+
* Story 3.2: @(prop) detection - finds property accessors in value position
|
|
18
|
+
*
|
|
19
|
+
* Future implementations will:
|
|
20
|
+
* - Detect $name, $(name), {{ expr }}, @{ } symbols
|
|
21
|
+
* - Track context to skip symbols inside strings, urls, and comments
|
|
22
|
+
*/
|
|
23
|
+
export class Scanner {
|
|
24
|
+
source;
|
|
25
|
+
constructor(source, _options = {}) {
|
|
26
|
+
this.source = source;
|
|
27
|
+
// Note: options.filename is accepted for API compatibility but not yet used
|
|
28
|
+
// Future: include filename in error messages for better debugging
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Finds the --- separator and splits source into preamble and CSS zones.
|
|
32
|
+
*
|
|
33
|
+
* Story 2.1: Zone detection
|
|
34
|
+
*
|
|
35
|
+
* Rules:
|
|
36
|
+
* - Separator must be exactly "---" at column 0 (start of line)
|
|
37
|
+
* - May have trailing whitespace
|
|
38
|
+
* - Must NOT be inside a multi-line comment (slash-star ... star-slash)
|
|
39
|
+
* - Only one separator allowed per file
|
|
40
|
+
*
|
|
41
|
+
* Note: Line endings are normalized to \n during processing.
|
|
42
|
+
* When no separator is found, cssZone returns the original source unchanged.
|
|
43
|
+
*
|
|
44
|
+
* @returns Zone split result with preamble, cssZone, and hasSeparator
|
|
45
|
+
* @throws LassTranspileError if multiple separators found
|
|
46
|
+
*/
|
|
47
|
+
findSeparator() {
|
|
48
|
+
// Normalize line endings to \n for consistent processing
|
|
49
|
+
const normalized = this.source.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
50
|
+
const lines = normalized.split('\n');
|
|
51
|
+
let separatorLineIndex = -1;
|
|
52
|
+
for (let i = 0; i < lines.length; i++) {
|
|
53
|
+
const line = lines[i];
|
|
54
|
+
// Check if this line is the separator (only if not inside a block comment)
|
|
55
|
+
if (!this.isInBlockComment(lines, i) && this.isSeparatorLine(line)) {
|
|
56
|
+
if (separatorLineIndex !== -1) {
|
|
57
|
+
// Multiple separators found
|
|
58
|
+
throw LassTranspileError.at('Multiple --- separators found. Only one is allowed per file.', ErrorCategory.SCAN, i + 1, 1, this.getOffset(lines, i));
|
|
59
|
+
}
|
|
60
|
+
separatorLineIndex = i;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (separatorLineIndex === -1) {
|
|
64
|
+
// No separator - entire file is CSS zone
|
|
65
|
+
return {
|
|
66
|
+
preamble: '',
|
|
67
|
+
cssZone: this.source,
|
|
68
|
+
hasSeparator: false,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Split at separator
|
|
72
|
+
const preambleLines = lines.slice(0, separatorLineIndex);
|
|
73
|
+
const cssLines = lines.slice(separatorLineIndex + 1);
|
|
74
|
+
return {
|
|
75
|
+
preamble: preambleLines.join('\n'),
|
|
76
|
+
cssZone: cssLines.join('\n'),
|
|
77
|
+
hasSeparator: true,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Checks if a line is the --- separator.
|
|
82
|
+
* Must be exactly "---" with optional trailing whitespace.
|
|
83
|
+
*/
|
|
84
|
+
isSeparatorLine(line) {
|
|
85
|
+
// Must start with exactly "---" (no leading whitespace)
|
|
86
|
+
// May have trailing whitespace
|
|
87
|
+
return /^---\s*$/.test(line);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Checks if line at index is inside a block comment.
|
|
91
|
+
* Scans from start of file to determine comment state at the START of the line.
|
|
92
|
+
*/
|
|
93
|
+
isInBlockComment(lines, targetLineIndex) {
|
|
94
|
+
let inComment = false;
|
|
95
|
+
for (let i = 0; i < targetLineIndex; i++) {
|
|
96
|
+
const line = lines[i];
|
|
97
|
+
let j = 0;
|
|
98
|
+
while (j < line.length) {
|
|
99
|
+
if (inComment) {
|
|
100
|
+
const endIdx = line.indexOf('*/', j);
|
|
101
|
+
if (endIdx !== -1) {
|
|
102
|
+
inComment = false;
|
|
103
|
+
j = endIdx + 2;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const startIdx = line.indexOf('/*', j);
|
|
111
|
+
if (startIdx !== -1) {
|
|
112
|
+
inComment = true;
|
|
113
|
+
j = startIdx + 2;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Also check if comment started on a previous line and extends to current line
|
|
122
|
+
// At this point, inComment tells us if we're in a comment at the START of targetLineIndex
|
|
123
|
+
return inComment;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Calculates character offset for a given line.
|
|
127
|
+
*/
|
|
128
|
+
getOffset(lines, lineIndex) {
|
|
129
|
+
let offset = 0;
|
|
130
|
+
for (let i = 0; i < lineIndex; i++) {
|
|
131
|
+
offset += lines[i].length + 1; // +1 for newline
|
|
132
|
+
}
|
|
133
|
+
return offset;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Scans the input and returns processed CSS.
|
|
137
|
+
*
|
|
138
|
+
* In passthrough mode (Story 1.4), this returns the input unchanged.
|
|
139
|
+
*
|
|
140
|
+
* @returns Scan result with processed CSS
|
|
141
|
+
*/
|
|
142
|
+
scan() {
|
|
143
|
+
// Story 1.4: Passthrough mode - return input unchanged
|
|
144
|
+
return {
|
|
145
|
+
css: this.source,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Finds {{ expr }} expressions in CSS zone and splits into alternating parts.
|
|
150
|
+
*
|
|
151
|
+
* Story 2.3: Expression interpolation
|
|
152
|
+
* Story 2.5: Universal {{ }} processing - works EVERYWHERE in CSS zone
|
|
153
|
+
*
|
|
154
|
+
* Returns alternating CSS chunks and JS expressions:
|
|
155
|
+
* - [css, expr, css, expr, css] - always starts and ends with CSS (possibly empty)
|
|
156
|
+
* - Expression content is trimmed of leading/trailing whitespace
|
|
157
|
+
*
|
|
158
|
+
* Handles nested braces in expressions (e.g., {{ fn({x:1}) }}) by tracking brace depth.
|
|
159
|
+
*
|
|
160
|
+
* Universal processing: {{ }} is detected and processed in ALL contexts:
|
|
161
|
+
* - Value position: `color: {{ x }};`
|
|
162
|
+
* - Inside strings: `content: "Hello {{ name }}!";`
|
|
163
|
+
* - Inside url(): `background: url("{{ path }}.jpg");`
|
|
164
|
+
* - Inside comments: `/* Version: {{ version }} */`
|
|
165
|
+
*
|
|
166
|
+
* @param cssZone - The CSS zone content to scan
|
|
167
|
+
* @returns ExpressionSplit with parts and expression positions
|
|
168
|
+
* @throws LassTranspileError for empty or unclosed expressions
|
|
169
|
+
*/
|
|
170
|
+
findExpressions(cssZone) {
|
|
171
|
+
const parts = [];
|
|
172
|
+
const expressionPositions = [];
|
|
173
|
+
let currentPos = 0;
|
|
174
|
+
let cssStart = 0;
|
|
175
|
+
while (currentPos < cssZone.length) {
|
|
176
|
+
// Find next {{ using simple indexOf - process everywhere in CSS zone
|
|
177
|
+
const openPos = cssZone.indexOf('{{', currentPos);
|
|
178
|
+
if (openPos === -1) {
|
|
179
|
+
// No more expressions - add remaining CSS
|
|
180
|
+
parts.push(cssZone.slice(cssStart));
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
// Found {{ - add CSS chunk before it
|
|
184
|
+
parts.push(cssZone.slice(cssStart, openPos));
|
|
185
|
+
expressionPositions.push(openPos);
|
|
186
|
+
// Find matching }} with brace depth tracking
|
|
187
|
+
const exprStart = openPos + 2;
|
|
188
|
+
let braceDepth = 0;
|
|
189
|
+
let closePos = -1;
|
|
190
|
+
for (let j = exprStart; j < cssZone.length - 1; j++) {
|
|
191
|
+
const char = cssZone[j];
|
|
192
|
+
if (char === '{') {
|
|
193
|
+
braceDepth++;
|
|
194
|
+
}
|
|
195
|
+
else if (char === '}') {
|
|
196
|
+
if (braceDepth === 0 && cssZone[j + 1] === '}') {
|
|
197
|
+
// Found matching }}
|
|
198
|
+
closePos = j;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
braceDepth--;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (closePos === -1) {
|
|
205
|
+
// Unclosed expression
|
|
206
|
+
const line = this.getLineNumber(cssZone, openPos);
|
|
207
|
+
const col = this.getColumnNumber(cssZone, openPos);
|
|
208
|
+
throw LassTranspileError.at('Unclosed {{ expression', ErrorCategory.SCAN, line, col, openPos);
|
|
209
|
+
}
|
|
210
|
+
// Extract and trim expression content
|
|
211
|
+
const exprContent = cssZone.slice(exprStart, closePos).trim();
|
|
212
|
+
if (exprContent === '') {
|
|
213
|
+
// Empty expression
|
|
214
|
+
const line = this.getLineNumber(cssZone, openPos);
|
|
215
|
+
const col = this.getColumnNumber(cssZone, openPos);
|
|
216
|
+
throw LassTranspileError.at('Empty {{ }} expression', ErrorCategory.SCAN, line, col, openPos);
|
|
217
|
+
}
|
|
218
|
+
parts.push(exprContent);
|
|
219
|
+
// Move past }}
|
|
220
|
+
currentPos = closePos + 2;
|
|
221
|
+
cssStart = currentPos;
|
|
222
|
+
}
|
|
223
|
+
// Ensure we always end with a CSS part (even if empty)
|
|
224
|
+
if (parts.length > 0 && parts.length % 2 === 0) {
|
|
225
|
+
parts.push('');
|
|
226
|
+
}
|
|
227
|
+
// Handle case where no expressions were found and we didn't add anything
|
|
228
|
+
if (parts.length === 0) {
|
|
229
|
+
parts.push(cssZone);
|
|
230
|
+
}
|
|
231
|
+
return { parts, expressionPositions };
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Gets the 1-based line number for a character offset.
|
|
235
|
+
*/
|
|
236
|
+
getLineNumber(text, offset) {
|
|
237
|
+
let line = 1;
|
|
238
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
239
|
+
if (text[i] === '\n')
|
|
240
|
+
line++;
|
|
241
|
+
}
|
|
242
|
+
return line;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Gets the 1-based column number for a character offset.
|
|
246
|
+
*/
|
|
247
|
+
getColumnNumber(text, offset) {
|
|
248
|
+
let col = 1;
|
|
249
|
+
for (let i = offset - 1; i >= 0 && text[i] !== '\n'; i--) {
|
|
250
|
+
col++;
|
|
251
|
+
}
|
|
252
|
+
return col;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Known CSS at-rules - kept for reference but not needed for @(prop) detection.
|
|
256
|
+
* The @(prop) syntax with parentheses is unambiguous - no collision with CSS at-rules.
|
|
257
|
+
*/
|
|
258
|
+
static CSS_AT_RULES = new Set([
|
|
259
|
+
'media',
|
|
260
|
+
'layer',
|
|
261
|
+
'supports',
|
|
262
|
+
'container',
|
|
263
|
+
'keyframes',
|
|
264
|
+
'font-face',
|
|
265
|
+
'import',
|
|
266
|
+
'charset',
|
|
267
|
+
'namespace',
|
|
268
|
+
'page',
|
|
269
|
+
'counter-style',
|
|
270
|
+
'font-feature-values',
|
|
271
|
+
'property',
|
|
272
|
+
'scope',
|
|
273
|
+
'starting-style',
|
|
274
|
+
]);
|
|
275
|
+
/**
|
|
276
|
+
* Finds @(prop) accessors in CSS zone.
|
|
277
|
+
*
|
|
278
|
+
* Story 3.2: Basic Property Lookup
|
|
279
|
+
* Refactored: Changed from @prop to @(prop) for unambiguous syntax
|
|
280
|
+
*
|
|
281
|
+
* Detection rules:
|
|
282
|
+
* - @(propname) in CSS value position (after :) is a Lass accessor
|
|
283
|
+
* - The explicit parentheses make this unambiguous - no collision with CSS at-rules
|
|
284
|
+
* - Supports both standard properties and custom properties: @(border), @(--custom)
|
|
285
|
+
*
|
|
286
|
+
* Valid CSS property names inside @():
|
|
287
|
+
* - Standard: letter or hyphen start, then letters/digits/hyphens
|
|
288
|
+
* - Custom: -- followed by letters/digits/hyphens
|
|
289
|
+
*
|
|
290
|
+
* @param cssZone - The CSS zone content to scan
|
|
291
|
+
* @returns Array of PropertyAccessor objects with propName and indices
|
|
292
|
+
*/
|
|
293
|
+
findPropertyAccessors(cssZone) {
|
|
294
|
+
return Scanner.findPropertyAccessorsStatic(cssZone);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Static version of findPropertyAccessors for use without Scanner instantiation.
|
|
298
|
+
* Used internally by transpiler to avoid creating unnecessary Scanner instances.
|
|
299
|
+
*
|
|
300
|
+
* Story 3.3: Handles @(prop) inside {{ }} expressions.
|
|
301
|
+
* - {{ doesn't reset inValuePosition (JS expression can contain @(prop))
|
|
302
|
+
* - }} doesn't reset inValuePosition (exiting expression, still in value)
|
|
303
|
+
* - Single { resets inValuePosition (entering CSS block)
|
|
304
|
+
* - @(prop) inside {{ }} is detected and will be quoted during resolution
|
|
305
|
+
*
|
|
306
|
+
* Refactored: Changed from @prop to @(prop) for unambiguous syntax.
|
|
307
|
+
* This eliminates ambiguity in JS context: @(border-width) is clear,
|
|
308
|
+
* unlike @border-width which could be @border minus width.
|
|
309
|
+
*
|
|
310
|
+
* @param cssZone - The CSS zone content to scan
|
|
311
|
+
* @returns Array of PropertyAccessor objects with propName and indices
|
|
312
|
+
*/
|
|
313
|
+
static findPropertyAccessorsStatic(cssZone) {
|
|
314
|
+
const accessors = [];
|
|
315
|
+
if (!cssZone) {
|
|
316
|
+
return accessors;
|
|
317
|
+
}
|
|
318
|
+
// Pattern for property name inside @():
|
|
319
|
+
// - Standard property: letter or hyphen start, then letters/digits/hyphens
|
|
320
|
+
// - Custom property: -- followed by letters/digits/hyphens
|
|
321
|
+
const propNamePattern = /^([a-zA-Z-][a-zA-Z0-9-]*|--[a-zA-Z0-9-]+)/;
|
|
322
|
+
// Track if we're in value position (after :)
|
|
323
|
+
let inValuePosition = false;
|
|
324
|
+
// Track expression depth for {{ }} (Story 3.3)
|
|
325
|
+
let expressionDepth = 0;
|
|
326
|
+
let i = 0;
|
|
327
|
+
while (i < cssZone.length) {
|
|
328
|
+
const char = cssZone[i];
|
|
329
|
+
const nextChar = cssZone[i + 1];
|
|
330
|
+
// Track colon for value position
|
|
331
|
+
if (char === ':') {
|
|
332
|
+
inValuePosition = true;
|
|
333
|
+
i++;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
// Handle {{ - entering JS expression (Story 3.3)
|
|
337
|
+
// DON'T reset inValuePosition - we can have @(prop) inside expressions
|
|
338
|
+
if (char === '{' && nextChar === '{') {
|
|
339
|
+
expressionDepth++;
|
|
340
|
+
i += 2;
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
// Handle }} - exiting JS expression (Story 3.3)
|
|
344
|
+
// DON'T reset inValuePosition - we're still in the CSS value after expression
|
|
345
|
+
if (char === '}' && nextChar === '}') {
|
|
346
|
+
expressionDepth--;
|
|
347
|
+
i += 2;
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
// Reset on semicolon (end of declaration) - but only if not inside expression
|
|
351
|
+
if (char === ';' && expressionDepth === 0) {
|
|
352
|
+
inValuePosition = false;
|
|
353
|
+
i++;
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
// Reset on single closing brace (end of block) - but only if not inside expression
|
|
357
|
+
if (char === '}' && expressionDepth === 0) {
|
|
358
|
+
inValuePosition = false;
|
|
359
|
+
i++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
// Reset on single opening brace (entering a new CSS block) - but only if not inside expression
|
|
363
|
+
if (char === '{' && expressionDepth === 0) {
|
|
364
|
+
inValuePosition = false;
|
|
365
|
+
i++;
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
// Check for @( pattern - the explicit accessor syntax
|
|
369
|
+
if (char === '@' && nextChar === '(') {
|
|
370
|
+
// Find the closing parenthesis
|
|
371
|
+
const afterParen = cssZone.slice(i + 2);
|
|
372
|
+
const closeParenIdx = afterParen.indexOf(')');
|
|
373
|
+
if (closeParenIdx !== -1) {
|
|
374
|
+
const insideParens = afterParen.slice(0, closeParenIdx);
|
|
375
|
+
const match = insideParens.match(propNamePattern);
|
|
376
|
+
// Check if the entire content inside parens is a valid property name
|
|
377
|
+
if (match && match[0] === insideParens) {
|
|
378
|
+
const propName = match[0];
|
|
379
|
+
// Detect if we're in value position (after :)
|
|
380
|
+
// Story 3.3: This works inside {{ }} because we don't reset inValuePosition there
|
|
381
|
+
if (inValuePosition) {
|
|
382
|
+
accessors.push({
|
|
383
|
+
propName,
|
|
384
|
+
startIndex: i,
|
|
385
|
+
// @(propname) = @ + ( + propname + ) = 3 + propname.length
|
|
386
|
+
endIndex: i + 3 + propName.length,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
// Move past the @(propname)
|
|
390
|
+
i += 3 + propName.length;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
i++;
|
|
396
|
+
}
|
|
397
|
+
return accessors;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Finds $param variables in CSS zone.
|
|
401
|
+
*
|
|
402
|
+
* Story 4.1: Variable Substitution
|
|
403
|
+
*
|
|
404
|
+
* Detection rules:
|
|
405
|
+
* - $param is detected when $ is followed by a valid JS identifier character
|
|
406
|
+
* - Valid identifier start: [a-zA-Z_$]
|
|
407
|
+
* - Valid identifier char: [a-zA-Z0-9_$]
|
|
408
|
+
* - Identifier stops at first non-identifier character (hyphen, space, ;, {, etc.)
|
|
409
|
+
* - Bare $ (not followed by valid identifier start) is treated as literal text
|
|
410
|
+
*
|
|
411
|
+
* Protected contexts (detection skipped):
|
|
412
|
+
* - Inside CSS string literals ("..." or '...')
|
|
413
|
+
* - Inside /* ... */ block comments
|
|
414
|
+
*
|
|
415
|
+
* Note: url() is NOT a protected context - $param inside url() IS substituted.
|
|
416
|
+
* Use {{ $param }} bridge syntax if you need dynamic content inside strings.
|
|
417
|
+
*
|
|
418
|
+
* @param cssZone - The CSS zone content to scan
|
|
419
|
+
* @returns Array of DollarVariable objects with varName and indices
|
|
420
|
+
*/
|
|
421
|
+
findDollarVariables(cssZone) {
|
|
422
|
+
return Scanner.findDollarVariablesStatic(cssZone);
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Static version of findDollarVariables for use without Scanner instantiation.
|
|
426
|
+
* Used internally by transpiler to avoid creating unnecessary Scanner instances.
|
|
427
|
+
*
|
|
428
|
+
* Story 4.1: Variable Substitution
|
|
429
|
+
*
|
|
430
|
+
* @param cssZone - The CSS zone content to scan
|
|
431
|
+
* @returns Array of DollarVariable objects with varName and indices
|
|
432
|
+
*/
|
|
433
|
+
static findDollarVariablesStatic(cssZone) {
|
|
434
|
+
const variables = [];
|
|
435
|
+
if (!cssZone) {
|
|
436
|
+
return variables;
|
|
437
|
+
}
|
|
438
|
+
// Context tracking for protected zones (strings, comments)
|
|
439
|
+
// Note: url() is NOT protected - $param inside url() IS substituted
|
|
440
|
+
const state = createContextState();
|
|
441
|
+
// Track {{ }} script block depth ($param not substituted inside)
|
|
442
|
+
let scriptBlockDepth = 0;
|
|
443
|
+
let i = 0;
|
|
444
|
+
while (i < cssZone.length) {
|
|
445
|
+
const char = cssZone[i];
|
|
446
|
+
const nextChar = cssZone[i + 1];
|
|
447
|
+
// Update context state (handles strings and comments)
|
|
448
|
+
const consumed = updateContextState(cssZone, i, state);
|
|
449
|
+
if (consumed === 2) {
|
|
450
|
+
i += 2;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
// Skip string quote characters (already handled by updateContextState)
|
|
454
|
+
if (char === '"' || char === "'") {
|
|
455
|
+
i++;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
// Track {{ }} script block depth (only when not in protected context)
|
|
459
|
+
if (!isInProtectedContext(state) && char === '{' && nextChar === '{') {
|
|
460
|
+
scriptBlockDepth++;
|
|
461
|
+
i += 2;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (!isInProtectedContext(state) && char === '}' && nextChar === '}' && scriptBlockDepth > 0) {
|
|
465
|
+
scriptBlockDepth--;
|
|
466
|
+
i += 2;
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
// Skip if in any protected context OR inside {{ }}
|
|
470
|
+
if (isInProtectedContext(state) || scriptBlockDepth > 0) {
|
|
471
|
+
i++;
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
// Check for $ followed by valid identifier start
|
|
475
|
+
if (char === '$' && nextChar !== undefined && Scanner.isIdentifierStart(nextChar)) {
|
|
476
|
+
const startIndex = i;
|
|
477
|
+
i++; // Move past $
|
|
478
|
+
// Consume identifier characters
|
|
479
|
+
let varName = '$';
|
|
480
|
+
while (i < cssZone.length && Scanner.isIdentifierChar(cssZone[i])) {
|
|
481
|
+
varName += cssZone[i];
|
|
482
|
+
i++;
|
|
483
|
+
}
|
|
484
|
+
variables.push({
|
|
485
|
+
varName,
|
|
486
|
+
startIndex,
|
|
487
|
+
endIndex: i,
|
|
488
|
+
});
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
i++;
|
|
492
|
+
}
|
|
493
|
+
return variables;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Checks if a character is a valid JS identifier start.
|
|
497
|
+
* Valid: a-z, A-Z, _, $
|
|
498
|
+
*/
|
|
499
|
+
static isIdentifierStart(char) {
|
|
500
|
+
return /^[a-zA-Z_$]$/.test(char);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Checks if a character is a valid JS identifier character.
|
|
504
|
+
* Valid: a-z, A-Z, 0-9, _, $
|
|
505
|
+
*/
|
|
506
|
+
static isIdentifierChar(char) {
|
|
507
|
+
return /^[a-zA-Z0-9_$]$/.test(char);
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Finds @prop shorthand accessors in CSS zone.
|
|
511
|
+
*
|
|
512
|
+
* Story 4.2: Style Lookup Shorthand
|
|
513
|
+
*
|
|
514
|
+
* Detection rules:
|
|
515
|
+
* - @prop in CSS value position (after :) is a Lass shorthand accessor
|
|
516
|
+
* - @prop shorthand only works when identifier starts with a letter [a-zA-Z]
|
|
517
|
+
* - Identifier continues with letters, digits, hyphens, underscores
|
|
518
|
+
* - NOT detected inside {{ }} script blocks (use explicit @(prop) there)
|
|
519
|
+
* - NOT detected inside protected contexts: strings, comments, url()
|
|
520
|
+
*
|
|
521
|
+
* Examples:
|
|
522
|
+
* - @border → shorthand for @(border)
|
|
523
|
+
* - @border-color → shorthand for @(border-color)
|
|
524
|
+
* - @--custom → NOT detected (starts with hyphen, use @(--custom))
|
|
525
|
+
* - @-webkit-foo → NOT detected (starts with hyphen, use @(-webkit-foo))
|
|
526
|
+
*
|
|
527
|
+
* @param cssZone - The CSS zone content to scan
|
|
528
|
+
* @returns Array of StyleLookupShorthand objects with propName and indices
|
|
529
|
+
*/
|
|
530
|
+
findStyleLookupShorthands(cssZone) {
|
|
531
|
+
return Scanner.findStyleLookupShorthandsStatic(cssZone);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Static version of findStyleLookupShorthands for use without Scanner instantiation.
|
|
535
|
+
* Used internally by transpiler to avoid creating unnecessary Scanner instances.
|
|
536
|
+
*
|
|
537
|
+
* Story 4.2: Style Lookup Shorthand
|
|
538
|
+
*
|
|
539
|
+
* @param cssZone - The CSS zone content to scan
|
|
540
|
+
* @returns Array of StyleLookupShorthand objects with propName and indices
|
|
541
|
+
*/
|
|
542
|
+
static findStyleLookupShorthandsStatic(cssZone) {
|
|
543
|
+
const shorthands = [];
|
|
544
|
+
if (!cssZone) {
|
|
545
|
+
return shorthands;
|
|
546
|
+
}
|
|
547
|
+
// Context tracking for protected zones (strings, comments)
|
|
548
|
+
// Note: url() is NOT a protected context - @prop inside url(@path) IS detected
|
|
549
|
+
const state = createContextState();
|
|
550
|
+
const contextStack = [{ type: 'css', braceDepth: 0 }];
|
|
551
|
+
// Track if we're in value position (after :)
|
|
552
|
+
let inValuePosition = false;
|
|
553
|
+
let i = 0;
|
|
554
|
+
while (i < cssZone.length) {
|
|
555
|
+
const char = cssZone[i];
|
|
556
|
+
const nextChar = cssZone[i + 1];
|
|
557
|
+
// Update context state (handles strings and comments)
|
|
558
|
+
const consumed = updateContextState(cssZone, i, state);
|
|
559
|
+
if (consumed === 2) {
|
|
560
|
+
i += 2;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
// Skip string quote characters (already handled by updateContextState)
|
|
564
|
+
if (char === '"' || char === "'") {
|
|
565
|
+
i++;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
// Skip if in protected context (strings, comments)
|
|
569
|
+
if (isInProtectedContext(state)) {
|
|
570
|
+
i++;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
// Track context transitions
|
|
574
|
+
const currentEntry = contextStack[contextStack.length - 1];
|
|
575
|
+
// Check for {{ - enters JS context
|
|
576
|
+
if (char === '{' && nextChar === '{') {
|
|
577
|
+
contextStack.push({ type: 'js', braceDepth: 0 });
|
|
578
|
+
i += 2;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
// Check for }} - exits JS context
|
|
582
|
+
if (char === '}' && nextChar === '}') {
|
|
583
|
+
// Pop until we find a 'js' context (may need to pop @{ css first)
|
|
584
|
+
while (contextStack.length > 1 && contextStack[contextStack.length - 1].type !== 'js') {
|
|
585
|
+
contextStack.pop();
|
|
586
|
+
}
|
|
587
|
+
if (contextStack.length > 1) {
|
|
588
|
+
contextStack.pop(); // pop the 'js'
|
|
589
|
+
}
|
|
590
|
+
i += 2;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
// Check for @{ - enters CSS context (style block inside JS)
|
|
594
|
+
if (char === '@' && nextChar === '{' && currentEntry.type === 'js') {
|
|
595
|
+
contextStack.push({ type: 'css', braceDepth: 0 });
|
|
596
|
+
i += 2;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
// Track single braces within @{ CSS context (for nested CSS blocks)
|
|
600
|
+
if (currentEntry.type === 'css' && contextStack.length > 1) {
|
|
601
|
+
// We're in a @{ block (not the root CSS context)
|
|
602
|
+
if (char === '{') {
|
|
603
|
+
currentEntry.braceDepth++;
|
|
604
|
+
inValuePosition = false;
|
|
605
|
+
i++;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
if (char === '}') {
|
|
609
|
+
if (currentEntry.braceDepth > 0) {
|
|
610
|
+
// Closing a nested CSS block within @{
|
|
611
|
+
currentEntry.braceDepth--;
|
|
612
|
+
inValuePosition = false;
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
// Closing the @{ block itself
|
|
616
|
+
contextStack.pop();
|
|
617
|
+
}
|
|
618
|
+
i++;
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
// Get current context type
|
|
623
|
+
const currentContext = contextStack[contextStack.length - 1].type;
|
|
624
|
+
// Skip if in JS context (not inside a @{ block)
|
|
625
|
+
if (currentContext === 'js') {
|
|
626
|
+
i++;
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
// Track colon for value position
|
|
630
|
+
if (char === ':') {
|
|
631
|
+
inValuePosition = true;
|
|
632
|
+
i++;
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
// Reset on semicolon (end of declaration)
|
|
636
|
+
if (char === ';') {
|
|
637
|
+
inValuePosition = false;
|
|
638
|
+
i++;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
// Reset on single opening brace (entering a new CSS block)
|
|
642
|
+
if (char === '{') {
|
|
643
|
+
inValuePosition = false;
|
|
644
|
+
i++;
|
|
645
|
+
continue;
|
|
646
|
+
}
|
|
647
|
+
// Reset on single closing brace (end of block)
|
|
648
|
+
if (char === '}') {
|
|
649
|
+
inValuePosition = false;
|
|
650
|
+
i++;
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
// Check for @ followed by letter (shorthand only works when starting with letter)
|
|
654
|
+
if (char === '@' && nextChar !== undefined && /^[a-zA-Z]$/.test(nextChar)) {
|
|
655
|
+
// Only detect in value position
|
|
656
|
+
if (inValuePosition) {
|
|
657
|
+
const startIndex = i;
|
|
658
|
+
i++; // Move past @
|
|
659
|
+
// Consume CSS identifier characters (letters, digits, hyphens, underscores)
|
|
660
|
+
let propName = '';
|
|
661
|
+
while (i < cssZone.length && Scanner.isCssIdentifierChar(cssZone[i])) {
|
|
662
|
+
propName += cssZone[i];
|
|
663
|
+
i++;
|
|
664
|
+
}
|
|
665
|
+
shorthands.push({
|
|
666
|
+
propName,
|
|
667
|
+
startIndex,
|
|
668
|
+
endIndex: i,
|
|
669
|
+
});
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
i++;
|
|
674
|
+
}
|
|
675
|
+
return shorthands;
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Checks if a character is a valid CSS identifier character.
|
|
679
|
+
* Valid: a-z, A-Z, 0-9, -, _
|
|
680
|
+
*/
|
|
681
|
+
static isCssIdentifierChar(char) {
|
|
682
|
+
return /^[a-zA-Z0-9_-]$/.test(char);
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Strips // single-line comments from CSS zone.
|
|
686
|
+
*
|
|
687
|
+
* Story 4.4: Single-Line Comment Stripping
|
|
688
|
+
*
|
|
689
|
+
* Detection rules:
|
|
690
|
+
* - // to end of line (including newline) is removed
|
|
691
|
+
* - Skip detection inside protected contexts: strings, url(), /* *\/
|
|
692
|
+
* - Full-line comments remove the entire line
|
|
693
|
+
* - Inline comments preserve content before //
|
|
694
|
+
*
|
|
695
|
+
* Note: url() is protected here (unlike $param/@prop) because
|
|
696
|
+
* url(https://...) contains // as part of the URL protocol.
|
|
697
|
+
*
|
|
698
|
+
* @param cssZone - The CSS zone content to process
|
|
699
|
+
* @returns CSS zone with // comments stripped
|
|
700
|
+
* @throws LassTranspileError if unclosed /* comment detected
|
|
701
|
+
*/
|
|
702
|
+
stripLineComments(cssZone) {
|
|
703
|
+
return Scanner.stripLineCommentsStatic(cssZone);
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Static version of stripLineComments for use without Scanner instantiation.
|
|
707
|
+
* Used internally by transpiler to avoid creating unnecessary Scanner instances.
|
|
708
|
+
*
|
|
709
|
+
* Story 4.4: Single-Line Comment Stripping
|
|
710
|
+
*
|
|
711
|
+
* @param cssZone - The CSS zone content to process
|
|
712
|
+
* @returns CSS zone with // comments stripped
|
|
713
|
+
* @throws LassTranspileError if unclosed /* comment detected
|
|
714
|
+
*/
|
|
715
|
+
static stripLineCommentsStatic(cssZone) {
|
|
716
|
+
if (!cssZone) {
|
|
717
|
+
return cssZone;
|
|
718
|
+
}
|
|
719
|
+
// Context tracking for protected zones (strings, block comments)
|
|
720
|
+
const state = createContextState();
|
|
721
|
+
// Local url() tracking - unique to this function because url(https://...)
|
|
722
|
+
// contains // that should NOT be treated as a comment
|
|
723
|
+
let inUrl = false;
|
|
724
|
+
let urlParenDepth = 0;
|
|
725
|
+
// Track position where /* started (for error reporting)
|
|
726
|
+
let blockCommentStartLine = -1;
|
|
727
|
+
let blockCommentStartOffset = -1;
|
|
728
|
+
let result = '';
|
|
729
|
+
let i = 0;
|
|
730
|
+
while (i < cssZone.length) {
|
|
731
|
+
const char = cssZone[i];
|
|
732
|
+
const nextChar = cssZone[i + 1];
|
|
733
|
+
// Track block comment start position for error reporting
|
|
734
|
+
if (!state.inString && !state.inBlockComment && char === '/' && nextChar === '*') {
|
|
735
|
+
blockCommentStartLine = Scanner.getLineNumberStatic(cssZone, i);
|
|
736
|
+
blockCommentStartOffset = i;
|
|
737
|
+
}
|
|
738
|
+
// Update context state (strings, block comments)
|
|
739
|
+
const consumed = updateContextState(cssZone, i, state);
|
|
740
|
+
if (consumed === 2) {
|
|
741
|
+
result += cssZone.slice(i, i + 2);
|
|
742
|
+
i += 2;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
// Handle string quote characters
|
|
746
|
+
if (char === '"' || char === "'") {
|
|
747
|
+
result += char;
|
|
748
|
+
i++;
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
// Track url() context (only when not in string or block comment)
|
|
752
|
+
if (!isInProtectedContext(state) && !inUrl) {
|
|
753
|
+
if (cssZone.slice(i, i + 4).toLowerCase() === 'url(') {
|
|
754
|
+
inUrl = true;
|
|
755
|
+
urlParenDepth = 1;
|
|
756
|
+
result += cssZone.slice(i, i + 4);
|
|
757
|
+
i += 4;
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
// Handle characters inside url()
|
|
762
|
+
if (inUrl) {
|
|
763
|
+
if (char === '(') {
|
|
764
|
+
urlParenDepth++;
|
|
765
|
+
}
|
|
766
|
+
else if (char === ')') {
|
|
767
|
+
urlParenDepth--;
|
|
768
|
+
if (urlParenDepth === 0) {
|
|
769
|
+
inUrl = false;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
result += char;
|
|
773
|
+
i++;
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
// Skip if in any protected context (string or block comment)
|
|
777
|
+
if (isInProtectedContext(state)) {
|
|
778
|
+
result += char;
|
|
779
|
+
i++;
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
// Check for // line comment
|
|
783
|
+
if (char === '/' && nextChar === '/') {
|
|
784
|
+
// Find end of line (the comment is from // to just before the newline)
|
|
785
|
+
let endOfLine = i;
|
|
786
|
+
while (endOfLine < cssZone.length && cssZone[endOfLine] !== '\n' && cssZone[endOfLine] !== '\r') {
|
|
787
|
+
endOfLine++;
|
|
788
|
+
}
|
|
789
|
+
// Skip past the comment content, stop at the newline (don't consume it)
|
|
790
|
+
i = endOfLine;
|
|
791
|
+
// Content before // is already in result, newline will be added on next iteration
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
result += char;
|
|
795
|
+
i++;
|
|
796
|
+
}
|
|
797
|
+
// Check for unclosed block comment at end of file
|
|
798
|
+
if (state.inBlockComment) {
|
|
799
|
+
throw LassTranspileError.at('Unclosed /* comment', ErrorCategory.SCAN, blockCommentStartLine, 1, blockCommentStartOffset);
|
|
800
|
+
}
|
|
801
|
+
return result;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Gets the 1-based line number for a character offset (static version).
|
|
805
|
+
*/
|
|
806
|
+
static getLineNumberStatic(text, offset) {
|
|
807
|
+
let line = 1;
|
|
808
|
+
for (let i = 0; i < offset && i < text.length; i++) {
|
|
809
|
+
if (text[i] === '\n')
|
|
810
|
+
line++;
|
|
811
|
+
}
|
|
812
|
+
return line;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
//# sourceMappingURL=scanner.js.map
|