tina4-nodejs 3.13.46 → 3.13.47

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/CLAUDE.md CHANGED
@@ -1,4 +1,4 @@
1
- # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.46)
1
+ # CLAUDE.md - AI Developer Guide for tina4-nodejs (v3.13.47)
2
2
 
3
3
  > This file helps AI assistants (Claude, Copilot, Cursor, etc.) understand and work on this codebase effectively.
4
4
 
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
 
5
5
 
6
- "version": "3.13.46",
6
+ "version": "3.13.47",
7
7
 
8
8
  "type": "module",
9
9
  "description": "Tina4 for Node.js/TypeScript \u2014 54 built-in features, zero dependencies",
@@ -122,6 +122,9 @@ function compileString(
122
122
  // 5. Resolve @include
123
123
  scss = resolveIncludes(scss, mixins);
124
124
 
125
+ // 5.5. Resolve #{ ... } interpolation (before $var substitution + nesting).
126
+ scss = resolveInterpolation(scss, variables);
127
+
125
128
  // 6. Substitute variables
126
129
  scss = substituteVariables(scss, variables);
127
130
 
@@ -182,6 +185,26 @@ function substituteVariables(scss: string, variables: Record<string, string>): s
182
185
  return scss;
183
186
  }
184
187
 
188
+ /**
189
+ * Resolve SCSS `#{ ... }` interpolation. Each `#{ expr }` is replaced by its
190
+ * resolved inner text: a `$variable` inside the braces resolves to its value,
191
+ * anything else is inlined verbatim (trimmed). This lets a value carry a
192
+ * variable inside a string context plain `$var` substitution can't reach —
193
+ * e.g. `calc(100% - #{$gap})` → `calc(100% - 20px)` — and lets a variable
194
+ * appear in a selector (`.icon-#{$name}` → `.icon-home`). Run BEFORE nested
195
+ * rule flattening so the literal braces never confuse the block matcher.
196
+ */
197
+ function resolveInterpolation(scss: string, variables: Record<string, string>): string {
198
+ const sorted = Object.keys(variables).sort((a, b) => b.length - a.length);
199
+ return scss.replace(/#\{([^{}]*)\}/g, (_m, inner: string) => {
200
+ let resolved = inner.trim();
201
+ for (const name of sorted) {
202
+ resolved = resolved.replaceAll(`$${name}`, variables[name]);
203
+ }
204
+ return resolved;
205
+ });
206
+ }
207
+
185
208
  // ── Mixins ───────────────────────────────────────────────────────
186
209
 
