ru.coon 3.0.80 → 3.0.81

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 CHANGED
@@ -1,3 +1,10 @@
1
+ # Version 3.0.81, [link](http://gitlab-dbr.sigma-it.local/dbr/ru.coon/-/commit/bd688d381953e5cc5d8011296b21dcd891bf1d7b)
2
+ * ## Fixes
3
+ * <span style='color:red'>fix</span> ([d82071], [link](http://gitlab-dbr.sigma-it.local/dbr/ru.coon/-/commit/d82071dc2c72b15caf116564cd84ec5b5900bfc6))
4
+
5
+ * refactoring ([9429b5], [link](http://gitlab-dbr.sigma-it.local/dbr/ru.coon/-/commit/9429b536e6d3b30cfbf0afe349b6638dd62933bf))
6
+ * update: CHANGELOG.md ([ef0a27], [link](http://gitlab-dbr.sigma-it.local/dbr/ru.coon/-/commit/ef0a27156e4b211f6d5fbd1b37dedc6b978b03c9))
7
+
1
8
  # Version 3.0.80, [link](http://gitlab-dbr.sigma-it.local/dbr/ru.coon/-/commit/3b43f1d4de9199df4725890955ab76c6e4491cdc)
2
9
  * refactoring OpenPanelPlugin ([67cd0c], [link](http://gitlab-dbr.sigma-it.local/dbr/ru.coon/-/commit/67cd0c0b004f89f7fb3607497a43f960ae4f0735))
3
10
  * update: CHANGELOG.md ([6e78bf], [link](http://gitlab-dbr.sigma-it.local/dbr/ru.coon/-/commit/6e78bf0dd1e0394557f2aba6b2fa56c1f022fc86))
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "name": "ru.coon"
5
5
  },
6
6
  "description": "",
7
- "version": "3.0.80",
7
+ "version": "3.0.81",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+http://gitlab-dbr.sigma-it.local/dbr/ru.coon"
@@ -0,0 +1,324 @@
1
+ /* eslint-disable require-jsdoc */
2
+ /**
3
+ * A template engine that uses indentation-based syntax to define structure,
4
+ * supporting mixins, loops, and variable interpolation.
5
+ */
6
+ class IndentTemplate {
7
+ /**
8
+ * Creates a new IndentTemplate instance.
9
+ * @param {string} [template=''] - The initial template string
10
+ */
11
+ constructor(template = '') {
12
+ this._template = template;
13
+ this._ast = null;
14
+ this._mixins = {};
15
+ }
16
+
17
+ /**
18
+ * Sets or changes the template string.
19
+ * @param {string} template - The template string to set
20
+ * @returns {IndentTemplate} This instance for chaining
21
+ */
22
+ setTemplate(template) {
23
+ this._template = template;
24
+ this._ast = null;
25
+ this._mixins = {};
26
+ return this; // chainable
27
+ }
28
+
29
+ /**
30
+ * Compiles the template by parsing it into an AST and collecting mixins.
31
+ * @returns {IndentTemplate} This instance for chaining
32
+ * @throws {Error} If the template is empty or has invalid syntax
33
+ */
34
+ compile() {
35
+ if (!this._template) {
36
+ throw new Error('Шаблон пуст. Используй setTemplate(template).');
37
+ }
38
+
39
+ const rawLines = this._template
40
+ .split('\n')
41
+ .map((l) => l.replace(/\r$/, ''))
42
+ .filter((l) => l.trim().length > 0);
43
+
44
+ const lines = rawLines.map((line) => {
45
+ const m = line.match(/^(\s*)/);
46
+ const indent = m ? m[1].length : 0;
47
+ const level = Math.floor(indent / 2); // 2 пробела = 1 уровень
48
+ return {
49
+ level,
50
+ content: line.trim(),
51
+ };
52
+ });
53
+
54
+ this._mixins = {};
55
+ let index = 0;
56
+
57
+ const parseBlock = (expectedLevel) => {
58
+ const nodes = [];
59
+
60
+ while (index < lines.length) {
61
+ const {level, content} = lines[index];
62
+
63
+ if (level < expectedLevel) {
64
+ break;
65
+ }
66
+ if (level > expectedLevel) {
67
+ // некорректный отступ — считаем конец этого блока
68
+ break;
69
+ }
70
+
71
+ // ---- миксин определение ------------------------------------
72
+ if (content.startsWith('@mixin ')) {
73
+ if (expectedLevel !== 0) {
74
+ throw new Error('@mixin должен быть на верхнем уровне без отступов');
75
+ }
76
+
77
+ const def = content.slice(7).trim(); // после "@mixin "
78
+ const parts = def.split(/\s+/);
79
+ const name = parts[0];
80
+ const params = parts.slice(1); // список имён параметров
81
+
82
+ index++;
83
+ // тело миксина — блок на уровне 1
84
+ const body = parseBlock(expectedLevel + 1);
85
+
86
+ this._mixins[name] = {params, body};
87
+ continue; // миксин не попадает в основной AST
88
+ }
89
+
90
+ // ---- цикл @for ---------------------------------------------
91
+ if (content.startsWith('@for ')) {
92
+ const forLine = content.slice(5).trim(); // убираем "@for "
93
+ const match = forLine.match(/^(\w+)\s+in\s+(.+)$/);
94
+ if (!match) {
95
+ throw new Error('Неверный синтаксис @for: ' + content);
96
+ }
97
+ const varName = match[1];
98
+ const listExpr = match[2].trim();
99
+
100
+ index++;
101
+ const children = parseBlock(expectedLevel + 1);
102
+
103
+ nodes.push({
104
+ type: 'for',
105
+ varName,
106
+ listExpr,
107
+ children,
108
+ });
109
+ continue;
110
+ }
111
+
112
+ // ---- использование миксина: @use name(arg1, arg2) ----------
113
+ if (content.startsWith('@use ')) {
114
+ const call = content.slice(5).trim();
115
+ const m = call.match(/^(\w+)\s*\((.*)\)$/);
116
+ if (!m) {
117
+ throw new Error('Неверный синтаксис @use: ' + content);
118
+ }
119
+ const mixinName = m[1];
120
+ const argsRaw = m[2].trim();
121
+ const argExprs = argsRaw ?
122
+ argsRaw.split(',').map((s) => s.trim()) :
123
+ [];
124
+
125
+ index++;
126
+
127
+ nodes.push({
128
+ type: 'use',
129
+ mixinName,
130
+ argExprs,
131
+ });
132
+ continue;
133
+ }
134
+
135
+ // ---- обычный тег -------------------------------------------
136
+ const [tag, ...restParts] = content.split(/\s+/);
137
+ const text = restParts.join(' ');
138
+ const selfClosing = new Set(['br', 'hr', 'img', 'input', 'meta', 'link']);
139
+ const lowerTag = tag.toLowerCase();
140
+ const isSelfClosing = selfClosing.has(lowerTag);
141
+
142
+ index++;
143
+
144
+ let children = [];
145
+ if (!text && !isSelfClosing && index < lines.length) {
146
+ const next = lines[index];
147
+ if (next.level > expectedLevel) {
148
+ children = parseBlock(expectedLevel + 1);
149
+ }
150
+ }
151
+
152
+ nodes.push({
153
+ type: 'tag',
154
+ tag,
155
+ text,
156
+ isSelfClosing,
157
+ children,
158
+ });
159
+ }
160
+
161
+ return nodes;
162
+ };
163
+
164
+ this._ast = parseBlock(0);
165
+ return this; // chainable
166
+ }
167
+
168
+ /**
169
+ * Builds the final HTML output by rendering the compiled template with provided data.
170
+ * @param {Object} [data={}] - The data object to render the template with
171
+ * @returns {string} The rendered HTML string
172
+ */
173
+ build(data = {}) {
174
+ if (!this._ast) {
175
+ this.compile();
176
+ }
177
+ return this._renderNodes(this._ast, data);
178
+ }
179
+
180
+ // ===================== RENDER =====================
181
+
182
+ /**
183
+ * Renders an array of nodes with the given scope.
184
+ * @param {Array} nodes - Array of AST nodes to render
185
+ * @param {Object} scope - The current scope/data context
186
+ * @returns {string} Concatenated rendered nodes
187
+ * @private
188
+ */
189
+ _renderNodes(nodes, scope) {
190
+ return nodes.map((node) => this._renderNode(node, scope)).join('');
191
+ }
192
+
193
+ /**
194
+ * Renders a single node based on its type.
195
+ * @param {Object} node - The AST node to render
196
+ * @param {Object} scope - The current scope/data context
197
+ * @returns {string} The rendered HTML for this node
198
+ * @private
199
+ */
200
+ _renderNode(node, scope) {
201
+ // for
202
+ if (node.type === 'for') {
203
+ const list = this._resolvePath(scope, node.listExpr);
204
+ if (!Array.isArray(list) || list.length === 0) {
205
+ return '';
206
+ }
207
+ let out = '';
208
+ for (const item of list) {
209
+ const childScope = Object.create(scope);
210
+ childScope[node.varName] = item;
211
+ out += this._renderNodes(node.children, childScope);
212
+ }
213
+ return out;
214
+ }
215
+
216
+ // use mixin
217
+ if (node.type === 'use') {
218
+ const def = this._mixins[node.mixinName];
219
+ if (!def) {
220
+ throw new Error(`Миксин "${node.mixinName}" не найден`);
221
+ }
222
+
223
+ const {params, body} = def;
224
+ const argValues = node.argExprs.map((expr) => this._resolvePath(scope, expr));
225
+
226
+ const childScope = Object.create(scope);
227
+ params.forEach((name, i) => {
228
+ childScope[name] = argValues[i];
229
+ });
230
+
231
+ return this._renderNodes(body, childScope);
232
+ }
233
+
234
+ // обычный тег
235
+ if (node.type === 'tag') {
236
+ const {tag, text, isSelfClosing, children} = node;
237
+
238
+ if (isSelfClosing) {
239
+ return `<${tag}>`;
240
+ }
241
+
242
+ const innerText = text ?
243
+ this._renderTextWithTemplates(text, scope) :
244
+ '';
245
+
246
+ const childrenHtml = children && children.length ?
247
+ this._renderNodes(children, scope) :
248
+ '';
249
+
250
+ return `<${tag}>${innerText}${childrenHtml}</${tag}>`;
251
+ }
252
+
253
+ return '';
254
+ }
255
+
256
+ /**
257
+ * Renders text with template expressions replaced by values from scope.
258
+ * @param {string} text - Text potentially containing template expressions
259
+ * @param {Object} scope - The current scope/data context
260
+ * @returns {string} Text with expressions replaced by values
261
+ * @private
262
+ */
263
+ _renderTextWithTemplates(text, scope) {
264
+ let result = '';
265
+ let lastIndex = 0;
266
+ const regex = /\{\{([^}]+)\}\}/g;
267
+
268
+ text.replace(regex, (match, expr, offset) => {
269
+ // статичный кусок до {{ ... }}
270
+ const staticPart = text.slice(lastIndex, offset);
271
+ result += this._escape(staticPart);
272
+
273
+ const value = this._resolvePath(scope, expr.trim());
274
+ const strVal = value == null ? '' : String(value);
275
+ result += this._escape(strVal);
276
+
277
+ lastIndex = offset + match.length;
278
+ return '';
279
+ });
280
+
281
+ // хвост после последнего {{ ... }}
282
+ const tail = text.slice(lastIndex);
283
+ result += this._escape(tail);
284
+
285
+ return result;
286
+ }
287
+
288
+ /**
289
+ * Resolves a dot-notation path in the scope object.
290
+ * @param {Object} scope - The scope object to look in
291
+ * @param {string} path - Dot-notation path like "user.name"
292
+ * @returns {*} The resolved value or undefined
293
+ * @private
294
+ */
295
+ _resolvePath(scope, path) {
296
+ if (!path) {
297
+ return undefined;
298
+ }
299
+ const parts = path.split('.');
300
+ let value = scope;
301
+ for (const part of parts) {
302
+ if (value == null) {
303
+ return undefined;
304
+ }
305
+ value = value[part];
306
+ }
307
+ return value;
308
+ }
309
+
310
+ /**
311
+ * Escapes HTML special characters in text.
312
+ * @param {string} text - Text to escape
313
+ * @returns {string} Escaped text
314
+ * @private
315
+ */
316
+ _escape(text) {
317
+ return String(text)
318
+ .replace(/&/g, '&amp;')
319
+ .replace(/</g, '&lt;')
320
+ .replace(/>/g, '&gt;');
321
+ }
322
+ }
323
+
324
+ export default IndentTemplate;
@@ -0,0 +1,420 @@
1
+ /**
2
+ * A template engine that parses and compiles templates with custom syntax.
3
+ * Supports elements, mixins, loops, conditions, and variable interpolation.
4
+ */
5
+ class Template {
6
+ /**
7
+ * Creates a new Template instance.
8
+ * @param {string} source - The template source string
9
+ */
10
+ constructor(source) {
11
+ this.source = source;
12
+ this.ast = this.parse(source);
13
+ this.renderFn = this.compile(this.ast);
14
+ }
15
+
16
+ /**
17
+ * Builds the final output by rendering the compiled template with provided context.
18
+ * @param {Object} context - The context object to render the template with
19
+ * @returns {string} The rendered output string
20
+ */
21
+ build(context = {}) {
22
+ return this.renderFn(context);
23
+ }
24
+
25
+ // ========== ПАРСЕР ==========
26
+
27
+ /**
28
+ * Parses the template source into an Abstract Syntax Tree (AST).
29
+ * @param {string} source - The template source string
30
+ * @returns {Object} The parsed AST representation
31
+ */
32
+ parse(source) {
33
+ const lines = source.split('\n').filter((line) => line.trim() !== '');
34
+ const mixins = new Map();
35
+ const {nodes} = this.parseBlock(lines, 0, 0, mixins);
36
+ return {type: 'Root', children: nodes, mixins};
37
+ }
38
+
39
+ /**
40
+ * Parses a block of lines at a specific indentation level.
41
+ * @param {string[]} lines - Array of template lines
42
+ * @param {number} start - Starting index in the lines array
43
+ * @param {number} indentLevel - Current indentation level
44
+ * @param {Map} mixins - Map of defined mixins
45
+ * @returns {Object} Object containing parsed nodes and the next index
46
+ */
47
+ parseBlock(lines, start, indentLevel, mixins) {
48
+ const nodes = [];
49
+ let i = start;
50
+
51
+ while (i < lines.length) {
52
+ const line = lines[i];
53
+ const match = line.match(/^ */);
54
+ const currentIndent = match ? match[0].length : 0;
55
+
56
+ if (currentIndent < indentLevel) {
57
+ break;
58
+ }
59
+ if (currentIndent !== indentLevel) {
60
+ throw new Error(`Invalid indentation at line ${i + 1}. Expected ${indentLevel}, got ${currentIndent}.`);
61
+ }
62
+
63
+ const content = line.trim();
64
+ let node;
65
+
66
+ if (content.startsWith('@mixin ')) {
67
+ const mixin = this.parseMixinDecl(content);
68
+ const {nodes: body, nextIndex} = this.parseBlock(lines, i + 1, indentLevel + 2, mixins);
69
+ mixin.body = body;
70
+ mixins.set(mixin.name, mixin);
71
+ node = mixin;
72
+ i = nextIndex;
73
+ } else if (content.startsWith('@use ')) {
74
+ const call = this.parseMixinCall(content);
75
+ const def = mixins.get(call.name);
76
+ if (!def) {
77
+ throw new Error(`Undefined mixin: ${call.name}`);
78
+ }
79
+ node = {type: 'MixinCall', definition: def, args: call.args};
80
+ i++;
81
+ } else if (content.startsWith('@each ')) {
82
+ const each = this.parseEach(content);
83
+ const {nodes: body, nextIndex} = this.parseBlock(lines, i + 1, indentLevel + 2, mixins);
84
+ each.children = body;
85
+ node = each;
86
+ i = nextIndex;
87
+ } else if (content.startsWith('@if ')) {
88
+ const condition = content.slice(4).trim();
89
+ const ifNode = {type: 'If', condition, thenBranch: [], elseBranch: []};
90
+ const {nodes: thenBody, nextIndex: afterThen} = this.parseBlock(lines, i + 1, indentLevel + 2, mixins);
91
+ ifNode.thenBranch = thenBody;
92
+ i = afterThen;
93
+
94
+ // Проверяем, есть ли @else сразу после
95
+ if (i < lines.length) {
96
+ const elseLine = lines[i].trim();
97
+ const elseIndent = lines[i].match(/^ */)[0].length;
98
+ if (elseIndent === indentLevel && elseLine === '@else') {
99
+ const {nodes: elseBody, nextIndex: afterElse} = this.parseBlock(lines, i + 1, indentLevel + 2, mixins);
100
+ ifNode.elseBranch = elseBody;
101
+ i = afterElse;
102
+ }
103
+ }
104
+
105
+ node = ifNode;
106
+ } else {
107
+ node = this.parseElement(content);
108
+ const {nodes: children, nextIndex} = this.parseBlock(lines, i + 1, indentLevel + 2, mixins);
109
+ node.children = children;
110
+ i = nextIndex;
111
+ }
112
+
113
+ if (node.type !== 'MixinDecl') {
114
+ nodes.push(node);
115
+ }
116
+ }
117
+
118
+ return {nodes, nextIndex: i};
119
+ }
120
+
121
+ /**
122
+ * Parses an element line into a structured object.
123
+ * @param {string} line - A line containing an element definition
124
+ * @returns {Object} Parsed element object
125
+ */
126
+ parseElement(line) {
127
+ const tagMatch = line.match(/^([a-zA-Z][a-zA-Z0-9]*)\b/);
128
+ if (!tagMatch) {
129
+ throw new Error(`Invalid tag: "${line}"`);
130
+ }
131
+ const tagName = tagMatch[1];
132
+ const rest = line.slice(tagMatch[0].length).trim();
133
+
134
+ const selfClosingTags = new Set([
135
+ 'img', 'br', 'hr', 'input', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr'
136
+ ]);
137
+
138
+ const attrs = {};
139
+ let text = '';
140
+
141
+ // Простой парсинг: всё, что содержит = → атрибут; остальное — текст
142
+ const tokens = rest.match(/(?:[^"\s=]+|"[^"]*")+/g) || [];
143
+ let inAttrs = true;
144
+ for (const t of tokens) {
145
+ if (t.includes('=')) {
146
+ const [key, val] = t.split('=', 2);
147
+ let finalVal = val;
148
+ if ((val.startsWith('"') && val.endsWith('"')) ||
149
+ (val.startsWith('\'') && val.endsWith('\''))) {
150
+ finalVal = val.slice(1, -1);
151
+ }
152
+ attrs[key] = finalVal;
153
+ } else if (inAttrs && !t.startsWith('"') && !t.includes('{{')) {
154
+ // Предполагаем, что это булев атрибут
155
+ attrs[t] = true;
156
+ } else {
157
+ inAttrs = false;
158
+ if (text) {
159
+ text += ' ';
160
+ }
161
+ text += t;
162
+ }
163
+ }
164
+
165
+ return {
166
+ type: 'Element',
167
+ tagName,
168
+ attrs,
169
+ text,
170
+ children: [],
171
+ selfClosing: selfClosingTags.has(tagName),
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Parses a mixin declaration line.
177
+ * @param {string} line - A line containing a mixin declaration
178
+ * @returns {Object} Parsed mixin declaration object
179
+ */
180
+ parseMixinDecl(line) {
181
+ const match = line.match(/^@mixin\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\)/);
182
+ if (!match) {
183
+ throw new Error(`Invalid mixin declaration: ${line}`);
184
+ }
185
+ const name = match[1];
186
+ const params = match[2].split(',').map((p) => p.trim()).filter((p) => p);
187
+ return {type: 'MixinDecl', name, params, body: []};
188
+ }
189
+
190
+ /**
191
+ * Parses a mixin call line.
192
+ * @param {string} line - A line containing a mixin call
193
+ * @returns {Object} Parsed mixin call object
194
+ */
195
+ parseMixinCall(line) {
196
+ const match = line.match(/^@use\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(([^)]*)\)/);
197
+ if (!match) {
198
+ throw new Error(`Invalid mixin call: ${line}`);
199
+ }
200
+ const name = match[1];
201
+ const argsStr = match[2];
202
+
203
+ const args = [];
204
+ let current = '';
205
+ let inQuote = null;
206
+ for (let k = 0; k < argsStr.length; k++) {
207
+ const c = argsStr[k];
208
+ if (c === inQuote) {
209
+ inQuote = null;
210
+ } else if (!inQuote && (c === '"' || c === '\'')) {
211
+ inQuote = c;
212
+ } else if (!inQuote && c === ',') {
213
+ args.push(current.trim());
214
+ current = '';
215
+ continue;
216
+ }
217
+ current += c;
218
+ }
219
+ if (current.trim()) {
220
+ args.push(current.trim());
221
+ }
222
+
223
+ return {type: 'MixinCall', name, args};
224
+ }
225
+
226
+ /**
227
+ * Parses an each loop declaration.
228
+ * @param {string} line - A line containing an each loop declaration
229
+ * @returns {Object} Parsed each loop object
230
+ */
231
+ parseEach(line) {
232
+ const match = line.match(/^@each\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+in\s+([a-zA-Z_][a-zA-Z0-9_.]*)/);
233
+ if (!match) {
234
+ throw new Error(`Invalid @each: ${line}`);
235
+ }
236
+ return {type: 'Each', iterator: match[1], collection: match[2], children: []};
237
+ }
238
+
239
+ // ========== КОМПИЛЯТОР ==========
240
+
241
+ /**
242
+ * Compiles the AST into a render function.
243
+ * @param {Object} ast - The Abstract Syntax Tree to compile
244
+ * @returns {Function} A function that renders the template with a given context
245
+ */
246
+ compile(ast) {
247
+ const code = this.generateCode(ast);
248
+ return new Function('ctx', `
249
+ const escape = (s) => {
250
+ if (s == null) return '';
251
+ return String(s)
252
+ .replace(/&/g, '&amp;')
253
+ .replace(/</g, '&lt;')
254
+ .replace(/>/g, '&gt;')
255
+ .replace(/"/g, '&quot;')
256
+ .replace(/'/g, '&#039;');
257
+ };
258
+ with(ctx || {}) {
259
+ return ${code};
260
+ }
261
+ `);
262
+ }
263
+
264
+ /**
265
+ * Generates JavaScript code from the AST nodes.
266
+ * @param {Object} node - The AST node to generate code for
267
+ * @returns {string} Generated JavaScript code as a string
268
+ */
269
+ generateCode(node) {
270
+ switch (node.type) {
271
+ case 'Root':
272
+ return node.children.map((child) => this.generateCode(child)).join(' + ') || '""';
273
+
274
+ case 'Element':
275
+ const {tagName, attrs, text, children: nodeChildren, selfClosing} = node;
276
+ let attrStr = '';
277
+ for (const [key, val] of Object.entries(attrs)) {
278
+ if (val === true) {
279
+ attrStr += ` ${key}`;
280
+ } else {
281
+ const expr = this.interpolateToExpr(val, true);
282
+ attrStr += ` ${key}="\${${expr}}}`;
283
+ }
284
+ }
285
+
286
+ const textExpr = text ? this.interpolateToExpr(text, false) : '';
287
+ const childrenExpr = nodeChildren.map((child) => this.generateCode(child)).join(' + ');
288
+ const content = [textExpr, childrenExpr].filter((s) => s).join(' + ');
289
+
290
+ if (selfClosing) {
291
+ return '`<' + tagName + attrStr + '>`';
292
+ } else {
293
+ if (content) {
294
+ return '`<' + tagName + attrStr + '>\${' + content + '}</' + tagName + '>``';
295
+ } else {
296
+ return '`<' + tagName + attrStr + '></' + tagName + '>``';
297
+ }
298
+ }
299
+
300
+ case 'Each':
301
+ const {iterator, collection, children} = node;
302
+ const body = children.map((child) => this.generateCode(child)).join(' + ') || '""';
303
+ return `(Array.isArray(${collection}) ? ${collection}.map(${iterator} => ${body}).join('') : '')`;
304
+
305
+ case 'If':
306
+ const {condition, thenBranch, elseBranch} = node;
307
+ const thenCode = thenBranch.map((child) => this.generateCode(child)).join(' + ') || '""';
308
+ const elseCode = elseBranch.map((child) => this.generateCode(child)).join(' + ') || '""';
309
+ return `(${condition} ? ${thenCode} : ${elseCode})`;
310
+
311
+ case 'MixinCall':
312
+ const {definition, args} = node;
313
+ const paramMap = {};
314
+ definition.params.forEach((param, idx) => {
315
+ paramMap[param] = args[idx] || '""';
316
+ });
317
+ const substitutedBody = this.substituteParamsInAST(definition.body, paramMap);
318
+ const bodyCode = substitutedBody.map((child) => this.generateCode(child)).join(' + ') || '""';
319
+ return bodyCode;
320
+
321
+ default:
322
+ return '""';
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Substitutes parameters in AST nodes with actual values.
328
+ * @param {Array} nodes - Array of AST nodes
329
+ * @param {Object} paramMap - Parameter mapping object
330
+ * @returns {Array} New array of AST nodes with substituted parameters
331
+ */
332
+ substituteParamsInAST(nodes, paramMap) {
333
+ return nodes.map((node) => {
334
+ const newNode = {...node};
335
+ if (node.type === 'Element') {
336
+ const newAttrs = {};
337
+ for (const [key, val] of Object.entries(node.attrs)) {
338
+ newAttrs[key] = this.substituteInString(val, paramMap);
339
+ }
340
+ newNode.attrs = newAttrs;
341
+ newNode.text = this.substituteInString(node.text, paramMap);
342
+ newNode.children = this.substituteParamsInAST(node.children, paramMap);
343
+ } else if (node.type === 'Each' || node.type === 'If') {
344
+ newNode.children = this.substituteParamsInAST(node.children || node.thenBranch || [], paramMap);
345
+ if (node.elseBranch) {
346
+ newNode.elseBranch = this.substituteParamsInAST(node.elseBranch, paramMap);
347
+ }
348
+ }
349
+ return newNode;
350
+ });
351
+ }
352
+
353
+ /**
354
+ * Substitutes parameter placeholders in a string with actual values.
355
+ * @param {string} str - String with parameter placeholders
356
+ * @param {Object} paramMap - Parameter mapping object
357
+ * @returns {string} String with substituted parameters
358
+ */
359
+ substituteInString(str, paramMap) {
360
+ if (!str) {
361
+ return str;
362
+ }
363
+ let result = str;
364
+ for (const [param, replacement] of Object.entries(paramMap)) {
365
+ const regex = new RegExp(`{{\\s*${param}\\s*}}`, 'g');
366
+ result = result.replace(regex, replacement);
367
+ }
368
+ return result;
369
+ }
370
+
371
+ /**
372
+ * Converts a string with interpolation expressions to executable JavaScript code.
373
+ * @param {string} str - String with interpolation expressions
374
+ * @param {boolean} inAttr - Whether the string is inside an attribute
375
+ * @returns {string} JavaScript code expression
376
+ */
377
+ interpolateToExpr(str, inAttr = false) {
378
+ if (!str || !/{{.*?}}/.test(str)) {
379
+ if (inAttr) {
380
+ return JSON.stringify(str);
381
+ } else {
382
+ return `escape(${JSON.stringify(str)})`;
383
+ }
384
+ }
385
+
386
+ str = str.replace(/`/g, '\\`').replace(/\$/g, '\\$');
387
+ const parts = [];
388
+ let lastIndex = 0;
389
+ str.replace(/{{\s*([^}]+?)\s*}}/g, (match, expr, offset) => {
390
+ if (offset > lastIndex) {
391
+ const literal = str.slice(lastIndex, offset);
392
+ if (inAttr) {
393
+ parts.push(JSON.stringify(literal));
394
+ } else {
395
+ parts.push(`escape(${JSON.stringify(literal)})`;
396
+ }
397
+ }
398
+ expr = expr.trim();
399
+ if (inAttr) {
400
+ parts.push(expr);
401
+ } else {
402
+ parts.push(`escape(${expr})`);
403
+ }
404
+ lastIndex = offset + match.length;
405
+ });
406
+
407
+ if (lastIndex < str.length) {
408
+ const literal = str.slice(lastIndex);
409
+ if (inAttr) {
410
+ parts.push(JSON.stringify(literal));
411
+ } else {
412
+ parts.push(`escape(${JSON.stringify(literal)})`;
413
+ }
414
+ }
415
+
416
+ return parts.join(' + ') || '""';
417
+ }
418
+ }
419
+
420
+ export default Template;
@@ -0,0 +1,27 @@
1
+ Router{
2
+ assignUrl
3
+ urlAction
4
+ }
5
+ OCP{
6
+ label: openComponentPlugin
7
+ }
8
+
9
+ RegisterComponent{
10
+ RouteUnit{
11
+ shape: class
12
+ action: string
13
+ componentType: string
14
+ componentId: string
15
+ parameters: object
16
+ shortUrl: string
17
+ id: string
18
+ }
19
+ }
20
+
21
+ OCP -> RegisterComponent
22
+ Menu -> RegisterComponent
23
+
24
+ RegisterComponent -> OpenComponent
25
+ RegisterComponent -> Router.assignUrl
26
+ OpenComponent -> CenterView.setActiveComponent
27
+ Router.urlAction -> RegisterComponent
@@ -0,0 +1,23 @@
1
+ changeUrl->Router.routes./\#r/ReportId{label: загрузить репорт в dev-режиме}
2
+ changeUrl->Router.routes./\#p/CustomPanelId :загрузить кастомную панель в dev-режиме
3
+ changeUrl->Router.routes./\#UiElementCd :начитать uiElementCd для загрузки компонента пункта меню
4
+
5
+ Router: {
6
+ routes: {
7
+ /\#r/ReportId
8
+ /\#p/CustomPanelId
9
+ /\#UiElementCd
10
+ }
11
+ routes./\#r/ReportId -> ReportPanel_create
12
+ routes./\#p/CustomPanelId -> loadUlElement(xtype=UiCustomPanel): async load
13
+ loadUlElement(xtype=UiCustomPanel) -> UiCustomPanel_instancing
14
+ routes./\#UiElementCd -> loadUlElement
15
+ loadUlElement -> 'xtype=ReportPanel'
16
+ loadUlElement -> 'xtype=UiCustomPanel' -> loadUlElement(xtype=UiCustomPanel)
17
+ }
18
+
19
+ Router.loadUlElement.'xtype=ReportPanel' -> ComponentFactory.createRootNavComponent
20
+
21
+ ComponentFactory: {
22
+ createRootNavComponent
23
+ }
@@ -0,0 +1,84 @@
1
+ Ext.define('Coon.nav.menu.MenuFavoriteElement', {
2
+ extend: 'Ext.Component',
3
+
4
+ xtype: 'MenuFavoriteElement',
5
+
6
+ config: {
7
+ menuItem: {},
8
+ },
9
+
10
+ renderData: {
11
+ iconCls: '',
12
+ iconBaseClass: 'svg-icon svg-icon-size-18',
13
+ text: '',
14
+ },
15
+
16
+ renderTpl: [
17
+ '<div id="{id}-divEl" data-ref="divEl" class="vb-item">',
18
+ '<i id="{id}-iconEl" data-ref="iconEl" class="vb-icon menu-item-main-icon {iconBaseClass} {iconCls}"></i>',
19
+ '<span class="menu-item-text-elem">',
20
+ '<i id="{id}-textEl" data-ref="textEl" class="vb-text svg-icon-size-18">{text}</i>',
21
+ '</span>',
22
+ '</div>'
23
+ ],
24
+ childEls: [
25
+ {
26
+ name: 'iconEl',
27
+ itemId: 'iconEl',
28
+ },
29
+ {
30
+ name: 'textEl',
31
+ itemId: 'textEl',
32
+ },
33
+ {
34
+ name: 'divEl',
35
+ itemId: 'divEl',
36
+ }
37
+ ],
38
+
39
+ initComponent() {
40
+ Ext.applyIf(
41
+ menubar.renderData,
42
+ {
43
+ iconCls: this.iconCls,
44
+ text: this.menuItem?.text,
45
+ }
46
+ );
47
+ this.callParent();
48
+ },
49
+
50
+ setText(text) {
51
+ text = text || this.menuItem.text;
52
+ if (!text) {
53
+ return;
54
+ }
55
+ this.textEl.setHtml(text || this.menuItem.text);
56
+ },
57
+ setSelected(state) {
58
+ if (state) {
59
+ this.divEl.addCls('selected');
60
+ } else {
61
+ this.divEl.removeCls('selected');
62
+ }
63
+ },
64
+
65
+ afterRender() {
66
+ const me = this;
67
+ me.iconCls && me.iconEl.addCls(me.iconCls);
68
+ me.setSelected(me.getMenuItem().selected);
69
+ me.setText();
70
+ me.divEl.on(
71
+ 'click',
72
+ function() {
73
+ if (typeof me.onClick === 'function') {
74
+ me.onClick.call(me.scope || me, me.menuItem.MENU_ENTRY_CD);
75
+ }
76
+ me.fireEvent('click', me.menuItem.MENU_ENTRY_CD);
77
+ }
78
+ );
79
+ },
80
+
81
+ onDestroy: function() {
82
+ this.divEl.removeAllListeners();
83
+ },
84
+ });
@@ -16,6 +16,9 @@ Ext.define('Coon.uielement.component.UiCustomController', {
16
16
  view.on('afterrender', function() {
17
17
  this.setCustomPanelTitle();
18
18
  this.processSecurePoints(view);
19
+ if (typeof this.onInit === 'function') {
20
+ this.onInit.call(view);
21
+ }
19
22
  }, this);
20
23
  },
21
24
 
@@ -20,7 +20,7 @@ Ext.define('Coon.uielement.plugin.OpenPanelPlugin', {
20
20
  publishes: 'uiElementCd',
21
21
  },
22
22
 
23
- init: function(view) {
23
+ init(view) {
24
24
  if (!this.handlerName) {
25
25
  Coon.log.error('OpenPanelPlugin.init: handlerName is required');
26
26
  return;
@@ -33,6 +33,7 @@ Ext.define('Coon.uielement.plugin.OpenPanelPlugin', {
33
33
  Coon.log.error('OpenPanelPlugin.init: uiElementCd is required');
34
34
  return;
35
35
  }
36
+ this.initialized = true;
36
37
  this.cmpController = view.getController();
37
38
  if (this.cmpController) {
38
39
  this.getViewModel = () => this.cmpController.getViewModel();
@@ -44,7 +45,14 @@ Ext.define('Coon.uielement.plugin.OpenPanelPlugin', {
44
45
  this.initBindable();
45
46
  },
46
47
 
47
- getFromModel: function(value) {
48
+ /**
49
+ * Возвращает значение, подставляя его из ViewModel, если
50
+ * передана строка в формате `{path.to.value}`.
51
+ *
52
+ * @param {String|*} value Значение либо ссылка на значение во ViewModel в фигурных скобках.
53
+ * @returns {*} Разрешённое значение: либо из ViewModel, либо исходный параметр.
54
+ */
55
+ getResolvedValue(value) {
48
56
  if (
49
57
  Ext.isString(value) &&
50
58
  value.length > 2 &&
@@ -56,40 +64,48 @@ Ext.define('Coon.uielement.plugin.OpenPanelPlugin', {
56
64
  return value;
57
65
  },
58
66
 
59
- handler: function() {
67
+ handler() {
68
+ if (!this.initialized) {
69
+ Coon.log.error('OpenPanelPlugin.handler: not initialized');
70
+ return;
71
+ }
60
72
  if (this.isTraceEnabled()) {
61
73
  this.tracePlugin(this.config.uiElementCd);
62
74
  }
63
- this.uiElementCd = this.config.uiElementCd;
64
- if (this.uiElementCd) {
65
- this.uiElementCd = this.getFromModel(this.uiElementCd);
75
+ if (this.getUiElementCd()) {
76
+ this.uiElementCd = this.getResolvedValue(this.uiElementCd);
77
+ if (!this.uiElementCd) {
78
+ Coon.log.error('OpenPanelPlugin.handler: uiElementCd is not set');
79
+ return;
80
+ }
66
81
  const command = Ext.create('command.GetUIElementCommand');
67
82
  command.on('failure', function(error) {
68
83
  Coon.log.error(`OpenPanelPlugin.handler: failed to get UIElement [${this.uiElementCd}]`, error);
69
- });
70
- command.on('complete', function(UIElementBean) {
71
- const elementNS = Coon.uielement.UIElementBeanFields;
72
- const exists = Ext.ClassManager.getByAlias('widget.' + UIElementBean[elementNS.$xtype]);
73
- if (!exists) {
74
- Coon.log.log(UIElementBean.xtype + ' does not exist');
75
- return;
76
- }
77
- this.panelXType = UIElementBean[elementNS.$xtype];
78
- try {
79
- this.openPanel(
80
- JSON5.parse(UIElementBean['propertyData'])
81
- );
82
- } catch (ex) {
83
- Coon.log.error('OpenPanelPlugin.handler: invalid propertyData JSON5', ex);
84
- return;
85
- }
86
84
  }, this);
85
+ command.on('complete', this.onUIElementLoaded, this);
87
86
  command.execute(this.uiElementCd);
88
87
  } else {
89
88
  this.openPanel();
90
89
  }
91
90
  },
92
91
 
92
+ onUIElementLoaded(UIElementBean) {
93
+ const exists = Ext.ClassManager.getByAlias('widget.' + UIElementBean.xtype);
94
+ if (!exists) {
95
+ Coon.log.log(UIElementBean.xtype + ' does not exist');
96
+ return;
97
+ }
98
+ this.panelXType = UIElementBean.xtype;
99
+ try {
100
+ this.openPanel(
101
+ JSON5.parse(UIElementBean['propertyData'])
102
+ );
103
+ } catch (ex) {
104
+ Coon.log.error('OpenPanelPlugin.handler: invalid propertyData JSON5', ex);
105
+ return;
106
+ }
107
+ },
108
+
93
109
  openInCenterView(panel, properties) {
94
110
  this.getCenterView();
95
111
  if (!this.centerView) {
@@ -98,7 +114,7 @@ Ext.define('Coon.uielement.plugin.OpenPanelPlugin', {
98
114
  Ext.toast(`В режиме редактирования кастомной панели нельзя открыть панель ${this.uiElementCd} во вкладке.`);
99
115
  }
100
116
  Coon.log.debug('panel cannot be open in centerView from CustomPanelEditor');
101
- return;
117
+ return true;
102
118
  }
103
119
 
104
120
  const componentContextId = Coon.util.ContextManager.generateKey(
@@ -180,23 +196,31 @@ Ext.define('Coon.uielement.plugin.OpenPanelPlugin', {
180
196
  setInitArguments() {
181
197
  this.initArguments = {};
182
198
  for (const name in this.parameters) {
183
- this.initArguments[name] = this.getFromModel(this.parameters[name]);
199
+ this.initArguments[name] = this.getResolvedValue(this.parameters[name]);
184
200
  }
185
201
  },
186
202
 
187
- openPanel: function(cmpProperties = {}) {
203
+ openPanel(cmpProperties = {}) {
188
204
  const properties = Ext.apply({}, cmpProperties, this.properties);
189
- this.panelXType = this.getFromModel(this.panelXType);
205
+ this.panelXType = this.getResolvedValue(this.panelXType);
190
206
  if (!Ext.ClassManager.getByAlias(`widget.${this.panelXType}`)) {
191
207
  Coon.log.error(`OpenPanelPlugin.openPanel: panelXType [${this.panelXType}] does not exist`);
192
208
  return;
193
209
  }
210
+
211
+ if (!this.getCenterView() && this.openInTab) {
212
+ return Coon.log.error('panel cannot be open in centerView from CustomPanelEditor');
213
+ }
194
214
  const panel = Ext.widget(this.panelXType, properties);
195
215
 
196
216
  this.setInitArguments();
197
217
 
198
218
  if (this.openInTab) {
199
- this.openInCenterView(panel, properties);
219
+ const error = this.openInCenterView(panel, properties);
220
+ if (error) {
221
+ panel.destroy();
222
+ return error;
223
+ }
200
224
  } else {
201
225
  this.openInWindowWrap(panel);
202
226
  }
package/src/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  Ext.define('Coon.version', {
2
2
  singleton: true,
3
- number: '3.0.80',
3
+ number: '3.0.81',
4
4
  });