Skip to content

Commit a16d33f

Browse files
committed
#7130 WIP - not fully stable yet
1 parent df2dfee commit a16d33f

File tree

9 files changed

+489
-138
lines changed

9 files changed

+489
-138
lines changed

.github/epic-string-based-templates.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,40 @@ Ensured that falsy values (e.g., `false`, `null`, `undefined`) in template inter
182182
**Description:**
183183
To improve the developer experience for those familiar with React, a major refactoring was undertaken. The framework's core `render()` method was renamed to `initVnode()` to more accurately reflect its purpose of creating the initial VNode and mounting the component. This freed up the `render` name, allowing `createTemplateVdom()` to be renamed to `render()`, providing a more intuitive and familiar API for functional components using HTML templates. This change also included renaming the `rendered` property to `vnodeInitialized`, the `autoRender` config to `autoInitVnode`, and the `rendering` flag to `isVnodeInitializing` to maintain semantic consistency throughout the framework.
184184

185+
### 15. Create a Robust VDOM-to-String Serializer
186+
187+
**Status: To Do**
188+
189+
**Description:**
190+
The `JSON.stringify` + regex method for generating the VDOM string during the build process is flawed. It incorrectly handles object keys that are not valid JavaScript identifiers (e.g., `data-foo`) and produces non-idiomatic code (quoted keys). A dedicated serializer is required for correctness and code quality.
191+
192+
**Implementation Details:**
193+
- **Tool:** A new, custom utility module.
194+
- **Location:** `buildScripts/util/vdomToString.mjs`.
195+
- **Method:**
196+
1. The utility will export a `vdomToString(vdom)` function that recursively traverses the VDOM object.
197+
2. It will check if each object key is a valid JavaScript identifier.
198+
3. Valid identifiers will be written to the output string without quotes (e.g., `tag:`).
199+
4. Invalid identifiers (e.g., `data-foo`) will be correctly wrapped in single quotes (e.g., `'data-foo':`).
200+
5. It will handle the build-time placeholders for runtime expressions, outputting them as raw, unquoted code.
201+
6. This new utility will completely replace the `JSON.stringify` and subsequent regex calls in the build scripts.
202+
203+
### 16. Refactor Build-Time Parser to be AST-Based for Robustness
204+
205+
**Status: To Do**
206+
207+
**Description:**
208+
The current build-time approach, which uses regular expressions to find and replace templates, has proven to be brittle and incorrect. It cannot handle nested `html` templates and can be easily fooled by JSDoc comments or strings that happen to contain the `html`` sequence, leading to build failures. To ensure correctness and consistency with the runtime environment, the build process must be refactored to use a proper JavaScript parser.
209+
210+
**Implementation Details:**
211+
- **Tools:** `acorn` (to parse JS into an Abstract Syntax Tree) and `astring` (to generate JS code from the AST).
212+
- **Method:**
213+
1. In the build script, for each `.mjs` file, use `acorn` to parse the entire file content into an AST.
214+
2. Traverse the AST, specifically looking for `TaggedTemplateExpression` nodes where the `tag` is an `Identifier` with the name `html`.
215+
3. Process these template nodes recursively (post-order traversal) to correctly handle nested templates from the inside out.
216+
4. The logic from `HtmlTemplateProcessorLogic` will be used to convert the template into its VDOM object representation.
217+
5. The original `TaggedTemplateExpression` node in the AST will be replaced with a new AST node representing the generated VDOM object (using an object-to-AST converter).
218+
6. Finally, use `astring` to generate the final, correct JavaScript code from the modified AST.
219+
7. This new, robust process will replace the fragile regex-based `replace` loop.
220+
185221

buildScripts/buildESModules.mjs

Lines changed: 117 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,64 @@
1+
import {generate} from 'astring';
12
import fs from 'fs-extra';
23
import path from 'path';
3-
import {minify as minifyJs} from 'terser';
4+
import * as acorn from 'acorn';
5+
import * as Terser from 'terser';
46
import {minifyHtml} from './util/minifyHtml.mjs';
7+
import { processHtmlTemplateLiteral } from './util/templateBuildProcessor.mjs';
8+
import { vdomToString } from './util/vdomToString.mjs';
9+
10+
// A simple JSON to AST converter
11+
function jsonToAst(json) {
12+
if (json === null) {
13+
return { type: 'Literal', value: null };
14+
}
15+
switch (typeof json) {
16+
case 'string':
17+
const exprMatch = json.match(/##__NEO_EXPR__(.*)##__NEO_EXPR__##/);
18+
if (exprMatch) {
19+
// This is a raw expression, parse it as a standalone expression
20+
try {
21+
const body = acorn.parse(exprMatch[1], {ecmaVersion: 'latest'}).body;
22+
if (body.length > 0 && body[0].type === 'ExpressionStatement') {
23+
return body[0].expression;
24+
}
25+
} catch (e) {
26+
console.error(`Failed to parse expression: ${exprMatch[1]}`, e);
27+
// Fallback to literal string if parsing fails
28+
return { type: 'Literal', value: json };
29+
}
30+
}
31+
return { type: 'Literal', value: json };
32+
case 'number':
33+
case 'boolean':
34+
return { type: 'Literal', value: json };
35+
case 'object':
36+
if (Array.isArray(json)) {
37+
return {
38+
type: 'ArrayExpression',
39+
elements: json.map(jsonToAst)
40+
};
41+
}
42+
const properties = Object.entries(json).map(([key, value]) => {
43+
const keyNode = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)
44+
? { type: 'Identifier', name: key }
45+
: { type: 'Literal', value: key };
46+
return {
47+
type: 'Property',
48+
key: keyNode,
49+
value: jsonToAst(value),
50+
kind: 'init',
51+
computed: keyNode.type === 'Literal'
52+
};
53+
});
54+
return { type: 'ObjectExpression', properties };
55+
default:
56+
return { type: 'Literal', value: null }; // for undefined, function, etc.
57+
}
58+
}
559

660
const
761
outputBasePath = 'dist/esm/',
8-
// Regex to find import statements with 'node_modules' in the path
9-
// It captures the entire import statement (excluding the leading 'import') and the path itself.
1062
regexImport = /(import(?:\s*(?:[\w*{}\n\r\t, ]+from\s*)?|\s*\(\s*)?)(["'`])((?:(?!\2).)*node_modules(?:(?!\2).)*)\2/g,
1163
root = path.resolve(),
1264
requireJson = path => JSON.parse(fs.readFileSync(path, 'utf-8')),
@@ -22,131 +74,129 @@ if (insideNeo) {
2274
inputDirectories = ['apps', 'docs', 'node_modules/neo.mjs/src', 'src']
2375
}
2476