187
210
  function extractMixins(
@@ -569,54 +569,137 @@ export function normalizeQuotes(sql: string): string {
569
569
  }
570
570
 
571
571
  /**
572
- * Split SQL text into individual statements on the given delimiter.
572
+ * Split SQL text into individual statements with a single-pass, quote- and
573
+ * comment-aware scanner. The split decision is made character by character so
574
+ * the delimiter only ever fires in real statement position.
573
575
  *
574
- * Strips line comments (`-- ...`) and block comments, handles stored
575
- * procedure blocks delimited by `$$` or `//`.
576
+ * This is the fix for issue #54: the old implementation split on `delimiter`
577
+ * BEFORE stripping `-- …` line comments, so a `;` inside a line comment
578
+ * fragmented one statement into several broken pieces. A scanner that knows
579
+ * where it is (code / comment / string) cannot make that mistake.
580
+ *
581
+ * Handled, in priority order, only when NOT already inside a stored-proc block:
582
+ * - `$$ … $$` and `// … //` stored-proc blocks are kept intact (inner `;` never
583
+ * splits). A `//` preceded by `:` is a URL scheme (`https://…`), not a delimiter.
584
+ * - `/* … *​/` block comments are stripped.
585
+ * - `-- …` line comments are stripped to end of line (the newline is kept).
586
+ * - `'…'` single-quoted strings and `"…"` double-quoted identifiers are copied
587
+ * verbatim, honouring the SQL doubled-quote escape (`''` / `""`); a `;`, `--`
588
+ * or `/*` inside a literal is data, not a delimiter or comment.
589
+ * Mirrors the tina4-python `_split_statements` / tina4-php scanner (parity).
576
590
  */
577
591
  export function splitStatements(sql: string, delimiter = ";"): string[] {
578
592
  // Normalize smart/curly quotes to straight ASCII first, so SQL pasted from
579
- // an editor/doc (which converts " → “ ” and ' → ‘ ’) actually runs. Mirrors
580
- // Python's _split_statements applying _normalize_quotes as its first line.
593
+ // an editor/doc (which converts " → “ ” and ' → ‘ ’) actually runs.
581
594
  sql = normalizeQuotes(sql);
582
595
 
583
- // Extract blocks delimited by $$ or // first, replacing with placeholders
584
- const blocks: string[] = [];
585
- const saveBlock = (_match: string, _p1: string): string => {
586
- blocks.push(_match);
587
- return `__BLOCK_${blocks.length - 1}__`;
588
- };
596
+ const statements: string[] = [];
597
+ let current = "";
598
+ const n = sql.length;
599
+ const dlen = delimiter.length;
600
+ let i = 0;
601
+ let inDollarBlock = false;
602
+ let inSlashBlock = false;
603
+
604
+ while (i < n) {
605
+ const ch = sql[i];
606
+
607
+ // $$ … $$ stored-proc block (toggle).
608
+ if (!inSlashBlock && ch === "$" && i + 1 < n && sql[i + 1] === "$") {
609
+ current += "$$";
610
+ i += 2;
611
+ inDollarBlock = !inDollarBlock;
612
+ continue;
613
+ }
614
+
615
+ // // … // stored-proc block (toggle) — but NOT a `://` URL scheme.
616
+ if (
617
+ !inDollarBlock && ch === "/" && i + 1 < n && sql[i + 1] === "/" &&
618
+ !(i > 0 && sql[i - 1] === ":")
619
+ ) {
620
+ current += "//";
621
+ i += 2;
622
+ inSlashBlock = !inSlashBlock;
623
+ continue;
624
+ }
589
625
 
590
- let processed = sql.replace(/\$\$([\s\S]*?)\$\$/g, saveBlock);
591
- // The `//` delimiters must NOT be preceded by a colon, so a URL scheme
592
- // (`https://…`) or other `://` literal inside a migration is never captured
593
- // as an opaque stored-proc block (it would otherwise swallow everything
594
- // between two `//` occurrences and skip statement splitting/cleaning).
595
- // Negative lookbehind `(?<!:)` mirrors Python's runner.
596
- processed = processed.replace(/(?<!:)\/\/([\s\S]*?)(?<!:)\/\//g, saveBlock);
626
+ // Inside a stored-proc block: consume verbatim (inner ; never splits).
627
+ if (inDollarBlock || inSlashBlock) {
628
+ current += ch;
629
+ i += 1;
630
+ continue;
631
+ }
597
632
 
598
- // Remove block comments (/* ... */)
599
- const clean = processed.replace(/\/\*[\s\S]*?\*\//g, "");
633
+ // Block comment /* */ — stripped.
634
+ if (ch === "/" && i + 1 < n && sql[i + 1] === "*") {
635
+ const end = sql.indexOf("*/", i + 2);
636
+ i = end !== -1 ? end + 2 : n;
637
+ continue;
638
+ }
600
639
 
601
- const statements: string[] = [];
602
- for (const part of clean.split(delimiter)) {
603
- const lines: string[] = [];
604
- for (const line of part.split("\n")) {
605
- const stripped = line.trim();
606
- if (!stripped || stripped.startsWith("--")) continue;
607
- // Remove inline comments
608
- const commentPos = line.indexOf("--");
609
- lines.push(commentPos >= 0 ? line.slice(0, commentPos) : line);
640
+ // Line comment -- … — stripped to end of line; the newline is left for the
641
+ // next iteration so line structure (and NEXT-line boundaries) survive.
642
+ if (ch === "-" && i + 1 < n && sql[i + 1] === "-") {
643
+ const end = sql.indexOf("\n", i + 2);
644
+ i = end !== -1 ? end : n;
645
+ continue;
610
646
  }
611
- let cleaned = lines.join("\n").trim();
612
647
 
613
- // Restore block placeholders
614
- for (let i = 0; i < blocks.length; i++) {
615
- cleaned = cleaned.replace(`__BLOCK_${i}__`, blocks[i]);
648
+ // Single-quoted string literal — '' escapes a quote. Copied verbatim.
649
+ if (ch === "'") {
650
+ current += "'";
651
+ i += 1;
652
+ while (i < n) {
653
+ if (sql[i] === "'" && i + 1 < n && sql[i + 1] === "'") {
654
+ current += "''";
655
+ i += 2;
656
+ } else if (sql[i] === "'") {
657
+ current += "'";
658
+ i += 1;
659
+ break;
660
+ } else {
661
+ current += sql[i];
662
+ i += 1;
663
+ }
664
+ }
665
+ continue;
666
+ }
667
+
668
+ // Double-quoted identifier — "" escapes a quote. Same verbatim handling.
669
+ if (ch === '"') {
670
+ current += '"';
671
+ i += 1;
672
+ while (i < n) {
673
+ if (sql[i] === '"' && i + 1 < n && sql[i + 1] === '"') {
674
+ current += '""';
675
+ i += 2;
676
+ } else if (sql[i] === '"') {
677
+ current += '"';
678
+ i += 1;
679
+ break;
680
+ } else {
681
+ current += sql[i];
682
+ i += 1;
683
+ }
684
+ }
685
+ continue;
616
686
  }
617
687
 
618
- if (cleaned) statements.push(cleaned);
688
+ // Statement delimiter — only reached outside blocks/comments/strings.
689
+ if (dlen > 0 && sql.startsWith(delimiter, i)) {
690
+ const stmt = current.trim();
691
+ if (stmt) statements.push(stmt);
692
+ current = "";
693
+ i += dlen;
694
+ continue;
695
+ }
696
+
697
+ current += ch;
698
+ i += 1;
619
699
  }
700
+
701
+ const stmt = current.trim();
702
+ if (stmt) statements.push(stmt);
620
703
  return statements;
621
704
  }
622
705