@thyn/core 0.0.350 → 0.0.352

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.
@@ -815,7 +815,7 @@ async function transformHTMLtoJSX(html, style) {
815
815
  const template = parseHTML("<template>" + processedHTML + "</template>");
816
816
  const rootElement = template.content.firstElementChild;
817
817
  let scopedStyle = null;
818
- if (style) {
818
+ if (style && rootElement) {
819
819
  addScopeId(rootElement, scopeId);
820
820
  scopedStyle = await scopeSelectors(style, scopeId);
821
821
  }
@@ -1,21 +1,142 @@
1
1
  export function extractParts(code) {
2
- const scriptMatch = code.match(/<script([^>]*?)>([\s\S]*?)<\/script>/);
3
- const styleMatch = code.match(/<style[^>]*>([\s\S]*?)<\/style>/);
4
- const html = code
5
- .replace(/<script[^>]*>[\s\S]*?<\/script>/, "")
6
- .replace(/<style[^>]*>[\s\S]*?<\/style>/, "")
7
- .trim();
2
+ // Helper to check if a position is inside a string literal or comment
3
+ function isInsideStringOrComment(code, pos) {
4
+ let inString = false;
5
+ let stringChar = '';
6
+ let escaped = false;
7
+ let inLineComment = false;
8
+ let inBlockComment = false;
9
+ for (let i = 0; i < pos; i++) {
10
+ const char = code[i];
11
+ const nextChar = code[i + 1];
12
+ if (inLineComment) {
13
+ if (char === '\n') {
14
+ inLineComment = false;
15
+ }
16
+ continue;
17
+ }
18
+ if (inBlockComment) {
19
+ if (char === '*' && nextChar === '/') {
20
+ inBlockComment = false;
21
+ i++; // skip the '/'
22
+ }
23
+ continue;
24
+ }
25
+ if (escaped) {
26
+ escaped = false;
27
+ continue;
28
+ }
29
+ if (char === '\\') {
30
+ escaped = true;
31
+ continue;
32
+ }
33
+ // Check for comment start
34
+ if (char === '/' && nextChar === '/') {
35
+ inLineComment = true;
36
+ i++; // skip the second '/'
37
+ continue;
38
+ }
39
+ if (char === '/' && nextChar === '*') {
40
+ inBlockComment = true;
41
+ i++; // skip the '*'
42
+ continue;
43
+ }
44
+ if (!inString && (char === '"' || char === "'" || char === '`')) {
45
+ inString = true;
46
+ stringChar = char;
47
+ }
48
+ else if (inString && char === stringChar) {
49
+ inString = false;
50
+ stringChar = '';
51
+ }
52
+ }
53
+ return inString || inLineComment || inBlockComment;
54
+ }
55
+ // Find first real <script> tag (not inside a string)
56
+ function findScriptSection(code) {
57
+ const openRegex = /<script([^>]*)>/gi;
58
+ let openMatch;
59
+ while ((openMatch = openRegex.exec(code)) !== null) {
60
+ if (!isInsideStringOrComment(code, openMatch.index)) {
61
+ const contentStart = openMatch.index + openMatch[0].length;
62
+ // Find the first </script> that is not inside a string
63
+ const closeRegex = /<\/script>/gi;
64
+ let closeMatch;
65
+ while ((closeMatch = closeRegex.exec(code)) !== null) {
66
+ if (closeMatch.index >= contentStart && !isInsideStringOrComment(code, closeMatch.index)) {
67
+ return {
68
+ start: openMatch.index,
69
+ contentStart: contentStart,
70
+ contentEnd: closeMatch.index,
71
+ end: closeMatch.index + closeMatch[0].length,
72
+ attrs: openMatch[1] || ''
73
+ };
74
+ }
75
+ }
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+ // Find first real <style> tag (not inside a string)
81
+ function findStyleSection(code) {
82
+ const openRegex = /<style[^>]*>/gi;
83
+ let openMatch;
84
+ while ((openMatch = openRegex.exec(code)) !== null) {
85
+ if (!isInsideStringOrComment(code, openMatch.index)) {
86
+ const contentStart = openMatch.index + openMatch[0].length;
87
+ // Find the first </style> that is not inside a string
88
+ const closeRegex = /<\/style>/gi;
89
+ let closeMatch;
90
+ while ((closeMatch = closeRegex.exec(code)) !== null) {
91
+ if (closeMatch.index >= contentStart && !isInsideStringOrComment(code, closeMatch.index)) {
92
+ return {
93
+ start: openMatch.index,
94
+ contentStart: contentStart,
95
+ contentEnd: closeMatch.index,
96
+ end: closeMatch.index + closeMatch[0].length
97
+ };
98
+ }
99
+ }
100
+ }
101
+ }
102
+ return null;
103
+ }
104
+ // Extract sections
105
+ const scriptSection = findScriptSection(code);
106
+ const styleSection = findStyleSection(code);
107
+ let script = "";
8
108
  let scriptLang = "js";
9
- if (scriptMatch && scriptMatch[1]) {
10
- const langMatch = scriptMatch[1].match(/lang\s*=\s*["']([^"']+)["']/);
109
+ if (scriptSection) {
110
+ script = code.slice(scriptSection.contentStart, scriptSection.contentEnd).trim();
111
+ const langMatch = scriptSection.attrs.match(/lang\s*=\s*["']([^"']+)["']/);
11
112
  if (langMatch) {
12
113
  scriptLang = langMatch[1];
13
114
  }
14
115
  }
116
+ let style = "";
117
+ if (styleSection) {
118
+ style = code.slice(styleSection.contentStart, styleSection.contentEnd).trim();
119
+ }
120
+ // Build HTML by removing script and style sections
121
+ // Remove from highest index to lowest to preserve indices
122
+ let html = code;
123
+ const sections = [];
124
+ if (scriptSection) {
125
+ sections.push({ start: scriptSection.start, end: scriptSection.end });
126
+ }
127
+ if (styleSection) {
128
+ sections.push({ start: styleSection.start, end: styleSection.end });
129
+ }
130
+ // Sort by start position descending (remove from end first)
131
+ sections.sort((a, b) => b.start - a.start);
132
+ for (const section of sections) {
133
+ html = html.slice(0, section.start) + html.slice(section.end);
134
+ }
135
+ html = html.trim();
15
136
  return {
16
- script: scriptMatch?.[2]?.trim() ?? "",
137
+ script,
17
138
  scriptLang,
18
- style: styleMatch?.[1]?.trim() ?? "",
139
+ style,
19
140
  html,
20
141
  };
21
142
  }
@@ -47,13 +168,13 @@ export function splitScript(script) {
47
168
  let inString = false;
48
169
  let stringChar = "";
49
170
  let inMultiLineComment = false;
171
+ let escaped = false;
50
172
  // Helper function to check if import is complete without semicolon
51
- function isImportComplete(line, braceCount, inString) {
173
+ function isImportComplete(lineIndex, braceCount, inString) {
52
174
  // If we have balanced braces and not in a string, check if next non-empty line starts a new statement
53
175
  if (braceCount === 0 && !inString) {
54
176
  // Look ahead to see if next line starts a new statement/declaration
55
- const nextLineIndex = lines.indexOf(line) + 1;
56
- for (let i = nextLineIndex; i < lines.length; i++) {
177
+ for (let i = lineIndex + 1; i < lines.length; i++) {
57
178
  const nextLine = lines[i].trim();
58
179
  if (!nextLine || nextLine.startsWith("//") || nextLine.startsWith("/*")) {
59
180
  continue; // Skip empty lines and comments
@@ -67,6 +188,7 @@ export function splitScript(script) {
67
188
  }
68
189
  return false;
69
190
  }
191
+ // Process each line, maintaining string/comment state
70
192
  for (let i = 0; i < lines.length; i++) {
71
193
  const line = lines[i];
72
194
  const trimmed = line.trim();
@@ -78,13 +200,34 @@ export function splitScript(script) {
78
200
  else {
79
201
  body.push(line);
80
202
  }
81
- if (line.includes("*/")) {
82
- inMultiLineComment = false;
203
+ // Check for end of multi-line comment, tracking strings
204
+ for (let j = 0; j < line.length; j++) {
205
+ const char = line[j];
206
+ if (escaped) {
207
+ escaped = false;
208
+ continue;
209
+ }
210
+ if (char === '\\' && inString) {
211
+ escaped = true;
212
+ continue;
213
+ }
214
+ if (!inString && (char === '"' || char === "'" || char === '`')) {
215
+ inString = true;
216
+ stringChar = char;
217
+ }
218
+ else if (inString && char === stringChar) {
219
+ inString = false;
220
+ stringChar = "";
221
+ }
222
+ else if (!inString && char === '*' && line[j + 1] === '/') {
223
+ inMultiLineComment = false;
224
+ break;
225
+ }
83
226
  }
84
227
  continue;
85
228
  }
86
- // Check for start of multi-line comment
87
- if (line.includes("/*") && !inString) {
229
+ // Check for start of multi-line comment (only if not in string)
230
+ if (!inString && line.includes("/*")) {
88
231
  inMultiLineComment = true;
89
232
  if (inImport) {
90
233
  currentImport.push(line);
@@ -92,15 +235,37 @@ export function splitScript(script) {
92
235
  else {
93
236
  body.push(line);
94
237
  }
95
- if (!line.includes("*/")) {
96
- continue;
238
+ // Check if comment ends on same line
239
+ for (let j = 0; j < line.length; j++) {
240
+ const char = line[j];
241
+ if (escaped) {
242
+ escaped = false;
243
+ continue;
244
+ }
245
+ if (char === '\\' && inString) {
246
+ escaped = true;
247
+ continue;
248
+ }
249
+ if (!inString && (char === '"' || char === "'" || char === '`')) {
250
+ inString = true;
251
+ stringChar = char;
252
+ }
253
+ else if (inString && char === stringChar) {
254
+ inString = false;
255
+ stringChar = "";
256
+ }
257
+ else if (!inString && char === '*' && line[j + 1] === '/') {
258
+ inMultiLineComment = false;
259
+ break;
260
+ }
97
261
  }
98
- else {
99
- inMultiLineComment = false;
262
+ if (!inMultiLineComment) {
263
+ // Comment ended on same line
264
+ continue;
100
265
  }
101
266
  }
102
- // Skip single-line comments when not in import
103
- if (trimmed.startsWith("//") && !inImport) {
267
+ // Skip single-line comments when not in import (only if not in string)
268
+ if (!inString && !inImport && trimmed.startsWith("//")) {
104
269
  body.push(line);
105
270
  continue;
106
271
  }
@@ -109,37 +274,46 @@ export function splitScript(script) {
109
274
  body.push(line);
110
275
  continue;
111
276
  }
112
- // Start of import statement
113
- if (!inImport && trimmed.startsWith("import")) {
277
+ // Process the line character by character to maintain string state
278
+ let lineBraceCount = 0;
279
+ let lineInString = inString;
280
+ let lineStringChar = stringChar;
281
+ let lineEscaped = false;
282
+ for (let j = 0; j < line.length; j++) {
283
+ const char = line[j];
284
+ if (lineEscaped) {
285
+ lineEscaped = false;
286
+ continue;
287
+ }
288
+ if (char === '\\' && lineInString) {
289
+ lineEscaped = true;
290
+ continue;
291
+ }
292
+ if (!lineInString && (char === '"' || char === "'" || char === '`')) {
293
+ lineInString = true;
294
+ lineStringChar = char;
295
+ }
296
+ else if (lineInString && char === lineStringChar) {
297
+ lineInString = false;
298
+ lineStringChar = "";
299
+ }
300
+ else if (!lineInString && char === '{') {
301
+ lineBraceCount++;
302
+ }
303
+ else if (!lineInString && char === '}') {
304
+ lineBraceCount--;
305
+ }
306
+ }
307
+ // Start of import statement (only if not inside a string)
308
+ if (!inImport && !inString && trimmed.startsWith("import")) {
114
309
  inImport = true;
115
310
  currentImport = [line];
116
- braceCount = 0;
117
- inString = false;
118
- // Count braces and track strings in the import line
119
- for (let j = 0; j < line.length; j++) {
120
- const char = line[j];
121
- if (inString) {
122
- if (char === stringChar && line[j - 1] !== "\\") {
123
- inString = false;
124
- stringChar = "";
125
- }
126
- }
127
- else {
128
- if (char === '"' || char === "'" || char === "`") {
129
- inString = true;
130
- stringChar = char;
131
- }
132
- else if (char === "{") {
133
- braceCount++;
134
- }
135
- else if (char === "}") {
136
- braceCount--;
137
- }
138
- }
139
- }
140
- // Check if import is complete
311
+ braceCount = lineBraceCount;
312
+ inString = lineInString;
313
+ stringChar = lineStringChar;
314
+ // Check if import is complete on this line
141
315
  if ((trimmed.endsWith(";") ||
142
- isImportComplete(trimmed, braceCount, inString)) &&
316
+ isImportComplete(i, braceCount, inString)) &&
143
317
  braceCount === 0 && !inString) {
144
318
  imports.push(currentImport.join("\n"));
145
319
  currentImport = [];
@@ -148,31 +322,12 @@ export function splitScript(script) {
148
322
  } // Continue import statement
149
323
  else if (inImport) {
150
324
  currentImport.push(line);
151
- // Count braces and track strings in the current line
152
- for (let j = 0; j < line.length; j++) {
153
- const char = line[j];
154
- if (inString) {
155
- if (char === stringChar && line[j - 1] !== "\\") {
156
- inString = false;
157
- stringChar = "";
158
- }
159
- }
160
- else {
161
- if (char === '"' || char === "'" || char === "`") {
162
- inString = true;
163
- stringChar = char;
164
- }
165
- else if (char === "{") {
166
- braceCount++;
167
- }
168
- else if (char === "}") {
169
- braceCount--;
170
- }
171
- }
172
- }
325
+ braceCount += lineBraceCount;
326
+ inString = lineInString;
327
+ stringChar = lineStringChar;
173
328
  // Check if import is complete
174
329
  if ((trimmed.endsWith(";") ||
175
- isImportComplete(trimmed, braceCount, inString)) &&
330
+ isImportComplete(i, braceCount, inString)) &&
176
331
  braceCount === 0 && !inString) {
177
332
  imports.push(currentImport.join("\n"));
178
333
  currentImport = [];
@@ -181,11 +336,21 @@ export function splitScript(script) {
181
336
  } // Regular body content
182
337
  else {
183
338
  body.push(line);
339
+ // Update global string state
340
+ inString = lineInString;
341
+ stringChar = lineStringChar;
184
342
  }
185
343
  }
186
- // Handle unterminated import (likely malformed)
344
+ // Handle unterminated import (likely malformed or still in string)
187
345
  if (currentImport.length > 0) {
188
- imports.push(currentImport.join("\n"));
346
+ if (inImport && !inString) {
347
+ // Import seems complete but wasn't captured properly
348
+ imports.push(currentImport.join("\n"));
349
+ }
350
+ else {
351
+ // Still in string or incomplete, treat as body
352
+ body.push(...currentImport);
353
+ }
189
354
  }
190
355
  return {
191
356
  imports: imports.filter((imp) => imp.trim()),
@@ -8,7 +8,7 @@
8
8
  "name": "thyn-app",
9
9
  "version": "0.0.0",
10
10
  "devDependencies": {
11
- "@thyn/core": "^0.0.349",
11
+ "@thyn/core": "^0.0.351",
12
12
  "vite": "^6.3.5"
13
13
  }
14
14
  },
@@ -742,9 +742,9 @@
742
742
  ]
743
743
  },
744
744
  "node_modules/@thyn/core": {
745
- "version": "0.0.349",
746
- "resolved": "https://registry.npmjs.org/@thyn/core/-/core-0.0.349.tgz",
747
- "integrity": "sha512-fqt0ReX+IIIe0ECmr6GdZEBICnA8r+WYDIpkQNO349Lm8+pTcGEHIH9F2csJHROCZ6PDLDoAfL2tJHUoIjGrWA==",
745
+ "version": "0.0.351",
746
+ "resolved": "https://registry.npmjs.org/@thyn/core/-/core-0.0.351.tgz",
747
+ "integrity": "sha512-67M0/0wrdz1mNIMZ9V7Nq1jEO1C1iLdoG9rnjFcDGlDQRFM/HlNizvVWOaq2RfapVpgtRqIsKAA5iBloLE2GIA==",
748
748
  "dev": true,
749
749
  "license": "MIT",
750
750
  "dependencies": {
package/docs/package.json CHANGED
@@ -9,7 +9,7 @@
9
9
  "preview": "vite preview"
10
10
  },
11
11
  "devDependencies": {
12
- "@thyn/core": "^0.0.349",
12
+ "@thyn/core": "^0.0.351",
13
13
  "vite": "^6.3.5"
14
14
  }
15
15
  }
@@ -17,47 +17,49 @@
17
17
  setTimeout(() => {
18
18
  Prism.highlightAll();
19
19
  });
20
- </script>
21
20
 
22
- <div class="main">
23
- <h1>
24
- <img src="/thyn.svg" />
25
- thyn
26
- </h1>
27
- <p class="tagline">{{ taglines[tagline() % taglines.length] }}</p>
28
- <div class="compiled-wrapper">
29
- <div>
30
- <h4>source</h4>
31
- <pre>
32
- <code class="code language-javascript">
33
- // App.thyn
34
- &lt;script&gt;
21
+ const codeSnippet = `// App.thyn
22
+ <script>
35
23
  const count = $signal(0);
36
- &lt;/script&gt;
24
+ </script>
37
25
 
38
- &lt;button onclick={() =&gt; count(c =&gt; c + 1)}&gt;
26
+ <button onclick={() => count(c => c + 1)}>
39
27
  Count: \{{ count() \}}
40
- &lt;/button&gt;
28
+ </button>
41
29
 
42
- &lt;style&gt;
30
+ <style>
43
31
  button {
44
32
  background: #333;
45
33
  }
46
- &lt;/style&gt;
34
+ </style>
47
35
 
48
36
  // main.js
49
37
  import { mount } from '@thyn/core';
50
38
  import App from './App.thyn';
51
39
 
52
- mount(App, document.body);
53
- </code>
40
+ mount(App, document.body);`;
41
+ </script>
42
+
43
+ <div class="main">
44
+ <h1>
45
+ <img src="/thyn.svg" />
46
+ thyn
47
+ </h1>
48
+ <p class="tagline">{{ taglines[tagline() % taglines.length] }}</p>
49
+ <div class="compiled-wrapper">
50
+ <div>
51
+ <h4>source</h4>
52
+ <pre>
53
+ <code class="code language-javascript">
54
+ {{ codeSnippet }}
55
+ </code>
54
56
  </pre>
55
57
  </div>
56
58
  <div>
57
59
  <h4>compiled</h4>
58
60
  <pre>
59
61
  <code class="compiled language-javascript">
60
- let u,s;const r=[];function l(t){r.push(t),s||(s=!0,queueMicrotask(()=&gt;{for(const n of r)f(n);r.length=0,s=!1}))}function p(t){const n=new Set;return(...e)=&gt;{if(!e.length)return u&amp;&amp;(n.add(u),u.deps.add(n)),t;const o=e[0],i=typeof o==&quot;function&quot;?o(t):o;if(i!==t){t=i;for(const d of n)l(d)}}}function f(t,n){n||a(t);const e=u;u=t;const o=t.run();o&amp;&amp;(t.td?t.td.push(o):t.td=[o]),u=e}function _(t,n){const e={run:t,deps:new Set,show:n};return f(e,!0),e}function a(t){const{deps:n,td:e}=t;if(n.size){for(const o of n)o.delete(t);n.clear()}if(e){for(const o of e)o();t.td=null}}function h(t,n){n.appendChild(t())}let c;function m(){if(!c){c=document.createElement(&quot;button&quot;),c.className=&quot;thyn-0&quot;;const t=document.createTextNode(&quot;&quot;);return c.appendChild(t),c}return c.cloneNode(!0)}function N(t){const n=p(0),e=m();return e.onclick=()=&gt;n(o=&gt;o+1),_(()=&gt;{e.firstChild.nodeValue=`Count: ${n()}`}),e}h(N,document.body);
62
+ let u,s;const r=[];function l(t){r.push(t),s||(s=!0,queueMicrotask(()=>{for(const n of r)f(n);r.length=0,s=!1}))}function p(t){const n=new Set;return(...e)=>{if(!e.length)return u&amp;&amp;(n.add(u),u.deps.add(n)),t;const o=e[0],i=typeof o==&quot;function&quot;?o(t):o;if(i!==t){t=i;for(const d of n)l(d)}}}function f(t,n){n||a(t);const e=u;u=t;const o=t.run();o&amp;&amp;(t.td?t.td.push(o):t.td=[o]),u=e}function _(t,n){const e={run:t,deps:new Set,show:n};return f(e,!0),e}function a(t){const{deps:n,td:e}=t;if(n.size){for(const o of n)o.delete(t);n.clear()}if(e){for(const o of e)o();t.td=null}}function h(t,n){n.appendChild(t())}let c;function m(){if(!c){c=document.createElement(&quot;button&quot;),c.className=&quot;thyn-0&quot;;const t=document.createTextNode(&quot;&quot;);return c.appendChild(t),c}return c.cloneNode(!0)}function N(t){const n=p(0),e=m();return e.onclick=()=>n(o=>o+1),_(()=>{e.firstChild.nodeValue=`Count: ${n()}`}),e}h(N,document.body);
61
63
  </code>
62
64
  </pre>
63
65
  </div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thyn/core",
3
- "version": "0.0.350",
3
+ "version": "0.0.352",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -906,7 +906,7 @@ async function transformHTMLtoJSX(html: string, style: string) {
906
906
  const rootElement = template.content.firstElementChild;
907
907
 
908
908
  let scopedStyle = null;
909
- if (style) {
909
+ if (style && rootElement) {
910
910
  addScopeId(rootElement, scopeId);
911
911
  scopedStyle = await scopeSelectors(style, scopeId);
912
912
  }
@@ -1,23 +1,163 @@
1
1
  export function extractParts(code: string) {
2
- const scriptMatch = code.match(/<script([^>]*?)>([\s\S]*?)<\/script>/);
3
- const styleMatch = code.match(/<style[^>]*>([\s\S]*?)<\/style>/);
4
- const html = code
5
- .replace(/<script[^>]*>[\s\S]*?<\/script>/, "")
6
- .replace(/<style[^>]*>[\s\S]*?<\/style>/, "")
7
- .trim();
2
+ // Helper to check if a position is inside a string literal or comment
3
+ function isInsideStringOrComment(code: string, pos: number): boolean {
4
+ let inString = false;
5
+ let stringChar = '';
6
+ let escaped = false;
7
+ let inLineComment = false;
8
+ let inBlockComment = false;
9
+
10
+ for (let i = 0; i < pos; i++) {
11
+ const char = code[i];
12
+ const nextChar = code[i + 1];
13
+
14
+ if (inLineComment) {
15
+ if (char === '\n') {
16
+ inLineComment = false;
17
+ }
18
+ continue;
19
+ }
20
+
21
+ if (inBlockComment) {
22
+ if (char === '*' && nextChar === '/') {
23
+ inBlockComment = false;
24
+ i++; // skip the '/'
25
+ }
26
+ continue;
27
+ }
28
+
29
+ if (escaped) {
30
+ escaped = false;
31
+ continue;
32
+ }
33
+
34
+ if (char === '\\') {
35
+ escaped = true;
36
+ continue;
37
+ }
38
+
39
+ // Check for comment start
40
+ if (char === '/' && nextChar === '/') {
41
+ inLineComment = true;
42
+ i++; // skip the second '/'
43
+ continue;
44
+ }
45
+
46
+ if (char === '/' && nextChar === '*') {
47
+ inBlockComment = true;
48
+ i++; // skip the '*'
49
+ continue;
50
+ }
51
+
52
+ if (!inString && (char === '"' || char === "'" || char === '`')) {
53
+ inString = true;
54
+ stringChar = char;
55
+ } else if (inString && char === stringChar) {
56
+ inString = false;
57
+ stringChar = '';
58
+ }
59
+ }
60
+
61
+ return inString || inLineComment || inBlockComment;
62
+ }
63
+
64
+ // Find first real <script> tag (not inside a string)
65
+ function findScriptSection(code: string): { start: number; contentStart: number; contentEnd: number; end: number; attrs: string } | null {
66
+ const openRegex = /<script([^>]*)>/gi;
67
+ let openMatch;
68
+
69
+ while ((openMatch = openRegex.exec(code)) !== null) {
70
+ if (!isInsideStringOrComment(code, openMatch.index)) {
71
+ const contentStart = openMatch.index + openMatch[0].length;
72
+
73
+ // Find the first </script> that is not inside a string
74
+ const closeRegex = /<\/script>/gi;
75
+ let closeMatch;
76
+ while ((closeMatch = closeRegex.exec(code)) !== null) {
77
+ if (closeMatch.index >= contentStart && !isInsideStringOrComment(code, closeMatch.index)) {
78
+ return {
79
+ start: openMatch.index,
80
+ contentStart: contentStart,
81
+ contentEnd: closeMatch.index,
82
+ end: closeMatch.index + closeMatch[0].length,
83
+ attrs: openMatch[1] || ''
84
+ };
85
+ }
86
+ }
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ // Find first real <style> tag (not inside a string)
93
+ function findStyleSection(code: string): { start: number; contentStart: number; contentEnd: number; end: number } | null {
94
+ const openRegex = /<style[^>]*>/gi;
95
+ let openMatch;
96
+
97
+ while ((openMatch = openRegex.exec(code)) !== null) {
98
+ if (!isInsideStringOrComment(code, openMatch.index)) {
99
+ const contentStart = openMatch.index + openMatch[0].length;
100
+
101
+ // Find the first </style> that is not inside a string
102
+ const closeRegex = /<\/style>/gi;
103
+ let closeMatch;
104
+ while ((closeMatch = closeRegex.exec(code)) !== null) {
105
+ if (closeMatch.index >= contentStart && !isInsideStringOrComment(code, closeMatch.index)) {
106
+ return {
107
+ start: openMatch.index,
108
+ contentStart: contentStart,
109
+ contentEnd: closeMatch.index,
110
+ end: closeMatch.index + closeMatch[0].length
111
+ };
112
+ }
113
+ }
114
+ }
115
+ }
116
+ return null;
117
+ }
8
118
 
119
+ // Extract sections
120
+ const scriptSection = findScriptSection(code);
121
+ const styleSection = findStyleSection(code);
122
+
123
+ let script = "";
9
124
  let scriptLang = "js";
10
- if (scriptMatch && scriptMatch[1]) {
11
- const langMatch = scriptMatch[1].match(/lang\s*=\s*["']([^"']+)["']/);
125
+
126
+ if (scriptSection) {
127
+ script = code.slice(scriptSection.contentStart, scriptSection.contentEnd).trim();
128
+ const langMatch = scriptSection.attrs.match(/lang\s*=\s*["']([^"']+)["']/);
12
129
  if (langMatch) {
13
130
  scriptLang = langMatch[1];
14
131
  }
15
132
  }
16
133
 
134
+ let style = "";
135
+ if (styleSection) {
136
+ style = code.slice(styleSection.contentStart, styleSection.contentEnd).trim();
137
+ }
138
+
139
+ // Build HTML by removing script and style sections
140
+ // Remove from highest index to lowest to preserve indices
141
+ let html = code;
142
+ const sections = [];
143
+ if (scriptSection) {
144
+ sections.push({ start: scriptSection.start, end: scriptSection.end });
145
+ }
146
+ if (styleSection) {
147
+ sections.push({ start: styleSection.start, end: styleSection.end });
148
+ }
149
+ // Sort by start position descending (remove from end first)
150
+ sections.sort((a, b) => b.start - a.start);
151
+
152
+ for (const section of sections) {
153
+ html = html.slice(0, section.start) + html.slice(section.end);
154
+ }
155
+ html = html.trim();
156
+
17
157
  return {
18
- script: scriptMatch?.[2]?.trim() ?? "",
158
+ script,
19
159
  scriptLang,
20
- style: styleMatch?.[1]?.trim() ?? "",
160
+ style,
21
161
  html,
22
162
  };
23
163
  };
@@ -46,20 +186,20 @@ export function splitScript(script: string) {
46
186
  const lines = script.split("\n");
47
187
  const imports = [];
48
188
  const body = [];
49
- let currentImport = [];
189
+ let currentImport: string[] = [];
50
190
  let inImport = false;
51
191
  let braceCount = 0;
52
192
  let inString = false;
53
193
  let stringChar = "";
54
194
  let inMultiLineComment = false;
195
+ let escaped = false;
55
196
 
56
197
  // Helper function to check if import is complete without semicolon
57
- function isImportComplete(line, braceCount, inString) {
198
+ function isImportComplete(lineIndex: number, braceCount: number, inString: boolean): boolean {
58
199
  // If we have balanced braces and not in a string, check if next non-empty line starts a new statement
59
200
  if (braceCount === 0 && !inString) {
60
201
  // Look ahead to see if next line starts a new statement/declaration
61
- const nextLineIndex = lines.indexOf(line) + 1;
62
- for (let i = nextLineIndex; i < lines.length; i++) {
202
+ for (let i = lineIndex + 1; i < lines.length; i++) {
63
203
  const nextLine = lines[i].trim();
64
204
  if (
65
205
  !nextLine || nextLine.startsWith("//") || nextLine.startsWith("/*")
@@ -76,6 +216,7 @@ export function splitScript(script: string) {
76
216
  return false;
77
217
  }
78
218
 
219
+ // Process each line, maintaining string/comment state
79
220
  for (let i = 0; i < lines.length; i++) {
80
221
  const line = lines[i];
81
222
  const trimmed = line.trim();
@@ -88,14 +229,33 @@ export function splitScript(script: string) {
88
229
  body.push(line);
89
230
  }
90
231
 
91
- if (line.includes("*/")) {
92
- inMultiLineComment = false;
232
+ // Check for end of multi-line comment, tracking strings
233
+ for (let j = 0; j < line.length; j++) {
234
+ const char = line[j];
235
+ if (escaped) {
236
+ escaped = false;
237
+ continue;
238
+ }
239
+ if (char === '\\' && inString) {
240
+ escaped = true;
241
+ continue;
242
+ }
243
+ if (!inString && (char === '"' || char === "'" || char === '`')) {
244
+ inString = true;
245
+ stringChar = char;
246
+ } else if (inString && char === stringChar) {
247
+ inString = false;
248
+ stringChar = "";
249
+ } else if (!inString && char === '*' && line[j + 1] === '/') {
250
+ inMultiLineComment = false;
251
+ break;
252
+ }
93
253
  }
94
254
  continue;
95
255
  }
96
256
 
97
- // Check for start of multi-line comment
98
- if (line.includes("/*") && !inString) {
257
+ // Check for start of multi-line comment (only if not in string)
258
+ if (!inString && line.includes("/*")) {
99
259
  inMultiLineComment = true;
100
260
  if (inImport) {
101
261
  currentImport.push(line);
@@ -103,15 +263,37 @@ export function splitScript(script: string) {
103
263
  body.push(line);
104
264
  }
105
265
 
106
- if (!line.includes("*/")) {
266
+ // Check if comment ends on same line
267
+ for (let j = 0; j < line.length; j++) {
268
+ const char = line[j];
269
+ if (escaped) {
270
+ escaped = false;
271
+ continue;
272
+ }
273
+ if (char === '\\' && inString) {
274
+ escaped = true;
275
+ continue;
276
+ }
277
+ if (!inString && (char === '"' || char === "'" || char === '`')) {
278
+ inString = true;
279
+ stringChar = char;
280
+ } else if (inString && char === stringChar) {
281
+ inString = false;
282
+ stringChar = "";
283
+ } else if (!inString && char === '*' && line[j + 1] === '/') {
284
+ inMultiLineComment = false;
285
+ break;
286
+ }
287
+ }
288
+
289
+ if (!inMultiLineComment) {
290
+ // Comment ended on same line
107
291
  continue;
108
- } else {
109
- inMultiLineComment = false;
110
292
  }
111
293
  }
112
294
 
113
- // Skip single-line comments when not in import
114
- if (trimmed.startsWith("//") && !inImport) {
295
+ // Skip single-line comments when not in import (only if not in string)
296
+ if (!inString && !inImport && trimmed.startsWith("//")) {
115
297
  body.push(line);
116
298
  continue;
117
299
  }
@@ -122,38 +304,50 @@ export function splitScript(script: string) {
122
304
  continue;
123
305
  }
124
306
 
125
- // Start of import statement
126
- if (!inImport && trimmed.startsWith("import")) {
127
- inImport = true;
128
- currentImport = [line];
129
- braceCount = 0;
130
- inString = false;
307
+ // Process the line character by character to maintain string state
308
+ let lineBraceCount = 0;
309
+ let lineInString = inString;
310
+ let lineStringChar = stringChar;
311
+ let lineEscaped = false;
131
312
 
132
- // Count braces and track strings in the import line
133
- for (let j = 0; j < line.length; j++) {
134
- const char = line[j];
313
+ for (let j = 0; j < line.length; j++) {
314
+ const char = line[j];
135
315
 
136
- if (inString) {
137
- if (char === stringChar && line[j - 1] !== "\\") {
138
- inString = false;
139
- stringChar = "";
140
- }
141
- } else {
142
- if (char === '"' || char === "'" || char === "`") {
143
- inString = true;
144
- stringChar = char;
145
- } else if (char === "{") {
146
- braceCount++;
147
- } else if (char === "}") {
148
- braceCount--;
149
- }
150
- }
316
+ if (lineEscaped) {
317
+ lineEscaped = false;
318
+ continue;
151
319
  }
152
320
 
153
- // Check if import is complete
321
+ if (char === '\\' && lineInString) {
322
+ lineEscaped = true;
323
+ continue;
324
+ }
325
+
326
+ if (!lineInString && (char === '"' || char === "'" || char === '`')) {
327
+ lineInString = true;
328
+ lineStringChar = char;
329
+ } else if (lineInString && char === lineStringChar) {
330
+ lineInString = false;
331
+ lineStringChar = "";
332
+ } else if (!lineInString && char === '{') {
333
+ lineBraceCount++;
334
+ } else if (!lineInString && char === '}') {
335
+ lineBraceCount--;
336
+ }
337
+ }
338
+
339
+ // Start of import statement (only if not inside a string)
340
+ if (!inImport && !inString && trimmed.startsWith("import")) {
341
+ inImport = true;
342
+ currentImport = [line];
343
+ braceCount = lineBraceCount;
344
+ inString = lineInString;
345
+ stringChar = lineStringChar;
346
+
347
+ // Check if import is complete on this line
154
348
  if (
155
349
  (trimmed.endsWith(";") ||
156
- isImportComplete(trimmed, braceCount, inString)) &&
350
+ isImportComplete(i, braceCount, inString)) &&
157
351
  braceCount === 0 && !inString
158
352
  ) {
159
353
  imports.push(currentImport.join("\n"));
@@ -163,32 +357,14 @@ export function splitScript(script: string) {
163
357
  } // Continue import statement
164
358
  else if (inImport) {
165
359
  currentImport.push(line);
166
-
167
- // Count braces and track strings in the current line
168
- for (let j = 0; j < line.length; j++) {
169
- const char = line[j];
170
-
171
- if (inString) {
172
- if (char === stringChar && line[j - 1] !== "\\") {
173
- inString = false;
174
- stringChar = "";
175
- }
176
- } else {
177
- if (char === '"' || char === "'" || char === "`") {
178
- inString = true;
179
- stringChar = char;
180
- } else if (char === "{") {
181
- braceCount++;
182
- } else if (char === "}") {
183
- braceCount--;
184
- }
185
- }
186
- }
360
+ braceCount += lineBraceCount;
361
+ inString = lineInString;
362
+ stringChar = lineStringChar;
187
363
 
188
364
  // Check if import is complete
189
365
  if (
190
366
  (trimmed.endsWith(";") ||
191
- isImportComplete(trimmed, braceCount, inString)) &&
367
+ isImportComplete(i, braceCount, inString)) &&
192
368
  braceCount === 0 && !inString
193
369
  ) {
194
370
  imports.push(currentImport.join("\n"));
@@ -198,12 +374,21 @@ export function splitScript(script: string) {
198
374
  } // Regular body content
199
375
  else {
200
376
  body.push(line);
377
+ // Update global string state
378
+ inString = lineInString;
379
+ stringChar = lineStringChar;
201
380
  }
202
381
  }
203
382
 
204
- // Handle unterminated import (likely malformed)
383
+ // Handle unterminated import (likely malformed or still in string)
205
384
  if (currentImport.length > 0) {
206
- imports.push(currentImport.join("\n"));
385
+ if (inImport && !inString) {
386
+ // Import seems complete but wasn't captured properly
387
+ imports.push(currentImport.join("\n"));
388
+ } else {
389
+ // Still in string or incomplete, treat as body
390
+ body.push(...currentImport);
391
+ }
207
392
  }
208
393
 
209
394
  return {
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import CodeSnippet from "./CodeSnippet.thyn";
3
+
4
+ describe("CodeSnippet component", () => {
5
+ it("should render codeSnippet string containing script/style tags", () => {
6
+ const root = CodeSnippet();
7
+ const display = root.querySelector('.display');
8
+
9
+ // The component should render the codeSnippet content
10
+ expect(display).toBeTruthy();
11
+ expect(display.textContent).toContain("// App.thyn");
12
+ expect(display.textContent).toContain("<script>");
13
+ expect(display.textContent).toContain("</script>");
14
+ expect(display.textContent).toContain("<style>");
15
+ expect(display.textContent).toContain("</style>");
16
+ expect(display.textContent).toContain("button {");
17
+ });
18
+
19
+ it("should apply the component's actual style, not style from codeSnippet", () => {
20
+ const root = CodeSnippet();
21
+ const display = root.querySelector('.display');
22
+
23
+ // The display element should have the component's style applied
24
+ // (white-space: pre and font-family: monospace from actual <style> section)
25
+ // Note: scoped CSS adds a class like 'thyn-e', so we check it contains 'display'
26
+ expect(display.className).toContain('display');
27
+ });
28
+ });
@@ -0,0 +1,31 @@
1
+ <script>
2
+ const count = $signal(0);
3
+
4
+ // This code snippet string contains <script> and <style> tags
5
+ // which should NOT be extracted as actual sections
6
+ const codeSnippet = `// App.thyn
7
+ <script>
8
+ const count = $signal(0);
9
+ </script>
10
+
11
+ <button onclick={() => count(c => c + 1)}>
12
+ Count: {{ count() }}
13
+ </button>
14
+
15
+ <style>
16
+ button {
17
+ background: #333;
18
+ }
19
+ </style>`;
20
+ </script>
21
+
22
+ <div>
23
+ <p class="display">{{ codeSnippet }}</p>
24
+ </div>
25
+
26
+ <style>
27
+ .display {
28
+ white-space: pre;
29
+ font-family: monospace;
30
+ }
31
+ </style>
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import ImportInString from "./ImportInString.thyn";
3
+
4
+ describe("ImportInString component", () => {
5
+ it("should not treat imports inside string literals as real imports", () => {
6
+ const root = ImportInString();
7
+ expect(root.textContent).toBe("42");
8
+ });
9
+
10
+ it("should include the import statements in the codeSnippet", () => {
11
+ const root = ImportInString();
12
+ // The codeSnippet should be available (this tests the script was parsed correctly)
13
+ expect(root).toBeTruthy();
14
+ });
15
+ });
@@ -0,0 +1,15 @@
1
+ <script>
2
+ const codeSnippet = `// main.js
3
+ import { mount } from '@thyn/core';
4
+ import App from './App.thyn';
5
+
6
+ mount(App, document.body);`;
7
+
8
+ const value = 42;
9
+ </script>
10
+
11
+ <div>{{ value }}</div>
12
+
13
+ <style>
14
+ div { color: red; }
15
+ </style>