pulse-js-framework 1.11.3 → 1.11.4

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.
Files changed (48) hide show
  1. package/cli/analyze.js +21 -8
  2. package/cli/build.js +83 -56
  3. package/cli/dev.js +108 -94
  4. package/cli/docs-test.js +52 -33
  5. package/cli/index.js +81 -51
  6. package/cli/mobile.js +92 -40
  7. package/cli/release.js +64 -46
  8. package/cli/scaffold.js +14 -13
  9. package/compiler/lexer.js +55 -54
  10. package/compiler/parser/core.js +1 -0
  11. package/compiler/parser/state.js +6 -12
  12. package/compiler/parser/style.js +17 -20
  13. package/compiler/parser/view.js +1 -3
  14. package/compiler/preprocessor.js +124 -262
  15. package/compiler/sourcemap.js +10 -4
  16. package/compiler/transformer/expressions.js +122 -106
  17. package/compiler/transformer/index.js +2 -4
  18. package/compiler/transformer/style.js +74 -7
  19. package/compiler/transformer/view.js +86 -36
  20. package/loader/esbuild-plugin-server-components.js +209 -0
  21. package/loader/esbuild-plugin.js +41 -93
  22. package/loader/parcel-plugin.js +37 -97
  23. package/loader/rollup-plugin-server-components.js +30 -169
  24. package/loader/rollup-plugin.js +27 -78
  25. package/loader/shared.js +362 -0
  26. package/loader/swc-plugin.js +65 -82
  27. package/loader/vite-plugin-server-components.js +30 -171
  28. package/loader/vite-plugin.js +25 -10
  29. package/loader/webpack-loader-server-components.js +21 -134
  30. package/loader/webpack-loader.js +25 -80
  31. package/package.json +52 -12
  32. package/runtime/dom-selector.js +2 -1
  33. package/runtime/form.js +4 -3
  34. package/runtime/http.js +6 -1
  35. package/runtime/logger.js +44 -24
  36. package/runtime/router/utils.js +14 -7
  37. package/runtime/security.js +13 -1
  38. package/runtime/server-components/actions-server.js +23 -19
  39. package/runtime/server-components/error-sanitizer.js +18 -18
  40. package/runtime/server-components/security.js +41 -24
  41. package/runtime/ssr-preload.js +5 -3
  42. package/runtime/testing.js +759 -0
  43. package/runtime/utils.js +3 -2
  44. package/server/utils.js +15 -9
  45. package/sw/index.js +2 -0
  46. package/types/loaders.d.ts +1043 -0
  47. package/compiler/parser/_extract.js +0 -393
  48. package/loader/README.md +0 -509
package/cli/release.js CHANGED
@@ -10,11 +10,11 @@
10
10
  * - Push to remote
11
11
  */
12
12
 
13
- import { readFileSync, writeFileSync, existsSync, unlinkSync } from 'fs';
13
+ import { readFileSync, writeFileSync, unlinkSync, openSync, closeSync, ftruncateSync } from 'fs';
14
14
  import { join, dirname } from 'path';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { execSync } from 'child_process';
17
- import { tmpdir } from 'os';
17
+ import { randomBytes } from 'crypto';
18
18
  import { createInterface } from 'readline';
19
19
  import https from 'https';
20
20
  import { log } from './logger.js';
@@ -23,6 +23,30 @@ import { runDocsTest } from './docs-test.js';
23
23
  const __dirname = dirname(fileURLToPath(import.meta.url));
24
24
  const root = join(__dirname, '..');
25
25
 
