structscript 1.3.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 -2
- package/lib/editor.html +3276 -0
- package/lib/interpreter.js +371 -21
- package/lib/main.js +261 -0
- package/lib/preload.js +21 -0
- package/package.json +7 -5
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/lib/main.js
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// ============================================================
|
|
3
|
+
// StructScript Editor — Electron Main Process
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
const { app, BrowserWindow, ipcMain, dialog, Menu, shell } = require('electron');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const os = require('os');
|
|
10
|
+
|
|
11
|
+
// The interpreter lives next to this file in lib/
|
|
12
|
+
const { Interpreter, SSError, buildWebDoc } = require('./interpreter');
|
|
13
|
+
|
|
14
|
+
const RECENT_FILE = path.join(os.homedir(), '.ss_recent.json');
|
|
15
|
+
const EDITOR_HTML = path.join(__dirname, 'editor.html');
|
|
16
|
+
|
|
17
|
+
// ── Recent files ─────────────────────────────────────────────
|
|
18
|
+
function loadRecent() {
|
|
19
|
+
try { return JSON.parse(fs.readFileSync(RECENT_FILE, 'utf8')); } catch (_) { return []; }
|
|
20
|
+
}
|
|
21
|
+
function saveRecent(list) {
|
|
22
|
+
try { fs.writeFileSync(RECENT_FILE, JSON.stringify(list.slice(0, 20)), 'utf8'); } catch (_) {}
|
|
23
|
+
}
|
|
24
|
+
function addRecent(filePath) {
|
|
25
|
+
const abs = path.resolve(filePath);
|
|
26
|
+
const list = loadRecent().filter(f => f !== abs);
|
|
27
|
+
list.unshift(abs);
|
|
28
|
+
saveRecent(list);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Run interpreter, capture output ──────────────────────────
|
|
32
|
+
function runSource(source, inputValues) {
|
|
33
|
+
const lines = [];
|
|
34
|
+
const interp = new Interpreter({
|
|
35
|
+
output: msg => lines.push({ text: String(msg), cls: '' }),
|
|
36
|
+
warn: msg => lines.push({ text: String(msg), cls: 'warn' }),
|
|
37
|
+
});
|
|
38
|
+
if (Array.isArray(inputValues)) interp._inputQueue = [...inputValues];
|
|
39
|
+
try {
|
|
40
|
+
interp.run(source);
|
|
41
|
+
return { ok: true, lines };
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return {
|
|
44
|
+
ok: false, lines,
|
|
45
|
+
error: {
|
|
46
|
+
message: e.message || String(e),
|
|
47
|
+
line: e.ssLine !== undefined ? e.ssLine + 1 : null,
|
|
48
|
+
snippet: e.snippet || null,
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── IPC handlers ─────────────────────────────────────────────
|
|
55
|
+
function registerIPC() {
|
|
56
|
+
|
|
57
|
+
ipcMain.handle('run', (_, { source, inputs }) => runSource(source, inputs || []));
|
|
58
|
+
|
|
59
|
+
ipcMain.handle('save', (_, { filePath, content }) => {
|
|
60
|
+
if (!filePath) return { error: 'No path' };
|
|
61
|
+
const abs = path.resolve(filePath);
|
|
62
|
+
try {
|
|
63
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
64
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
65
|
+
addRecent(abs);
|
|
66
|
+
return { ok: true, path: abs };
|
|
67
|
+
} catch (e) { return { error: e.message }; }
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
ipcMain.handle('open', (_, { filePath }) => {
|
|
71
|
+
if (!filePath) return { error: 'No path' };
|
|
72
|
+
const abs = path.resolve(filePath);
|
|
73
|
+
try {
|
|
74
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
75
|
+
addRecent(abs);
|
|
76
|
+
return { ok: true, path: abs, content, name: path.basename(abs) };
|
|
77
|
+
} catch (e) { return { error: e.message }; }
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
ipcMain.handle('recent', () => {
|
|
81
|
+
const files = loadRecent().filter(f => { try { fs.accessSync(f); return true; } catch (_) { return false; } });
|
|
82
|
+
return { files };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
ipcMain.handle('new', (_, { name } = {}) => {
|
|
86
|
+
const base = (name || 'untitled.ss').replace(/\.ss$/i, '') + '.ss';
|
|
87
|
+
const dir = os.homedir();
|
|
88
|
+
const full = path.join(dir, base);
|
|
89
|
+
const starter = '// New StructScript file\n\nlet name = "World"\nsay "Hello, {name}!"\n';
|
|
90
|
+
try {
|
|
91
|
+
if (!fs.existsSync(full)) fs.writeFileSync(full, starter, 'utf8');
|
|
92
|
+
addRecent(full);
|
|
93
|
+
return { ok: true, path: full, content: fs.readFileSync(full, 'utf8'), name: path.basename(full) };
|
|
94
|
+
} catch (e) { return { error: e.message }; }
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
ipcMain.handle('browse', (_, { dir }) => {
|
|
98
|
+
const target = dir || os.homedir();
|
|
99
|
+
try {
|
|
100
|
+
const entries = fs.readdirSync(target, { withFileTypes: true });
|
|
101
|
+
const items = entries
|
|
102
|
+
.filter(e => e.isDirectory() || e.name.endsWith('.ss'))
|
|
103
|
+
.map(e => ({ name: e.name, isDir: e.isDirectory(), path: path.join(target, e.name) }));
|
|
104
|
+
return { dir: target, parent: path.dirname(target), items };
|
|
105
|
+
} catch (e) { return { error: e.message }; }
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Native open-file dialog
|
|
109
|
+
ipcMain.handle('dialog-open', async (event) => {
|
|
110
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
111
|
+
const result = await dialog.showOpenDialog(win, {
|
|
112
|
+
title: 'Open StructScript File',
|
|
113
|
+
filters: [{ name: 'StructScript', extensions: ['ss'] }, { name: 'All Files', extensions: ['*'] }],
|
|
114
|
+
properties: ['openFile'],
|
|
115
|
+
});
|
|
116
|
+
if (result.canceled || !result.filePaths.length) return { canceled: true };
|
|
117
|
+
const filePath = result.filePaths[0];
|
|
118
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
119
|
+
addRecent(filePath);
|
|
120
|
+
return { ok: true, path: filePath, content, name: path.basename(filePath) };
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Native save-as dialog
|
|
124
|
+
ipcMain.handle('dialog-save', async (event, { suggested, content }) => {
|
|
125
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
126
|
+
const result = await dialog.showSaveDialog(win, {
|
|
127
|
+
title: 'Save StructScript File',
|
|
128
|
+
defaultPath: suggested || path.join(os.homedir(), 'untitled.ss'),
|
|
129
|
+
filters: [{ name: 'StructScript', extensions: ['ss'] }, { name: 'All Files', extensions: ['*'] }],
|
|
130
|
+
});
|
|
131
|
+
if (result.canceled || !result.filePath) return { canceled: true };
|
|
132
|
+
fs.writeFileSync(result.filePath, content, 'utf8');
|
|
133
|
+
addRecent(result.filePath);
|
|
134
|
+
return { ok: true, path: result.filePath, name: path.basename(result.filePath) };
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Save HTML output
|
|
138
|
+
ipcMain.handle('dialog-save-html', async (event, { content, suggested }) => {
|
|
139
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
140
|
+
const result = await dialog.showSaveDialog(win, {
|
|
141
|
+
title: 'Save Web Page',
|
|
142
|
+
defaultPath: suggested || path.join(os.homedir(), 'page.html'),
|
|
143
|
+
filters: [{ name: 'HTML File', extensions: ['html'] }],
|
|
144
|
+
});
|
|
145
|
+
if (result.canceled || !result.filePath) return { canceled: true };
|
|
146
|
+
fs.writeFileSync(result.filePath, content, 'utf8');
|
|
147
|
+
return { ok: true, path: result.filePath };
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Reveal file in Finder / Explorer
|
|
151
|
+
ipcMain.handle('show-in-folder', (_, { filePath }) => {
|
|
152
|
+
if (filePath) shell.showItemInFolder(filePath);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Homedir
|
|
156
|
+
ipcMain.handle('homedir', () => os.homedir());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Window ────────────────────────────────────────────────────
|
|
160
|
+
function createWindow(filePath) {
|
|
161
|
+
const win = new BrowserWindow({
|
|
162
|
+
width: 1200,
|
|
163
|
+
height: 780,
|
|
164
|
+
minWidth: 700,
|
|
165
|
+
minHeight: 480,
|
|
166
|
+
title: 'StructScript Editor',
|
|
167
|
+
backgroundColor: '#0a1614',
|
|
168
|
+
webPreferences: {
|
|
169
|
+
preload: path.join(__dirname, 'preload.js'),
|
|
170
|
+
contextIsolation: true,
|
|
171
|
+
nodeIntegration: false,
|
|
172
|
+
},
|
|
173
|
+
// macOS traffic lights look better with this
|
|
174
|
+
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
|
175
|
+
icon: path.join(__dirname, 'icon.png'), // optional — won't crash if missing
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
win.loadFile(EDITOR_HTML, filePath ? { query: { file: filePath } } : {});
|
|
179
|
+
|
|
180
|
+
// Remove default menu on Windows/Linux; keep a minimal one
|
|
181
|
+
buildAppMenu(win);
|
|
182
|
+
|
|
183
|
+
return win;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildAppMenu(win) {
|
|
187
|
+
const isMac = process.platform === 'darwin';
|
|
188
|
+
const template = [
|
|
189
|
+
...(isMac ? [{ role: 'appMenu' }] : []),
|
|
190
|
+
{
|
|
191
|
+
label: 'File',
|
|
192
|
+
submenu: [
|
|
193
|
+
{ label: 'New File', accelerator: 'CmdOrCtrl+N', click: () => win.webContents.executeJavaScript('newFile()') },
|
|
194
|
+
{ label: 'Open…', accelerator: 'CmdOrCtrl+O', click: () => win.webContents.executeJavaScript('openFile()') },
|
|
195
|
+
{ type: 'separator' },
|
|
196
|
+
{ label: 'Save', accelerator: 'CmdOrCtrl+S', click: () => win.webContents.executeJavaScript('saveFile()') },
|
|
197
|
+
{ label: 'Save As…', accelerator: 'CmdOrCtrl+Shift+S', click: () => win.webContents.executeJavaScript('saveFileAs()') },
|
|
198
|
+
{ type: 'separator' },
|
|
199
|
+
isMac ? { role: 'close' } : { role: 'quit' },
|
|
200
|
+
],
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
label: 'Edit',
|
|
204
|
+
submenu: [
|
|
205
|
+
{ role: 'undo' }, { role: 'redo' }, { type: 'separator' },
|
|
206
|
+
{ role: 'cut' }, { role: 'copy' }, { role: 'paste' },
|
|
207
|
+
{ role: 'selectAll' },
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
label: 'Run',
|
|
212
|
+
submenu: [
|
|
213
|
+
{ label: 'Run Script', accelerator: 'CmdOrCtrl+Return', click: () => win.webContents.executeJavaScript('runCode()') },
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
label: 'View',
|
|
218
|
+
submenu: [
|
|
219
|
+
{ role: 'reload' },
|
|
220
|
+
{ type: 'separator' },
|
|
221
|
+
{ role: 'toggleDevTools' },
|
|
222
|
+
{ type: 'separator' },
|
|
223
|
+
{ role: 'resetZoom' }, { role: 'zoomIn' }, { role: 'zoomOut' },
|
|
224
|
+
{ type: 'separator' },
|
|
225
|
+
{ role: 'togglefullscreen' },
|
|
226
|
+
],
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
role: 'help',
|
|
230
|
+
submenu: [
|
|
231
|
+
{ label: 'StructScript Docs', click: () => shell.openExternal('https://structscript.dev') },
|
|
232
|
+
],
|
|
233
|
+
},
|
|
234
|
+
];
|
|
235
|
+
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── App lifecycle ─────────────────────────────────────────────
|
|
239
|
+
app.whenReady().then(() => {
|
|
240
|
+
registerIPC();
|
|
241
|
+
|
|
242
|
+
// File passed as CLI arg (e.g. double-click a .ss file on desktop)
|
|
243
|
+
const filePath = process.argv.find(a => a.endsWith('.ss') && !a.includes('node_modules'));
|
|
244
|
+
createWindow(filePath || null);
|
|
245
|
+
|
|
246
|
+
// macOS: re-open window when dock icon is clicked
|
|
247
|
+
app.on('activate', () => {
|
|
248
|
+
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
app.on('window-all-closed', () => {
|
|
253
|
+
if (process.platform !== 'darwin') app.quit();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// macOS: open files dragged onto the dock icon
|
|
257
|
+
app.on('open-file', (event, filePath) => {
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
if (app.isReady()) createWindow(filePath);
|
|
260
|
+
else app.once('ready', () => createWindow(filePath));
|
|
261
|
+
});
|