@symbo.ls/mermaid 3.14.4 → 3.14.5
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/CHANGELOG.md +9 -0
- package/dist/esm/bin/start.js +2 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/src/bundle.js +97 -0
- package/dist/esm/src/render.js +61 -0
- package/dist/esm/src/route.js +1 -0
- package/dist/esm/src/server.js +1 -0
- package/dist/esm/src/utils/deepDestringify.js +1 -0
- package/dist/esm/src/utils/funcql-transpile.js +1 -0
- package/dist/esm/src/utils/logger.js +1 -0
- package/dist/esm/src/utils/vartranformer.js +1 -0
- package/package.json +62 -17
- package/index.js +0 -21
- package/src/bundle.js +0 -217
- package/src/render.js +0 -502
- package/src/route.js +0 -80
- package/src/server.js +0 -190
- package/src/template.html +0 -23
- package/src/utils/deepDestringify.js +0 -149
- package/src/utils/funcql-transpile.js +0 -346
- package/src/utils/logger.js +0 -131
- package/src/utils/vartranformer.js +0 -30
package/src/server.js
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Express factory for the mermaid runner.
|
|
3
|
-
*
|
|
4
|
-
* Self-sustaining: pass `jsonPath` to serve a project from a local JSON file
|
|
5
|
-
* with no infrastructure dependencies. Or inject a `getProjectData` callback
|
|
6
|
-
* to source data from KV / API / database / anywhere.
|
|
7
|
-
*
|
|
8
|
-
* import { createMermaidApp } from '@symbo.ls/mermaid';
|
|
9
|
-
*
|
|
10
|
-
* // From a JSON file:
|
|
11
|
-
* const app = createMermaidApp({ jsonPath: './app.json' });
|
|
12
|
-
*
|
|
13
|
-
* // From a custom data source (e.g. KV worker):
|
|
14
|
-
* const app = createMermaidApp({
|
|
15
|
-
* async getProjectData(req, { appkey }) {
|
|
16
|
-
* return { data: await myKv.fetch(appkey), channel: 'production' };
|
|
17
|
-
* },
|
|
18
|
-
* });
|
|
19
|
-
*
|
|
20
|
-
* app.listen(3333);
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import path from 'path';
|
|
24
|
-
import fs from 'fs';
|
|
25
|
-
import express from 'express';
|
|
26
|
-
import cors from 'cors';
|
|
27
|
-
import log from './utils/logger.js';
|
|
28
|
-
import { STATIC_EXT } from './route.js';
|
|
29
|
-
import { renderProjectHtml, renderNotPublishedHtml, generateSitemap } from './render.js';
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Load project data from a local JSON file (or app.json inside a directory).
|
|
33
|
-
*/
|
|
34
|
-
export function loadJsonData(jsonPath) {
|
|
35
|
-
const resolved = path.resolve(jsonPath);
|
|
36
|
-
let filePath = resolved;
|
|
37
|
-
|
|
38
|
-
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
39
|
-
filePath = path.join(resolved, 'app.json');
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (!fs.existsSync(filePath)) {
|
|
43
|
-
throw new Error(`JSON data file not found: ${filePath}`);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const raw = fs.readFileSync(filePath, 'utf8');
|
|
47
|
-
const data = JSON.parse(raw);
|
|
48
|
-
log.info(`[MERMAID] Loaded project data from ${filePath}`);
|
|
49
|
-
return data;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function normalizeAppkey(rawAppkey, req) {
|
|
53
|
-
if (typeof rawAppkey !== 'string') return rawAppkey;
|
|
54
|
-
|
|
55
|
-
// When renderUrl includes its own query, split and merge nested params
|
|
56
|
-
if (rawAppkey.includes('?') && req) {
|
|
57
|
-
const [baseAppkey, nestedQuery] = rawAppkey.split('?');
|
|
58
|
-
rawAppkey = baseAppkey.replace(/\/$/, '');
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
const nestedParams = new URLSearchParams(nestedQuery);
|
|
62
|
-
for (const [key, value] of nestedParams.entries()) {
|
|
63
|
-
if (req.query[key] === undefined) req.query[key] = value;
|
|
64
|
-
}
|
|
65
|
-
} catch (err) {
|
|
66
|
-
log.warn(`[MERMAID] Failed to parse nested query in renderUrl: ${err.message}`);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Strip scheme, port, leading www., trailing slash
|
|
71
|
-
return rawAppkey
|
|
72
|
-
.replace(/^https?:\/\//, '')
|
|
73
|
-
.split(':')[0]
|
|
74
|
-
.replace(/^www\./, '')
|
|
75
|
-
.replace(/\/$/, '');
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Create an Express app that serves Symbols projects.
|
|
80
|
-
*
|
|
81
|
-
* @param {object} options
|
|
82
|
-
* @param {string} [options.jsonPath] — local JSON file (or directory containing app.json)
|
|
83
|
-
* @param {string} [options.appkeyOverride] — force a specific appkey for all requests
|
|
84
|
-
* @param {string} [options.channel='production'] — channel for JSON-mode rendering
|
|
85
|
-
* @param {Function} [options.getProjectData] — async (req, ctx) → { data, appkey?, channel?, isProd? }
|
|
86
|
-
* @param {boolean} [options.cors=true] — enable CORS middleware
|
|
87
|
-
* @param {object} [options.editor] — { bundle: string } to inject preview-editor for non-prod
|
|
88
|
-
* @returns {express.Application}
|
|
89
|
-
*/
|
|
90
|
-
export function createMermaidApp(options = {}) {
|
|
91
|
-
const {
|
|
92
|
-
jsonPath,
|
|
93
|
-
appkeyOverride,
|
|
94
|
-
channel: defaultChannel = 'production',
|
|
95
|
-
getProjectData,
|
|
96
|
-
cors: enableCors = true,
|
|
97
|
-
editor,
|
|
98
|
-
} = options;
|
|
99
|
-
|
|
100
|
-
// Pre-load JSON data if a path is provided
|
|
101
|
-
let jsonData = null;
|
|
102
|
-
if (jsonPath) jsonData = loadJsonData(jsonPath);
|
|
103
|
-
|
|
104
|
-
const app = express();
|
|
105
|
-
|
|
106
|
-
if (enableCors) app.use(cors({ origin: '*', methods: 'GET, POST' }));
|
|
107
|
-
app.use(express.json());
|
|
108
|
-
|
|
109
|
-
app.get('/health', (_req, res) =>
|
|
110
|
-
res.send({ alive: true, service: 'mermaid', version: '3.14.0' }),
|
|
111
|
-
);
|
|
112
|
-
|
|
113
|
-
app.get('*', async (req, res) => {
|
|
114
|
-
try {
|
|
115
|
-
const pathname = req.originalUrl.split('?')[0];
|
|
116
|
-
|
|
117
|
-
// Skip static asset requests
|
|
118
|
-
if (STATIC_EXT.test(pathname)) {
|
|
119
|
-
if (pathname === '/favicon.ico') return res.status(204).end();
|
|
120
|
-
return res.status(404).send('Not Found');
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Resolve appkey from override / renderUrl / Host header
|
|
124
|
-
let rawAppkey = appkeyOverride || req.query.renderUrl || req.headers.host;
|
|
125
|
-
rawAppkey = normalizeAppkey(rawAppkey, req);
|
|
126
|
-
|
|
127
|
-
let data = null;
|
|
128
|
-
let appkey = rawAppkey;
|
|
129
|
-
let channel = defaultChannel;
|
|
130
|
-
let isProd = false;
|
|
131
|
-
|
|
132
|
-
if (jsonData) {
|
|
133
|
-
data = jsonData;
|
|
134
|
-
log.debug('[MERMAID] Using local JSON data');
|
|
135
|
-
} else if (getProjectData) {
|
|
136
|
-
const result = await getProjectData(req, { appkey: rawAppkey });
|
|
137
|
-
if (result) {
|
|
138
|
-
data = result.data;
|
|
139
|
-
if (result.appkey) appkey = result.appkey;
|
|
140
|
-
if (result.channel) channel = result.channel;
|
|
141
|
-
if (typeof result.isProd === 'boolean') isProd = result.isProd;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (!appkey) {
|
|
146
|
-
return res.send(
|
|
147
|
-
'Hello, this is Mermaid (Symbols runner). Read developer documentation at https://symbols.app/developers',
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
if (!data) {
|
|
152
|
-
log.warn(`[MERMAID] Project not found for appkey: ${appkey}`);
|
|
153
|
-
return res.status(404).type('html').send(renderNotPublishedHtml(appkey, channel));
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Sitemap
|
|
157
|
-
if (pathname === '/sitemap.xml') {
|
|
158
|
-
if (generateSitemap && data.pages && Object.keys(data.pages).length) {
|
|
159
|
-
const baseUrl = `https://${appkey}`;
|
|
160
|
-
const sitemap = generateSitemap(baseUrl, data.pages);
|
|
161
|
-
return res.type('application/xml').send(sitemap);
|
|
162
|
-
}
|
|
163
|
-
return res.status(404).send('No sitemap available');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const userId = req.headers['x-user-id'] || '';
|
|
167
|
-
|
|
168
|
-
const result = await renderProjectHtml(data, {
|
|
169
|
-
appkey,
|
|
170
|
-
pathname,
|
|
171
|
-
channel,
|
|
172
|
-
isProd,
|
|
173
|
-
req,
|
|
174
|
-
userId,
|
|
175
|
-
editorBundle: editor?.bundle,
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
return res
|
|
179
|
-
.status(result.status)
|
|
180
|
-
.set({ host: 'https://' + appkey + '/' })
|
|
181
|
-
.type(result.contentType === 'text/html' ? 'html' : result.contentType)
|
|
182
|
-
.send(result.html);
|
|
183
|
-
} catch (error) {
|
|
184
|
-
log.error(`[MERMAID] Error in request: ${error}`);
|
|
185
|
-
return res.status(500).send('Internal Server Error');
|
|
186
|
-
}
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
return app;
|
|
190
|
-
}
|
package/src/template.html
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html'${{{ ROOT_ATTRS }}}'>
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8" />
|
|
5
|
-
'${{{ HEAD_BOOT }}}'
|
|
6
|
-
'${{{ META }}}'
|
|
7
|
-
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
8
|
-
'${{{ IMPORTMAP }}}'
|
|
9
|
-
</head>
|
|
10
|
-
<body>
|
|
11
|
-
<div data-ssr>'${{{ BODY }}}'</div>
|
|
12
|
-
<script>
|
|
13
|
-
window.process = { env: { NODE_ENV: '${{{ NODE_ENV }}}' } };
|
|
14
|
-
</script>
|
|
15
|
-
<script>
|
|
16
|
-
'${{{ SMBLS_IIFE }}}';
|
|
17
|
-
</script>
|
|
18
|
-
<script>
|
|
19
|
-
'${{{ BUNDLE }}}';
|
|
20
|
-
</script>
|
|
21
|
-
'${{{ EDITOR }}}'
|
|
22
|
-
</body>
|
|
23
|
-
</html>
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Inspired by @symbo.ls/utils deepDestringifyFunctions but implemented locally to remove the
|
|
4
|
-
// dependency on the external package. The implementation is pure-JS and node
|
|
5
|
-
// friendly (it works whether `window` is available or not).
|
|
6
|
-
//
|
|
7
|
-
// The function walks an object tree and converts any string that represents a
|
|
8
|
-
// JavaScript function back into an executable function by using `eval`.
|
|
9
|
-
//
|
|
10
|
-
// const obj = { fn: "(x) => x * 2" }
|
|
11
|
-
// deepDestringifyFunctions(obj) // => { fn: [Function] }
|
|
12
|
-
//
|
|
13
|
-
// It keeps the structure (arrays / nested objects) intact and leaves all other
|
|
14
|
-
// primitives untouched.
|
|
15
|
-
//
|
|
16
|
-
// NOTE: Executing code coming from a string is potentially dangerous. Make sure
|
|
17
|
-
// the input is trusted or sandboxed appropriately before calling this helper.
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Tests if the provided string represents a JavaScript function.
|
|
21
|
-
* We intentionally keep the (rather heavy) RegExp logic from the original
|
|
22
|
-
* implementation because it has been field-tested and produces reliable
|
|
23
|
-
* results for arrow functions, classic functions and async functions.
|
|
24
|
-
*
|
|
25
|
-
* @param {string} str
|
|
26
|
-
* @returns {boolean}
|
|
27
|
-
*/
|
|
28
|
-
function hasFunction(str) {
|
|
29
|
-
if (!str) return false;
|
|
30
|
-
|
|
31
|
-
const trimmed = str.trim().replace(/\n\s*/g, ' ').trim();
|
|
32
|
-
if (trimmed === '' || trimmed === '{}' || trimmed === '[]') return false;
|
|
33
|
-
|
|
34
|
-
const patterns = [
|
|
35
|
-
/^\(\s*\{[^}]*\}\s*\)\s*=>/, // ({ ... }) =>
|
|
36
|
-
/^(\([^)]*\)|[^=]*)\s*=>/, // () => or x =>
|
|
37
|
-
/^function[\s(]/, // function () {}
|
|
38
|
-
/^async\s+/, // async () => {}
|
|
39
|
-
/^\(\s*function/, // (function () { })
|
|
40
|
-
/^[a-zA-Z_$][a-zA-Z0-9_$]*\s*=>/, // named =>
|
|
41
|
-
];
|
|
42
|
-
|
|
43
|
-
const isFunctionLike = patterns.some((p) => p.test(trimmed));
|
|
44
|
-
const isObjectLiteral = trimmed.startsWith('{') && !trimmed.includes('=>');
|
|
45
|
-
const isArrayLiteral = trimmed.startsWith('[');
|
|
46
|
-
const isJSONLike = /^["[{]/.test(trimmed) && !trimmed.includes('=>');
|
|
47
|
-
|
|
48
|
-
return isFunctionLike && !isObjectLiteral && !isArrayLiteral && !isJSONLike;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
const isString = (val) => typeof val === 'string';
|
|
52
|
-
const isArray = Array.isArray;
|
|
53
|
-
const isObject = (val) => val !== null && typeof val === 'object' && !isArray(val);
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Recursively converts stringified functions inside an object (or array) back
|
|
57
|
-
* to real functions.
|
|
58
|
-
*
|
|
59
|
-
* @template T
|
|
60
|
-
* @param {T} obj - The source object (or array).
|
|
61
|
-
* @param {any} [target={}] - Used internally for recursion, pass nothing.
|
|
62
|
-
* @returns {T} A new object with functions revived.
|
|
63
|
-
*/
|
|
64
|
-
export function deepDestringifyFunctions(obj, target = Array.isArray(obj) ? [] : {}) {
|
|
65
|
-
for (const prop in obj) {
|
|
66
|
-
if (!Object.prototype.hasOwnProperty.call(obj, prop)) continue;
|
|
67
|
-
|
|
68
|
-
const value = obj[prop];
|
|
69
|
-
|
|
70
|
-
// 1. String ==> maybe turn into function
|
|
71
|
-
if (isString(value)) {
|
|
72
|
-
if (hasFunction(value)) {
|
|
73
|
-
try {
|
|
74
|
-
const fn = (0, Function)('return (' + value + ')')();
|
|
75
|
-
target[prop] = fn;
|
|
76
|
-
} catch (_) {
|
|
77
|
-
// If evaluation fails, preserve the original string.
|
|
78
|
-
target[prop] = value;
|
|
79
|
-
}
|
|
80
|
-
} else {
|
|
81
|
-
target[prop] = value;
|
|
82
|
-
}
|
|
83
|
-
continue;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// 2. Array ==> recurse per entry
|
|
87
|
-
if (isArray(value)) {
|
|
88
|
-
target[prop] = [];
|
|
89
|
-
value.forEach((item) => {
|
|
90
|
-
if (isString(item)) {
|
|
91
|
-
if (hasFunction(item)) {
|
|
92
|
-
try {
|
|
93
|
-
const fn = (0, Function)('return (' + item + ')')();
|
|
94
|
-
target[prop].push(fn);
|
|
95
|
-
} catch (_) {
|
|
96
|
-
target[prop].push(item);
|
|
97
|
-
}
|
|
98
|
-
} else {
|
|
99
|
-
target[prop].push(item);
|
|
100
|
-
}
|
|
101
|
-
} else if (isObject(item) || isArray(item)) {
|
|
102
|
-
target[prop].push(deepDestringifyFunctions(item));
|
|
103
|
-
} else {
|
|
104
|
-
target[prop].push(item);
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// 3. Nested object ==> recurse
|
|
111
|
-
if (isObject(value)) {
|
|
112
|
-
target[prop] = deepDestringifyFunctions(value);
|
|
113
|
-
continue;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// 4. Primitive (number, boolean, function, null, undefined)
|
|
117
|
-
target[prop] = value;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
return target;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Strips function-like strings from an object tree, replacing them with null.
|
|
125
|
-
* Use in environments where eval/Function are blocked (e.g. Cloudflare Workers)
|
|
126
|
-
* so that brender renders empty placeholders instead of raw function source code.
|
|
127
|
-
*/
|
|
128
|
-
export function stripFunctionStrings(obj, target = Array.isArray(obj) ? [] : {}) {
|
|
129
|
-
for (const prop in obj) {
|
|
130
|
-
if (!Object.prototype.hasOwnProperty.call(obj, prop)) continue;
|
|
131
|
-
const value = obj[prop];
|
|
132
|
-
if (isString(value)) {
|
|
133
|
-
target[prop] = hasFunction(value) ? null : value;
|
|
134
|
-
} else if (isArray(value)) {
|
|
135
|
-
target[prop] = value.map((item) => {
|
|
136
|
-
if (isString(item) && hasFunction(item)) return null;
|
|
137
|
-
if (isObject(item) || isArray(item)) return stripFunctionStrings(item);
|
|
138
|
-
return item;
|
|
139
|
-
});
|
|
140
|
-
} else if (isObject(value)) {
|
|
141
|
-
target[prop] = stripFunctionStrings(value);
|
|
142
|
-
} else {
|
|
143
|
-
target[prop] = value;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
return target;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export default deepDestringifyFunctions;
|
|
@@ -1,346 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Transpile a stringified JavaScript function into a funcql schema.
|
|
5
|
-
*
|
|
6
|
-
* Handles common DOMQL patterns:
|
|
7
|
-
* - Property access: (el, s) => s.visible
|
|
8
|
-
* - Ternary: (el, s) => s.active ? 'yes' : 'no'
|
|
9
|
-
* - Negation: (el, s) => !s.hidden
|
|
10
|
-
* - Comparison: (el, s) => s.count > 0
|
|
11
|
-
* - el.call(): (el, s) => el.call("polyglot", s.key)
|
|
12
|
-
* - Logical AND/OR: (el, s) => s.a && s.b
|
|
13
|
-
* - Void comparison: (el, s) => s.x !== void 0
|
|
14
|
-
* - Concatenation: (el, s) => s.count + " items"
|
|
15
|
-
*
|
|
16
|
-
* For unsupported patterns, returns null (caller should keep the original string).
|
|
17
|
-
*
|
|
18
|
-
* @param {string} source - stringified JS function
|
|
19
|
-
* @returns {{ schema: *, args: string[] } | null}
|
|
20
|
-
*/
|
|
21
|
-
export const transpile = (source) => {
|
|
22
|
-
if (typeof source !== 'string') return null;
|
|
23
|
-
|
|
24
|
-
const trimmed = source.trim();
|
|
25
|
-
|
|
26
|
-
// Parse arrow function: (args) => body
|
|
27
|
-
const arrowMatch = trimmed.match(/^\(?([^)]*?)\)?\s*=>\s*([\s\S]+)$/);
|
|
28
|
-
if (!arrowMatch) return null;
|
|
29
|
-
|
|
30
|
-
const rawArgs = arrowMatch[1];
|
|
31
|
-
const body = arrowMatch[2].trim();
|
|
32
|
-
|
|
33
|
-
// Extract argument names
|
|
34
|
-
const args = rawArgs
|
|
35
|
-
.split(',')
|
|
36
|
-
.map((a) => a.trim())
|
|
37
|
-
.filter(Boolean);
|
|
38
|
-
|
|
39
|
-
// Skip multi-line / block bodies
|
|
40
|
-
if (body.startsWith('{')) return null;
|
|
41
|
-
|
|
42
|
-
// Map standard DOMQL arg names to funcql context paths
|
|
43
|
-
// (el, s) → el = element, s = state
|
|
44
|
-
// (el, s, ctx) → ctx = context
|
|
45
|
-
const argMap = buildArgMap(args);
|
|
46
|
-
|
|
47
|
-
// Try to transpile the expression body
|
|
48
|
-
const schema = transpileExpr(body, argMap);
|
|
49
|
-
if (schema === null) return null;
|
|
50
|
-
|
|
51
|
-
return { schema, args };
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Build a mapping from arg names to funcql context paths.
|
|
56
|
-
* Standard DOMQL patterns: (el, s, ctx) or (element, state, context)
|
|
57
|
-
*/
|
|
58
|
-
const buildArgMap = (args) => {
|
|
59
|
-
const map = {};
|
|
60
|
-
if (args[0]) map[args[0]] = 'el'; // element
|
|
61
|
-
if (args[1]) map[args[1]] = 'state'; // state
|
|
62
|
-
if (args[2]) map[args[2]] = 'context'; // context
|
|
63
|
-
return map;
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Transpile a single JS expression to a funcql schema node.
|
|
68
|
-
*/
|
|
69
|
-
const transpileExpr = (expr, argMap) => {
|
|
70
|
-
const e = expr.trim();
|
|
71
|
-
if (!e) return null;
|
|
72
|
-
|
|
73
|
-
// Ternary: condition ? thenExpr : elseExpr
|
|
74
|
-
const ternary = splitTernary(e);
|
|
75
|
-
if (ternary) {
|
|
76
|
-
const cond = transpileExpr(ternary.condition, argMap);
|
|
77
|
-
const then_ = transpileExpr(ternary.consequent, argMap);
|
|
78
|
-
const else_ = transpileExpr(ternary.alternate, argMap);
|
|
79
|
-
if (cond !== null && then_ !== null && else_ !== null) {
|
|
80
|
-
return { if: [cond, then_, else_] };
|
|
81
|
-
}
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
// el.call("polyglot", ...) → ['call', 'polyglot', ...args]
|
|
86
|
-
const callMatch = e.match(/^(\w+)\.call\((.+)\)$/);
|
|
87
|
-
if (callMatch) {
|
|
88
|
-
const callArgs = splitCallArgs(callMatch[2]);
|
|
89
|
-
if (callArgs) {
|
|
90
|
-
const resolvedArgs = callArgs.map((a) => transpileExpr(a.trim(), argMap));
|
|
91
|
-
if (resolvedArgs.every((a) => a !== null)) {
|
|
92
|
-
return ['call', ...resolvedArgs];
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return null;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// Logical OR: a || b (lowest precedence, split first)
|
|
99
|
-
{
|
|
100
|
-
const parts = splitOnOperator(e, '||');
|
|
101
|
-
if (parts) {
|
|
102
|
-
const left = transpileExpr(parts.left, argMap);
|
|
103
|
-
const right = transpileExpr(parts.right, argMap);
|
|
104
|
-
if (left !== null && right !== null) {
|
|
105
|
-
return ['||', left, right];
|
|
106
|
-
}
|
|
107
|
-
return null;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Logical AND: a && b
|
|
112
|
-
{
|
|
113
|
-
const parts = splitOnOperator(e, '&&');
|
|
114
|
-
if (parts) {
|
|
115
|
-
const left = transpileExpr(parts.left, argMap);
|
|
116
|
-
const right = transpileExpr(parts.right, argMap);
|
|
117
|
-
if (left !== null && right !== null) {
|
|
118
|
-
return ['&&', left, right];
|
|
119
|
-
}
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Comparison operators: ===, !==, >, <, >=, <=
|
|
125
|
-
for (const op of ['!==', '===', '>=', '<=', '>', '<']) {
|
|
126
|
-
const parts = splitOnOperator(e, op);
|
|
127
|
-
if (parts) {
|
|
128
|
-
const left = transpileExpr(parts.left, argMap);
|
|
129
|
-
const right = transpileExpr(parts.right, argMap);
|
|
130
|
-
if (left !== null && right !== null) {
|
|
131
|
-
return [op, left, right];
|
|
132
|
-
}
|
|
133
|
-
return null;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Negation: !expr
|
|
138
|
-
if (e.startsWith('!')) {
|
|
139
|
-
const inner = transpileExpr(e.slice(1).trim(), argMap);
|
|
140
|
-
if (inner !== null && typeof inner === 'string') {
|
|
141
|
-
return '!' + inner;
|
|
142
|
-
}
|
|
143
|
-
return null;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// String concatenation with + (left-associative, collect all parts)
|
|
147
|
-
if (e.includes('+') && !e.startsWith('"') && !e.startsWith("'")) {
|
|
148
|
-
const allParts = splitAllOnOperator(e, '+');
|
|
149
|
-
if (allParts && allParts.length >= 2) {
|
|
150
|
-
const transpiled = allParts.map((p) => transpileExpr(p.trim(), argMap));
|
|
151
|
-
if (transpiled.every((t) => t !== null)) {
|
|
152
|
-
// Build left-associative tree: ['+', ['+', a, b], c]
|
|
153
|
-
let result = transpiled[0];
|
|
154
|
-
for (let i = 1; i < transpiled.length; i++) {
|
|
155
|
-
result = ['+', result, transpiled[i]];
|
|
156
|
-
}
|
|
157
|
-
return result;
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// String literal: "..." or '...' → prefix with = so funcql treats as literal
|
|
163
|
-
if ((e.startsWith('"') && e.endsWith('"')) || (e.startsWith("'") && e.endsWith("'"))) {
|
|
164
|
-
return '=' + e.slice(1, -1);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// void 0 / undefined — use special sentinel that funcql evaluate can handle
|
|
168
|
-
if (e === 'void 0' || e === 'undefined') return '__undefined__';
|
|
169
|
-
|
|
170
|
-
// Numeric literal
|
|
171
|
-
if (/^-?\d+(\.\d+)?$/.test(e)) return Number(e);
|
|
172
|
-
|
|
173
|
-
// Boolean
|
|
174
|
-
if (e === 'true') return true;
|
|
175
|
-
if (e === 'false') return false;
|
|
176
|
-
if (e === 'null') return null;
|
|
177
|
-
|
|
178
|
-
// Empty string
|
|
179
|
-
if (e === '""' || e === "''") return '';
|
|
180
|
-
|
|
181
|
-
// Property access path: s.x.y.z → remap arg names
|
|
182
|
-
if (/^[\w$.[\]]+$/.test(e) && !e.includes('(')) {
|
|
183
|
-
return remapPath(e, argMap);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Bracket access: s["key"] or s[variable]
|
|
187
|
-
// Skip for now — too complex
|
|
188
|
-
|
|
189
|
-
return null;
|
|
190
|
-
};
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Remap argument names in a dot-path to funcql context paths.
|
|
194
|
-
* e.g. s.root.lang → state.root.lang (when argMap says s→state)
|
|
195
|
-
*/
|
|
196
|
-
const remapPath = (path, argMap) => {
|
|
197
|
-
const parts = path.split('.');
|
|
198
|
-
const first = parts[0];
|
|
199
|
-
if (argMap[first]) {
|
|
200
|
-
parts[0] = argMap[first];
|
|
201
|
-
return parts.join('.');
|
|
202
|
-
}
|
|
203
|
-
return path;
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* Split a ternary expression respecting nesting.
|
|
208
|
-
* Returns { condition, consequent, alternate } or null.
|
|
209
|
-
*/
|
|
210
|
-
const splitTernary = (expr) => {
|
|
211
|
-
let depth = 0;
|
|
212
|
-
let qIdx = -1;
|
|
213
|
-
let cIdx = -1;
|
|
214
|
-
|
|
215
|
-
for (let i = 0; i < expr.length; i++) {
|
|
216
|
-
const ch = expr[i];
|
|
217
|
-
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
218
|
-
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
219
|
-
else if (ch === '?' && depth === 0 && qIdx === -1) qIdx = i;
|
|
220
|
-
else if (ch === ':' && depth === 0 && qIdx !== -1) {
|
|
221
|
-
cIdx = i;
|
|
222
|
-
break;
|
|
223
|
-
}
|
|
224
|
-
// Skip strings
|
|
225
|
-
if (ch === '"' || ch === "'") {
|
|
226
|
-
const quote = ch;
|
|
227
|
-
i++;
|
|
228
|
-
while (i < expr.length && expr[i] !== quote) {
|
|
229
|
-
if (expr[i] === '\\') i++;
|
|
230
|
-
i++;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
if (qIdx === -1 || cIdx === -1) return null;
|
|
236
|
-
|
|
237
|
-
return {
|
|
238
|
-
condition: expr.slice(0, qIdx).trim(),
|
|
239
|
-
consequent: expr.slice(qIdx + 1, cIdx).trim(),
|
|
240
|
-
alternate: expr.slice(cIdx + 1).trim(),
|
|
241
|
-
};
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Split expression on a binary operator (respecting nesting).
|
|
246
|
-
* Returns { left, right } or null.
|
|
247
|
-
*/
|
|
248
|
-
const splitOnOperator = (expr, op) => {
|
|
249
|
-
let depth = 0;
|
|
250
|
-
const opLen = op.length;
|
|
251
|
-
|
|
252
|
-
for (let i = 0; i < expr.length - opLen + 1; i++) {
|
|
253
|
-
const ch = expr[i];
|
|
254
|
-
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
255
|
-
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
256
|
-
// Skip strings
|
|
257
|
-
if (ch === '"' || ch === "'") {
|
|
258
|
-
const quote = ch;
|
|
259
|
-
i++;
|
|
260
|
-
while (i < expr.length && expr[i] !== quote) {
|
|
261
|
-
if (expr[i] === '\\') i++;
|
|
262
|
-
i++;
|
|
263
|
-
}
|
|
264
|
-
continue;
|
|
265
|
-
}
|
|
266
|
-
if (depth === 0 && expr.slice(i, i + opLen) === op) {
|
|
267
|
-
// Make sure we're not inside a longer operator
|
|
268
|
-
const before = i > 0 ? expr[i - 1] : '';
|
|
269
|
-
const after = i + opLen < expr.length ? expr[i + opLen] : '';
|
|
270
|
-
// Skip if this is part of ===, !==, etc.
|
|
271
|
-
if (op === '=' && (before === '!' || before === '=' || after === '=')) continue;
|
|
272
|
-
if (op === '>' && before === '=') continue;
|
|
273
|
-
if (op === '<' && after === '=') continue;
|
|
274
|
-
|
|
275
|
-
return {
|
|
276
|
-
left: expr.slice(0, i).trim(),
|
|
277
|
-
right: expr.slice(i + opLen).trim(),
|
|
278
|
-
};
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
return null;
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Split expression on ALL occurrences of a binary operator (respecting nesting).
|
|
286
|
-
* Returns array of parts or null.
|
|
287
|
-
*/
|
|
288
|
-
const splitAllOnOperator = (expr, op) => {
|
|
289
|
-
const parts = [];
|
|
290
|
-
let depth = 0;
|
|
291
|
-
let start = 0;
|
|
292
|
-
const opLen = op.length;
|
|
293
|
-
|
|
294
|
-
for (let i = 0; i < expr.length; i++) {
|
|
295
|
-
const ch = expr[i];
|
|
296
|
-
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
297
|
-
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
298
|
-
if (ch === '"' || ch === "'") {
|
|
299
|
-
const quote = ch;
|
|
300
|
-
i++;
|
|
301
|
-
while (i < expr.length && expr[i] !== quote) {
|
|
302
|
-
if (expr[i] === '\\') i++;
|
|
303
|
-
i++;
|
|
304
|
-
}
|
|
305
|
-
continue;
|
|
306
|
-
}
|
|
307
|
-
if (depth === 0 && expr.slice(i, i + opLen) === op) {
|
|
308
|
-
parts.push(expr.slice(start, i));
|
|
309
|
-
start = i + opLen;
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
parts.push(expr.slice(start));
|
|
313
|
-
|
|
314
|
-
if (parts.length < 2) return null;
|
|
315
|
-
return parts;
|
|
316
|
-
};
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* Split function call arguments respecting nesting.
|
|
320
|
-
*/
|
|
321
|
-
const splitCallArgs = (str) => {
|
|
322
|
-
const args = [];
|
|
323
|
-
let depth = 0;
|
|
324
|
-
let start = 0;
|
|
325
|
-
|
|
326
|
-
for (let i = 0; i < str.length; i++) {
|
|
327
|
-
const ch = str[i];
|
|
328
|
-
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
329
|
-
else if (ch === ')' || ch === ']' || ch === '}') depth--;
|
|
330
|
-
else if (ch === ',' && depth === 0) {
|
|
331
|
-
args.push(str.slice(start, i));
|
|
332
|
-
start = i + 1;
|
|
333
|
-
}
|
|
334
|
-
// Skip strings
|
|
335
|
-
if (ch === '"' || ch === "'") {
|
|
336
|
-
const quote = ch;
|
|
337
|
-
i++;
|
|
338
|
-
while (i < str.length && str[i] !== quote) {
|
|
339
|
-
if (str[i] === '\\') i++;
|
|
340
|
-
i++;
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
args.push(str.slice(start));
|
|
345
|
-
return args.filter((a) => a.trim());
|
|
346
|
-
};
|