26
+ /**
27
+ * Atomically read-modify-write a file using a file descriptor to prevent TOCTOU races.
28
+ * Opens the file with 'r+' flag, reads content, applies transform, truncates, and writes.
29
+ * Returns false if the file doesn't exist (ENOENT), true on success.
30
+ */
31
+ function atomicReadModifyWrite(filePath, transformFn) {
32
+ let fd;
33
+ try {
34
+ fd = openSync(filePath, 'r+');
35
+ } catch (err) {
36
+ if (err.code === 'ENOENT') return false;
37
+ throw err;
38
+ }
39
+ try {
40
+ const content = readFileSync(fd, 'utf-8');
41
+ const newContent = transformFn(content);
42
+ ftruncateSync(fd, 0);
43
+ writeFileSync(fd, newContent, { encoding: 'utf-8' });
44
+ } finally {
45
+ closeSync(fd);
46
+ }
47
+ return true;
48
+ }
49
+
26
50
  /**
27
51
  * Prompt user for input
28
52
  */
@@ -211,17 +235,15 @@ function updatePackageJson(newVersion) {
211
235
  */
212
236
  function updateDocsState(newVersion) {
213
237
  const statePath = join(root, 'docs/src/state.js');
214
- if (!existsSync(statePath)) {
215
- log.warn(' docs/src/state.js not found, skipping');
216
- return;
217
- }
218
238
 
219
- let content = readFileSync(statePath, 'utf-8');
220
- content = content.replace(
221
- /export const version = '[^']+'/,
222
- `export const version = '${newVersion}'`
223
- );
224
- writeFileSync(statePath, content);
239
+ const updated = atomicReadModifyWrite(statePath, (content) => {
240
+ return content.replace(
241
+ /export const version = '[^']+'/,
242
+ `export const version = '${newVersion}'`
243
+ );
244
+ });
245
+
246
+ if (!updated) { log.warn(' docs/src/state.js not found, skipping'); return; }
225
247
  log.info(` Updated docs/src/state.js to v${newVersion}`);
226
248
  }
227
249
 
