structscript 1.4.0 → 1.4.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.
@@ -33,17 +33,10 @@ const HELP = `
33
33
  ${BANNER}
34
34
  ${t(C.bold, 'USAGE')}
35
35
  ${t(C.lime, 'structscript')} ${t(C.teal, '<file.ss>')} Run a .ss file
36
- ${t(C.lime, 'structscript')} ${t(C.teal, 'editor')} Open the visual editor (IDE)
37
- ${t(C.lime, 'structscript')} ${t(C.teal, 'editor <file.ss>')} Open a file in the editor
38
36
  ${t(C.lime, 'structscript')} ${t(C.teal, 'repl')} Start interactive REPL
39
37
  ${t(C.lime, 'structscript')} ${t(C.teal, 'run <file.ss>')} Run a .ss file (explicit)
40
38
  ${t(C.lime, 'structscript')} ${t(C.teal, 'check <file.ss>')} Check syntax without running
41
39
  ${t(C.lime, 'structscript')} ${t(C.teal, 'version')} Show version info
42
-
43
- ${t(C.bold, 'WEB OUTPUT')}
44
- ${t(C.grey, '# .ss files with page/add commands auto-generate .html:')}
45
- ${t(C.grey, '$ structscript page.ss → writes page.html')}
46
- ${t(C.grey, '$ structscript page.ss --output=out.html')}
47
40
  ${t(C.lime, 'structscript')} ${t(C.teal, 'help')} Show this help
48
41
 
49
42
  ${t(C.bold, 'OPTIONS')}
@@ -81,60 +74,7 @@ if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
81
74
  process.exit(0);
82
75
  }
83
76
 
84
- if (cmd === 'editor') {
85
- const { spawnSync } = require('child_process');
86
- const mainJs = path.join(__dirname, '../lib/main.js');
87
- const fileArg = params[1] ? [path.resolve(params[1])] : [];
88
-
89
- // Find electron — it lives in a local node_modules next to the structscript package
90
- function findElectron() {
91
- // 1. Already installed next to this package
92
- const localPkg = path.join(__dirname, '../node_modules/.bin/electron');
93
- if (fs.existsSync(localPkg)) return localPkg;
94
- // On Windows the bin has .cmd extension
95
- if (fs.existsSync(localPkg + '.cmd')) return localPkg + '.cmd';
96
- // 2. Try requiring it (works if installed as a dep somewhere up the tree)
97
- try { return require('electron'); } catch (_) {}
98
- return null;
99
- }
100
-
101
- let electronPath = findElectron();
102
-
103
- if (!electronPath) {
104
- // Auto-install electron into the package's own node_modules
105
- console.log(t(C.teal, ' Installing Electron for the first time (this takes ~30s)…'));
106
- const pkgDir = path.join(__dirname, '..');
107
- const install = spawnSync(
108
- process.execPath, // node
109
- [require.resolve('npm/bin/npm-cli'), 'install', '--prefix', pkgDir, 'electron', '--save-optional'],
110
- { stdio: 'inherit', cwd: pkgDir }
111
- );
112
- if (install.status !== 0) {
113
- // npm-cli path failed, try plain npm command
114
- const install2 = spawnSync(
115
- process.platform === 'win32' ? 'npm.cmd' : 'npm',
116
- ['install', '--prefix', pkgDir, 'electron'],
117
- { stdio: 'inherit', cwd: pkgDir, shell: true }
118
- );
119
- if (install2.status !== 0) {
120
- console.error(t(C.red, 'Failed to install Electron. Please run:'));
121
- console.error(t(C.grey, ' npm install electron'));
122
- console.error(t(C.grey, ' (inside: ' + pkgDir + ')'));
123
- process.exit(1);
124
- }
125
- }
126
- electronPath = findElectron();
127
- if (!electronPath) {
128
- console.error(t(C.red, 'Could not locate Electron after install. Please run:'));
129
- console.error(t(C.grey, ' npm install electron (inside: ' + path.join(__dirname, '..') + ')'));
130
- process.exit(1);
131
- }
132
- console.log(t(C.lime, ' Electron installed!'));
133
- }
134
-
135
- const result = spawnSync(electronPath, [mainJs, ...fileArg], { stdio: 'inherit' });
136
- process.exit(result.status || 0);
137
- } else if (cmd === 'repl') {
77
+ if (cmd === 'repl') {
138
78
  startREPL();
139
79
  } else if (cmd === 'run') {
140
80
  const file = params[1];
@@ -173,14 +113,9 @@ function runFile(filePath) {
173
113
  output: msg => console.log(msg),
174
114
  warn: msg => console.warn(t(C.yellow, '⚠ ' + msg)),
175
115
  });
176
- interp._sourceFile = resolved;
177
-
178
- // --output flag: structscript page.ss --output page.html
179
- const outFlag = args.find(a => a.startsWith('--output='));
180
- if (outFlag) interp._htmlOutputFile = outFlag.split('=')[1];
181
116
 
182
117
  try {
183
- interp.run(source);
118
+ interp.run(source, resolved);
184
119
  if (showTime) {
185
120
  const elapsed = Date.now() - t0;
186
121
  console.error(t(C.grey, `\n⏱ ${elapsed}ms`));
package/lib/editor.html CHANGED
@@ -230,6 +230,7 @@ body {
230
230
  border: none; outline: none;
231
231
  color: transparent;
232
232
  caret-color: var(--accent);
233
+ z-index: 2;
233
234
  font-family: 'DM Mono', monospace;
234
235
  font-size: var(--font-size);
235
236
  line-height: var(--line-h);
@@ -1479,7 +1480,7 @@ class Interpreter {
1479
1480
  // Unary minus
1480
1481
  if(expr[0]==='-') return -this._eval(expr.slice(1),env);
1481
1482
  // Method call: obj.method(args)
1482
- const methodM=expr.match(/^(.+?)\.([a-zA-Z_]\w*)\((.*)\)$/s);
1483
+ const methodM=expr.match(/^([a-zA-Z_]\w*(?:\[.+?\])?(?:\.[a-zA-Z_]\w*(?:\[.+?\])?)*)\.([a-zA-Z_]\w*)\((.*)\)$/s);
1483
1484
  if(methodM){
1484
1485
  const objExpr=methodM[1], mname=methodM[2], argsRaw=methodM[3].trim();
1485
1486
  const obj=this._eval(objExpr,env);
@@ -1583,8 +1584,9 @@ class Interpreter {
1583
1584
  if(c===')'||c===']')depth++;
1584
1585
  else if(c==='('||c==='[')depth--;
1585
1586
  else if(depth===0&&c==='-'){
1586
- const prev=expr[i-1];
1587
- if(/[\w\d\)"\'\]]/.test(prev)) return i;
1587
+ let prev=i-1;
1588
+ while(prev>=0&&expr[prev]===' ')prev--;
1589
+ if(prev>=0&&/[\w\d\)"'\]]/.test(expr[prev])) return i;
1588
1590
  }
1589
1591
  }
1590
1592
  return -1;
@@ -2491,181 +2493,204 @@ function isWebCode(code) {
2491
2493
 
2492
2494
  // Build a full HTML document from StructScript web commands
2493
2495
  function buildWebDoc(code) {
2494
- // Page-level settings
2495
- let pageTitle = 'StructScript Page';
2496
+ let pageTitle = 'StructScript Page';
2496
2497
  let pageStyles = {};
2497
- let bodyClass = '';
2498
-
2499
- // Element tree
2500
- const elements = []; // { tag, id, classes, text, html, styles, attrs, events, children, parent }
2501
- let currentEl = null;
2502
- let currentIndent = 0;
2503
- const elStack = []; // stack for nested elements
2498
+ let cssBlocks = []; // raw CSS rule strings from `css` blocks
2499
+ const elements = [];
2500
+ const elStack = [];
2501
+ let i = 0;
2504
2502
 
2505
2503
  const lines = code.split('\n');
2506
- let i = 0;
2507
2504
 
2508
- function getIndent(line) {
2509
- let n = 0; while (n < line.length && line[n] === ' ') n++; return n;
2510
- }
2505
+ function getIndent(l) { let n=0; while(n<l.length&&l[n]===' ')n++; return n; }
2511
2506
 
2512
- function parseStringArg(s) {
2507
+ function parseStr(s) {
2513
2508
  s = s.trim();
2514
- if ((s.startsWith('"') && s.endsWith('"')) ||
2515
- (s.startsWith("'") && s.endsWith("'"))) return s.slice(1,-1);
2509
+ if((s.startsWith('"')&&s.endsWith('"'))||(s.startsWith("'")&&s.endsWith("'"))) return s.slice(1,-1);
2516
2510
  return s;
2517
2511
  }
2518
2512
 
2513
+ // camelCase → kebab-case
2514
+ function toKebab(s) { return s.replace(/([A-Z])/g,'-$1').toLowerCase(); }
2515
+ // kebab-case or camelCase → camelCase (for object keys)
2516
+ function toCamel(s) { return s.replace(/-([a-z])/g,(_,c)=>c.toUpperCase()); }
2517
+
2519
2518
  while (i < lines.length) {
2520
- const raw = lines[i];
2521
- const t = raw.trim();
2522
- const ind = getIndent(raw);
2519
+ const raw = lines[i], t = raw.trim(), ind = getIndent(raw);
2523
2520
  i++;
2524
-
2525
2521
  if (!t || t.startsWith('//')) continue;
2526
2522
 
2527
- // ── page "title": ────────────────────────────────────
2523
+ // ── page "title": ──────────────────────────────────────
2528
2524
  if (/^page\b/.test(t)) {
2529
- const m = t.match(/^page\s+"([^"]*)"\s*:?$/) ||
2530
- t.match(/^page\s+'([^']*)'\s*:?$/);
2531
- if (m) pageTitle = m[1];
2532
- currentEl = { _type: 'page', styles: {}, attrs: {} };
2525
+ const m = t.match(/^page\s+"([^"]*)"|^page\s+\'([^']*)\'/);
2526
+ if (m) pageTitle = m[1]||m[2];
2527
+ const el = { _type:'page', styles:{}, attrs:{} };
2533
2528
  elStack.length = 0;
2534
- elStack.push({ el: currentEl, indent: ind });
2529
+ elStack.push({ el, indent: ind });
2530
+ continue;
2531
+ }
2532
+
2533
+ // ── css: (raw CSS block) ────────────────────────────────
2534
+ // css:
2535
+ // .btn { background: red }
2536
+ // @media (max-width: 600px) { ... }
2537
+ if (/^css\s*:?$/.test(t)) {
2538
+ const cssLines = [];
2539
+ while (i < lines.length) {
2540
+ const nr = lines[i], ni = getIndent(nr);
2541
+ if (nr.trim() === '' || ni > ind) { cssLines.push(nr.slice(ind+2||0)); i++; }
2542
+ else break;
2543
+ }
2544
+ cssBlocks.push(cssLines.join('\n'));
2535
2545
  continue;
2536
2546
  }
2537
2547
 
2538
- // ── add TAG "id/class": ──────────────────────────────
2539
- const addM = t.match(/^add\s+(\w+)(?:\s+"([^"]*)")?(?:\s+'([^']*)')?\s*:?$/);
2548
+ // ── add TAG "id": ───────────────────────────────────────
2549
+ const addM = t.match(/^add\s+(\w+)(?:\s+"([^"]*)")?(?:\s+\'([^']*)\')?\s*:?$/);
2540
2550
  if (addM) {
2541
- const tag = addM[1].toLowerCase();
2542
- const rawId = addM[2] || addM[3] || '';
2543
- // Pop stack to find parent at correct indent
2544
2551
  while (elStack.length > 1 && elStack[elStack.length-1].indent >= ind) elStack.pop();
2545
2552
  const parent = elStack[elStack.length-1]?.el || null;
2553
+ const rawId = addM[2]||addM[3]||'';
2546
2554
  const el = {
2547
- tag, id: rawId.startsWith('#') ? rawId.slice(1) : (rawId.includes(' ') ? '' : rawId),
2555
+ tag: addM[1].toLowerCase(),
2556
+ id: rawId.startsWith('#') ? rawId.slice(1) : (rawId.includes(' ')||rawId.startsWith('.')?'':rawId),
2548
2557
  classes: rawId.startsWith('.') ? [rawId.slice(1)] : (rawId.includes(' ') ? rawId.split(' ') : []),
2549
- text: '', html: '', styles: {}, attrs: {}, events: [], children: [],
2550
- parent, _indent: ind
2558
+ text:'', html:'', styles:{}, hoverStyles:{}, focusStyles:{}, attrs:{}, events:[], children:[],
2559
+ _indent: ind, _anim: null, _cssRules: [],
2551
2560
  };
2552
2561
  if (parent && parent._type !== 'page') parent.children.push(el);
2553
2562
  else elements.push(el);
2554
- currentEl = el;
2555
2563
  elStack.push({ el, indent: ind });
2556
2564
  continue;
2557
2565
  }
2558
2566
 
2559
- // ── Properties inside an element block ───────────────
2560
- if (currentEl && ind > (elStack[elStack.length-1]?.indent ?? -1)) {
2561
- const el = elStack[elStack.length-1].el;
2567
+ // ── Properties inside an element ───────────────────────
2568
+ const parentEntry = elStack[elStack.length-1];
2569
+ if (parentEntry && ind > parentEntry.indent) {
2570
+ const el = parentEntry.el;
2562
2571
 
2563
- // text "content"
2572
+ // text / html
2564
2573
  const textM = t.match(/^text\s+(.+)$/);
2565
- if (textM) { el.text = parseStringArg(textM[1]); continue; }
2566
-
2567
- // html "<raw>"
2574
+ if (textM) { el.text = parseStr(textM[1]); continue; }
2568
2575
  const htmlM = t.match(/^html\s+(.+)$/);
2569
- if (htmlM) { el.html = parseStringArg(htmlM[1]); continue; }
2576
+ if (htmlM) { el.html = parseStr(htmlM[1]); continue; }
2570
2577
 
2571
- // style property "value" OR style property: "value"
2578
+ // style prop "value" any valid CSS property (camel or kebab)
2572
2579
  const styleM = t.match(/^style\s+([\w-]+)\s*:?\s+(.+)$/);
2573
2580
  if (styleM) {
2574
- const prop = styleM[1].replace(/-([a-z])/g, (_,c) => c.toUpperCase());
2575
- el.styles[prop] = parseStringArg(styleM[2]);
2576
- if (el._type === 'page') pageStyles[prop] = parseStringArg(styleM[2]);
2581
+ const key = toCamel(styleM[1]);
2582
+ const val = parseStr(styleM[2]);
2583
+ el.styles[key] = val;
2584
+ if (el._type === 'page') pageStyles[key] = val;
2577
2585
  continue;
2578
2586
  }
2579
2587
 
2580
- // attr name "value"
2581
- const attrM = t.match(/^attr\s+(\w+)\s+(.+)$/);
2582
- if (attrM) { el.attrs[attrM[1]] = parseStringArg(attrM[2]); continue; }
2588
+ // hover prop "value" — generates :hover CSS rule
2589
+ const hoverM = t.match(/^hover\s+([\w-]+)\s*:?\s+(.+)$/);
2590
+ if (hoverM) {
2591
+ el.hoverStyles[toCamel(hoverM[1])] = parseStr(hoverM[2]);
2592
+ continue;
2593
+ }
2594
+
2595
+ // focus prop "value" — generates :focus CSS rule
2596
+ const focusM = t.match(/^focus\s+([\w-]+)\s*:?\s+(.+)$/);
2597
+ if (focusM) {
2598
+ el.focusStyles[toCamel(focusM[1])] = parseStr(focusM[2]);
2599
+ continue;
2600
+ }
2601
+
2602
+ // transition "prop duration easing"
2603
+ const transM = t.match(/^transition\s+(.+)$/);
2604
+ if (transM) { el.styles.transition = parseStr(transM[1]); continue; }
2583
2605
 
2584
- // class "name"
2606
+ // attr / class
2607
+ const attrM = t.match(/^attr\s+(\w+)\s+(.+)$/);
2608
+ if (attrM) { el.attrs[attrM[1]] = parseStr(attrM[2]); continue; }
2585
2609
  const classM = t.match(/^class\s+(.+)$/);
2586
- if (classM) { el.classes.push(parseStringArg(classM[1])); continue; }
2610
+ if (classM) { el.classes.push(parseStr(classM[1])); continue; }
2611
+
2612
+ // animate TYPE DURATION EASING
2613
+ const animM = t.match(/^animate\s+(\w+)(?:\s+([\d.]+))?(?:\s+(\w+))?$/);
2614
+ if (animM) {
2615
+ el._anim = { type: animM[1], dur: parseFloat(animM[2]||0.4), easing: animM[3]||'ease' };
2616
+ continue;
2617
+ }
2587
2618
 
2588
- // on EVENT: (collect handler lines)
2619
+ // on EVENT: (collect indented handler lines)
2589
2620
  const onM = t.match(/^on\s+(\w+)\s*:?$/);
2590
2621
  if (onM) {
2591
- const evName = onM[1];
2592
2622
  const handlerLines = [];
2593
2623
  while (i < lines.length) {
2594
- const nextRaw = lines[i];
2595
- const nextInd = getIndent(nextRaw);
2596
- if (!nextRaw.trim() || nextInd <= ind) break;
2597
- handlerLines.push(nextRaw.slice(nextInd));
2598
- i++;
2624
+ const nr = lines[i], ni = getIndent(nr);
2625
+ if (!nr.trim() || ni <= ind) break;
2626
+ handlerLines.push(nr.slice(ni)); i++;
2599
2627
  }
2600
- el.events.push({ event: evName, code: handlerLines.join('\n') });
2601
- continue;
2602
- }
2603
-
2604
- // animate TYPE DURATION
2605
- const animM = t.match(/^animate\s+(\w+)(?:\s+([\d.]+))?(?:\s+(\w+))?$/);
2606
- if (animM) {
2607
- el._anim = { type: animM[1], dur: parseFloat(animM[2] || 0.4), easing: animM[3] || 'ease' };
2628
+ el.events.push({ event: onM[1], code: handlerLines.join('\n') });
2608
2629
  continue;
2609
2630
  }
2610
2631
  }
2611
2632
  }
2612
2633
 
2613
- // ── Render elements to HTML ───────────────────────────
2634
+ // ── Render ──────────────────────────────────────────────────
2635
+ const pseudoRules = []; // accumulated :hover, :focus rules
2636
+
2614
2637
  function renderEl(el) {
2615
2638
  if (el._type === 'page') return '';
2616
- const tag = el.tag;
2617
- const idAttr = el.id ? ` id="${el.id}"` : '';
2618
- const clsAttr = el.classes.length ? ` class="${el.classes.join(' ')}"` : '';
2639
+ const tag = el.tag;
2640
+ const idAttr = el.id ? ` id="${el.id}"` : '';
2641
+ const clsAttr = el.classes.length ? ` class="${el.classes.join(' ')}"` : '';
2619
2642
 
2620
- // Build style string
2621
- let styleStr = Object.entries(el.styles).map(([k,v]) => {
2622
- const cssProp = k.replace(/([A-Z])/g, '-$1').toLowerCase();
2623
- return `${cssProp}:${v}`;
2624
- }).join(';');
2643
+ // Inline styles
2644
+ let styleStr = Object.entries(el.styles).map(([k,v]) => `${toKebab(k)}:${v}`).join(';');
2625
2645
 
2626
- // Animation keyframe via inline style
2646
+ // Animation
2627
2647
  if (el._anim) {
2628
2648
  const animMap = {
2629
- fadeIn: `fadeIn ${el._anim.dur}s ${el._anim.easing} forwards`,
2649
+ fadeIn: `fadeIn ${el._anim.dur}s ${el._anim.easing} forwards`,
2630
2650
  fadeOut: `fadeOut ${el._anim.dur}s ${el._anim.easing} forwards`,
2631
2651
  slideIn: `slideIn ${el._anim.dur}s ${el._anim.easing} forwards`,
2632
2652
  slideUp: `slideUp ${el._anim.dur}s ${el._anim.easing} forwards`,
2633
- bounce: `bounce ${el._anim.dur}s ${el._anim.easing} infinite`,
2634
- pulse: `pulse ${el._anim.dur}s ${el._anim.easing} infinite`,
2635
- spin: `spin ${el._anim.dur}s linear infinite`,
2636
- shake: `shake ${el._anim.dur}s ${el._anim.easing}`,
2637
- pop: `pop ${el._anim.dur}s ${el._anim.easing} forwards`,
2653
+ bounce: `bounce ${el._anim.dur}s ${el._anim.easing} infinite`,
2654
+ pulse: `pulse ${el._anim.dur}s ${el._anim.easing} infinite`,
2655
+ spin: `spin ${el._anim.dur}s linear infinite`,
2656
+ shake: `shake ${el._anim.dur}s ${el._anim.easing}`,
2657
+ pop: `pop ${el._anim.dur}s ${el._anim.easing} forwards`,
2638
2658
  };
2639
- const animVal = animMap[el._anim.type] || `${el._anim.type} ${el._anim.dur}s ${el._anim.easing}`;
2640
- styleStr += (styleStr ? ';' : '') + `animation:${animVal}`;
2659
+ styleStr += (styleStr?';':'') + `animation:${animMap[el._anim.type]||`${el._anim.type} ${el._anim.dur}s ${el._anim.easing}`}`;
2641
2660
  }
2642
2661
 
2643
- const styleAttr = styleStr ? ` style="${styleStr}"` : '';
2644
-
2645
- // Extra attributes
2646
- const extraAttrs = Object.entries(el.attrs)
2647
- .map(([k,v]) => ` ${k}="${v}"`).join('');
2662
+ // Generate :hover and :focus rules (needs a selector — use id if available, else generate one)
2663
+ if (Object.keys(el.hoverStyles).length || Object.keys(el.focusStyles).length) {
2664
+ // Ensure element has an id for targeting
2665
+ if (!el.id) { el.id = '_ss_' + Math.random().toString(36).slice(2,8); }
2666
+ if (Object.keys(el.hoverStyles).length) {
2667
+ const hoverStr = Object.entries(el.hoverStyles).map(([k,v])=>`${toKebab(k)}:${v}`).join(';');
2668
+ pseudoRules.push(`#${el.id}:hover{${hoverStr}}`);
2669
+ }
2670
+ if (Object.keys(el.focusStyles).length) {
2671
+ const focusStr = Object.entries(el.focusStyles).map(([k,v])=>`${toKebab(k)}:${v}`).join(';');
2672
+ pseudoRules.push(`#${el.id}:focus{${focusStr}}`);
2673
+ }
2674
+ }
2648
2675
 
