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
package/src/index.js
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ApexDoc — Public API
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const apex = require('apexdoc');
|
|
8
|
+
*
|
|
9
|
+
* const ast = apex.parse('./doc.apx');
|
|
10
|
+
* const html = apex.render(ast, 'html');
|
|
11
|
+
* apex.export(ast, 'pdf', './dist/doc.pdf');
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const Tokenizer = require('./tokenizer');
|
|
17
|
+
const Parser = require('./parser');
|
|
18
|
+
const Resolver = require('./resolver');
|
|
19
|
+
const HTMLRenderer = require('./renderer/html');
|
|
20
|
+
|
|
21
|
+
// ── Core Pipeline ───────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse an .apx source string or file path into a resolved AST.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} input — Raw .apx source OR a file path ending in .apx
|
|
27
|
+
* @param {object} context — Optional variable context to inject
|
|
28
|
+
* @returns {object} Resolved AST document node
|
|
29
|
+
*/
|
|
30
|
+
function parse(input, context = {}) {
|
|
31
|
+
let source = input;
|
|
32
|
+
|
|
33
|
+
// Accept file paths
|
|
34
|
+
if (typeof input === 'string' && input.endsWith('.apx') && fs.existsSync(input)) {
|
|
35
|
+
source = fs.readFileSync(input, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 1. Tokenize
|
|
39
|
+
const tokenizer = new Tokenizer(source);
|
|
40
|
+
const tokens = tokenizer.tokenize();
|
|
41
|
+
|
|
42
|
+
// 2. Parse tokens → raw AST
|
|
43
|
+
const parser = new Parser(tokens);
|
|
44
|
+
const rawAst = parser.parse();
|
|
45
|
+
|
|
46
|
+
// 3. Resolve variables, expressions, conditionals, loops
|
|
47
|
+
const resolver = new Resolver(rawAst, context);
|
|
48
|
+
const ast = resolver.resolve();
|
|
49
|
+
|
|
50
|
+
// Attach any parse/resolve errors
|
|
51
|
+
ast._errors = [...(parser.errors || []), ...(resolver.errors || [])];
|
|
52
|
+
|
|
53
|
+
return ast;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Render a resolved AST to a target format string.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} ast — Resolved AST from parse()
|
|
60
|
+
* @param {string} target — 'html' | 'text' | 'json' | 'md'
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
63
|
+
function render(ast, target = 'html') {
|
|
64
|
+
switch (target.toLowerCase()) {
|
|
65
|
+
case 'html':
|
|
66
|
+
return new HTMLRenderer(ast).render();
|
|
67
|
+
case 'json':
|
|
68
|
+
return JSON.stringify(ast, null, 2);
|
|
69
|
+
case 'text':
|
|
70
|
+
return renderPlainText(ast);
|
|
71
|
+
case 'md':
|
|
72
|
+
return renderMarkdown(ast);
|
|
73
|
+
default:
|
|
74
|
+
throw new ApexError(`Unknown render target: "${target}". Use: html, json, text, md`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse + render in one step.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} input — Source or file path
|
|
82
|
+
* @param {string} target — Render target
|
|
83
|
+
* @param {object} context — Variable context
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function compile(input, target = 'html', context = {}) {
|
|
87
|
+
const ast = parse(input, context);
|
|
88
|
+
return render(ast, target);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Parse + render + write to output file.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} input — Source or file path
|
|
95
|
+
* @param {string} target — Render target
|
|
96
|
+
* @param {string} outputPath — Destination file path
|
|
97
|
+
* @param {object} context — Variable context
|
|
98
|
+
*/
|
|
99
|
+
function exportFile(input, target = 'html', outputPath, context = {}) {
|
|
100
|
+
const output = compile(input, target, context);
|
|
101
|
+
|
|
102
|
+
// Auto-generate output path if not given
|
|
103
|
+
if (!outputPath) {
|
|
104
|
+
const base = typeof input === 'string' && input.endsWith('.apx')
|
|
105
|
+
? input.replace(/\.apx$/, '')
|
|
106
|
+
: 'output';
|
|
107
|
+
const ext = { html: '.html', text: '.txt', json: '.json', md: '.md' };
|
|
108
|
+
outputPath = base + (ext[target] || '.html');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
112
|
+
fs.writeFileSync(outputPath, output, 'utf8');
|
|
113
|
+
return outputPath;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Linter ──────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Validate an .apx source and return a list of errors/warnings.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} input — Source or file path
|
|
122
|
+
* @returns {{ errors: Array, warnings: Array, valid: boolean }}
|
|
123
|
+
*/
|
|
124
|
+
function lint(input) {
|
|
125
|
+
const errors = [];
|
|
126
|
+
const warnings = [];
|
|
127
|
+
|
|
128
|
+
let source = input;
|
|
129
|
+
if (typeof input === 'string' && input.endsWith('.apx') && fs.existsSync(input)) {
|
|
130
|
+
source = fs.readFileSync(input, 'utf8');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const ast = parse(source);
|
|
135
|
+
|
|
136
|
+
// Propagate parse errors
|
|
137
|
+
if (ast._errors) {
|
|
138
|
+
errors.push(...ast._errors);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Structural checks
|
|
142
|
+
const checks = _runLintChecks(source, ast);
|
|
143
|
+
errors.push(...checks.errors);
|
|
144
|
+
warnings.push(...checks.warnings);
|
|
145
|
+
|
|
146
|
+
} catch (err) {
|
|
147
|
+
errors.push({ code: 'APX001', message: err.message, line: 0 });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { errors, warnings, valid: errors.length === 0 };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _runLintChecks(source, ast) {
|
|
154
|
+
const errors = [];
|
|
155
|
+
const warnings = [];
|
|
156
|
+
const lines = source.split('\n');
|
|
157
|
+
|
|
158
|
+
// APX002: Unclosed blocks
|
|
159
|
+
let openCount = 0;
|
|
160
|
+
let closeCount = 0;
|
|
161
|
+
lines.forEach((line, i) => {
|
|
162
|
+
const t = line.trim();
|
|
163
|
+
if (t.startsWith('%%') && !t.startsWith('%%end') && !t.includes('%%end')) openCount++;
|
|
164
|
+
if (t === '%%end') closeCount++;
|
|
165
|
+
});
|
|
166
|
+
if (openCount !== closeCount) {
|
|
167
|
+
errors.push({
|
|
168
|
+
code: 'APX002',
|
|
169
|
+
message: `Unclosed blocks detected: ${openCount} opened, ${closeCount} closed`,
|
|
170
|
+
line: 0,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// APX005: Undefined variables in expressions
|
|
175
|
+
const vars = new Set(Object.keys(ast._vars || {}));
|
|
176
|
+
const exprRe = /\{(\w+)\}/g;
|
|
177
|
+
lines.forEach((line, i) => {
|
|
178
|
+
let m;
|
|
179
|
+
while ((m = exprRe.exec(line)) !== null) {
|
|
180
|
+
const name = m[1];
|
|
181
|
+
if (!vars.has(name) && !_isBuiltinFn(name)) {
|
|
182
|
+
warnings.push({
|
|
183
|
+
code: 'APX005',
|
|
184
|
+
message: `Possibly undefined variable: "{${name}}"`,
|
|
185
|
+
line: i + 1,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// APX009: Unknown theme reference
|
|
192
|
+
const metaTheme = ast.meta?.fields?.theme;
|
|
193
|
+
const knownThemes = new Set([
|
|
194
|
+
'default', 'dark-ocean', 'neon-city', 'sunset', 'minimal-light',
|
|
195
|
+
'forest', 'candy', 'terminal', 'academic', 'newspaper',
|
|
196
|
+
...Object.keys(ast.style?.themes || {}),
|
|
197
|
+
]);
|
|
198
|
+
if (metaTheme && !knownThemes.has(metaTheme)) {
|
|
199
|
+
warnings.push({
|
|
200
|
+
code: 'APX009',
|
|
201
|
+
message: `Theme "${metaTheme}" is not defined in %%style or built-in themes`,
|
|
202
|
+
line: 0,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { errors, warnings };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function _isBuiltinFn(name) {
|
|
210
|
+
const builtins = ['today', 'now', 'timestamp', 'uuid', 'random', 'uppercase',
|
|
211
|
+
'lowercase', 'length', 'sum', 'avg', 'round', 'readingTime', 'wordCount'];
|
|
212
|
+
return builtins.includes(name);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── AST Inspector ───────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Return the raw AST as a clean JSON string (for debugging).
|
|
219
|
+
*/
|
|
220
|
+
function inspect(input) {
|
|
221
|
+
const ast = parse(input);
|
|
222
|
+
return JSON.stringify(ast, null, 2);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── Plain Text Renderer ─────────────────────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function renderPlainText(ast) {
|
|
228
|
+
const lines = [];
|
|
229
|
+
|
|
230
|
+
function walkNode(node) {
|
|
231
|
+
if (!node) return '';
|
|
232
|
+
switch (node.type) {
|
|
233
|
+
case 'Heading': return '#'.repeat(node.level) + ' ' + walkChildren(node) + '\n';
|
|
234
|
+
case 'Paragraph': return walkChildren(node) + '\n\n';
|
|
235
|
+
case 'Text': return node.value;
|
|
236
|
+
case 'Bold': return '**' + walkChildren(node) + '**';
|
|
237
|
+
case 'Italic': return '_' + walkChildren(node) + '_';
|
|
238
|
+
case 'CodeInline': return '`' + node.value + '`';
|
|
239
|
+
case 'MathInline': return '$' + node.value + '$';
|
|
240
|
+
case 'Expression': return String(node.resolved ?? node.value);
|
|
241
|
+
case 'Link': return walkChildren(node) + ' (' + node.href + ')';
|
|
242
|
+
case 'List': return node.items.map(i => '- ' + walkChildren(i)).join('\n') + '\n';
|
|
243
|
+
case 'Blockquote': return '> ' + walkChildren(node) + '\n';
|
|
244
|
+
case 'HR': return '---\n';
|
|
245
|
+
case 'Block': return walkChildren(node);
|
|
246
|
+
default: return walkChildren(node) || '';
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function walkChildren(node) {
|
|
251
|
+
if (!node.children && !node.items && !node.body) return '';
|
|
252
|
+
const arr = node.children || node.items || node.body || [];
|
|
253
|
+
return arr.map(walkNode).join('');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return ast.body.map(walkNode).join('');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── Markdown Renderer ───────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function renderMarkdown(ast) {
|
|
262
|
+
// Best-effort Markdown export
|
|
263
|
+
// Apex features without Markdown equivalents are stripped or approximated
|
|
264
|
+
|
|
265
|
+
function walkNode(node) {
|
|
266
|
+
if (!node) return '';
|
|
267
|
+
switch (node.type) {
|
|
268
|
+
case 'Heading': return '#'.repeat(node.level) + ' ' + walkChildren(node) + '\n\n';
|
|
269
|
+
case 'Paragraph': return walkChildren(node) + '\n\n';
|
|
270
|
+
case 'Text': return node.value;
|
|
271
|
+
case 'Bold': return '**' + walkChildren(node) + '**';
|
|
272
|
+
case 'Italic': return '_' + walkChildren(node) + '_';
|
|
273
|
+
case 'Underline': return '__' + walkChildren(node) + '__';
|
|
274
|
+
case 'Strike': return '~~' + walkChildren(node) + '~~';
|
|
275
|
+
case 'CodeInline': return '`' + node.value + '`';
|
|
276
|
+
case 'MathInline': return '$' + node.value + '$';
|
|
277
|
+
case 'Expression': return String(node.resolved ?? node.value);
|
|
278
|
+
case 'Link': return '[' + walkChildren(node) + '](' + node.href + ')';
|
|
279
|
+
case 'Blockquote': return '> ' + walkChildren(node) + '\n\n';
|
|
280
|
+
case 'HR': return '---\n\n';
|
|
281
|
+
case 'List': return node.items.map(i => {
|
|
282
|
+
const prefix = i.type === 'TaskItem'
|
|
283
|
+
? (i.state === 'done' ? '- [x] ' : '- [ ] ')
|
|
284
|
+
: '- ';
|
|
285
|
+
return prefix + walkChildren(i);
|
|
286
|
+
}).join('\n') + '\n\n';
|
|
287
|
+
case 'Block': {
|
|
288
|
+
if (node.name === 'code') {
|
|
289
|
+
const raw = node.children?.[0]?.value || '';
|
|
290
|
+
const lang = node.props?.lang || '';
|
|
291
|
+
return '```' + lang + '\n' + raw + '\n```\n\n';
|
|
292
|
+
}
|
|
293
|
+
if (node.name === 'math') {
|
|
294
|
+
const raw = node.children?.[0]?.value || '';
|
|
295
|
+
return '$$\n' + raw + '\n$$\n\n';
|
|
296
|
+
}
|
|
297
|
+
return walkChildren(node);
|
|
298
|
+
}
|
|
299
|
+
default: return walkChildren(node) || '';
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function walkChildren(node) {
|
|
304
|
+
const arr = node.children || node.items || node.body || [];
|
|
305
|
+
return arr.map(walkNode).join('');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const meta = ast.meta?.fields || {};
|
|
309
|
+
let front = '';
|
|
310
|
+
if (Object.keys(meta).length > 0) {
|
|
311
|
+
front = '---\n' + Object.entries(meta)
|
|
312
|
+
.map(([k, v]) => `${k}: ${JSON.stringify(v)}`)
|
|
313
|
+
.join('\n') + '\n---\n\n';
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return front + ast.body.map(walkNode).join('');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── Error Class ─────────────────────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
class ApexError extends Error {
|
|
322
|
+
constructor(message, code = 'APX000', line = 0) {
|
|
323
|
+
super(message);
|
|
324
|
+
this.name = 'ApexError';
|
|
325
|
+
this.code = code;
|
|
326
|
+
this.line = line;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ── Utilities ───────────────────────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Create a blank .apx document template string.
|
|
334
|
+
*/
|
|
335
|
+
function template(type = 'document') {
|
|
336
|
+
const templates = {
|
|
337
|
+
document: `%%meta
|
|
338
|
+
title: "My Document"
|
|
339
|
+
author: "Author"
|
|
340
|
+
theme: dark-ocean
|
|
341
|
+
%%end
|
|
342
|
+
|
|
343
|
+
%%style
|
|
344
|
+
--primary: #00f5d4
|
|
345
|
+
--bg: #0d0d0d
|
|
346
|
+
--text: #f0f0f0
|
|
347
|
+
--font: "Inter", sans-serif
|
|
348
|
+
%%end
|
|
349
|
+
|
|
350
|
+
%%body
|
|
351
|
+
|
|
352
|
+
# My Document
|
|
353
|
+
|
|
354
|
+
Welcome to **ApexDoc** — the last document format you'll ever need.
|
|
355
|
+
|
|
356
|
+
%%end
|
|
357
|
+
`,
|
|
358
|
+
presentation: `%%meta
|
|
359
|
+
title: "My Presentation"
|
|
360
|
+
mode: presentation
|
|
361
|
+
theme: dark-ocean
|
|
362
|
+
%%end
|
|
363
|
+
|
|
364
|
+
%%style
|
|
365
|
+
--primary: #00f5d4
|
|
366
|
+
%%end
|
|
367
|
+
|
|
368
|
+
%%body
|
|
369
|
+
|
|
370
|
+
%%slide {transition: fade}
|
|
371
|
+
# Slide One
|
|
372
|
+
Your content here.
|
|
373
|
+
%%end
|
|
374
|
+
|
|
375
|
+
%%slide {transition: slideLeft}
|
|
376
|
+
# Slide Two
|
|
377
|
+
More content here.
|
|
378
|
+
%%end
|
|
379
|
+
|
|
380
|
+
%%end
|
|
381
|
+
`,
|
|
382
|
+
report: `%%meta
|
|
383
|
+
title: "Report"
|
|
384
|
+
author: "Author"
|
|
385
|
+
toc: true
|
|
386
|
+
theme: minimal-light
|
|
387
|
+
%%end
|
|
388
|
+
|
|
389
|
+
%%style
|
|
390
|
+
--primary: #1a1a2e
|
|
391
|
+
--bg: #ffffff
|
|
392
|
+
--text: #222222
|
|
393
|
+
%%end
|
|
394
|
+
|
|
395
|
+
%%body
|
|
396
|
+
|
|
397
|
+
# Executive Summary
|
|
398
|
+
|
|
399
|
+
Write your summary here.
|
|
400
|
+
|
|
401
|
+
## Section One
|
|
402
|
+
|
|
403
|
+
Content goes here.
|
|
404
|
+
|
|
405
|
+
## Section Two
|
|
406
|
+
|
|
407
|
+
Content goes here.
|
|
408
|
+
|
|
409
|
+
%%end
|
|
410
|
+
`,
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
return templates[type] || templates.document;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
module.exports = {
|
|
419
|
+
// Core
|
|
420
|
+
parse,
|
|
421
|
+
render,
|
|
422
|
+
compile,
|
|
423
|
+
export: exportFile,
|
|
424
|
+
|
|
425
|
+
// Tools
|
|
426
|
+
lint,
|
|
427
|
+
inspect,
|
|
428
|
+
template,
|
|
429
|
+
|
|
430
|
+
// Renderers (direct access)
|
|
431
|
+
HTMLRenderer,
|
|
432
|
+
|
|
433
|
+
// Classes (for extending)
|
|
434
|
+
Tokenizer,
|
|
435
|
+
Parser,
|
|
436
|
+
Resolver,
|
|
437
|
+
ApexError,
|
|
438
|
+
};
|