@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.
@@ -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
+ }