apexfile 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +94 -0
- package/README.md +415 -0
- package/bin/cli.js +334 -0
- package/package.json +59 -0
- package/src/ast/index.js +260 -0
- package/src/index.js +438 -0
- package/src/parser/index.js +594 -0
- package/src/renderer/html.js +983 -0
- package/src/resolver/index.js +442 -0
- package/src/tokenizer/index.js +518 -0
- package/src/tokenizer/tokens.js +75 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ApexDoc Resolver
|
|
5
|
+
* Walks the AST and resolves:
|
|
6
|
+
* - Variables (@set name = "Cold" → {name} = "Cold")
|
|
7
|
+
* - Expressions ({price * 1.1})
|
|
8
|
+
* - Built-in functions ({today()}, {uppercase("hello")})
|
|
9
|
+
* - Filters ({name | uppercase})
|
|
10
|
+
* - Conditionals (@if / @elseif / @else)
|
|
11
|
+
* - Loops (@each)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
class Resolver {
|
|
15
|
+
constructor(ast, context = {}) {
|
|
16
|
+
this.ast = ast;
|
|
17
|
+
this.context = {
|
|
18
|
+
...this._builtinContext(),
|
|
19
|
+
...context,
|
|
20
|
+
};
|
|
21
|
+
this.errors = [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Entry Point ────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
resolve() {
|
|
27
|
+
// Extract variables from @set nodes first (first pass)
|
|
28
|
+
this._collectVars(this.ast.body, this.context);
|
|
29
|
+
|
|
30
|
+
// Second pass: resolve expressions and expand conditionals/loops
|
|
31
|
+
const resolvedBody = this._resolveNodes(this.ast.body, this.context);
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
...this.ast,
|
|
35
|
+
body: resolvedBody,
|
|
36
|
+
_vars: this.context,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ── First Pass: Collect Variables ──────────────────────────────
|
|
41
|
+
|
|
42
|
+
_collectVars(nodes, ctx) {
|
|
43
|
+
for (const node of nodes) {
|
|
44
|
+
if (!node) continue;
|
|
45
|
+
if (node.type === 'SetVar') {
|
|
46
|
+
ctx[node.name] = this._evalExpression(node.value, ctx);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Second Pass: Resolve Nodes ─────────────────────────────────
|
|
52
|
+
|
|
53
|
+
_resolveNodes(nodes, ctx) {
|
|
54
|
+
const result = [];
|
|
55
|
+
|
|
56
|
+
for (const node of nodes) {
|
|
57
|
+
if (!node) continue;
|
|
58
|
+
|
|
59
|
+
switch (node.type) {
|
|
60
|
+
case 'SetVar':
|
|
61
|
+
// Already collected in first pass — emit nothing
|
|
62
|
+
ctx[node.name] = this._evalExpression(node.value, ctx);
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case 'Conditional':
|
|
66
|
+
result.push(...this._resolveConditional(node, ctx));
|
|
67
|
+
break;
|
|
68
|
+
|
|
69
|
+
case 'Loop':
|
|
70
|
+
result.push(...this._resolveLoop(node, ctx));
|
|
71
|
+
break;
|
|
72
|
+
|
|
73
|
+
case 'Expression':
|
|
74
|
+
result.push({
|
|
75
|
+
...node,
|
|
76
|
+
resolved: this._evalExpression(node.value, ctx),
|
|
77
|
+
});
|
|
78
|
+
break;
|
|
79
|
+
|
|
80
|
+
case 'Block':
|
|
81
|
+
case 'Heading':
|
|
82
|
+
case 'Paragraph':
|
|
83
|
+
case 'Blockquote':
|
|
84
|
+
case 'List':
|
|
85
|
+
result.push(this._resolveChildren(node, ctx));
|
|
86
|
+
break;
|
|
87
|
+
|
|
88
|
+
default:
|
|
89
|
+
result.push(node);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return result;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
_resolveChildren(node, ctx) {
|
|
98
|
+
const fields = ['children', 'items', 'body', 'consequent', 'fallback'];
|
|
99
|
+
const resolved = { ...node };
|
|
100
|
+
|
|
101
|
+
for (const field of fields) {
|
|
102
|
+
if (Array.isArray(node[field])) {
|
|
103
|
+
resolved[field] = this._resolveNodes(node[field], ctx);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return resolved;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Conditional Resolution ─────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
_resolveConditional(node, ctx) {
|
|
113
|
+
// Evaluate the @if condition
|
|
114
|
+
if (this._evalCondition(node.condition, ctx)) {
|
|
115
|
+
return this._resolveNodes(node.consequent, ctx);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Evaluate @elseif branches
|
|
119
|
+
for (const alt of node.alternates) {
|
|
120
|
+
if (this._evalCondition(alt.condition, ctx)) {
|
|
121
|
+
return this._resolveNodes(alt.body, ctx);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// @else fallback
|
|
126
|
+
if (node.fallback.length > 0) {
|
|
127
|
+
return this._resolveNodes(node.fallback, ctx);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Loop Resolution ────────────────────────────────────────────
|
|
134
|
+
|
|
135
|
+
_resolveLoop(node, ctx) {
|
|
136
|
+
const result = [];
|
|
137
|
+
|
|
138
|
+
let collection = this._evalExpression(node.source, ctx);
|
|
139
|
+
|
|
140
|
+
// range(start, end) shorthand
|
|
141
|
+
if (typeof collection === 'string') {
|
|
142
|
+
const rangeMatch = collection.match(/^range\((\d+),\s*(\d+)\)$/);
|
|
143
|
+
if (rangeMatch) {
|
|
144
|
+
const start = parseInt(rangeMatch[1]);
|
|
145
|
+
const end = parseInt(rangeMatch[2]);
|
|
146
|
+
collection = Array.from({ length: end - start + 1 }, (_, i) => start + i);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (!Array.isArray(collection)) {
|
|
151
|
+
this.errors.push({ message: `@each source "${node.source}" is not iterable`, line: node.line });
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const item of collection) {
|
|
156
|
+
const loopCtx = {
|
|
157
|
+
...ctx,
|
|
158
|
+
[node.variable]: item,
|
|
159
|
+
};
|
|
160
|
+
result.push(...this._resolveNodes(node.body, loopCtx));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Expression Evaluator ───────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
_evalExpression(expr, ctx) {
|
|
169
|
+
if (expr === undefined || expr === null) return '';
|
|
170
|
+
const str = String(expr).trim();
|
|
171
|
+
|
|
172
|
+
// 1. Quoted string literal — strip quotes immediately
|
|
173
|
+
if ((str.startsWith('"') && str.endsWith('"')) ||
|
|
174
|
+
(str.startsWith("'") && str.endsWith("'"))) {
|
|
175
|
+
return str.slice(1, -1);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 2. JSON array literal ["A","B"] or object {}
|
|
179
|
+
if ((str.startsWith('[') && str.endsWith(']')) ||
|
|
180
|
+
(str.startsWith('{') && str.endsWith('}'))) {
|
|
181
|
+
try { return JSON.parse(str); } catch (_) {}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 3. Built-in functions
|
|
185
|
+
const builtinResult = this._evalBuiltin(str, ctx);
|
|
186
|
+
if (builtinResult !== null) return builtinResult;
|
|
187
|
+
|
|
188
|
+
// 4. Pipe filters expr | filter
|
|
189
|
+
if (str.includes('|')) {
|
|
190
|
+
const pipeIdx = str.lastIndexOf('|');
|
|
191
|
+
const left = str.slice(0, pipeIdx).trim();
|
|
192
|
+
const filter = str.slice(pipeIdx + 1).trim();
|
|
193
|
+
const base = this._evalExpression(left, ctx);
|
|
194
|
+
return this._applyFilter(base, filter, ctx);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 5. Simple variable lookup
|
|
198
|
+
if (/^\w+$/.test(str) && str in ctx) {
|
|
199
|
+
return ctx[str];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 6. Dot notation user.name (letters/underscore only — not numbers)
|
|
203
|
+
if (/^[a-zA-Z_][\w.]*$/.test(str)) {
|
|
204
|
+
const val = this._dotLookup(str, ctx);
|
|
205
|
+
if (val !== undefined && val !== '') return val;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 7. Pure number
|
|
209
|
+
if (/^-?\d+\.?\d*$/.test(str)) return parseFloat(str);
|
|
210
|
+
|
|
211
|
+
// 8. Arithmetic/comparison expression price * 1.1 + 5
|
|
212
|
+
try {
|
|
213
|
+
const evaluated = this._safeEval(str, ctx);
|
|
214
|
+
if (evaluated !== undefined) return evaluated;
|
|
215
|
+
} catch (_) {}
|
|
216
|
+
|
|
217
|
+
// 9. Return as-is (unresolvable string)
|
|
218
|
+
return str;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
_evalCondition(condition, ctx) {
|
|
222
|
+
try {
|
|
223
|
+
return !!this._safeEval(condition, ctx);
|
|
224
|
+
} catch (_) {
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Safe arithmetic/comparison evaluator.
|
|
231
|
+
* Only allows: numbers, booleans, comparison ops, arithmetic ops, variable names.
|
|
232
|
+
*/
|
|
233
|
+
_safeEval(expr, ctx) {
|
|
234
|
+
// Substitute variables into expression
|
|
235
|
+
let safe = expr.replace(/\b([a-zA-Z_]\w*)\b/g, (match) => {
|
|
236
|
+
if (match in ctx) {
|
|
237
|
+
const val = ctx[match];
|
|
238
|
+
if (typeof val === 'string') return JSON.stringify(val);
|
|
239
|
+
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
|
|
240
|
+
return JSON.stringify(val);
|
|
241
|
+
}
|
|
242
|
+
// Leave unknown identifiers — they'll cause a safe eval failure
|
|
243
|
+
return match;
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// After substitution, reject anything with bare identifiers (not in quotes)
|
|
247
|
+
// Strip out string contents first, then check for raw letters
|
|
248
|
+
const stripped = safe.replace(/"[^"]*"|'[^']*'/g, '""');
|
|
249
|
+
if (/[a-zA-Z_]/.test(stripped)) return undefined;
|
|
250
|
+
|
|
251
|
+
// eslint-disable-next-line no-new-func
|
|
252
|
+
try {
|
|
253
|
+
return Function(`"use strict"; return (${safe});`)();
|
|
254
|
+
} catch (_) {
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Built-in Functions ─────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
_evalBuiltin(str, ctx) {
|
|
262
|
+
// today()
|
|
263
|
+
if (str === 'today()') return new Date().toISOString().slice(0, 10);
|
|
264
|
+
|
|
265
|
+
// now()
|
|
266
|
+
if (str === 'now()') return new Date().toISOString().slice(0, 19);
|
|
267
|
+
|
|
268
|
+
// timestamp()
|
|
269
|
+
if (str === 'timestamp()') return Math.floor(Date.now() / 1000);
|
|
270
|
+
|
|
271
|
+
// uuid()
|
|
272
|
+
if (str === 'uuid()') return this._uuid();
|
|
273
|
+
|
|
274
|
+
// readingTime()
|
|
275
|
+
if (str === 'readingTime()') return ctx.__readingTime || 'N/A';
|
|
276
|
+
|
|
277
|
+
// wordCount()
|
|
278
|
+
if (str === 'wordCount()') return ctx.__wordCount || 0;
|
|
279
|
+
|
|
280
|
+
// random(min, max)
|
|
281
|
+
const randomMatch = str.match(/^random\((\d+),\s*(\d+)\)$/);
|
|
282
|
+
if (randomMatch) {
|
|
283
|
+
const min = parseInt(randomMatch[1]);
|
|
284
|
+
const max = parseInt(randomMatch[2]);
|
|
285
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// uppercase("text")
|
|
289
|
+
const upperMatch = str.match(/^uppercase\(["']?(.+?)["']?\)$/);
|
|
290
|
+
if (upperMatch) return this._evalExpression(upperMatch[1], ctx).toString().toUpperCase();
|
|
291
|
+
|
|
292
|
+
// lowercase("text")
|
|
293
|
+
const lowerMatch = str.match(/^lowercase\(["']?(.+?)["']?\)$/);
|
|
294
|
+
if (lowerMatch) return this._evalExpression(lowerMatch[1], ctx).toString().toLowerCase();
|
|
295
|
+
|
|
296
|
+
// length(expr)
|
|
297
|
+
const lenMatch = str.match(/^length\((.+)\)$/);
|
|
298
|
+
if (lenMatch) {
|
|
299
|
+
const val = this._evalExpression(lenMatch[1], ctx);
|
|
300
|
+
return Array.isArray(val) ? val.length : String(val).length;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// sum([...]) or sum(varName)
|
|
304
|
+
const sumMatch = str.match(/^sum\((.+)\)$/);
|
|
305
|
+
if (sumMatch) {
|
|
306
|
+
const val = this._evalExpression(sumMatch[1], ctx);
|
|
307
|
+
const arr = Array.isArray(val) ? val : [val];
|
|
308
|
+
return arr.reduce((a, b) => a + Number(b), 0);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// avg([...]) or avg(varName)
|
|
312
|
+
const avgMatch = str.match(/^avg\((.+)\)$/);
|
|
313
|
+
if (avgMatch) {
|
|
314
|
+
const val = this._evalExpression(avgMatch[1], ctx);
|
|
315
|
+
const arr = Array.isArray(val) ? val : [val];
|
|
316
|
+
return arr.reduce((a, b) => a + Number(b), 0) / arr.length;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// round(expr, decimals)
|
|
320
|
+
const roundMatch = str.match(/^round\((.+),\s*(\d+)\)$/);
|
|
321
|
+
if (roundMatch) {
|
|
322
|
+
const val = this._evalExpression(roundMatch[1], ctx);
|
|
323
|
+
const decs = parseInt(roundMatch[2]);
|
|
324
|
+
return parseFloat(Number(val).toFixed(decs));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── Filters ────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
_applyFilter(value, filter, ctx) {
|
|
333
|
+
// uppercase
|
|
334
|
+
if (filter === 'uppercase') return String(value).toUpperCase();
|
|
335
|
+
|
|
336
|
+
// lowercase
|
|
337
|
+
if (filter === 'lowercase') return String(value).toLowerCase();
|
|
338
|
+
|
|
339
|
+
// comma (number formatting)
|
|
340
|
+
if (filter === 'comma') return Number(value).toLocaleString();
|
|
341
|
+
|
|
342
|
+
// join(", ")
|
|
343
|
+
const joinMatch = filter.match(/^join\(["']?([^"']*)["']?\)$/);
|
|
344
|
+
if (joinMatch && Array.isArray(value)) return value.join(joinMatch[1]);
|
|
345
|
+
|
|
346
|
+
// truncate(n)
|
|
347
|
+
const truncMatch = filter.match(/^truncate\((\d+)\)$/);
|
|
348
|
+
if (truncMatch) {
|
|
349
|
+
const n = parseInt(truncMatch[1]);
|
|
350
|
+
return String(value).length > n ? String(value).slice(0, n) + '…' : String(value);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// currency("USD")
|
|
354
|
+
const currMatch = filter.match(/^currency\(["']?(\w+)["']?\)$/);
|
|
355
|
+
if (currMatch) {
|
|
356
|
+
return new Intl.NumberFormat('en-US', {
|
|
357
|
+
style: 'currency',
|
|
358
|
+
currency: currMatch[1] || 'USD',
|
|
359
|
+
}).format(Number(value));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// format("DD MMM YYYY") - date formatting
|
|
363
|
+
const fmtMatch = filter.match(/^format\(["'](.+)["']\)$/);
|
|
364
|
+
if (fmtMatch) {
|
|
365
|
+
const d = new Date(value);
|
|
366
|
+
const fmt = fmtMatch[1];
|
|
367
|
+
return this._formatDate(d, fmt);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// round(n)
|
|
371
|
+
const roundMatch = filter.match(/^round\((\d+)\)$/);
|
|
372
|
+
if (roundMatch) return parseFloat(Number(value).toFixed(parseInt(roundMatch[1])));
|
|
373
|
+
|
|
374
|
+
return value;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Dot Notation Lookup ────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
_dotLookup(path, ctx) {
|
|
380
|
+
const parts = path.split('.');
|
|
381
|
+
let val = ctx;
|
|
382
|
+
for (const part of parts) {
|
|
383
|
+
if (val === null || val === undefined) return '';
|
|
384
|
+
val = val[part];
|
|
385
|
+
}
|
|
386
|
+
return val ?? '';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Date Formatter ─────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
_formatDate(d, fmt) {
|
|
392
|
+
if (isNaN(d)) return fmt;
|
|
393
|
+
const pad = n => String(n).padStart(2, '0');
|
|
394
|
+
const months = ['January','February','March','April','May','June',
|
|
395
|
+
'July','August','September','October','November','December'];
|
|
396
|
+
const short = months.map(m => m.slice(0, 3));
|
|
397
|
+
|
|
398
|
+
return fmt
|
|
399
|
+
.replace('YYYY', d.getFullYear())
|
|
400
|
+
.replace('YY', String(d.getFullYear()).slice(-2))
|
|
401
|
+
.replace('MMMM', months[d.getMonth()])
|
|
402
|
+
.replace('MMM', short[d.getMonth()])
|
|
403
|
+
.replace('MM', pad(d.getMonth() + 1))
|
|
404
|
+
.replace('DD', pad(d.getDate()))
|
|
405
|
+
.replace('HH', pad(d.getHours()))
|
|
406
|
+
.replace('mm', pad(d.getMinutes()))
|
|
407
|
+
.replace('ss', pad(d.getSeconds()));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// ── Built-in Context ───────────────────────────────────────────
|
|
411
|
+
|
|
412
|
+
_builtinContext() {
|
|
413
|
+
return {
|
|
414
|
+
const: {
|
|
415
|
+
c: 299792458,
|
|
416
|
+
G: 6.674e-11,
|
|
417
|
+
h: 6.626e-34,
|
|
418
|
+
pi: Math.PI,
|
|
419
|
+
e: Math.E,
|
|
420
|
+
},
|
|
421
|
+
color: {
|
|
422
|
+
lighten: (c, p) => c, // Placeholder — HTML renderer expands
|
|
423
|
+
darken: (c, p) => c,
|
|
424
|
+
alpha: (c, a) => c,
|
|
425
|
+
complement: (c) => c,
|
|
426
|
+
mix: (a, b, p) => a,
|
|
427
|
+
contrast: (a, b) => '4.5:1',
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ── UUID Generator ─────────────────────────────────────────────
|
|
433
|
+
|
|
434
|
+
_uuid() {
|
|
435
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
436
|
+
const r = Math.random() * 16 | 0;
|
|
437
|
+
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
module.exports = Resolver;
|