2649
- // Event handlers inject inline onclick etc.
2650
- const evAttrs = el.events.map(ev => {
2651
- const escaped = ev.code.replace(/"/g, '&quot;').replace(/\n/g, ' ');
2676
+ const styleAttr = styleStr ? ` style="${styleStr}"` : '';
2677
+ const extraAttrs = Object.entries(el.attrs).map(([k,v])=>` ${k}="${v}"`).join('');
2678
+ const evAttrs = el.events.map(ev=>{
2679
+ const escaped = ev.code.replace(/"/g,'&quot;').replace(/\n/g,' ');
2652
2680
  return ` data-ss-on${ev.event}="${escaped}"`;
2653
2681
  }).join('');
2654
2682
 
2655
- // Self-closing tags
2656
2683
  const selfClose = ['img','input','br','hr','meta','link'].includes(tag);
2657
- if (selfClose) return `<${tag}${idAttr}${clsAttr}${styleAttr}${extraAttrs}/>\n`;
2658
-
2684
+ if (selfClose) return `<${tag}${idAttr}${clsAttr}${styleAttr}${extraAttrs}/>
2685
+ `;
2659
2686
  const inner = el.html || el.text || el.children.map(renderEl).join('');
2660
- return `<${tag}${idAttr}${clsAttr}${styleAttr}${extraAttrs}${evAttrs}>${inner}</${tag}>\n`;
2687
+ return `<${tag}${idAttr}${clsAttr}${styleAttr}${extraAttrs}${evAttrs}>${inner}</${tag}>
2688
+ `;
2661
2689
  }
2662
2690
 
2663
- const bodyStyleStr = Object.entries(pageStyles)
2664
- .map(([k,v]) => `${k.replace(/([A-Z])/g,'-$1').toLowerCase()}:${v}`).join(';');
2691
+ const bodyStyleStr = Object.entries(pageStyles).map(([k,v])=>`${toKebab(k)}:${v}`).join(';');
2692
+ const bodyHtml = elements.map(renderEl).join('');
2665
2693
 
2666
- const bodyHtml = elements.map(renderEl).join('');
2667
-
2668
- // Animation keyframes
2669
2694
  const KEYFRAMES = `
2670
2695
  @keyframes fadeIn { from{opacity:0;transform:translateY(16px)} to{opacity:1;transform:none} }
2671
2696
  @keyframes fadeOut { from{opacity:1} to{opacity:0} }
@@ -2678,12 +2703,14 @@ function buildWebDoc(code) {
2678
2703
  @keyframes pop { 0%{transform:scale(0.5);opacity:0} 70%{transform:scale(1.1)} 100%{transform:scale(1);opacity:1} }
2679
2704
  `;
2680
2705
 
2681
- // Event-wiring script: wire data-ss-on* attributes to JS event listeners
2682
2706
  const EVENT_TYPES = ['click','mouseover','mouseout','mouseenter','mouseleave','keydown','keyup','change','focus','blur','dblclick'];
2683
2707
  const evScript = EVENT_TYPES.map(ev =>
2684
2708
  `document.querySelectorAll('[data-ss-on${ev}]').forEach(function(el){el.addEventListener('${ev}',function(){try{(new Function(this.getAttribute('data-ss-on${ev}')))()}catch(e){console.error(e)}}.bind(el));});`
2685
2709
  ).join('\n');
2686
2710
 
2711
+ const allPseudoCSS = pseudoRules.join('\n');
2712
+ const allCustomCSS = cssBlocks.join('\n');
2713
+
2687
2714
  return `<!DOCTYPE html>
2688
2715
  <html lang="en">
2689
2716
  <head>
@@ -2693,6 +2720,8 @@ function buildWebDoc(code) {
2693
2720
  <style>
2694
2721
  *{box-sizing:border-box;margin:0;padding:0}
2695
2722
  ${KEYFRAMES}
2723
+ ${allPseudoCSS}
2724
+ ${allCustomCSS}
2696
2725
  </style>
2697
2726
  </head>
2698
2727
  <body${bodyStyleStr ? ` style="${bodyStyleStr}"` : ''}>
@@ -51,6 +51,8 @@ class Interpreter {
51
51
  this.structs = {};
52
52
  this.callDepth = 0;
53
53
  this.sourceLines = [];
54
+ this._sourceFile = null; // set by CLI to the running file's path
55
+ this._importedFiles = new Set(); // prevent circular imports
54
56
  this._registerBuiltins();
55
57
  }
56
58
 
@@ -62,25 +64,6 @@ class Interpreter {
62
64
  G.define('print', a => { this.outputFn(a.map(x => this._str(x)).join(' ')); return null; });
63
65
  G.define('warn', a => { this.warnFn(this._str(a[0])); return null; });
64
66
 
65
- // input(prompt?) — synchronous stdin read, works like Python's input()
66
- G.define('input', a => {
67
- const prompt = a[0] !== undefined ? this._str(a[0]) : '';
68
- if (prompt) process.stdout.write(prompt);
69
- // Read synchronously from stdin one byte at a time until newline
70
- const buf = Buffer.alloc(1);
71
- let result = '';
72
- while (true) {
73
- let bytesRead = 0;
74
- try { bytesRead = require('fs').readSync(process.stdin.fd, buf, 0, 1, null); }
75
- catch (e) { break; }
76
- if (bytesRead === 0) break;
77
- const ch = buf.toString('utf8');
78
- if (ch === '\n') break;
79
- if (ch !== '\r') result += ch;
80
- }
81
- return result;
82
- });
83
-
84
67
  // Math
85
68
  G.define('abs', a => Math.abs(a[0]));
86
69
  G.define('sqrt', a => Math.sqrt(a[0]));
@@ -160,9 +143,99 @@ class Interpreter {
160
143
  G.define('assert', a => { if (!a[0]) throw new SSError('Assertion failed' + (a[1] ? ': ' + a[1] : '')); return null; });
161
144
  G.define('error', a => { throw new SSError(String(a[0] ?? 'Runtime error')); });
162
145
  G.define('exit', a => { process.exit(a[0] ?? 0); });
146
+
147
+ // ── HTTP / API ─────────────────────────────────────────────────
148
+ // Synchronous HTTP using a child process so we don't need async
149
+ const _httpFetch = (url, method, body, headers) => {
150
+ const { execFileSync } = require('child_process');
151
+ const script = `
152
+ const h = require('https'), u = require('url'), http = require('http');
153
+ const parsed = new u.URL(${JSON.stringify('__URL__')}.replace('__URL__', process.argv[1]));
154
+ const lib = parsed.protocol === 'https:' ? h : http;
155
+ const opts = {
156
+ hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
157
+ path: parsed.pathname + parsed.search,
158
+ method: process.argv[2] || 'GET',
159
+ headers: JSON.parse(process.argv[3] || '{}'),
160
+ };
161
+ const bodyData = process.argv[4] || '';
162
+ if (bodyData) opts.headers['Content-Length'] = Buffer.byteLength(bodyData);
163
+ const req = lib.request(opts, res => {
164
+ let d = ''; res.on('data', c => d += c);
165
+ res.on('end', () => process.stdout.write(JSON.stringify({ status: res.statusCode, headers: res.headers, body: d })));
166
+ });
167
+ req.on('error', e => process.stdout.write(JSON.stringify({ error: e.message })));
168
+ if (bodyData) req.write(bodyData);
169
+ req.end();
170
+ `;
171
+ try {
172
+ const mergedHeaders = Object.assign({ 'User-Agent': 'StructScript/1.4' }, headers || {});
173
+ if (body && !mergedHeaders['Content-Type']) mergedHeaders['Content-Type'] = 'application/json';
174
+ const raw = execFileSync(process.execPath, ['-e', script, '--', url, method || 'GET',
175
+ JSON.stringify(mergedHeaders), body ? (typeof body === 'string' ? body : JSON.stringify(body)) : ''],
176
+ { timeout: 15000, maxBuffer: 10 * 1024 * 1024 });
177
+ return JSON.parse(raw.toString());
178
+ } catch (e) {
179
+ throw new SSError('fetch error: ' + (e.message || String(e)));
180
+ }
181
+ };
182
+
183
+ // fetch(url) → returns response object { status, body, json() }
184
+ G.define('fetch', a => {
185
+ const url = String(a[0] ?? '');
186
+ const options = (a[1] && typeof a[1] === 'object') ? a[1] : {};
187
+ const method = options.method || 'GET';
188
+ const body = options.body || null;
189
+ const headers = options.headers || {};
190
+ const res = _httpFetch(url, method, body, headers);
191
+ if (res.error) throw new SSError('fetch failed: ' + res.error);
192
+ // Try to auto-parse JSON
193
+ let parsed = null;
194
+ try { parsed = JSON.parse(res.body); } catch (_) {}
195
+ return {
196
+ __struct: 'Response',
197
+ status: res.status,
198
+ ok: res.status >= 200 && res.status < 300,
199
+ body: res.body,
200
+ data: parsed, // auto-parsed JSON (or nothing if not JSON)
201
+ headers: res.headers,
202
+ };
203
+ });
204
+
205
+ // fetchJson(url, options?) → directly returns parsed JSON data
206
+ G.define('fetchJson', a => {
207
+ const url = String(a[0] ?? '');
208
+ const options = (a[1] && typeof a[1] === 'object') ? a[1] : {};
209
+ const res = _httpFetch(url, options.method || 'GET', options.body || null,
210
+ Object.assign({ 'Accept': 'application/json' }, options.headers || {}));
211
+ if (res.error) throw new SSError('fetchJson failed: ' + res.error);
212
+ try { return JSON.parse(res.body); }
213
+ catch (_) { throw new SSError('fetchJson: response is not valid JSON\n' + res.body.slice(0, 200)); }
214
+ });
215
+
216
+ // fetchPost(url, data, headers?) → POST with JSON body, returns parsed response
217
+ G.define('fetchPost', a => {
218
+ const url = String(a[0] ?? '');
219
+ const data = a[1] !== undefined ? a[1] : {};
220
+ const headers = (a[2] && typeof a[2] === 'object') ? a[2] : {};
221
+ const body = typeof data === 'string' ? data : JSON.stringify(data);
222
+ const res = _httpFetch(url, 'POST', body, Object.assign({ 'Accept': 'application/json' }, headers));
223
+ if (res.error) throw new SSError('fetchPost failed: ' + res.error);
224
+ let parsed = null;
225
+ try { parsed = JSON.parse(res.body); } catch (_) {}
226
+ return {
227
+ __struct: 'Response',
228
+ status: res.status,
229
+ ok: res.status >= 200 && res.status < 300,
230
+ body: res.body,
231
+ data: parsed,
232
+ headers: res.headers,
233
+ };
234
+ });
163
235
  }
164
236
 
165
- run(source) {
237
+ run(source, sourceFile) {
238
+ if (sourceFile) this._sourceFile = sourceFile;
166
239
  this.sourceLines = source.split('\n');
167
240
  this._execBlock(this.sourceLines, 0, this.sourceLines.length, this.globals);
168
241
  }
@@ -206,6 +279,38 @@ class Interpreter {
206
279
  }
207
280
 
208
281
  _exec(line, allLines, lineIdx, indent, env) {
282
+ // import "file.ss"
283
+ if (line.startsWith('import ')) {
284
+ const m = line.match(/^import\s+"([^"]+)"|^import\s+'([^']+)'/);
285
+ if (!m) throw new SSError('Invalid import — use: import "filename.ss"');
286
+ const importPath = m[1] || m[2];
287
+ const fs = require('fs');
288
+ const path = require('path');
289
+
290
+ // Resolve relative to the currently running file, or cwd
291
+ const base = this._sourceFile ? path.dirname(this._sourceFile) : process.cwd();
292
+ const absPath = path.resolve(base, importPath);
293
+
294
+ if (this._importedFiles.has(absPath)) return null; // already imported
295
+ this._importedFiles.add(absPath);
296
+
297
+ if (!fs.existsSync(absPath)) throw new SSError(`import: file not found: "${importPath}" (resolved to ${absPath})`);
298
+ const source = fs.readFileSync(absPath, 'utf8');
299
+
300
+ // Run the imported file in the same global environment
301
+ const savedSourceFile = this._sourceFile;
302
+ const savedSourceLines = this.sourceLines;
303
+ this._sourceFile = absPath;
304
+ this.sourceLines = source.split('\n');
305
+ try {
306
+ this._execBlock(this.sourceLines, 0, this.sourceLines.length, env);
307
+ } finally {
308
+ this._sourceFile = savedSourceFile;
309
+ this.sourceLines = savedSourceLines;
310
+ }
311
+ return null;
312
+ }
313
+
209
314
  // say shorthand
210
315
  if (line.startsWith('say ')) { const v = this._eval(line.slice(4).trim(), env); this.outputFn(this._str(v)); return null; }
211
316
 
@@ -736,4 +841,249 @@ class Interpreter {
736
841
  }
737
842
  }
738
843
 
739
- module.exports = { Interpreter, SSError, Environment };
844
+ // ── Web Engine (also used by main.js / Electron) ──────────────────────────────
845
+ function isWebCode(code) {
846
+ return /^\s*(page|add)\s+/m.test(code);
847
+ }
848
+
849
+ // Build a full HTML document from StructScript web commands
850
+ function buildWebDoc(code) {
851
+ let pageTitle = 'StructScript Page';
852
+ let pageStyles = {};
853
+ let cssBlocks = []; // raw CSS rule strings from `css` blocks
854
+ const elements = [];
855
+ const elStack = [];
856
+ let i = 0;
857
+
858
+ const lines = code.split('\n');
859
+
860
+ function getIndent(l) { let n=0; while(n<l.length&&l[n]===' ')n++; return n; }
861
+
862
+ function parseStr(s) {
863
+ s = s.trim();
864
+ if((s.startsWith('"')&&s.endsWith('"'))||(s.startsWith("'")&&s.endsWith("'"))) return s.slice(1,-1);
865
+ return s;
866
+ }
867
+
868
+ // camelCase → kebab-case
869
+ function toKebab(s) { return s.replace(/([A-Z])/g,'-$1').toLowerCase(); }
870
+ // kebab-case or camelCase → camelCase (for object keys)
871
+ function toCamel(s) { return s.replace(/-([a-z])/g,(_,c)=>c.toUpperCase()); }
872
+
873
+ while (i < lines.length) {
874
+ const raw = lines[i], t = raw.trim(), ind = getIndent(raw);
875
+ i++;
876
+ if (!t || t.startsWith('//')) continue;
877
+
878
+ // ── page "title": ──────────────────────────────────────
879
+ if (/^page\b/.test(t)) {
880
+ const m = t.match(/^page\s+"([^"]*)"|^page\s+\'([^']*)\'/);
881
+ if (m) pageTitle = m[1]||m[2];
882
+ const el = { _type:'page', styles:{}, attrs:{} };
883
+ elStack.length = 0;
884
+ elStack.push({ el, indent: ind });
885
+ continue;
886
+ }
887
+
888
+ // ── css: (raw CSS block) ────────────────────────────────
889
+ // css:
890
+ // .btn { background: red }
891
+ // @media (max-width: 600px) { ... }
892
+ if (/^css\s*:?$/.test(t)) {
893
+ const cssLines = [];
894
+ while (i < lines.length) {
895
+ const nr = lines[i], ni = getIndent(nr);
896
+ if (nr.trim() === '' || ni > ind) { cssLines.push(nr.slice(ind+2||0)); i++; }
897
+ else break;
898
+ }
899
+ cssBlocks.push(cssLines.join('\n'));
900
+ continue;
901
+ }
902
+
903
+ // ── add TAG "id": ───────────────────────────────────────
904
+ const addM = t.match(/^add\s+(\w+)(?:\s+"([^"]*)")?(?:\s+\'([^']*)\')?\s*:?$/);
905
+ if (addM) {
906
+ while (elStack.length > 1 && elStack[elStack.length-1].indent >= ind) elStack.pop();
907
+ const parent = elStack[elStack.length-1]?.el || null;
908
+ const rawId = addM[2]||addM[3]||'';
909
+ const el = {
910
+ tag: addM[1].toLowerCase(),
911
+ id: rawId.startsWith('#') ? rawId.slice(1) : (rawId.includes(' ')||rawId.startsWith('.')?'':rawId),
912
+ classes: rawId.startsWith('.') ? [rawId.slice(1)] : (rawId.includes(' ') ? rawId.split(' ') : []),
913
+ text:'', html:'', styles:{}, hoverStyles:{}, focusStyles:{}, attrs:{}, events:[], children:[],
914
+ _indent: ind, _anim: null, _cssRules: [],
915
+ };
916
+ if (parent && parent._type !== 'page') parent.children.push(el);
917
+ else elements.push(el);
918
+ elStack.push({ el, indent: ind });
919
+ continue;
920
+ }
921
+
922
+ // ── Properties inside an element ───────────────────────
923
+ const parentEntry = elStack[elStack.length-1];
924
+ if (parentEntry && ind > parentEntry.indent) {
925
+ const el = parentEntry.el;
926
+
927
+ // text / html
928
+ const textM = t.match(/^text\s+(.+)$/);
929
+ if (textM) { el.text = parseStr(textM[1]); continue; }
930
+ const htmlM = t.match(/^html\s+(.+)$/);
931
+ if (htmlM) { el.html = parseStr(htmlM[1]); continue; }
932
+
933
+ // style prop "value" — any valid CSS property (camel or kebab)
934
+ const styleM = t.match(/^style\s+([\w-]+)\s*:?\s+(.+)$/);
935
+ if (styleM) {
936
+ const key = toCamel(styleM[1]);
937
+ const val = parseStr(styleM[2]);
938
+ el.styles[key] = val;
939
+ if (el._type === 'page') pageStyles[key] = val;
940
+ continue;
941
+ }
942
+
943
+ // hover prop "value" — generates :hover CSS rule
944
+ const hoverM = t.match(/^hover\s+([\w-]+)\s*:?\s+(.+)$/);
945
+ if (hoverM) {
946
+ el.hoverStyles[toCamel(hoverM[1])] = parseStr(hoverM[2]);
947
+ continue;
948
+ }
949
+
950
+ // focus prop "value" — generates :focus CSS rule
951
+ const focusM = t.match(/^focus\s+([\w-]+)\s*:?\s+(.+)$/);
952
+ if (focusM) {
953
+ el.focusStyles[toCamel(focusM[1])] = parseStr(focusM[2]);
954
+ continue;
955
+ }
956
+
957
+ // transition "prop duration easing"
958
+ const transM = t.match(/^transition\s+(.+)$/);
959
+ if (transM) { el.styles.transition = parseStr(transM[1]); continue; }
960
+
961
+ // attr / class
962
+ const attrM = t.match(/^attr\s+(\w+)\s+(.+)$/);
963
+ if (attrM) { el.attrs[attrM[1]] = parseStr(attrM[2]); continue; }
964
+ const classM = t.match(/^class\s+(.+)$/);
965
+ if (classM) { el.classes.push(parseStr(classM[1])); continue; }
966
+
967
+ // animate TYPE DURATION EASING
968
+ const animM = t.match(/^animate\s+(\w+)(?:\s+([\d.]+))?(?:\s+(\w+))?$/);
969
+ if (animM) {
970
+ el._anim = { type: animM[1], dur: parseFloat(animM[2]||0.4), easing: animM[3]||'ease' };
971
+ continue;
972
+ }
973
+
974
+ // on EVENT: (collect indented handler lines)
975
+ const onM = t.match(/^on\s+(\w+)\s*:?$/);
976
+ if (onM) {
977
+ const handlerLines = [];
978
+ while (i < lines.length) {
979
+ const nr = lines[i], ni = getIndent(nr);
980
+ if (!nr.trim() || ni <= ind) break;
981
+ handlerLines.push(nr.slice(ni)); i++;
982
+ }
983
+ el.events.push({ event: onM[1], code: handlerLines.join('\n') });
984
+ continue;
985
+ }
986
+ }
987
+ }
988
+
989
+ // ── Render ──────────────────────────────────────────────────
990
+ const pseudoRules = []; // accumulated :hover, :focus rules
991
+
992
+ function renderEl(el) {
993
+ if (el._type === 'page') return '';
994
+ const tag = el.tag;
995
+ const idAttr = el.id ? ` id="${el.id}"` : '';
996
+ const clsAttr = el.classes.length ? ` class="${el.classes.join(' ')}"` : '';
997
+
998
+ // Inline styles
999
+ let styleStr = Object.entries(el.styles).map(([k,v]) => `${toKebab(k)}:${v}`).join(';');
1000
+
1001
+ // Animation
1002
+ if (el._anim) {
1003
+ const animMap = {
1004
+ fadeIn: `fadeIn ${el._anim.dur}s ${el._anim.easing} forwards`,
1005
+ fadeOut: `fadeOut ${el._anim.dur}s ${el._anim.easing} forwards`,
1006
+ slideIn: `slideIn ${el._anim.dur}s ${el._anim.easing} forwards`,
1007
+ slideUp: `slideUp ${el._anim.dur}s ${el._anim.easing} forwards`,
1008
+ bounce: `bounce ${el._anim.dur}s ${el._anim.easing} infinite`,
1009
+ pulse: `pulse ${el._anim.dur}s ${el._anim.easing} infinite`,
1010
+ spin: `spin ${el._anim.dur}s linear infinite`,
1011
+ shake: `shake ${el._anim.dur}s ${el._anim.easing}`,
1012
+ pop: `pop ${el._anim.dur}s ${el._anim.easing} forwards`,
1013
+ };
1014
+ styleStr += (styleStr?';':'') + `animation:${animMap[el._anim.type]||`${el._anim.type} ${el._anim.dur}s ${el._anim.easing}`}`;
1015
+ }
1016
+
1017
+ // Generate :hover and :focus rules (needs a selector — use id if available, else generate one)
1018
+ if (Object.keys(el.hoverStyles).length || Object.keys(el.focusStyles).length) {
1019
+ // Ensure element has an id for targeting
1020
+ if (!el.id) { el.id = '_ss_' + Math.random().toString(36).slice(2,8); }
1021
+ if (Object.keys(el.hoverStyles).length) {
1022
+ const hoverStr = Object.entries(el.hoverStyles).map(([k,v])=>`${toKebab(k)}:${v}`).join(';');
1023
+ pseudoRules.push(`#${el.id}:hover{${hoverStr}}`);
1024
+ }
1025
+ if (Object.keys(el.focusStyles).length) {
1026
+ const focusStr = Object.entries(el.focusStyles).map(([k,v])=>`${toKebab(k)}:${v}`).join(';');
1027
+ pseudoRules.push(`#${el.id}:focus{${focusStr}}`);
1028
+ }
1029
+ }
1030
+
1031
+ const styleAttr = styleStr ? ` style="${styleStr}"` : '';
1032
+ const extraAttrs = Object.entries(el.attrs).map(([k,v])=>` ${k}="${v}"`).join('');
1033
+ const evAttrs = el.events.map(ev=>{
1034
+ const escaped = ev.code.replace(/"/g,'&quot;').replace(/\n/g,' ');
1035
+ return ` data-ss-on${ev.event}="${escaped}"`;
1036
+ }).join('');
1037
+
1038
+ const selfClose = ['img','input','br','hr','meta','link'].includes(tag);
1039
+ if (selfClose) return `<${tag}${idAttr}${clsAttr}${styleAttr}${extraAttrs}/>
1040
+ `;
1041
+ const inner = el.html || el.text || el.children.map(renderEl).join('');
1042
+ return `<${tag}${idAttr}${clsAttr}${styleAttr}${extraAttrs}${evAttrs}>${inner}</${tag}>
1043
+ `;
1044
+ }
1045
+
1046
+ const bodyStyleStr = Object.entries(pageStyles).map(([k,v])=>`${toKebab(k)}:${v}`).join(';');
1047
+ const bodyHtml = elements.map(renderEl).join('');
1048
+
1049
+ const KEYFRAMES = `
1050
+ @keyframes fadeIn { from{opacity:0;transform:translateY(16px)} to{opacity:1;transform:none} }
1051
+ @keyframes fadeOut { from{opacity:1} to{opacity:0} }
1052
+ @keyframes slideIn { from{transform:translateX(-40px);opacity:0} to{transform:none;opacity:1} }
1053
+ @keyframes slideUp { from{transform:translateY(40px);opacity:0} to{transform:none;opacity:1} }
1054
+ @keyframes bounce { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-16px)} }
1055
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
1056
+ @keyframes spin { from{transform:rotate(0deg)} to{transform:rotate(360deg)} }
1057
+ @keyframes shake { 0%,100%{transform:translateX(0)} 25%{transform:translateX(-8px)} 75%{transform:translateX(8px)} }
1058
+ @keyframes pop { 0%{transform:scale(0.5);opacity:0} 70%{transform:scale(1.1)} 100%{transform:scale(1);opacity:1} }
1059
+ `;
1060
+
1061
+ const EVENT_TYPES = ['click','mouseover','mouseout','mouseenter','mouseleave','keydown','keyup','change','focus','blur','dblclick'];
1062
+ const evScript = EVENT_TYPES.map(ev =>
1063
+ `document.querySelectorAll('[data-ss-on${ev}]').forEach(function(el){el.addEventListener('${ev}',function(){try{(new Function(this.getAttribute('data-ss-on${ev}')))()}catch(e){console.error(e)}}.bind(el));});`
1064
+ ).join('\n');
1065
+
1066
+ const allPseudoCSS = pseudoRules.join('\n');
1067
+ const allCustomCSS = cssBlocks.join('\n');
1068
+
1069
+ return `<!DOCTYPE html>
1070
+ <html lang="en">
1071
+ <head>
1072
+ <meta charset="UTF-8">
1073
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1074
+ <title>${pageTitle}</title>
1075
+ <style>
1076
+ *{box-sizing:border-box;margin:0;padding:0}
1077
+ ${KEYFRAMES}
1078
+ ${allPseudoCSS}
1079
+ ${allCustomCSS}
1080
+ </style>
1081
+ </head>
1082
+ <body${bodyStyleStr ? ` style="${bodyStyleStr}"` : ''}>
1083
+ ${bodyHtml}
1084
+ <script>(function(){${evScript}})()</\/script>
1085
+ </body>
1086
+ </html>`;
1087
+ }
1088
+
1089
+ module.exports = { Interpreter, SSError, Environment, isWebCode, buildWebDoc };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "structscript",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "description": "The StructScript programming language — clean, readable scripting for everyone. Includes a built-in visual editor.",
5
5
  "keywords": ["structscript", "language", "interpreter", "scripting", "programming-language"],
6
6
  "author": "StructScript",