@nml-lang/compiler-ts 2.2.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/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +70 -0
- package/dist/index.js.map +1 -0
- package/dist/lexer.d.ts +30 -0
- package/dist/lexer.d.ts.map +1 -0
- package/dist/lexer.js +132 -0
- package/dist/lexer.js.map +1 -0
- package/dist/parser.d.ts +55 -0
- package/dist/parser.d.ts.map +1 -0
- package/dist/parser.js +915 -0
- package/dist/parser.js.map +1 -0
- package/dist/renderer.d.ts +19 -0
- package/dist/renderer.d.ts.map +1 -0
- package/dist/renderer.js +186 -0
- package/dist/renderer.js.map +1 -0
- package/package.json +43 -0
- package/src/index.ts +116 -0
- package/src/lexer.ts +186 -0
- package/src/parser.ts +1108 -0
- package/src/renderer.ts +236 -0
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NML Renderer
|
|
3
|
+
* Ports generate_html from nml_parse.py to TypeScript.
|
|
4
|
+
* Walks the expanded AST and produces a formatted HTML string.
|
|
5
|
+
*
|
|
6
|
+
* generateHtml is async to support @include directives which may
|
|
7
|
+
* read files from the filesystem, Cloudflare R2, D1, or memory.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { buildAst, renderVariables, isTruthy, NMLParserError, type ASTNode } from "./parser.js";
|
|
11
|
+
import { dirname, join, resolve } from "path";
|
|
12
|
+
|
|
13
|
+
const INDENT_WIDTH = 4;
|
|
14
|
+
|
|
15
|
+
const VOID_ELEMENTS = new Set([
|
|
16
|
+
"area", "base", "br", "col", "embed", "hr", "img", "input",
|
|
17
|
+
"link", "meta", "param", "source", "track", "wbr",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export interface RenderOptions {
|
|
21
|
+
/** Async function to read a file by absolute path. Required for @include. */
|
|
22
|
+
readFile?: (path: string) => Promise<string>;
|
|
23
|
+
/** Absolute path of the file being rendered — used to resolve relative @include paths. */
|
|
24
|
+
basePath?: string;
|
|
25
|
+
/** Internal: tracks seen file paths for circular include detection. */
|
|
26
|
+
_seenFiles?: Set<string>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function generateHtml(
|
|
30
|
+
ast: ASTNode[],
|
|
31
|
+
indentLevel = 0,
|
|
32
|
+
context: Record<string, unknown> = {},
|
|
33
|
+
opts: RenderOptions = {}
|
|
34
|
+
): Promise<string> {
|
|
35
|
+
let html = "";
|
|
36
|
+
const indent = " ".repeat(indentLevel * INDENT_WIDTH);
|
|
37
|
+
|
|
38
|
+
for (const node of ast) {
|
|
39
|
+
// Merge node-level context (component props)
|
|
40
|
+
let nodeContext = context;
|
|
41
|
+
if (node.__context__) {
|
|
42
|
+
nodeContext = { ...context, ...node.__context__ };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Plain text node
|
|
46
|
+
if (node.element === "__text__") {
|
|
47
|
+
html += `${indent}${renderVariables(node.content, nodeContext)}\n`;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// @include — render-time partial resolution
|
|
52
|
+
if (node.element === "@include") {
|
|
53
|
+
html += await resolveInclude(node, indentLevel, nodeContext, opts);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// @each — loop directive
|
|
58
|
+
if (node.element === "@each") {
|
|
59
|
+
const itemsPath = node.attributes["items"] as string;
|
|
60
|
+
const asName = node.attributes["as"] as string;
|
|
61
|
+
const arr = resolveContextPath(itemsPath, nodeContext);
|
|
62
|
+
if (Array.isArray(arr)) {
|
|
63
|
+
for (const item of arr) {
|
|
64
|
+
const childCtx = { ...nodeContext, [asName]: item };
|
|
65
|
+
html += await generateHtml(node.children, indentLevel, childCtx, opts);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// @if — conditional directive
|
|
72
|
+
if (node.element === "@if") {
|
|
73
|
+
const condition = node.attributes["condition"] as string;
|
|
74
|
+
const condVal = resolveContextPath(condition, nodeContext);
|
|
75
|
+
if (isTruthy(condVal)) {
|
|
76
|
+
html += await generateHtml(node.children, indentLevel, nodeContext, opts);
|
|
77
|
+
} else if (node.elseBranch && node.elseBranch.length > 0) {
|
|
78
|
+
html += await generateHtml(node.elseBranch, indentLevel, nodeContext, opts);
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Internal structural nodes — skip (defensive: @else/@endif/@endeach removed by pass)
|
|
84
|
+
if (["@define", "@slot", "@style", "__comment__", "__root__", "@else", "@endif", "@endeach"].includes(node.element)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const tag = node.element;
|
|
89
|
+
|
|
90
|
+
// Doctype
|
|
91
|
+
if (tag === "doctype") {
|
|
92
|
+
const cls = node.attributes["class"];
|
|
93
|
+
const clsStr = Array.isArray(cls) ? cls.join(" ") : (cls as string) ?? "";
|
|
94
|
+
if (clsStr === "html") {
|
|
95
|
+
html += `${indent}<!DOCTYPE html>\n`;
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Build attribute string
|
|
101
|
+
let attrString = "";
|
|
102
|
+
for (const [key, value] of Object.entries(node.attributes)) {
|
|
103
|
+
if (key.startsWith("__")) continue; // skip internal keys
|
|
104
|
+
if (value === true) {
|
|
105
|
+
attrString += ` ${key}`;
|
|
106
|
+
} else if (Array.isArray(value)) {
|
|
107
|
+
const rendered = renderVariables(value.join(" "), nodeContext);
|
|
108
|
+
attrString += ` ${key}="${rendered}"`;
|
|
109
|
+
} else {
|
|
110
|
+
const rendered = renderVariables(String(value), nodeContext);
|
|
111
|
+
attrString += ` ${key}="${rendered}"`;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
html += `${indent}<${tag}${attrString}`;
|
|
116
|
+
|
|
117
|
+
// Void elements self-close
|
|
118
|
+
if (VOID_ELEMENTS.has(tag)) {
|
|
119
|
+
html += ">\n";
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
html += ">";
|
|
124
|
+
|
|
125
|
+
const content = renderVariables(node.content, nodeContext);
|
|
126
|
+
const children = node.children;
|
|
127
|
+
const multiline = node.multiline_content;
|
|
128
|
+
|
|
129
|
+
if (content) {
|
|
130
|
+
html += content;
|
|
131
|
+
} else if (children.length > 0) {
|
|
132
|
+
// Inline single text child
|
|
133
|
+
if (
|
|
134
|
+
children.length === 1 &&
|
|
135
|
+
children[0].element === "__text__"
|
|
136
|
+
) {
|
|
137
|
+
html += renderVariables(children[0].content, nodeContext);
|
|
138
|
+
} else {
|
|
139
|
+
html += "\n";
|
|
140
|
+
html += await generateHtml(children, indentLevel + 1, nodeContext, opts);
|
|
141
|
+
html += indent;
|
|
142
|
+
}
|
|
143
|
+
} else if (multiline.length > 0) {
|
|
144
|
+
html += "\n";
|
|
145
|
+
for (const line of multiline) {
|
|
146
|
+
html += `${indent} ${renderVariables(line, nodeContext)}\n`;
|
|
147
|
+
}
|
|
148
|
+
html += indent;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
html += `</${tag}>\n`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return indentLevel === 0 ? html.trimEnd() : html;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// @include resolution
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
async function resolveInclude(
|
|
162
|
+
node: ASTNode,
|
|
163
|
+
indentLevel: number,
|
|
164
|
+
parentContext: Record<string, unknown>,
|
|
165
|
+
opts: RenderOptions
|
|
166
|
+
): Promise<string> {
|
|
167
|
+
const relativePath = node.attributes["file"] as string;
|
|
168
|
+
const overridesRaw = node.attributes["overrides"] as string;
|
|
169
|
+
|
|
170
|
+
if (!opts.readFile) {
|
|
171
|
+
throw new NMLParserError(
|
|
172
|
+
`@include("${relativePath}") requires a readFile option — pass it via CompilerAdapter.render() or nmlCompiler.render()`,
|
|
173
|
+
node.loc
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Resolve to absolute path
|
|
178
|
+
const base = opts.basePath ? dirname(opts.basePath) : process.cwd();
|
|
179
|
+
const absolutePath = resolve(join(base, relativePath));
|
|
180
|
+
|
|
181
|
+
// Circular include detection
|
|
182
|
+
const seen = opts._seenFiles ?? new Set<string>();
|
|
183
|
+
if (seen.has(absolutePath)) {
|
|
184
|
+
throw new NMLParserError(
|
|
185
|
+
`Circular @include detected: "${relativePath}" is already in the include stack`,
|
|
186
|
+
node.loc
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Read the partial source
|
|
191
|
+
let src: string;
|
|
192
|
+
try {
|
|
193
|
+
src = await opts.readFile(absolutePath);
|
|
194
|
+
} catch {
|
|
195
|
+
throw new NMLParserError(
|
|
196
|
+
`@include: cannot read file "${relativePath}" (resolved to "${absolutePath}")`,
|
|
197
|
+
node.loc
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Parse overrides: { ...parentCtx, ...overrides }
|
|
202
|
+
let overrides: Record<string, unknown> = {};
|
|
203
|
+
if (overridesRaw) {
|
|
204
|
+
try {
|
|
205
|
+
overrides = JSON.parse(overridesRaw);
|
|
206
|
+
} catch {
|
|
207
|
+
// Non-JSON override strings are silently ignored (may be complex expressions)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const partialContext = { ...parentContext, ...overrides };
|
|
211
|
+
|
|
212
|
+
// Parse and render the partial with child seen-set
|
|
213
|
+
const childSeen = new Set(seen);
|
|
214
|
+
childSeen.add(absolutePath);
|
|
215
|
+
|
|
216
|
+
const ast = buildAst(src);
|
|
217
|
+
return generateHtml(ast, indentLevel, partialContext, {
|
|
218
|
+
...opts,
|
|
219
|
+
basePath: absolutePath,
|
|
220
|
+
_seenFiles: childSeen,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Context path resolver (used by @each and @if)
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function resolveContextPath(path: string, context: Record<string, unknown>): unknown {
|
|
229
|
+
const parts = path.split(".");
|
|
230
|
+
let current: unknown = context;
|
|
231
|
+
for (const part of parts) {
|
|
232
|
+
if (current === null || current === undefined) return undefined;
|
|
233
|
+
current = (current as Record<string, unknown>)[part];
|
|
234
|
+
}
|
|
235
|
+
return current;
|
|
236
|
+
}
|