structscript 1.4.0 → 1.5.0

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.
@@ -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,332 @@ class Interpreter {
736
841
  }
737
842
  }
738
843
 
739
- module.exports = { Interpreter, SSError, Environment };
844
+ // ── Web Engine ──────────────────────────────────────────────────────────────
845
+
846
+ function isWebCode(code) {
847
+ return /^\s*(page\b|add\s+\w|css\s*:?$|script\s*:?$)/m.test(code);
848
+ }
849
+
850
+ function buildWebDoc(rawCode) {
851
+ // Normalise line endings
852
+ const code = rawCode.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
853
+
854
+ let pageTitle = 'StructScript Page';
855
+ let pageLang = 'en';
856
+ let pageStyles = {};
857
+ let headTags = [];
858
+ let cssBlocks = [];
859
+ let jsBlocks = [];
860
+ let bodyAttrs = {};
861
+ const elements = [];
862
+ const elStack = [];
863
+ let i = 0;
864
+ let _uid = 0;
865
+ const lines = code.split('\n');
866
+
867
+ function indent(l) { let n=0; while(n<l.length&&l[n]===' ')n++; return n; }
868
+ function str(s) { s=s.trim(); return (s[0]==='"'&&s[s.length-1]==='"')||(s[0]==="'"&&s[s.length-1]==="'") ? s.slice(1,-1) : s; }
869
+ function kebab(s) { return s.replace(/([A-Z])/g,'-$1').toLowerCase(); }
870
+ function camel(s) { return s.replace(/-([a-z])/g,(_,c)=>c.toUpperCase()); }
871
+ function uid() { return '_ss'+(++_uid); }
872
+
873
+ // ── parse one line, advancing i if it consumes more ──────────
874
+ while (i < lines.length) {
875
+ const raw = lines[i], t = raw.trim(), ind = indent(raw);
876
+ i++;
877
+ if (!t || t.startsWith('//')) continue;
878
+
879
+ // page "Title" lang? :
880
+ if (/^page\b/.test(t)) {
881
+ const m = t.match(/^page\s+"([^"]*)"|^page\s+'([^']*)'/);
882
+ if (m) pageTitle = m[1]||m[2];
883
+ const lm = t.match(/\blang\s+"([^"]+)"/);
884
+ if (lm) pageLang = lm[1];
885
+ // consume indented page-level directives
886
+ while (i < lines.length) {
887
+ const nr = lines[i].trim(), ni = indent(lines[i]);
888
+ if (!nr || nr.startsWith('//')) { i++; continue; }
889
+ if (ni <= ind) break;
890
+ i++;
891
+ let m2;
892
+ if ((m2=nr.match(/^style\s+([\w-]+)\s+(.+)$/))) { pageStyles[camel(m2[1])]=str(m2[2]); continue; }
893
+ if ((m2=nr.match(/^charset\s+(.+)$/))) { headTags.push(`<meta charset="${str(m2[1])}">`); continue; }
894
+ if ((m2=nr.match(/^viewport\s+(.+)$/))) { headTags.push(`<meta name="viewport" content="${str(m2[1])}">`); continue; }
895
+ if ((m2=nr.match(/^meta\s+([\w-]+)\s+(.+)$/))) { headTags.push(`<meta name="${m2[1]}" content="${str(m2[2])}">`); continue; }
896
+ if ((m2=nr.match(/^metahttp\s+([\w-]+)\s+(.+)$/))) { headTags.push(`<meta http-equiv="${m2[1]}" content="${str(m2[2])}">`); continue; }
897
+ if ((m2=nr.match(/^link\s+(.+)$/))) { headTags.push(`<link ${str(m2[1])}>`); continue; }
898
+ if ((m2=nr.match(/^favicon\s+(.+)$/))) { headTags.push(`<link rel="icon" href="${str(m2[1])}">`); continue; }
899
+ if ((m2=nr.match(/^font\s+(.+)$/))) { headTags.push(`<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=${str(m2[1]).replace(/ /g,'+')}:wght@300;400;600;700;800&display=swap">`); continue; }
900
+ if ((m2=nr.match(/^script\s+src\s+(.+)$/))) { headTags.push(`<script src="${str(m2[1])}"><\/script>`); continue; }
901
+ if ((m2=nr.match(/^bodyattr\s+([\w-]+)\s+(.+)$/))) { bodyAttrs[m2[1]]=str(m2[2]); continue; }
902
+ if ((m2=nr.match(/^(og|twitter):([\w:]+)\s+(.+)$/))){ headTags.push(`<meta property="${m2[1]}:${m2[2]}" content="${str(m2[3])}">`); continue; }
903
+ if ((m2=nr.match(/^canonical\s+(.+)$/))) { headTags.push(`<link rel="canonical" href="${str(m2[1])}">`); continue; }
904
+ if ((m2=nr.match(/^base\s+(.+)$/))) { headTags.push(`<base href="${str(m2[1])}">`); continue; }
905
+ }
906
+ continue;
907
+ }
908
+
909
+ // css: raw CSS block
910
+ if (/^css\s*:?$/.test(t)) {
911
+ const buf=[];
912
+ while(i<lines.length){ const nr=lines[i],ni=indent(nr); if(!nr.trim()||ni>ind){buf.push(nr.slice(Math.min(ni,ind+2)));i++;}else break; }
913
+ cssBlocks.push(buf.join('\n'));
914
+ continue;
915
+ }
916
+
917
+ // script: raw JS block
918
+ if (/^script\s*:?$/.test(t)) {
919
+ const buf=[];
920
+ while(i<lines.length){ const nr=lines[i],ni=indent(nr); if(!nr.trim()||ni>ind){buf.push(nr.slice(Math.min(ni,ind+2)));i++;}else break; }
921
+ jsBlocks.push(buf.join('\n'));
922
+ continue;
923
+ }
924
+
925
+ // add TAG "id/classes": — the workhorse
926
+ // Supports: add div "myId" add div ".btn.active" add div "#main .hero big"
927
+ const addM = t.match(/^add\s+([\w-]+)(?:\s+"([^"]*)")?(?:\s+'([^']*)')?\s*:?$/);
928
+ if (addM) {
929
+ while (elStack.length>1 && elStack[elStack.length-1].indent>=ind) elStack.pop();
930
+ const parent = elStack.length ? elStack[elStack.length-1].el : null;
931
+ const rawSel = (addM[2]||addM[3]||'').trim();
932
+ const el = {
933
+ tag:addM[1].toLowerCase(), id:'', classes:[],
934
+ text:'', html:'',
935
+ styles:{}, pseudo:{}, mediaRules:[],
936
+ beforeContent:null, afterContent:null,
937
+ attrs:{}, events:[], children:[],
938
+ _indent:ind, _anim:null,
939
+ };
940
+ // parse selector tokens: #id .class plain-word→id
941
+ rawSel.split(/\s+/).filter(Boolean).forEach(tok => {
942
+ if (tok.startsWith('#')) el.id = tok.slice(1);
943
+ else if (tok.startsWith('.')) el.classes.push(tok.slice(1));
944
+ else if (!el.id) el.id = tok;
945
+ else el.classes.push(tok);
946
+ });
947
+ if (parent && !parent._isPage) parent.children.push(el);
948
+ else elements.push(el);
949
+ elStack.push({el, indent:ind});
950
+ continue;
951
+ }
952
+
953
+ // ── properties inside an element ─────────────────────────
954
+ const pe = elStack[elStack.length-1];
955
+ if (!pe || ind <= pe.indent) continue;
956
+ const el = pe.el;
957
+
958
+ let m2;
959
+
960
+ // content
961
+ if ((m2=t.match(/^text\s+(.+)$/))) { el.text=str(m2[1]); continue; }
962
+ if ((m2=t.match(/^html\s+(.+)$/))) { el.html=str(m2[1]); continue; }
963
+
964
+ // common attrs — shorthand keywords
965
+ const ATTR_KW = {
966
+ src:1,href:1,alt:1,title:1,type:1,name:1,value:1,
967
+ for:1,action:1,method:1,target:1,rel:1,rows:1,cols:1,
968
+ min:1,max:1,step:1,role:1,tabindex:1,colspan:1,rowspan:1,
969
+ width:1,height:1,loading:1,decoding:1,crossorigin:1,
970
+ enctype:1,accept:1,pattern:1,maxlength:1,minlength:1,
971
+ download:1,sizes:1,srcset:1,poster:1,preload:1,
972
+ sandbox:1,allow:1,frameborder:1,scrolling:1,
973
+ };
974
+ if ((m2=t.match(/^([\w-]+)\s+(.+)$/)) && ATTR_KW[m2[1]]) { el.attrs[m2[1]]=str(m2[2]); continue; }
975
+
976
+ // boolean attrs
977
+ if (/^(checked|disabled|readonly|required|autoplay|loop|controls|muted|multiple|autofocus|hidden|open|selected|novalidate|async|defer|reversed|ismap|allowfullscreen|default|formnovalidate|spellcheck)$/.test(t))
978
+ { el.attrs[t]=''; continue; }
979
+
980
+ // aria-*, data-*
981
+ if ((m2=t.match(/^aria-([\w-]+)\s+(.+)$/))) { el.attrs[`aria-${m2[1]}`]=str(m2[2]); continue; }
982
+ if ((m2=t.match(/^data-([\w-]+)\s+(.+)$/))) { el.attrs[`data-${m2[1]}`]=str(m2[2]); continue; }
983
+
984
+ // generic attr fallback
985
+ if ((m2=t.match(/^attr\s+([\w-]+)\s+(.+)$/))) { el.attrs[m2[1]]=str(m2[2]); continue; }
986
+
987
+ // class
988
+ if ((m2=t.match(/^class\s+(.+)$/))) { str(m2[1]).split(/\s+/).forEach(c=>c&&el.classes.push(c)); continue; }
989
+
990
+ // style — any CSS property, camelCase or kebab
991
+ if ((m2=t.match(/^style\s+([\w-]+)\s+(.+)$/))) { el.styles[camel(m2[1])]=str(m2[2]); continue; }
992
+
993
+ // pseudo-class styles: hover / focus / active / visited / checked / disabled / focusVisible / focusWithin / placeholder / selection
994
+ if ((m2=t.match(/^(hover|focus|active|visited|checked|disabled|focus-visible|focus-within|placeholder|first-child|last-child|nth-child|not|invalid|valid)\s+([\w-]+)\s+(.+)$/)))
995
+ { if(!el.pseudo[m2[1]])el.pseudo[m2[1]]={}; el.pseudo[m2[1]][camel(m2[2])]=str(m2[3]); continue; }
996
+
997
+ // pseudo-elements: before / after / first-line / first-letter / marker / selection / backdrop
998
+ if ((m2=t.match(/^before\s+(.+)$/))) { el.beforeContent=str(m2[1]); continue; }
999
+ if ((m2=t.match(/^after\s+(.+)$/))) { el.afterContent=str(m2[1]); continue; }
1000
+
1001
+ // shorthand style helpers
1002
+ if ((m2=t.match(/^transition\s+(.+)$/))) { el.styles.transition=str(m2[1]); continue; }
1003
+ if ((m2=t.match(/^transform\s+(.+)$/))) { el.styles.transform=str(m2[1]); continue; }
1004
+ if ((m2=t.match(/^filter\s+(.+)$/))) { el.styles.filter=str(m2[1]); continue; }
1005
+ if ((m2=t.match(/^grid\s+(.+)$/))) { el.styles.gridTemplateColumns=str(m2[1]); continue; }
1006
+ if ((m2=t.match(/^flex\s+(.+)$/))) { el.styles.flex=str(m2[1]); continue; }
1007
+ if ((m2=t.match(/^gap\s+(.+)$/))) { el.styles.gap=str(m2[1]); continue; }
1008
+ if ((m2=t.match(/^bg\s+(.+)$/))) { el.styles.background=str(m2[1]); continue; }
1009
+ if ((m2=t.match(/^shadow\s+(.+)$/))) { el.styles.boxShadow=str(m2[1]); continue; }
1010
+ if ((m2=t.match(/^clip\s+(.+)$/))) { el.styles.clipPath=str(m2[1]); continue; }
1011
+ if ((m2=t.match(/^mask\s+(.+)$/))) { el.styles.mask=str(m2[1]); continue; }
1012
+ if ((m2=t.match(/^outline\s+(.+)$/))) { el.styles.outline=str(m2[1]); continue; }
1013
+ if ((m2=t.match(/^overflow\s+(.+)$/))) { el.styles.overflow=str(m2[1]); continue; }
1014
+ if ((m2=t.match(/^cursor\s+(.+)$/))) { el.styles.cursor=str(m2[1]); continue; }
1015
+ if ((m2=t.match(/^opacity\s+(.+)$/))) { el.styles.opacity=str(m2[1]); continue; }
1016
+ if ((m2=t.match(/^zindex\s+(.+)$/))) { el.styles.zIndex=str(m2[1]); continue; }
1017
+ if ((m2=t.match(/^var\s+(--[\w-]+)\s+(.+)$/))) { el.styles[m2[1]]=str(m2[2]); continue; }
1018
+
1019
+ // @media per-element: media "max-width:600px" prop value
1020
+ if ((m2=t.match(/^media\s+"([^"]+)"\s+([\w-]+)\s+(.+)$/)))
1021
+ { el.mediaRules.push({q:m2[1], p:camel(m2[2]), v:str(m2[3])}); continue; }
1022
+ // media "value" (HTML attribute, e.g. on <link> or <source>)
1023
+ if ((m2=t.match(/^media\s+(.+)$/)) && !m2[1].trim().match(/^"[^"]*"\s+[\w-]+/))
1024
+ { el.attrs.media=str(m2[1]); continue; }
1025
+ // placeholder "text" as HTML attribute (not pseudo-class)
1026
+ if ((m2=t.match(/^placeholder\s+(.+)$/)) && !m2[1].match(/^[\w-]/))
1027
+ { el.attrs.placeholder=str(m2[1]); continue; }
1028
+
1029
+ // animate NAME DURATION? EASING?
1030
+ if ((m2=t.match(/^animate\s+(\w+)(?:\s+([\d.]+))?(?:\s+([\w-]+))?$/)))
1031
+ { el._anim={name:m2[1], dur:parseFloat(m2[2]||0.4), ease:m2[3]||'ease'}; continue; }
1032
+
1033
+ // on EVENT: — raw JS handler, indented body
1034
+ if ((m2=t.match(/^on\s+([\w]+)\s*:?$/))) {
1035
+ const buf=[];
1036
+ while(i<lines.length){ const nr=lines[i],ni=indent(nr); if(!nr.trim()||ni<=ind)break; buf.push(nr.slice(ni)); i++; }
1037
+ el.events.push({ev:m2[1], code:buf.join('\n')});
1038
+ continue;
1039
+ }
1040
+ }
1041
+
1042
+ // ── Render ────────────────────────────────────────────────
1043
+ const pseudoCSS = [];
1044
+ const mediaCSS = {};
1045
+
1046
+ const VOID = new Set(['area','base','br','col','embed','hr','img','input','link','meta','param','source','track','wbr']);
1047
+
1048
+ function ensureId(el) { if (!el.id) el.id=uid(); return el.id; }
1049
+
1050
+ function renderEl(el) {
1051
+ const tag = el.tag;
1052
+
1053
+ // Build inline style string
1054
+ let styleStr = Object.entries(el.styles).map(([k,v])=>`${kebab(k)}:${v}`).join(';');
1055
+
1056
+ // Animations
1057
+ if (el._anim) {
1058
+ const ANIMS = {
1059
+ fadeIn:'fadeIn',fadeOut:'fadeOut',
1060
+ slideIn:'slideIn',slideInRight:'slideInRight',
1061
+ slideUp:'slideUp',slideDown:'slideDown',
1062
+ pop:'pop',bounce:'bounce',spin:'spin',
1063
+ pulse:'pulse',shake:'shake',flip:'flip',
1064
+ zoom:'zoom',wiggle:'wiggle',typewriter:'typewriter',
1065
+ };
1066
+ const aname = ANIMS[el._anim.name]||el._anim.name;
1067
+ const fill = /^(bounce|spin|pulse)$/.test(el._anim.name)?'infinite':'both';
1068
+ const ease = el._anim.name==='spin'?'linear':el._anim.ease;
1069
+ styleStr += (styleStr?';':'')+`animation:${aname} ${el._anim.dur}s ${ease} ${fill}`;
1070
+ }
1071
+
1072
+ // Pseudo-class rules
1073
+ Object.entries(el.pseudo).forEach(([pseudo, styles]) => {
1074
+ const r = Object.entries(styles).map(([k,v])=>`${kebab(k)}:${v}`).join(';');
1075
+ if (r) { ensureId(el); pseudoCSS.push(`#${el.id}:${pseudo}{${r}}`); }
1076
+ });
1077
+
1078
+ // before/after pseudo-elements
1079
+ if (el.beforeContent!==null) { ensureId(el); pseudoCSS.push(`#${el.id}::before{content:"${el.beforeContent.replace(/"/g,'\\"')}"}`); }
1080
+ if (el.afterContent!==null) { ensureId(el); pseudoCSS.push(`#${el.id}::after{content:"${el.afterContent.replace(/"/g,'\\"')}"}`); }
1081
+
1082
+ // @media rules
1083
+ el.mediaRules.forEach(({q,p,v}) => {
1084
+ const s = el.id ? `#${el.id}` : (el.classes[0] ? `.${el.classes[0]}` : tag);
1085
+ if (!mediaCSS[q]) mediaCSS[q]=[];
1086
+ mediaCSS[q].push(`${s}{${kebab(p)}:${v}}`);
1087
+ });
1088
+
1089
+ // Build attribute string
1090
+ const idA = el.id ? ` id="${el.id}"` : '';
1091
+ const clsA = el.classes.length ? ` class="${el.classes.join(' ')}"` : '';
1092
+ const styA = styleStr ? ` style="${styleStr}"` : '';
1093
+ const xtra = Object.entries(el.attrs).map(([k,v])=>v===''?` ${k}`:` ${k}="${v}"`).join('');
1094
+ const evts = el.events.map(({ev,code})=>{
1095
+ return ` data-ss-${ev}="${code.replace(/"/g,'&quot;').replace(/\n/g,' ')}"`;
1096
+ }).join('');
1097
+
1098
+ if (VOID.has(tag)) return `<${tag}${idA}${clsA}${styA}${xtra}>\n`;
1099
+ const inner = el.html || el.text || el.children.map(renderEl).join('');
1100
+ return `<${tag}${idA}${clsA}${styA}${xtra}${evts}>${inner}</${tag}>\n`;
1101
+ }
1102
+
1103
+ const bodyStyleStr = Object.entries(pageStyles).map(([k,v])=>`${kebab(k)}:${v}`).join(';');
1104
+ const bodyXtra = Object.entries(bodyAttrs).map(([k,v])=>` ${k}="${v}"`).join('');
1105
+ const bodyHtml = elements.map(renderEl).join('');
1106
+
1107
+ const mediaCSSStr = Object.entries(mediaCSS)
1108
+ .map(([q,rules])=>`@media (${q}){${rules.join('')}}`).join('\n');
1109
+
1110
+ const KEYFRAMES = `
1111
+ @keyframes fadeIn {0%{opacity:0;transform:translateY(16px)}100%{opacity:1;transform:none}}
1112
+ @keyframes fadeOut {0%{opacity:1}100%{opacity:0}}
1113
+ @keyframes slideIn {0%{transform:translateX(-40px);opacity:0}100%{transform:none;opacity:1}}
1114
+ @keyframes slideInRight {0%{transform:translateX(40px);opacity:0}100%{transform:none;opacity:1}}
1115
+ @keyframes slideUp {0%{transform:translateY(40px);opacity:0}100%{transform:none;opacity:1}}
1116
+ @keyframes slideDown {0%{transform:translateY(-40px);opacity:0}100%{transform:none;opacity:1}}
1117
+ @keyframes bounce {0%,100%{transform:translateY(0)}50%{transform:translateY(-18px)}}
1118
+ @keyframes pulse {0%,100%{opacity:1}50%{opacity:0.4}}
1119
+ @keyframes spin {to{transform:rotate(360deg)}}
1120
+ @keyframes shake {0%,100%{transform:translateX(0)}20%{transform:translateX(-8px)}40%{transform:translateX(8px)}60%{transform:translateX(-5px)}80%{transform:translateX(5px)}}
1121
+ @keyframes pop {0%{transform:scale(0.5);opacity:0}70%{transform:scale(1.1)}100%{transform:scale(1);opacity:1}}
1122
+ @keyframes flip {0%{transform:rotateY(-90deg);opacity:0}100%{transform:rotateY(0);opacity:1}}
1123
+ @keyframes zoom {0%{transform:scale(0);opacity:0}100%{transform:scale(1);opacity:1}}
1124
+ @keyframes wiggle {0%,100%{transform:rotate(0)}25%{transform:rotate(-8deg)}75%{transform:rotate(8deg)}}
1125
+ @keyframes typewriter {from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 0 0 0)}}`;
1126
+
1127
+ // All DOM events wired up
1128
+ const ALL_EVENTS = [
1129
+ 'click','dblclick','contextmenu',
1130
+ 'mousedown','mouseup','mouseover','mouseout','mouseenter','mouseleave','mousemove',
1131
+ 'keydown','keyup','keypress',
1132
+ 'input','change','focus','blur','submit','reset','select',
1133
+ 'scroll','resize','wheel',
1134
+ 'dragstart','drag','dragend','dragover','dragenter','dragleave','drop',
1135
+ 'touchstart','touchmove','touchend','touchcancel',
1136
+ 'pointerdown','pointermove','pointerup','pointercancel','pointerenter','pointerleave',
1137
+ 'animationstart','animationend','animationiteration',
1138
+ 'transitionstart','transitionend',
1139
+ 'load','error','abort','canplay','play','pause','ended','timeupdate','volumechange',
1140
+ 'copy','cut','paste','beforeinput',
1141
+ 'fullscreenchange','visibilitychange',
1142
+ ];
1143
+ const evScript = ALL_EVENTS.map(ev =>
1144
+ `document.querySelectorAll('[data-ss-${ev}]').forEach(function(el){el.addEventListener('${ev}',function(event){try{(new Function('event',this.getAttribute('data-ss-${ev}'))).call(this,event)}catch(e){console.error(e)}}.bind(el));});`
1145
+ ).join('\n');
1146
+
1147
+ return `<!DOCTYPE html>
1148
+ <html lang="${pageLang}">
1149
+ <head>
1150
+ <meta charset="UTF-8">
1151
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1152
+ <title>${pageTitle}</title>
1153
+ ${headTags.join('\n')}
1154
+ <style>
1155
+ *{box-sizing:border-box;margin:0;padding:0}
1156
+ ${KEYFRAMES}
1157
+ ${pseudoCSS.join('\n')}
1158
+ ${mediaCSSStr}
1159
+ ${cssBlocks.join('\n')}
1160
+ </style>
1161
+ </head>
1162
+ <body${bodyStyleStr?` style="${bodyStyleStr}"`:''}>
1163
+ ${bodyHtml}<script>(function(){
1164
+ ${evScript}
1165
+ ${jsBlocks.join('\n')}
1166
+ })()</script>
1167
+ </body>
1168
+ </html>`;
1169
+ }
1170
+
1171
+
1172
+ 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.5.0",
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",