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.
- package/bin/structscript.js +2 -67
- package/lib/editor.html +135 -106
- package/lib/interpreter.js +371 -21
- package/package.json +1 -1
package/bin/structscript.js
CHANGED
|
@@ -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 === '
|
|
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(/^(
|
|
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
|
-
|
|
1587
|
-
|
|
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
|
-
|
|
2495
|
-
let pageTitle = 'StructScript Page';
|
|
2496
|
+
let pageTitle = 'StructScript Page';
|
|
2496
2497
|
let pageStyles = {};
|
|
2497
|
-
let
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
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(
|
|
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
|
|
2507
|
+
function parseStr(s) {
|
|
2513
2508
|
s = s.trim();
|
|
2514
|
-
if
|
|
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
|
-
|
|
2531
|
-
|
|
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
|
|
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
|
|
2539
|
-
const addM = t.match(/^add\s+(\w+)(?:\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
|
|
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:
|
|
2550
|
-
|
|
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
|
|
2560
|
-
|
|
2561
|
-
|
|
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
|
|
2572
|
+
// text / html
|
|
2564
2573
|
const textM = t.match(/^text\s+(.+)$/);
|
|
2565
|
-
if (textM) { el.text =
|
|
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 =
|
|
2576
|
+
if (htmlM) { el.html = parseStr(htmlM[1]); continue; }
|
|
2570
2577
|
|
|
2571
|
-
// style
|
|
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
|
|
2575
|
-
|
|
2576
|
-
|
|
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
|
-
//
|
|
2581
|
-
const
|
|
2582
|
-
if (
|
|
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
|
|
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(
|
|
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
|
|
2595
|
-
|
|
2596
|
-
|
|
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:
|
|
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
|
|
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
|
|
2617
|
-
const idAttr
|
|
2618
|
-
const clsAttr
|
|
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
|
-
//
|
|
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
|
|
2646
|
+
// Animation
|
|
2627
2647
|
if (el._anim) {
|
|
2628
2648
|
const animMap = {
|
|
2629
|
-
fadeIn:
|
|
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:
|
|
2634
|
-
pulse:
|
|
2635
|
-
spin:
|
|
2636
|
-
shake:
|
|
2637
|
-
pop:
|
|
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
|
-
|
|
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
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
.
|
|
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
|
-
|
|
2650
|
-
const
|
|
2651
|
-
|
|
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,'"').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}
|
|
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}
|
|
2687
|
+
return `<${tag}${idAttr}${clsAttr}${styleAttr}${extraAttrs}${evAttrs}>${inner}</${tag}>
|
|
2688
|
+
`;
|
|
2661
2689
|
}
|
|
2662
2690
|
|
|
2663
|
-
const bodyStyleStr = Object.entries(pageStyles)
|
|
2664
|
-
|
|
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}"` : ''}>
|
package/lib/interpreter.js
CHANGED
|
@@ -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
|
-
|
|
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,'"').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.
|
|
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",
|