@@ -230,12 +252,6 @@ function updateDocsState(newVersion) {
230
252
  */
231
253
  function updateChangelog(newVersion, title, changes) {
232
254
  const changelogPath = join(root, 'CHANGELOG.md');
233
- if (!existsSync(changelogPath)) {
234
- log.warn(' CHANGELOG.md not found, skipping');
235
- return;
236
- }
237
-
238
- let content = readFileSync(changelogPath, 'utf-8');
239
255
 
240
256
  // Build changelog entry
241
257
  const date = getCurrentDate();
@@ -277,18 +293,22 @@ function updateChangelog(newVersion, title, changes) {
277
293
  entry += '\n';
278
294
  }
279
295
 
280
- // Insert after the header section (after line 6)
281
- const lines = content.split('\n');
282
- const insertIndex = lines.findIndex(line => line.startsWith('## ['));
296
+ // Atomically read-modify-write to prevent TOCTOU race (CWE-367)
297
+ const updated = atomicReadModifyWrite(changelogPath, (content) => {
298
+ const lines = content.split('\n');
299
+ const insertIndex = lines.findIndex(line => line.startsWith('## ['));
283
300
 
284
- if (insertIndex !== -1) {
285
- lines.splice(insertIndex, 0, entry);
286
- } else {
287
- // No existing version entries, add after header
288
- lines.push(entry);
289
- }
301
+ if (insertIndex !== -1) {
302
+ lines.splice(insertIndex, 0, entry);
303
+ } else {
304
+ // No existing version entries, add after header
305
+ lines.push(entry);
306
+ }
290
307
 
291
- writeFileSync(changelogPath, lines.join('\n'));
308
+ return lines.join('\n');
309
+ });
310
+
311
+ if (!updated) { log.warn(' CHANGELOG.md not found, skipping'); return; }
292
312
  log.info(` Updated CHANGELOG.md with v${newVersion}`);
293
313
  }
294
314
 
@@ -297,12 +317,7 @@ function updateChangelog(newVersion, title, changes) {
297
317
  */
298
318
  function updateDocsChangelog(newVersion, title, changes) {
299
319
  const changelogPagePath = join(root, 'docs/src/pages/ChangelogPage.js');
300
- if (!existsSync(changelogPagePath)) {
301
- log.warn(' docs/src/pages/ChangelogPage.js not found, skipping');
302
- return;
303
- }
304
320
 
305
- let content = readFileSync(changelogPagePath, 'utf-8');
306
321
  const monthYear = getCurrentMonthYear();
307
322
 
308
323
  // Build HTML changelog section
@@ -337,15 +352,18 @@ function updateDocsChangelog(newVersion, title, changes) {
337
352
  </section>
338
353
  `;
339
354
 
340
- // Find where to insert (after the intro paragraph, before first section)
341
- const insertMarker = '<section class="doc-section changelog-section">';
342
- const insertIndex = content.indexOf(insertMarker);
355
+ // Atomically read-modify-write to prevent TOCTOU race (CWE-367)
356
+ const updated = atomicReadModifyWrite(changelogPagePath, (content) => {
357
+ const insertMarker = '<section class="doc-section changelog-section">';
358
+ const insertIndex = content.indexOf(insertMarker);
343
359
 
344
- if (insertIndex !== -1) {
345
- content = content.slice(0, insertIndex) + section + content.slice(insertIndex);
346
- }
360
+ if (insertIndex !== -1) {
361
+ return content.slice(0, insertIndex) + section + content.slice(insertIndex);
362
+ }
363
+ return content;
364
+ });
347
365
 
348
- writeFileSync(changelogPagePath, content);
366
+ if (!updated) { log.warn(' docs/src/pages/ChangelogPage.js not found, skipping'); return; }
349
367
  log.info(` Updated docs/src/pages/ChangelogPage.js with v${newVersion}`);
350
368
  }
351
369
 
@@ -593,8 +611,8 @@ function createGitHubRelease(version, title, changes) {
593
611
  notes += '\n';
594
612
  }
595
613
 
596
- // Write notes to temp file
597
- const tempFile = join(tmpdir(), `pulse-release-notes-${Date.now()}.md`);
614
+ // Write notes to a temp file in the project root (avoids os.tmpdir() for CWE-377)
615
+ const tempFile = join(root, `.release-notes-${randomBytes(16).toString('hex')}.tmp.md`);
598
616
  writeFileSync(tempFile, notes, 'utf-8');
599
617
 
600
618
  try {
@@ -656,8 +674,8 @@ function gitCommitTagPush(newVersion, title, changes, dryRun = false) {
656
674
  return { success: false, stage: 'add', error: result.error };
657
675
  }
658
676
 
659
- // git commit using temp file for cross-platform compatibility
660
- const tempFile = join(tmpdir(), `pulse-release-${Date.now()}.txt`);
677
+ // git commit using temp file for cross-platform compatibility (avoids os.tmpdir() for CWE-377)
678
+ const tempFile = join(root, `.commit-msg-${randomBytes(16).toString('hex')}.tmp`);
661
679
  writeFileSync(tempFile, commitMessage, 'utf-8');
662
680
  try {
663
681
  result = execGitCommand(`git commit -F "${tempFile}"`, 'git commit');
@@ -769,8 +787,8 @@ function gitCommitTagNoPush(newVersion, title, changes) {
769
787
  return { success: false, stage: 'add', error: result.error };
770
788
  }
771
789
 
772
- // git commit using temp file for cross-platform compatibility
773
- const tempFile = join(tmpdir(), `pulse-release-${Date.now()}.txt`);
790
+ // git commit using temp file for cross-platform compatibility (avoids os.tmpdir() for CWE-377)
791
+ const tempFile = join(root, `.commit-msg-${randomBytes(16).toString('hex')}.tmp`);
774
792
  writeFileSync(tempFile, commitMessage, 'utf-8');
775
793
  try {
776
794
  result = execGitCommand(`git commit -F "${tempFile}"`, 'git commit');
package/cli/scaffold.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Generate components, pages, stores, and other project files
4
4
  */
5
5
 
6
- import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
6
+ import { mkdirSync, writeFileSync } from 'fs';
7
7
  import { join, dirname, relative, basename } from 'path';
8
8
  import { log } from './logger.js';
9
9
  import { parseArgs } from './utils/file-utils.js';
@@ -957,13 +957,6 @@ export async function runScaffold(args) {
957
957
  const fileName = toCase(name, type === 'component' || type === 'page' || type === 'layout' ? 'pascal' : 'camel');
958
958
  const fullPath = join(process.cwd(), dir, fileName + scaffoldType.extension);
959
959
 
960
- // Check if file already exists
961
- if (existsSync(fullPath) && !options.force && !options.f) {
962
- log.error(`File already exists: ${relative(process.cwd(), fullPath)}`);
963
- log.info('Use --force to overwrite.');
964
- process.exit(1);
965
- }
966
-
967
960
  // Generate content
968
961
  const content = scaffoldType.template(name, {
969
962
  withState: options.state !== false,
@@ -973,13 +966,21 @@ export async function runScaffold(args) {
973
966
 
974
967
  // Create directory if needed
975
968
  const outputDir = dirname(fullPath);
976
- if (!existsSync(outputDir)) {
977
- mkdirSync(outputDir, { recursive: true });
969
+ mkdirSync(outputDir, { recursive: true });
970
+
971
+ // Write file (atomic create unless --force)
972
+ const forceOverwrite = options.force || options.f;
973
+ try {
974
+ writeFileSync(fullPath, content, forceOverwrite ? undefined : { flag: 'wx' });
975
+ } catch (err) {
976
+ if (err.code === 'EEXIST') {
977
+ log.error(`File already exists: ${relative(process.cwd(), fullPath)}`);
978
+ log.info('Use --force to overwrite.');
979
+ process.exit(1);
980
+ }
981
+ throw err;
978
982
  }
979
983
 
980
- // Write file
981
- writeFileSync(fullPath, content);
982
-
983
984
  log.success(`Created ${type}: ${relative(process.cwd(), fullPath)}`);
984
985
 
985
986
  // Show next steps
package/compiler/lexer.js CHANGED
@@ -197,11 +197,18 @@ export class Token {
197
197
  */
198
198
  export class Lexer {
199
199
  constructor(source) {
200
+ if (source == null) {
201
+ throw new Error('Lexer: source must be a string, got ' + typeof source);
202
+ }
200
203
  this.source = source;
201
204
  this.pos = 0;
202
205
  this.line = 1;
203
206
  this.column = 1;
204
207
  this.tokens = [];
208
+ // Block context tracking (avoids O(n) backward scans)
209
+ this._currentBlock = null; // 'view' | 'style' | 'state' | 'actions' | 'router' | 'store' | null
210
+ this._blockDepth = 0; // brace depth within current block
211
+ this._parenDepth = 0; // parentheses depth within current block (for expression context)
205
212
  }
206
213
 
207
214
  /**
@@ -264,6 +271,8 @@ export class Lexer {
264
271
  }
265
272
  } else if (char === '/' && this.peek() === '*') {
266
273
  // Multi-line comment
274
+ const commentLine = this.line;
275
+ const commentCol = this.column;
267
276
  this.advance(); // /
268
277
  this.advance(); // *
269
278
  while (!this.isEOF() && !(this.current() === '*' && this.peek() === '/')) {
@@ -272,6 +281,9 @@ export class Lexer {
272
281
  if (!this.isEOF()) {
273
282
  this.advance(); // *
274
283
  this.advance(); // /
284
+ } else {
285
+ // Unclosed block comment — emit error token so downstream gets a clear diagnostic
286
+ this.tokens.push(new Token(TokenType.ERROR, 'Unterminated block comment', commentLine, commentCol));
275
287
  }
276
288
  } else {
277
289
  break;
@@ -572,10 +584,19 @@ export class Lexer {
572
584
  /**
573
585
  * Tokenize the entire source
574
586
  */
587
+ /**
588
+ * Set of top-level block keywords that define context
589
+ */
590
+ static BLOCK_KEYWORDS = new Set([
591
+ TokenType.VIEW, TokenType.STYLE, TokenType.STATE,
592
+ TokenType.ACTIONS, TokenType.ROUTER, TokenType.STORE
593
+ ]);
594
+
575
595
  tokenize() {
576
596
  this.tokens = [];
577
- let inViewBlock = false;
578
- let braceDepth = 0;
597
+ this._currentBlock = null;
598
+ this._blockDepth = 0;
599
+ let _pendingBlock = null; // keyword waiting for its opening brace
579
600
 
580
601
  while (!this.isEOF()) {
581
602
  this.skipWhitespace();
@@ -645,20 +666,35 @@ export class Lexer {
645
666
  switch (char) {
646
667
  case '{':
647
668
  this.advance();
648
- braceDepth++;
669
+ if (_pendingBlock) {
670
+ this._currentBlock = _pendingBlock;
671
+ this._blockDepth = 1;
672
+ this._parenDepth = 0;
673
+ _pendingBlock = null;
674
+ } else if (this._currentBlock) {
675
+ this._blockDepth++;
676
+ }
649
677
  this.tokens.push(new Token(TokenType.LBRACE, '{', startLine, startColumn));
650
678
  continue;
651
679
  case '}':
652
680
  this.advance();
653
- braceDepth--;
681
+ if (this._currentBlock) {
682
+ this._blockDepth--;
683
+ if (this._blockDepth === 0) {
684
+ this._currentBlock = null;
685
+ this._parenDepth = 0;
686
+ }
687
+ }
654
688
  this.tokens.push(new Token(TokenType.RBRACE, '}', startLine, startColumn));
655
689
  continue;
656
690
  case '(':
657
691
  this.advance();
692
+ if (this._currentBlock) this._parenDepth++;
658
693
  this.tokens.push(new Token(TokenType.LPAREN, '(', startLine, startColumn));
659
694
  continue;
660
695
  case ')':
661
696
  this.advance();
697
+ if (this._currentBlock) this._parenDepth--;
662
698
  this.tokens.push(new Token(TokenType.RPAREN, ')', startLine, startColumn));
663
699
  continue;
664
700
  case '[':
@@ -875,9 +911,14 @@ export class Lexer {
875
911
  const forceIdent = inStyle && cssReservedWords.has(word);
876
912
 
877
913
  if (shouldBeKeyword) {
878
- this.tokens.push(this.readIdentifier(false));
914
+ const token = this.readIdentifier(false);
915
+ this.tokens.push(token);
916
+ // Track top-level block keywords for context detection
917
+ if (Lexer.BLOCK_KEYWORDS.has(token.type) && !this._currentBlock) {
918
+ _pendingBlock = token.type.toLowerCase();
919
+ }
879
920
  } else if (this.isViewContext() && this.couldBeSelector()) {
880
- // Only treat as selector if not a keyword
921
+ // Only treat as selector if in view context (checks paren depth)
881
922
  this.tokens.push(this.readSelector());
882
923
  } else {
883
924
  this.tokens.push(this.readIdentifier(forceIdent));
@@ -896,23 +937,10 @@ export class Lexer {
896
937
 
897
938
  /**
898
939
  * Check if we're in a style context (inside style block)
940
+ * Uses tracked block context for O(1) lookup
899
941
  */
900
942
  isStyleContext() {
901
- // Look back through tokens for 'style' keyword
902
- for (let i = this.tokens.length - 1; i >= 0; i--) {
903
- const token = this.tokens[i];
904
- if (token.type === TokenType.STYLE) {
905
- return true;
906
- }
907
- if (token.type === TokenType.STATE ||
908
- token.type === TokenType.VIEW ||
909
- token.type === TokenType.ACTIONS ||
910
- token.type === TokenType.ROUTER ||
911
- token.type === TokenType.STORE) {
912
- return false;
913
- }
914
- }
915
- return false;
943
+ return this._currentBlock === 'style';
916
944
  }
917
945
 
918
946
  /**
@@ -933,11 +961,13 @@ export class Lexer {
933
961
 
934
962
  /**
935
963
  * Check if we're in a view context where selectors are expected
964
+ * Uses tracked block context and paren depth for O(1) lookup
936
965
  */
937
966
  isViewContext() {
938
- // Look back through tokens for 'view' keyword
939
- let inView = false;
940
- let parenDepth = 0;
967
+ if (this._currentBlock !== 'view') return false;
968
+
969
+ // Inside parentheses = expression context, not selector context
970
+ if (this._parenDepth > 0) return false;
941
971
 
942
972
  // After @ token, next word is directive name (not selector)
943
973
  const lastToken = this.tokens[this.tokens.length - 1];
@@ -945,36 +975,7 @@ export class Lexer {
945
975
  return false;
946
976
  }
947
977
 
948
- for (let i = this.tokens.length - 1; i >= 0; i--) {
949
- const token = this.tokens[i];
950
-
951
- // Track parentheses depth (backwards)
952
- if (token.type === TokenType.RPAREN) {
953
- parenDepth++;
954
- } else if (token.type === TokenType.LPAREN) {
955
- parenDepth--;
956
- // If we go negative, we're inside parentheses (expression context)
957
- if (parenDepth < 0) {
958
- return false; // Inside expression, not selector context
959
- }
960
- }
961
-
962
- if (token.type === TokenType.VIEW) {
963
- inView = true;
964
- break;
965
- }
966
- if (token.type === TokenType.STATE ||
967
- token.type === TokenType.ACTIONS ||
968
- token.type === TokenType.STYLE ||
969
- token.type === TokenType.ROUTER ||
970
- token.type === TokenType.STORE ||
971
- token.type === TokenType.ROUTES ||
972
- token.type === TokenType.GETTERS) {
973
- return false;
974
- }
975
- }
976
-
977
- return inView;
978
+ return true;
978
979
  }
979
980
 
980
981
  /**
@@ -39,6 +39,7 @@ export const NodeType = {
39
39
  A11yDirective: 'A11yDirective',
40
40
  LiveDirective: 'LiveDirective',
41
41
  FocusTrapDirective: 'FocusTrapDirective',
42
+ SrOnlyDirective: 'SrOnlyDirective',
42
43
 
43
44
  // SSR directives
44
45
  ClientDirective: 'ClientDirective',
@@ -34,15 +34,19 @@ Parser.prototype.parsePropsBlock = function() {
34
34
  };
35
35
 
36
36
  /**
37
- * Parse a props property (name: defaultValue)
37
+ * Parse a property declaration (name: value) — shared by props and state blocks
38
38
  */
39
- Parser.prototype.parsePropsProperty = function() {
39
+ Parser.prototype.parseProperty = function() {
40
40
  const name = this.expect(TokenType.IDENT);
41
41
  this.expect(TokenType.COLON);
42
42
  const value = this.parseValue();
43
43
  return new ASTNode(NodeType.Property, { name: name.value, value });
44
44
  };
45
45
 
46
+ // Aliases for semantic clarity in callers
47
+ Parser.prototype.parsePropsProperty = Parser.prototype.parseProperty;
48
+ Parser.prototype.parseStateProperty = Parser.prototype.parseProperty;
49
+
46
50
  // ============================================================
47
51
  // State Block Parsing
48
52
  // ============================================================
@@ -63,16 +67,6 @@ Parser.prototype.parseStateBlock = function() {
63
67
  return new ASTNode(NodeType.StateBlock, { properties });
64
68
  };
65
69
 
66
- /**
67
- * Parse a state property
68
- */
69
- Parser.prototype.parseStateProperty = function() {
70
- const name = this.expect(TokenType.IDENT);
71
- this.expect(TokenType.COLON);
72
- const value = this.parseValue();
73
- return new ASTNode(NodeType.Property, { name: name.value, value });
74
- };
75
-
76
70
  // ============================================================
77
71
  // Value Parsing Utilities
78
72
  // ============================================================
@@ -59,6 +59,8 @@ Parser.prototype.parseStyleBlock = function() {
59
59
  // Parsing failed - likely preprocessor syntax (LESS/SASS/Stylus)
60
60
  parseError = error;
61
61
  rules = []; // Clear any partial parse
62
+ // Warn so users know structured CSS parsing fell back to raw mode
63
+ console.warn(`[pulse] CSS parse warning: falling back to raw mode — ${error.message}`);
62
64
  }
63
65
 
64
66
  // Restore position to after the closing }
@@ -195,7 +197,10 @@ Parser.prototype.parseStyleRule = function() {
195
197
  };
196
198
 
197
199
  /**
198
- * Check if current position is a nested rule
200
+ * Check if current position is a nested rule.
201
+ * Scans forward for the first decisive token ({, :, or }) to determine
202
+ * if this is a nested rule (selector) or a property declaration.
203
+ * Bounded lookahead prevents O(n²) on deeply nested CSS.
199
204
  */
200
205
  Parser.prototype.isNestedRule = function() {
201
206
  const currentToken = this.peek(0);
@@ -207,35 +212,27 @@ Parser.prototype.isNestedRule = function() {
207
212
  }
208
213
 
209
214
  const startLine = currentToken.line;
210
- let i = 0;
215
+ // Bounded lookahead — selectors/property names are never longer than ~50 tokens
216
+ const maxLookahead = 50;
211
217
 
212
- while (this.peek(i) && this.peek(i).type !== TokenType.EOF) {
218
+ for (let i = 0; i < maxLookahead; i++) {
213
219
  const token = this.peek(i);
220
+ if (!token || token.type === TokenType.EOF) return false;
214
221
 
215
- // Found { before : - this is a nested rule
222
+ // Found { before : nested rule
216
223
  if (token.type === TokenType.LBRACE) return true;
217
224
 
218
- // Found : - this is a property, not a nested rule
225
+ // Found : before { property declaration
219
226
  if (token.type === TokenType.COLON) return false;
220
227
 
221
- // Found } - end of current rule
228
+ // Found } end of current rule block
222
229
  if (token.type === TokenType.RBRACE) return false;
223
230
 
224
- if (token.line > startLine && i > 0) {
225
- const nextLine = token.line;
226
- let j = i;
227
- while (this.peek(j) && this.peek(j).line === nextLine) {
228
- const t = this.peek(j);
229
- if (t.type === TokenType.LBRACE) return true;
230
- if (t.type === TokenType.COLON) return false;
231
- if (t.type === TokenType.RBRACE) return false;
232
- j++;
233
- }
234
- return false;
235
- }
236
-
237
- i++;
231
+ // If we've moved past the starting line without finding a decisive token,
232
+ // default to property (most common case in CSS)
233
+ if (i > 0 && token.line > startLine) return false;
238
234
  }
235
+
239
236
  return false;
240
237
  };
241
238
 
@@ -622,7 +622,5 @@ Parser.prototype.parseFocusTrapDirective = function() {
622
622
  * Parse @srOnly directive - visually hidden but accessible text
623
623
  */
624
624
  Parser.prototype.parseSrOnlyDirective = function() {
625
- return new ASTNode(NodeType.A11yDirective, {
626
- attrs: { srOnly: true }
627
- });
625
+ return new ASTNode(NodeType.SrOnlyDirective, {});
628
626
  };