25-
/**
26-
* @param {String} match
27-
* @param {String} p1 will be "import {marked} from " (or similar, including the 'import' keyword and everything up to the first quote)
28-
* @param {String} p2 will be the quote character (', ", or `)
29-
* @param {String} p3 will be the original path string (e.g., '../../../../node_modules/marked/lib/marked.esm.js')
30-
* @returns {String}
31-
*/
3277
function adjustImportPathHandler(match, p1, p2, p3) {
3378
let newPath;
34-
3579
if (p3.includes('/node_modules/neo.mjs/')) {
3680
newPath = p3.replace('/node_modules/neo.mjs/', '/')
3781
} else {
38-
newPath = '../../' + p3; // Prepend 2 levels up
82+
newPath = '../../' + p3;
3983
}
40-
41-
// Reconstruct the import statement with the new path
4284
return p1 + p2 + newPath + p2
4385
}
4486

45-
/**
46-
*
47-
* @param {String} inputDir
48-
* @param {String} outputDir
49-
* @returns {Promise<void>}
50-
*/
5187
async function minifyDirectory(inputDir, outputDir) {
5288
if (fs.existsSync(inputDir)) {
5389
fs.mkdirSync(outputDir, {recursive: true});
54-
5590
const dirents = fs.readdirSync(inputDir, {recursive: true, withFileTypes: true});
56-
5791
for (const dirent of dirents) {
58-
// Intended to skip the docs/output folder, since the content is already minified
5992
if (dirent.path.includes('/docs/output/')) {
6093
continue
6194
}
62-
6395
if (dirent.isFile()) {
6496
const
6597
inputPath = path.join(dirent.path, dirent.name),
6698
relativePath = path.relative(inputDir, inputPath),
6799
outputPath = path.join(outputDir, relativePath),
68100
content = fs.readFileSync(inputPath, 'utf8');
69-
70101
await minifyFile(content, outputPath)
71-
}
72-
// Copy resources folders
73-
else if (dirent.name === 'resources') {
102+
} else if (dirent.name === 'resources') {
74103
const
75104
inputPath = path.join(dirent.path, dirent.name),
76105
relativePath = path.relative(inputDir, inputPath),
77106
outputPath = path.join(outputDir, relativePath);
78-
79107
fs.mkdirSync(path.dirname(outputPath), {recursive: true});
80-
81108
fs.copySync(inputPath, outputPath);
82-
83-
// Minify all JSON files inside the copied folder
84109
const resourcesEntries = fs.readdirSync(outputPath, {recursive: true, withFileTypes: true});
85-
86110
for (const resource of resourcesEntries) {
87-
if (resource.isFile()) {
88-
if (resource.name.endsWith('.json')) {
89-
const
90-
resourcePath = path.join(resource.path, resource.name),
91-
content = fs.readFileSync(resourcePath, 'utf8');
92-
93-
fs.writeFileSync(resourcePath, JSON.stringify(JSON.parse(content)))
94-
}
111+
if (resource.isFile() && resource.name.endsWith('.json')) {
112+
const
113+
resourcePath = path.join(resource.path, resource.name),
114+
content = fs.readFileSync(resourcePath, 'utf8');
115+
fs.writeFileSync(resourcePath, JSON.stringify(JSON.parse(content)))
95116
}
96117
}
97118
}
98119
}
99120
}
100121
}
101122

102-
/**
103-
* @param {String} content
104-
* @param {String} outputPath
105-
* @returns {Promise<void>}
106-
*/
107123
async function minifyFile(content, outputPath) {
108124
fs.mkdirSync(path.dirname(outputPath), {recursive: true});
109-
110125
try {
111-
// Minify JSON files
112126
if (outputPath.endsWith('.json')) {
113127
const jsonContent = JSON.parse(content);
114-
115128
if (outputPath.endsWith('neo-config.json')) {
116129
Object.assign(jsonContent, {
117130
basePath : '../../' + jsonContent.basePath,
118131
environment : 'dist/esm',
119132
mainPath : './Main.mjs',
120133
workerBasePath: jsonContent.basePath + 'src/worker/'
121134
});
122-
123135
if (!insideNeo) {
124136
jsonContent.appPath = jsonContent.appPath.substring(6)
125137
}
126138
}
127-
128139
fs.writeFileSync(outputPath, JSON.stringify(jsonContent));
129140
console.log(`Minified JSON: ${outputPath}`)
130-
}
131-
// Minify HTML files
132-
else if (outputPath.endsWith('.html')) {
141+
} else if (outputPath.endsWith('.html')) {
133142
const minifiedContent = await minifyHtml(content);
134-
135143
fs.writeFileSync(outputPath, minifiedContent);
136144
console.log(`Minified HTML: ${outputPath}`)
137-
}
138-
// Minify JS files
139-
else if (outputPath.endsWith('.mjs')) {
145+
} else if (outputPath.endsWith('.mjs')) {
140146
let adjustedContent = content.replace(regexImport, adjustImportPathHandler);
141147

142-
const result = await minifyJs(adjustedContent, {
143-
module: true,
144-
compress: {
145-
dead_code: true
146-
},
147-
mangle: {
148-
toplevel: true
148+
// AST-based processing for html templates
149+
const ast = acorn.parse(adjustedContent, {ecmaVersion: 'latest', sourceType: 'module'});
150+
151+
// Simple post-order traversal
152+
const nodesToProcess = [];
153+
function walk(node, parent, key, index) {
154+
if (!node) return;
155+
Object.entries(node).forEach(([key, value]) => {
156+
if (Array.isArray(value)) {
157+
value.forEach((child, i) => walk(child, node, key, i));
158+
} else if (typeof value === 'object' && value !== null) {
159+
walk(value, node, key);
160+
}
161+
});
162+
nodesToProcess.push({node, parent, key, index});
163+
}
164+
walk(ast);
165+
166+
let hasChanges = false;
167+
for (const {node, parent, key, index} of nodesToProcess) {
168+
if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') {
169+
const templateLiteral = node.quasi;
170+
const strings = templateLiteral.quasis.map(q => q.value.cooked);
171+
const expressionCodeStrings = templateLiteral.expressions.map(expr => adjustedContent.substring(expr.start, expr.end));
172+
173+
const componentNameMap = {};
174+
expressionCodeStrings.forEach(exprCode => {
175+
if (/^[A-Z][a-zA-Z0-9]*$/.test(exprCode.trim())) {
176+
componentNameMap[exprCode.trim()] = { __neo_component_name__: exprCode.trim() };
177+
}
178+
});
179+
180+
const vdom = await processHtmlTemplateLiteral(strings, expressionCodeStrings, componentNameMap);
181+
const vdomAst = jsonToAst(vdom);
182+
183+
if (parent) {
184+
if (index !== undefined) {
185+
parent[key][index] = vdomAst;
186+
} else {
187+
parent[key] = vdomAst;
188+
}
189+
hasChanges = true;
190+
}
149191
}
192+
}
193+
194+
let currentContent = hasChanges ? generate(ast) : adjustedContent;
195+
196+
const result = await Terser.minify(currentContent, {
197+
module: true,
198+
compress: { dead_code: true },
199+
mangle: { toplevel: true }
150200
});
151201

152202
fs.writeFileSync(outputPath, result.code);
@@ -161,27 +211,22 @@ const
161211
swContent = fs.readFileSync(path.resolve(root, 'ServiceWorker.mjs'), 'utf8'),
162212
promises = [minifyFile(swContent, path.resolve(root, outputBasePath, 'ServiceWorker.mjs'))];
163213

164-
// Execute the minification
165214
inputDirectories.forEach(folder => {
166215
const outputPath = path.resolve(root, outputBasePath, folder.replace('node_modules/neo.mjs/', ''));
167-
168216
promises.push(minifyDirectory(path.resolve(root, folder), outputPath)
169217
.catch(err => {
170218
console.error('dist/esm Minification failed:', err);
171-
process.exit(1) // Exit with error code
219+
process.exit(1)
172220
})
173221
)
174222
});
175223

176224
Promise.all(promises).then(() => {
177-
// Copying the already skipped and minified docs/output folder
178225
const docsOutputPath = path.resolve(root, 'docs/output');
179-
180226
if (fs.existsSync(docsOutputPath)) {
181227
fs.copySync(docsOutputPath, path.resolve(root, outputBasePath, 'docs/output'))
182228
}
183-
184229
const processTime = (Math.round((new Date - startDate) * 100) / 100000).toFixed(2);
185230
console.log(`\nTotal time for dist/esm: ${processTime}s`);
186231
process.exit()
187-
})
232+
})

0 commit comments

Comments
 (0)