@morit/cli 1.6.0 → 1.9.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/README.md +3 -2
- package/assets/plugin_contract.json +827 -116
- package/package.json +3 -2
- package/src/cli.js +19 -1
- package/src/morit-script.js +174 -0
- package/src/preview-v3.js +83 -0
- package/src/script-contract.js +62 -0
- package/src/script-data.js +41 -0
- package/src/script-project.js +60 -0
- package/src/script-runtime.js +252 -0
- package/src/script-schema.js +32 -0
- package/src/script-spec.js +113 -0
- package/src/ui-v3.js +159 -0
- package/src/workspace.js +205 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@morit/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "Official Morit Developer CLI for Cloud Projects, validation, builds, and deployments",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,8 @@
|
|
|
20
20
|
"README.md"
|
|
21
21
|
],
|
|
22
22
|
"scripts": {
|
|
23
|
-
"build": "node scripts/sync-contract.mjs",
|
|
23
|
+
"build": "node scripts/sync-contract.mjs && node scripts/sync-script-contract.mjs",
|
|
24
|
+
"check:contract": "node scripts/sync-contract.mjs --check",
|
|
24
25
|
"test": "node --test",
|
|
25
26
|
"prepack": "npm run build && npm test"
|
|
26
27
|
},
|
package/src/cli.js
CHANGED
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
readProjectDirectory,
|
|
12
12
|
scaffoldProjectDirectory,
|
|
13
13
|
validateProjectDirectory,
|
|
14
|
+
verifyPackageFile,
|
|
15
|
+
runtimeTestProjectDirectory,
|
|
16
|
+
compileProjectDirectory,
|
|
14
17
|
} from "./workspace.js";
|
|
15
18
|
|
|
16
19
|
const DEFAULT_API_URL = "https://developers.moring.co";
|
|
@@ -20,9 +23,11 @@ Usage:
|
|
|
20
23
|
morit login [--token <access-token>] [--api-url <origin>]
|
|
21
24
|
morit logout
|
|
22
25
|
morit docs
|
|
23
|
-
morit plugin setup [directory] --id <plugin.id> --name <name> --publisher <publisher>
|
|
26
|
+
morit plugin setup [directory] --id <plugin.id> --name <name> --publisher <publisher> [--ui-schema 2|3]
|
|
24
27
|
morit plugin add [directory]
|
|
28
|
+
morit plugin runtime-test [directory] [--module <id> --export <name>] [--fixture <fixtures/file.json>]
|
|
25
29
|
morit plugin validate [directory]
|
|
30
|
+
morit plugin verify <file.mplg>
|
|
26
31
|
morit plugin preview [directory] [--output <file.html>]
|
|
27
32
|
morit plugin build [directory] [--output <file.mplg>]
|
|
28
33
|
morit plugin sync [directory] [--pull] [--force]
|
|
@@ -177,6 +182,7 @@ async function setupPlugin(root, flags) {
|
|
|
177
182
|
publisher: flags.publisher,
|
|
178
183
|
description: flags.description || "",
|
|
179
184
|
advanced: true,
|
|
185
|
+
ui_schema: Number(flags.ui_schema ?? flags["ui-schema"] ?? 3),
|
|
180
186
|
});
|
|
181
187
|
project = await readProjectDirectory(root);
|
|
182
188
|
}
|
|
@@ -362,10 +368,22 @@ export async function runCli(argv, options = {}) {
|
|
|
362
368
|
output(stdout, await setupPlugin(root, parsed.flags), json);
|
|
363
369
|
return 0;
|
|
364
370
|
}
|
|
371
|
+
if (action === "compile") {
|
|
372
|
+
output(stdout, await compileProjectDirectory(root), true);
|
|
373
|
+
return 0;
|
|
374
|
+
}
|
|
375
|
+
if (action === "runtime-test") {
|
|
376
|
+
output(stdout, await runtimeTestProjectDirectory(root, parsed.flags), json);
|
|
377
|
+
return 0;
|
|
378
|
+
}
|
|
365
379
|
if (action === "validate") {
|
|
366
380
|
output(stdout, await validateProjectDirectory(root), json);
|
|
367
381
|
return 0;
|
|
368
382
|
}
|
|
383
|
+
if (action === "verify") {
|
|
384
|
+
output(stdout, await verifyPackageFile(root), json);
|
|
385
|
+
return 0;
|
|
386
|
+
}
|
|
369
387
|
if (action === "preview") {
|
|
370
388
|
output(stdout, await previewProjectDirectory(
|
|
371
389
|
root,
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { validateAst } from './script-contract.js';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
// Morit source is parsed, never passed to JavaScript eval/Function/vm.
|
|
5
|
+
export class ScriptError extends Error {
|
|
6
|
+
constructor(code, message, span) {
|
|
7
|
+
super(message); this.name = 'ScriptError'; this.code = code; this.span = span;
|
|
8
|
+
}
|
|
9
|
+
toJSON() { return { code: this.code, message: this.message, span: this.span }; }
|
|
10
|
+
}
|
|
11
|
+
const denied = new Set(['__proto__', 'prototype', 'constructor', 'eval', 'Function', 'globalThis', 'process', 'require', 'import', 'while', 'class', 'new', 'this', 'async', 'await', 'delete']);
|
|
12
|
+
const precedence = { '??': 1, '||': 2, '&&': 3, '==': 4, '!=': 4, '===': 4, '!==': 4, '<': 5, '>': 5, '<=': 5, '>=': 5, '+': 6, '-': 6, '*': 7, '/': 7, '%': 7 };
|
|
13
|
+
const bytes = value => Buffer.byteLength(value, 'utf8');
|
|
14
|
+
const fail = (code, message, span) => { throw new ScriptError(code, message, span); };
|
|
15
|
+
|
|
16
|
+
class Parser {
|
|
17
|
+
constructor(source, path, inline) {
|
|
18
|
+
this.source = source; this.path = path; this.inline = inline; this.tokens = []; this.cursor = 0; this.nodes = 0; this.depth = 0;
|
|
19
|
+
const pattern = /\s+|\/\/[^\n]*|\/\*[\s\S]*?\*\/|(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|(?:\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|[A-Za-z_$][A-Za-z0-9_$]*|===|!==|=>|==|!=|<=|>=|&&|\|\||\?\?|[{}()[\].,;:?+*/%!=<>-]/gy;
|
|
20
|
+
let offset = 0;
|
|
21
|
+
while (offset < source.length) {
|
|
22
|
+
pattern.lastIndex = offset; const match = pattern.exec(source);
|
|
23
|
+
if (!match) this.error('Invalid token', offset);
|
|
24
|
+
const value = match[0]; offset = pattern.lastIndex;
|
|
25
|
+
if (/^\s|^\/\//.test(value) || value.startsWith('/*')) continue;
|
|
26
|
+
if (denied.has(value)) this.error(`Forbidden identifier: ${value}`, match.index);
|
|
27
|
+
this.tokens.push({ value, offset: match.index });
|
|
28
|
+
}
|
|
29
|
+
this.tokens.push({ value: '<eof>', offset });
|
|
30
|
+
}
|
|
31
|
+
span(offset = this.peek().offset) {
|
|
32
|
+
const lines = this.source.slice(0, offset).split('\n');
|
|
33
|
+
return { file: this.path, line: lines.length, column: lines.at(-1).length + 1, offset };
|
|
34
|
+
}
|
|
35
|
+
error(message, offset) { fail('SYNTAX', message, this.span(offset)); }
|
|
36
|
+
peek(n = 0) { return this.tokens[this.cursor + n] || this.tokens.at(-1); }
|
|
37
|
+
take(value) { if (this.peek().value !== value) return false; this.cursor++; return true; }
|
|
38
|
+
need(value) { if (!this.take(value)) this.error(`Expected ${value}, got ${this.peek().value}`); }
|
|
39
|
+
identifier() {
|
|
40
|
+
const token = this.peek();
|
|
41
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(token.value) || denied.has(token.value)) this.error('Expected identifier');
|
|
42
|
+
this.cursor++; return token.value;
|
|
43
|
+
}
|
|
44
|
+
node(type, fields, offset) {
|
|
45
|
+
if (++this.nodes > (this.inline ? 64 : 2048)) fail('AST_LIMIT', 'AST node limit exceeded', this.span(offset));
|
|
46
|
+
return { type, ...fields, span: this.span(offset) };
|
|
47
|
+
}
|
|
48
|
+
module() {
|
|
49
|
+
const functions = []; const names = new Set();
|
|
50
|
+
while (this.peek().value !== '<eof>') {
|
|
51
|
+
const offset = this.peek().offset; const exported = this.take('export');
|
|
52
|
+
this.need('function'); const name = this.identifier();
|
|
53
|
+
if (names.has(name)) this.error(`Duplicate function ${name}`); names.add(name);
|
|
54
|
+
const params = this.parameters(); const body = this.block();
|
|
55
|
+
functions.push(this.node('function', { name, params, body, exported }, offset));
|
|
56
|
+
}
|
|
57
|
+
return { type: 'module', version: 1, functions };
|
|
58
|
+
}
|
|
59
|
+
parameters() {
|
|
60
|
+
this.need('('); const params = [];
|
|
61
|
+
if (!this.take(')')) { do { params.push(this.identifier()); } while (this.take(',')); this.need(')'); }
|
|
62
|
+
if (new Set(params).size !== params.length || params.length > 16) this.error('Invalid function parameters');
|
|
63
|
+
return params;
|
|
64
|
+
}
|
|
65
|
+
block() {
|
|
66
|
+
if (++this.depth > 32) fail('AST_LIMIT', 'Nesting limit exceeded');
|
|
67
|
+
this.need('{'); const body = [];
|
|
68
|
+
while (!this.take('}')) {
|
|
69
|
+
if (this.peek().value === '<eof>') this.error('Unterminated block');
|
|
70
|
+
body.push(this.statement());
|
|
71
|
+
}
|
|
72
|
+
this.depth--; return body;
|
|
73
|
+
}
|
|
74
|
+
statement() {
|
|
75
|
+
const offset = this.peek().offset;
|
|
76
|
+
if (this.take('const') || this.take('let')) {
|
|
77
|
+
const mutable = this.tokens[this.cursor - 1].value === 'let'; const name = this.identifier();
|
|
78
|
+
this.need('='); const value = this.expression(); this.take(';');
|
|
79
|
+
return this.node('declare', { name, mutable, value }, offset);
|
|
80
|
+
}
|
|
81
|
+
if (this.take('return')) { const value = this.expression(); this.take(';'); return this.node('return', { value }, offset); }
|
|
82
|
+
if (this.take('if')) {
|
|
83
|
+
this.need('('); const condition = this.expression(); this.need(')'); const then = this.block();
|
|
84
|
+
const otherwise = this.take('else') ? (this.peek().value === 'if' ? [this.statement()] : this.block()) : [];
|
|
85
|
+
return this.node('if', { condition, then, else: otherwise }, offset);
|
|
86
|
+
}
|
|
87
|
+
if (this.take('for')) {
|
|
88
|
+
this.need('('); this.need('const'); const name = this.identifier(); this.need('of'); const items = this.expression(); this.need(')');
|
|
89
|
+
return this.node('for', { name, items, body: this.block() }, offset);
|
|
90
|
+
}
|
|
91
|
+
if (this.peek(1).value === '=') {
|
|
92
|
+
const name = this.identifier(); this.need('='); const value = this.expression(); this.take(';');
|
|
93
|
+
return this.node('assign', { name, value }, offset);
|
|
94
|
+
}
|
|
95
|
+
const value = this.expression(); this.take(';'); return this.node('expression', { value }, offset);
|
|
96
|
+
}
|
|
97
|
+
expression(min = 0) {
|
|
98
|
+
if (++this.depth > 32) fail('AST_LIMIT', 'Expression nesting limit exceeded');
|
|
99
|
+
const offset = this.peek().offset; let left = this.primary();
|
|
100
|
+
while (true) {
|
|
101
|
+
const op = this.peek().value; const rank = precedence[op];
|
|
102
|
+
if (rank === undefined || rank < min) break;
|
|
103
|
+
this.cursor++; left = this.node('binary', { op, left, right: this.expression(rank + 1) }, offset);
|
|
104
|
+
}
|
|
105
|
+
if (min === 0 && this.take('?')) {
|
|
106
|
+
const then = this.expression(); this.need(':'); const otherwise = this.expression();
|
|
107
|
+
left = this.node('conditional', { condition: left, then, else: otherwise }, offset);
|
|
108
|
+
}
|
|
109
|
+
this.depth--; return left;
|
|
110
|
+
}
|
|
111
|
+
primary() {
|
|
112
|
+
const { value, offset } = this.peek(); let result;
|
|
113
|
+
if (['!', '-', '+'].includes(value)) { this.cursor++; return this.node('unary', { op: value, value: this.expression(8) }, offset); }
|
|
114
|
+
if (/^\d/.test(value)) {
|
|
115
|
+
this.cursor++; const number = Number(value); if (!Number.isFinite(number)) this.error('Non-finite number');
|
|
116
|
+
result = this.node('literal', { value: number }, offset);
|
|
117
|
+
} else if (/^["']/.test(value)) {
|
|
118
|
+
this.cursor++;
|
|
119
|
+
try { result = this.node('literal', { value: JSON.parse(value[0] === '"' ? value : '"' + value.slice(1, -1).replace(/\\'/g, "'").replace(/(?<!\\)"/g, '\\"') + '"') }, offset); }
|
|
120
|
+
catch { this.error('Invalid string escape', offset); }
|
|
121
|
+
} else if (['true', 'false', 'null'].includes(value)) {
|
|
122
|
+
this.cursor++; result = this.node('literal', { value: JSON.parse(value) }, offset);
|
|
123
|
+
} else if (this.take('[')) {
|
|
124
|
+
const items = []; if (!this.take(']')) { do { items.push(this.expression()); } while (this.take(',') && this.peek().value !== ']'); this.need(']'); }
|
|
125
|
+
result = this.node('array', { items }, offset);
|
|
126
|
+
} else if (this.take('{')) {
|
|
127
|
+
const entries = []; const keys = new Set();
|
|
128
|
+
if (!this.take('}')) {
|
|
129
|
+
do {
|
|
130
|
+
let key = this.peek().value;
|
|
131
|
+
if (/^["']/.test(key)) { const literal = this.primary(); if (literal.type !== 'literal') this.error('Expected literal key'); key = literal.value; }
|
|
132
|
+
else key = this.identifier();
|
|
133
|
+
if (denied.has(key) || keys.has(key)) this.error('Forbidden or duplicate object key'); keys.add(key);
|
|
134
|
+
this.need(':'); entries.push({ key, value: this.expression() });
|
|
135
|
+
} while (this.take(',') && this.peek().value !== '}'); this.need('}');
|
|
136
|
+
}
|
|
137
|
+
result = this.node('object', { entries }, offset);
|
|
138
|
+
} else if (value === '(') {
|
|
139
|
+
let end = this.cursor + 1;
|
|
140
|
+
while (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(this.tokens[end]?.value || '') || this.tokens[end]?.value === ',') end++;
|
|
141
|
+
if (this.tokens[end]?.value === ')' && this.tokens[end + 1]?.value === '=>') {
|
|
142
|
+
if (this.inline) this.error('Functions belong in logic/*.morit');
|
|
143
|
+
const params = this.parameters(); this.need('=>');
|
|
144
|
+
result = this.node('lambda', { params, body: this.peek().value === '{' ? this.block() : [this.node('return', { value: this.expression() }, offset)] }, offset);
|
|
145
|
+
} else { this.need('('); result = this.expression(); this.need(')'); }
|
|
146
|
+
} else {
|
|
147
|
+
const name = this.identifier();
|
|
148
|
+
if (this.take('=>')) {
|
|
149
|
+
if (this.inline) this.error('Functions belong in logic/*.morit');
|
|
150
|
+
result = this.node('lambda', { params: [name], body: this.peek().value === '{' ? this.block() : [this.node('return', { value: this.expression() }, offset)] }, offset);
|
|
151
|
+
} else result = this.node('identifier', { name }, offset);
|
|
152
|
+
}
|
|
153
|
+
while (true) {
|
|
154
|
+
if (this.take('.')) result = this.node('member', { target: result, key: this.node('literal', { value: this.identifier() }, offset) }, offset);
|
|
155
|
+
else if (this.take('[')) { const key = this.expression(); this.need(']'); result = this.node('member', { target: result, key }, offset); }
|
|
156
|
+
else if (this.take('(')) {
|
|
157
|
+
const args = []; if (!this.take(')')) { do { args.push(this.expression()); } while (this.take(',')); this.need(')'); }
|
|
158
|
+
if (this.inline && result.type === 'member' && ['map', 'filter', 'reduce'].includes(result.key.value)) this.error('Collection transforms belong in logic/*.morit');
|
|
159
|
+
result = this.node('call', { target: result, args }, offset);
|
|
160
|
+
} else break;
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function compileExpression(source, path = '<inline>') {
|
|
167
|
+
if (typeof source !== 'string' || bytes(source) > 256) fail('SOURCE_LIMIT', 'Inline expression exceeds 256 UTF-8 bytes');
|
|
168
|
+
const parser = new Parser(source, path, true); const ast = parser.expression(); parser.need('<eof>'); return validateAst(ast, {inline: true});
|
|
169
|
+
}
|
|
170
|
+
export function compileModule(source, path = 'logic/main.morit') {
|
|
171
|
+
if (typeof source !== 'string' || bytes(source) > 32768) fail('SOURCE_LIMIT', 'Module exceeds 32 KiB');
|
|
172
|
+
const ast = validateAst(new Parser(source, path, false).module());
|
|
173
|
+
return { version: 1, source_sha256: createHash('sha256').update(source).digest('hex'), ast };
|
|
174
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {createHash} from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
export function renderV3PreviewHtml(manifest, files={}) {
|
|
5
|
+
const configs=manifest.ui_extensions.filter(e=>e.config?.ui_schema===3);
|
|
6
|
+
const bindings={};
|
|
7
|
+
const scan=value=>{if(typeof value==='string'){for(const m of value.matchAll(/\{\{=([\s\S]*?)\}\}/g)){const source=m[1].trim();bindings[source]=createHash('sha256').update(source).digest('hex');}}else if(Array.isArray(value))value.forEach(scan);else if(value&&typeof value==='object')for(const [k,v]of Object.entries(value))if(k!=='compiled')scan(v);};
|
|
8
|
+
configs.forEach(scan);
|
|
9
|
+
let fixture={};try{fixture=JSON.parse(files['fixtures/live.json']??files['fixtures/day.json']??'{}');}catch{}
|
|
10
|
+
const redact=value=>Array.isArray(value)?value.map(redact):value&&typeof value==='object'?Object.fromEntries(Object.entries(value).map(([key,item])=>[key,/secret|token|authorization|password|api[_-]?key/i.test(key)?'[REDACTED]':redact(item)])):value;
|
|
11
|
+
fixture=redact(fixture);
|
|
12
|
+
const code=['script-spec.js','script-contract.js','script-schema.js','script-runtime.js','script-data.js'].map(file=>readFileSync(new URL(file,import.meta.url),'utf8').replace(/^import .*;\s*$/gm,'').replace(/^export \{[^}]*\};\s*$/gm,'').replace(/\bexport (?=(const|function|class))/g,'')).join('\n');
|
|
13
|
+
const json=JSON.stringify({configs,bindings,fixture}).replaceAll('<','\\u003c');
|
|
14
|
+
return `<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Morit Runtime v3 Preview</title><style>
|
|
15
|
+
:root{font:16px system-ui;color-scheme:light dark;--surface:light-dark(#f5faf7,#141b18);--card:light-dark(#e9f1ec,#25342c);--ink:light-dark(#142b21,#e2eee6);--accent:light-dark(#006b58,#6cdbb5)}*{box-sizing:border-box}body{margin:0;background:var(--surface);color:var(--ink)}header,main{padding:16px;max-width:1280px;margin:auto}button,input,select{font:inherit;padding:10px 16px;border-radius:18px;border:1px solid var(--accent);background:var(--surface);color:var(--ink)}button{cursor:pointer}p{margin:0;overflow-wrap:anywhere}.stack{display:flex;flex-direction:column;gap:8px;min-width:0}.row{display:flex;gap:8px;flex-wrap:wrap}.row>*{min-width:0;flex:1}.card{padding:12px;background:var(--card);border-radius:20px}.grid{display:grid;gap:12px;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr))}.skeleton{background:var(--card);height:40px;border-radius:12px}dialog{max-width:min(90vw,600px);max-height:85vh;border:0;border-radius:24px;padding:24px;background:var(--surface);color:var(--ink)}dialog::backdrop{background:#0007}pre{white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px}details{margin:16px}.sheet{margin: auto auto 0;width:100%;max-width:800px}@media(prefers-reduced-motion:no-preference){.animated{animation:appear .2s}@keyframes appear{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}}</style><header><h1>Morit Runtime v3</h1><label>API response fixture <input id="fixture" type="file" accept="application/json"></label><button id="reload">Refresh</button></header><main id="screens"></main><script>${code}\n(${browser.toString()})(${json});</script></html>`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function browser({configs,bindings,fixture}) {
|
|
19
|
+
const screens=document.querySelector('#screens');
|
|
20
|
+
function element(tag,text){const e=document.createElement(tag);if(text!=null)e.textContent=String(text);return e;}
|
|
21
|
+
function clean(value){return Array.isArray(value)?value.map(clean):value&&typeof value==='object'?Object.fromEntries(Object.entries(value).map(([k,v])=>[k,/secret|token|authorization|password|api[_-]?key/i.test(k)?'[REDACTED]':clean(v)])):value;}
|
|
22
|
+
fixture=clean(fixture);
|
|
23
|
+
const surfaces=[];
|
|
24
|
+
for(const extension of configs){
|
|
25
|
+
const c=extension.config,modules=c.compiled.modules, state={},computed={},data={},raw={},errors={},locals={},trace={},timeline=[];let count=0;
|
|
26
|
+
let runtime=screenRuntime();
|
|
27
|
+
const host=element('section');screens.append(host);
|
|
28
|
+
function screenRuntime(){const width=innerWidth,height=innerHeight;return runtimeSnapshot({platform:'preview',screen:{width,height,orientation:width>=height?'landscape':'portrait',size_class:width<600?'compact':width<840?'medium':'expanded',text_scale:1,platform:'preview'}});}
|
|
29
|
+
const scope=lexical=>({state,computed,data,runtime,context:{platform:'preview'},...lexical});
|
|
30
|
+
function evalValue(value,lexical={}){
|
|
31
|
+
if(typeof value==='string'){
|
|
32
|
+
const matches=[...value.matchAll(/\{\{=([\s\S]*?)\}\}/g)];
|
|
33
|
+
const run=m=>{const ast=c.compiled.expressions[bindings[m[1].trim()]];const result=new ScriptRuntime({modules,profile:'inline'}).evaluate(ast,scope(lexical));trace[bindings[m[1].trim()]]=result.diagnostics;return result.value;};
|
|
34
|
+
if(matches.length===1&&matches[0][0]===value)return run(matches[0]);
|
|
35
|
+
return value.replace(/\{\{=([\s\S]*?)\}\}/g,(whole,source)=>run([whole,source])??'');
|
|
36
|
+
}
|
|
37
|
+
if(Array.isArray(value))return value.map(v=>evalValue(v,lexical));
|
|
38
|
+
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([k,v])=>[k,evalValue(v,lexical)]));
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
for(const[k,v]of Object.entries(c.initial_state??{}))state[k]=evalValue(v);
|
|
42
|
+
function compute(){for(const k of c.compiled.computed_order??[]){const def=c.computed_state[k];computed[k]=def?.module?new ScriptRuntime({modules}).invoke(def.module,def.export,evalValue(def.input),scope({})).value:evalValue(def?.expression??def);}}
|
|
43
|
+
function load(){for(const id of c.compiled.source_order??[]){const s=c.data_sources.find(s=>s.id===id);if(s.when!==undefined&&!evalValue(s.when))continue;try{let value=fixture&&typeof fixture==='object'&&Object.hasOwn(fixture,id)?fixture[id]:fixture;raw[id]=clean(value);const steps=[];for(const t of Array.isArray(s.transform)?s.transform:s.transform?[s.transform]:[]){const input=clean(value),result=new ScriptRuntime({modules}).invoke(t.module,t.export,value,scope({}));value=result.value;steps.push({module:t.module,export:t.export,input,output:clean(value),diagnostics:result.diagnostics});}trace[id]=steps;data[id]=value;delete errors[id];timeline.push({type:'source_completed',source:id});}catch(e){errors[id]=e.message;timeline.push({type:'source_failed',source:id,error:e.message});}}render();}
|
|
44
|
+
function action(a,lexical){if(!a)return;const resolved=evalValue(a,lexical);if(a.type==='emit'){const handler=lexical._events?.[resolved.event];if(handler)action(handler,{...(lexical._parent??lexical),event:resolved.payload??{}});}if(a.type==='set_state')Object.assign(a.scope==='root'?state:lexical.state??state,resolved.values);if(a.type==='refresh'){load();return;}if(a.type==='flow'){for(const child of a.actions??[])action(child,lexical);}compute();render();}
|
|
45
|
+
function slot(value,lexical){const e=element('div');e.className='stack';for(const n of Array.isArray(value)?value:value?[value]:[])e.append(node(n,lexical));return e;}
|
|
46
|
+
function node(n,lexical={}){
|
|
47
|
+
if(++count>2000)throw Error('Rendered node budget exceeded');
|
|
48
|
+
if(n.visible_when!=null&&!evalValue(n.visible_when,lexical))return element('span');
|
|
49
|
+
const raw=n.props??{},p=evalValue(Object.fromEntries(Object.entries(raw).filter(([k])=>!(n.type==='repeat'&&k==='key'))),lexical),slots=n.slots??{};
|
|
50
|
+
const breakpoint=innerWidth<600?'compact':innerWidth<840?'medium':'expanded';
|
|
51
|
+
for(const[k,v]of Object.entries(p))if(v&&typeof v==='object'&&['compact','medium','expanded'].some(k=>k in v))p[k]=v[breakpoint]??v.compact??Object.values(v)[0];
|
|
52
|
+
if(n.type==='if')return slot(slots[p.condition?'then':'else'],lexical);
|
|
53
|
+
if(n.type==='repeat'){const e=element('div');e.className=p.layout==='grid'?'grid':'stack';if(p.layout==='grid')e.style.gridTemplateColumns=`repeat(${Math.max(1,Math.min(12,Number(p.columns??1)))},minmax(0,1fr))`;for(const[item,index]of (Array.isArray(p.items)?p.items:[]).slice(0,200).map((v,i)=>[v,i])){const next={...lexical,[raw.as]:item,[raw.index_as??'index']:index};e.append(slot(slots.item??n.children,{...next,_key:evalValue(raw.key,next)}));}return e;}
|
|
54
|
+
if(n.type==='component'){const def=c.components[p.component],key=`${p.component}:${p.key??lexical._key??n.id??''}`;const local=locals[key]??=(def.initial_state?evalValue(def.initial_state,lexical):{});const localComputed={};const next={...lexical,props:{...evalValue(def.default_props??{},lexical),...(p.props??{})},state:local,root:{state},computed:localComputed,_slots:slots,_events:p.events??{},_parent:lexical};for(const[k,v]of Object.entries(def.computed_state??{}))localComputed[k]=evalValue(v,next);return node(def.view,next);}
|
|
55
|
+
if(n.type==='slot')return slot(lexical._slots?.[p.name],lexical._parent??lexical);
|
|
56
|
+
if(n.type==='data_state'){const status=errors[p.source]?'error':data[p.source]==null?'loading':p.empty_when===true||(Array.isArray(data[p.source])&&!data[p.source].length)?'empty':'content';return slot(slots[status]??{type:'text',props:{text:errors[p.source]??''}},{...lexical,error:errors[p.source]??null});}
|
|
57
|
+
if(['date_field','time_field','field','switch','select'].includes(n.type)){
|
|
58
|
+
const label=element('label',p.label??'');const input=element(n.type==='select'?'select':'input');const target=lexical.state??state;
|
|
59
|
+
input.type=n.type==='date_field'?'date':n.type==='time_field'?'time':n.type==='switch'?'checkbox':p.input_type??'text';
|
|
60
|
+
if(n.type==='select')for(const opt of p.options??[]){const o=element('option',opt.label??opt);o.value=opt.value??opt;input.append(o);}
|
|
61
|
+
input.value=target[p.state_key]??'';input.checked=target[p.state_key]===true;input.disabled=p.enabled===false;input.required=p.required===true;
|
|
62
|
+
if(p.minimum!=null)input.min=p.minimum;if(p.maximum!=null)input.max=p.maximum;if(p.semantic_label)input.setAttribute('aria-label',p.semantic_label);
|
|
63
|
+
input.onchange=()=>{target[p.state_key]=input.type==='checkbox'?input.checked:input.value;load();};label.append(input);if(p.error_text||p.helper_text)label.append(element('small',p.error_text??p.helper_text));return label;
|
|
64
|
+
}
|
|
65
|
+
if(n.type==='button'||n.type==='chip'){const e=element('button',p.label??'');e.onclick=()=>action(n.action,lexical);return e;}
|
|
66
|
+
if(n.type==='dialog'||n.type==='sheet'){const button=element('button',p.label??p.title??'Open');button.onclick=()=>{const dialog=element('dialog');if(n.type==='sheet')dialog.className='sheet';dialog.append(element('h2',p.title??''),slot(n.children,lexical));const close=element('button','닫기');close.onclick=()=>dialog.close();dialog.append(close);host.append(dialog);dialog.showModal();dialog.onclose=()=>dialog.remove();};return button;}
|
|
67
|
+
if(n.type==='text'||n.type==='metric'||n.type==='empty')return element('p',p.text??p.value??p.title??'');
|
|
68
|
+
const e=element('div');e.className=n.type==='grid'?'grid':['row','wrap','flex'].includes(n.type)?'row':['card','surface','section'].includes(n.type)?'card stack':n.type==='skeleton'?'skeleton':n.type==='animated'?'animated stack':'stack';
|
|
69
|
+
if(p.title)e.append(element('h3',p.title));if(p.subtitle)e.append(element('p',p.subtitle));
|
|
70
|
+
if(p.semantic_label)e.setAttribute('aria-label',p.semantic_label);if(p.tooltip)e.title=p.tooltip;if(p.opacity!=null)e.style.opacity=p.opacity;
|
|
71
|
+
for(const [name,css] of [['width','width'],['height','height'],['min_width','minWidth'],['max_width','maxWidth'],['min_height','minHeight'],['max_height','maxHeight']])if(typeof p[name]==='number')e.style[css]=`${p[name]}px`;
|
|
72
|
+
if(p.padding!=null)e.style.padding=typeof p.padding==='number'?`${p.padding}px`:p.padding;if(p.margin!=null)e.style.margin=typeof p.margin==='number'?`${p.margin}px`:p.margin;
|
|
73
|
+
if(p.background_color)e.style.backgroundColor=p.background_color;if(p.foreground_color)e.style.color=p.foreground_color;if(p.border_radius!=null)e.style.borderRadius=`${p.border_radius}px`;
|
|
74
|
+
if(n.type==='grid'&&p.columns)e.style.gridTemplateColumns=`repeat(${Math.max(1,Math.min(12,Number(p.columns)))},minmax(0,1fr))`;
|
|
75
|
+
for(const child of n.children??[])e.append(node(child,lexical));return e;
|
|
76
|
+
}
|
|
77
|
+
function render(){runtime=screenRuntime();count=0;host.replaceChildren(element('h2',extension.title));try{compute();host.append(node(c.view));}catch(e){host.append(element('p',`${e.code??'UI'}: ${e.message}`));}const details=element('details');details.append(element('summary','Runtime Inspector'));details.append(element('pre',JSON.stringify(clean({root_state:state,computed_state:computed,raw_data:raw,data,local_state:locals,runtime,errors,transform_steps:trace,request_timeline:timeline.slice(-200),component_instances:Object.keys(locals),layout:{screen:runtime.screen,overflow:[]}}),null,2)));host.append(details);}
|
|
78
|
+
surfaces.push(load);load();
|
|
79
|
+
}
|
|
80
|
+
document.querySelector('#reload').onclick=()=>surfaces.forEach(load=>load());
|
|
81
|
+
document.querySelector('#fixture').onchange=async e=>{const f=e.target.files[0];if(f&&f.size<=262144){fixture=clean(JSON.parse(await f.text()));surfaces.forEach(load=>load());}};
|
|
82
|
+
addEventListener('resize',()=>surfaces.forEach(load=>load()),{passive:true});
|
|
83
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { SCRIPT_CONTRACT } from './script-spec.js';
|
|
2
|
+
export { SCRIPT_CONTRACT };
|
|
3
|
+
const fields = SCRIPT_CONTRACT.nodes;
|
|
4
|
+
const expressions = new Set(['literal', 'ref', 'identifier', 'array', 'object', 'member', 'binary', 'unary', 'conditional', 'call']);
|
|
5
|
+
const statements = new Set(['declare', 'assign', 'return', 'expression', 'if', 'for']);
|
|
6
|
+
const deny = new Set(['__proto__', 'prototype', 'constructor', 'eval', 'Function', 'globalThis', 'process', 'require']);
|
|
7
|
+
export function validateAst(ast, { inline = false } = {}) {
|
|
8
|
+
let count = 0;
|
|
9
|
+
const error = message => { const err = new Error(message); err.code = 'AST'; throw err; };
|
|
10
|
+
const name = value => { if (typeof value !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(value) || deny.has(value)) error('Invalid AST identifier'); };
|
|
11
|
+
function visit(node, kinds, depth = 0) {
|
|
12
|
+
if (++count > (inline ? 64 : 2048) || depth > 64) error('AST budget exceeded');
|
|
13
|
+
if (!node || typeof node !== 'object' || Array.isArray(node) || !kinds.has(node.type)) error('Invalid AST node');
|
|
14
|
+
const expected = fields[node.type];
|
|
15
|
+
if (Object.keys(node).some(key => !['type', 'span', ...expected].includes(key)) || expected.some(key => !Object.hasOwn(node, key))) error('Invalid AST fields');
|
|
16
|
+
const expr = child => visit(child, expressions, depth + 1);
|
|
17
|
+
const list = (values, kinds) => {
|
|
18
|
+
if (!Array.isArray(values) || values.length > 2048) error('Invalid AST array');
|
|
19
|
+
for (const value of values) visit(value, kinds, depth + 1);
|
|
20
|
+
};
|
|
21
|
+
if (node.name !== undefined) name(node.name);
|
|
22
|
+
if (node.params !== undefined) {
|
|
23
|
+
if (!Array.isArray(node.params) || node.params.length > 16 || new Set(node.params).size !== node.params.length) error('Invalid parameters');
|
|
24
|
+
node.params.forEach(name);
|
|
25
|
+
}
|
|
26
|
+
if (node.span !== undefined && (!node.span || typeof node.span.file !== 'string' || node.span.file.length > 512 || !['line', 'column', 'offset'].every(k => Number.isInteger(node.span[k]) && node.span[k] >= 0))) error('Invalid source span');
|
|
27
|
+
switch (node.type) {
|
|
28
|
+
case 'module':
|
|
29
|
+
if (node.version !== 1) error('Unknown AST version');
|
|
30
|
+
list(node.functions, new Set(['function']));
|
|
31
|
+
if (new Set(node.functions.map(fn => fn.name)).size !== node.functions.length) error('Duplicate function');
|
|
32
|
+
break;
|
|
33
|
+
case 'function': if (typeof node.exported !== 'boolean') error('Invalid export'); list(node.body, statements); break;
|
|
34
|
+
case 'lambda': if (inline) error('Inline callback forbidden'); list(node.body, statements); break;
|
|
35
|
+
case 'literal': if (node.value !== null && !['string', 'number', 'boolean'].includes(typeof node.value)) error('Literal must be scalar'); if (typeof node.value === 'number' && !Number.isFinite(node.value)) error('Invalid number'); break;
|
|
36
|
+
case 'ref': if (typeof node.path !== 'string' || !node.path.startsWith('/') || node.path.length > 256) error('Invalid reference'); break;
|
|
37
|
+
case 'identifier': break;
|
|
38
|
+
case 'array': list(node.items, expressions); break;
|
|
39
|
+
case 'object':
|
|
40
|
+
if (!Array.isArray(node.entries) || node.entries.length > 512) error('Invalid object');
|
|
41
|
+
const keys = new Set();
|
|
42
|
+
for (const entry of node.entries) {
|
|
43
|
+
if (!entry || typeof entry.key !== 'string' || deny.has(entry.key) || keys.has(entry.key) || Object.keys(entry).sort().join(',') !== 'key,value') error('Invalid object entry');
|
|
44
|
+
keys.add(entry.key); expr(entry.value);
|
|
45
|
+
}
|
|
46
|
+
break;
|
|
47
|
+
case 'member': expr(node.target); expr(node.key); break;
|
|
48
|
+
case 'binary': if (!['??','||','&&','==','!=','===','!==','<','>','<=','>=','+','-','*','/','%'].includes(node.op)) error('Invalid operator'); expr(node.left); expr(node.right); break;
|
|
49
|
+
case 'unary': if (!['!','-','+'].includes(node.op)) error('Invalid unary operator'); expr(node.value); break;
|
|
50
|
+
case 'conditional': expr(node.condition); expr(node.then); expr(node.else); break;
|
|
51
|
+
case 'call':
|
|
52
|
+
if (inline && node.target?.type === 'member' && ['map','filter','reduce'].includes(node.target.key?.value)) error('Inline collection transform forbidden');
|
|
53
|
+
expr(node.target); list(node.args, new Set([...expressions, 'lambda'])); break;
|
|
54
|
+
case 'declare': if (typeof node.mutable !== 'boolean') error('Invalid declaration'); expr(node.value); break;
|
|
55
|
+
case 'assign': case 'return': case 'expression': expr(node.value); break;
|
|
56
|
+
case 'if': expr(node.condition); list(node.then, statements); list(node.else, statements); break;
|
|
57
|
+
case 'for': expr(node.items); list(node.body, statements); break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
visit(ast, ast?.type === 'module' && !inline ? new Set(['module']) : expressions);
|
|
61
|
+
return ast;
|
|
62
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** JSONPath subset: property, quoted property, integer index and wildcard. */
|
|
2
|
+
export function selectJson(value, path) {
|
|
3
|
+
if (typeof path !== 'string' || path.length > 512 || !path.startsWith('$')) throw new Error('Invalid JSON path');
|
|
4
|
+
const pattern = /\.([A-Za-z_][A-Za-z0-9_-]*)|\['([^'\\]*)'\]|\[(\d+|\*)\]/gy;
|
|
5
|
+
let offset = 1, values = [value], wildcard = false, segments = 0;
|
|
6
|
+
while (offset < path.length) {
|
|
7
|
+
if (++segments > 20) throw new Error('JSON path exceeds 20 segments');
|
|
8
|
+
pattern.lastIndex = offset; const match = pattern.exec(path);
|
|
9
|
+
if (!match) throw new Error('Unsupported JSON path syntax');
|
|
10
|
+
offset = pattern.lastIndex;
|
|
11
|
+
const key = match[1] ?? match[2] ?? (match[3] === '*' ? '*' : Number(match[3]));
|
|
12
|
+
if (['__proto__','constructor','prototype'].includes(key)) throw new Error('Forbidden JSON path property');
|
|
13
|
+
if (key === '*' && match[3] === '*') {
|
|
14
|
+
wildcard = true;
|
|
15
|
+
values = values.flatMap(item => Array.isArray(item) ? item : item && typeof item === 'object' ? Object.keys(item).sort().map(k => item[k]) : []);
|
|
16
|
+
} else values = values.map(item => item != null && typeof item === 'object' && Object.hasOwn(item,key) ? item[key] : null);
|
|
17
|
+
if (values.length > 1000) throw new Error('JSON path result exceeds 1000 items');
|
|
18
|
+
}
|
|
19
|
+
return wildcard ? values : values[0];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function runtimeSnapshot({now = new Date(), timezone = Intl.DateTimeFormat().resolvedOptions().timeZone, locale = 'en-US', platform = 'preview', screen = {}} = {}) {
|
|
23
|
+
const instant = now instanceof Date ? now : new Date(now);
|
|
24
|
+
if (!Number.isFinite(instant.valueOf())) throw new Error('Invalid runtime clock');
|
|
25
|
+
const parts = Object.fromEntries(new Intl.DateTimeFormat('en-US', {timeZone: timezone, year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(instant).map(p => [p.type,p.value]));
|
|
26
|
+
return Object.freeze({now: instant.toISOString(), today:`${parts.year}-${parts.month}-${parts.day}`, timezone, locale, platform, screen: {...screen}});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function dependencyOrder(graph) {
|
|
30
|
+
const complete = new Set(), active = new Set(), result = [];
|
|
31
|
+
function visit(id) {
|
|
32
|
+
if (active.has(id)) throw new Error(`Dependency cycle: ${[...active,id].join(' -> ')}`);
|
|
33
|
+
if (complete.has(id)) return;
|
|
34
|
+
if (!Object.hasOwn(graph,id)) throw new Error(`Unknown dependency ${id}`);
|
|
35
|
+
active.add(id);
|
|
36
|
+
for (const dependency of graph[id]) visit(dependency);
|
|
37
|
+
active.delete(id); complete.add(id); result.push(id);
|
|
38
|
+
}
|
|
39
|
+
for (const id of Object.keys(graph)) visit(id);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { validateValueSchema } from './script-schema.js';
|
|
3
|
+
export { validateValueSchema, assertSchema } from './script-schema.js';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { compileExpression, compileModule } from './morit-script.js';
|
|
6
|
+
import { validateAst, SCRIPT_CONTRACT } from './script-contract.js';
|
|
7
|
+
|
|
8
|
+
const hash = source => createHash('sha256').update(source).digest('hex');
|
|
9
|
+
const fail = message => { throw new Error(`Morit Script: ${message}`); };
|
|
10
|
+
|
|
11
|
+
/** Compile source modules and inline expressions without mutating source files. */
|
|
12
|
+
export function compileScriptProject(config, files) {
|
|
13
|
+
const declarations = config.logic_modules ?? [];
|
|
14
|
+
if (!Array.isArray(declarations) || declarations.length > SCRIPT_CONTRACT.limits.modules) fail('at most 16 modules are allowed');
|
|
15
|
+
const modules = Object.create(null), artifacts = Object.create(null), expressions = Object.create(null), warnings = [];
|
|
16
|
+
let total = 0;
|
|
17
|
+
for (const declaration of declarations) {
|
|
18
|
+
if (!declaration || typeof declaration !== 'object' || Object.keys(declaration).some(key => !['id','path','exports'].includes(key))) fail('invalid module declaration');
|
|
19
|
+
const {id, path, exports} = declaration;
|
|
20
|
+
if (typeof id !== 'string' || !/^[a-z][a-z0-9_]{0,63}$/.test(id) || ['state','computed','data','props','runtime','context','html','date','string','object','array','number'].includes(id) || Object.hasOwn(modules,id)) fail('invalid or duplicate module id');
|
|
21
|
+
if (typeof path !== 'string' || !/^logic\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*\.morit$/.test(path)) fail('module path must be logic/*.morit');
|
|
22
|
+
const source = files[path]; if (typeof source !== 'string') fail(`missing source ${path}`);
|
|
23
|
+
total += Buffer.byteLength(source);
|
|
24
|
+
if (total > SCRIPT_CONTRACT.limits.source_bytes) fail('total source exceeds 128 KiB');
|
|
25
|
+
if (!exports || typeof exports !== 'object' || Array.isArray(exports) || !Object.keys(exports).length) fail('module exports require typed input/output');
|
|
26
|
+
const compiled = compileModule(source, path);
|
|
27
|
+
if (compiled.ast.functions.some(fn => fn.exported && fn.params.length !== 1)) fail('exports require exactly one typed input');
|
|
28
|
+
const actual = compiled.ast.functions.filter(fn => fn.exported).map(fn => fn.name).sort();
|
|
29
|
+
if (JSON.stringify(actual) !== JSON.stringify(Object.keys(exports).sort())) fail(`declared exports do not match ${path}`);
|
|
30
|
+
for (const [name, schema] of Object.entries(exports)) {
|
|
31
|
+
if (!schema || typeof schema !== 'object' || Object.keys(schema).sort().join(',') !== 'input_schema,output_schema') fail(`${id}.${name} requires input_schema and output_schema`);
|
|
32
|
+
validateValueSchema(schema.input_schema); validateValueSchema(schema.output_schema);
|
|
33
|
+
}
|
|
34
|
+
modules[id] = {...compiled, exports, source};
|
|
35
|
+
artifacts[path.replace(/\.morit$/, '.ast.json')] = JSON.stringify(compiled);
|
|
36
|
+
}
|
|
37
|
+
const counts = new Map();
|
|
38
|
+
function visit(value, path = 'config', depth = 0) {
|
|
39
|
+
if (depth > 40) fail('configuration nesting exceeds 40');
|
|
40
|
+
if (typeof value === 'string') {
|
|
41
|
+
for (const match of value.matchAll(/\{\{=([\s\S]*?)\}\}/g)) {
|
|
42
|
+
const source = match[1].trim(); const id = hash(source);
|
|
43
|
+
expressions[id] ??= compileExpression(source, path);
|
|
44
|
+
counts.set(source, (counts.get(source) ?? 0) + 1);
|
|
45
|
+
}
|
|
46
|
+
} else if (Array.isArray(value)) value.forEach((item,index) => visit(item, `${path}[${index}]`, depth+1));
|
|
47
|
+
else if (value && typeof value === 'object') for (const [key,item] of Object.entries(value)) visit(item, `${path}.${key}`,depth+1);
|
|
48
|
+
}
|
|
49
|
+
visit(config);
|
|
50
|
+
for (const [source,count] of counts) if (count > 1) warnings.push({code:'REPEATED_INLINE',message:'Extract repeated expression to computed_state or a module',expression:source,count});
|
|
51
|
+
return {version:1, modules, expressions, artifacts, warnings};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function verifyCompiledModule(source, compiled) {
|
|
55
|
+
if (!compiled || compiled.version !== 1 || compiled.source_sha256 !== hash(source)) fail('source hash mismatch');
|
|
56
|
+
validateAst(compiled.ast);
|
|
57
|
+
const expected = compileModule(source, compiled.ast.functions[0]?.span?.file ?? 'logic/main.morit');
|
|
58
|
+
if (!isDeepStrictEqual(expected.ast, compiled.ast)) fail('compiled AST does not match source');
|
|
59
|
+
}
|
|
60
|
+
|