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/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
+ };