structscript 1.4.1 → 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.
@@ -841,249 +841,332 @@ class Interpreter {
841
841
  }
842
842
  }
843
843
 
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>`;
844
+ // ── Web Engine ──────────────────────────────────────────────────────────────
845
+
846
+ function isWebCode(code) {
847
+ return /^\s*(page\b|add\s+\w|css\s*:?$|script\s*:?$)/m.test(code);
1087
848
  }
1088
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
+
1089
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.1",
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",