neo.mjs 10.2.0 → 10.3.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/.github/CONCEPT.md +2 -4
- package/.github/GETTING_STARTED.md +72 -51
- package/.github/RELEASE_NOTES/v10.2.1.md +17 -0
- package/.github/RELEASE_NOTES/v10.3.0.md +54 -0
- package/.github/epic-string-based-templates.md +690 -0
- package/ServiceWorker.mjs +2 -2
- package/apps/covid/view/GalleryContainer.mjs +1 -1
- package/apps/covid/view/HelixContainer.mjs +1 -1
- package/apps/covid/view/MainContainer.mjs +1 -1
- package/apps/covid/view/WorldMapContainer.mjs +4 -4
- package/apps/covid/view/country/Table.mjs +1 -1
- package/apps/portal/index.html +1 -1
- package/apps/portal/view/home/FooterContainer.mjs +1 -1
- package/apps/portal/view/learn/ContentComponent.mjs +1 -1
- package/apps/realworld/api/Base.mjs +2 -2
- package/apps/sharedcovid/view/GalleryContainer.mjs +1 -1
- package/apps/sharedcovid/view/HelixContainer.mjs +1 -1
- package/apps/sharedcovid/view/MainContainer.mjs +1 -1
- package/apps/sharedcovid/view/MainContainerController.mjs +1 -1
- package/apps/sharedcovid/view/WorldMapContainer.mjs +4 -4
- package/buildScripts/buildESModules.mjs +23 -75
- package/buildScripts/bundleParse5.mjs +27 -0
- package/buildScripts/util/astTemplateProcessor.mjs +210 -0
- package/buildScripts/util/templateBuildProcessor.mjs +331 -0
- package/buildScripts/util/vdomToString.mjs +46 -0
- package/buildScripts/webpack/development/webpack.config.appworker.mjs +11 -0
- package/buildScripts/webpack/loader/template-loader.mjs +21 -0
- package/buildScripts/webpack/production/webpack.config.appworker.mjs +11 -0
- package/examples/README.md +1 -1
- package/examples/component/wrapper/googleMaps/MarkerDialog.mjs +2 -2
- package/examples/form/field/email/MainContainer.mjs +0 -1
- package/examples/form/field/number/MainContainer.mjs +0 -1
- package/examples/form/field/picker/MainContainer.mjs +0 -1
- package/examples/form/field/time/MainContainer.mjs +0 -1
- package/examples/form/field/trigger/copyToClipboard/MainContainer.mjs +0 -1
- package/examples/form/field/url/MainContainer.mjs +0 -1
- package/examples/functional/nestedTemplateComponent/Component.mjs +100 -0
- package/examples/functional/nestedTemplateComponent/MainContainer.mjs +48 -0
- package/examples/functional/nestedTemplateComponent/app.mjs +6 -0
- package/examples/functional/nestedTemplateComponent/index.html +11 -0
- package/examples/functional/nestedTemplateComponent/neo-config.json +6 -0
- package/examples/functional/templateComponent/Component.mjs +61 -0
- package/examples/functional/templateComponent/MainContainer.mjs +48 -0
- package/examples/functional/templateComponent/app.mjs +6 -0
- package/examples/functional/templateComponent/index.html +11 -0
- package/examples/functional/templateComponent/neo-config.json +6 -0
- package/learn/gettingstarted/Setup.md +29 -12
- package/learn/guides/fundamentals/ApplicationBootstrap.md +2 -2
- package/learn/guides/fundamentals/InstanceLifecycle.md +5 -5
- package/learn/guides/uibuildingblocks/HtmlTemplates.md +191 -0
- package/learn/guides/uibuildingblocks/HtmlTemplatesUnderTheHood.md +156 -0
- package/learn/guides/uibuildingblocks/WorkingWithVDom.md +1 -1
- package/learn/tree.json +2 -0
- package/package.json +62 -56
- package/src/DefaultConfig.mjs +3 -3
- package/src/button/Base.mjs +13 -4
- package/src/calendar/view/calendars/List.mjs +1 -1
- package/src/calendar/view/month/Component.mjs +1 -1
- package/src/calendar/view/week/Component.mjs +1 -1
- package/src/component/Abstract.mjs +1 -1
- package/src/component/Base.mjs +33 -27
- package/src/container/Base.mjs +14 -7
- package/src/controller/Application.mjs +5 -5
- package/src/dialog/Base.mjs +6 -6
- package/src/draggable/DragProxyComponent.mjs +4 -4
- package/src/form/field/ComboBox.mjs +1 -1
- package/src/functional/_export.mjs +2 -1
- package/src/functional/component/Base.mjs +142 -93
- package/src/functional/util/HtmlTemplateProcessor.mjs +243 -0
- package/src/functional/util/html.mjs +24 -67
- package/src/list/Base.mjs +2 -2
- package/src/manager/Toast.mjs +1 -1
- package/src/menu/List.mjs +1 -1
- package/src/mixin/VdomLifecycle.mjs +87 -90
- package/src/tab/Container.mjs +2 -2
- package/src/tooltip/Base.mjs +1 -1
- package/src/tree/Accordion.mjs +2 -2
- package/src/worker/App.mjs +7 -7
- package/test/components/files/component/Base.mjs +1 -1
- package/test/siesta/siesta.js +2 -0
- package/test/siesta/tests/classic/Button.mjs +5 -5
- package/test/siesta/tests/functional/Button.mjs +6 -6
- package/test/siesta/tests/functional/HtmlTemplateComponent.mjs +193 -33
- package/test/siesta/tests/functional/Parse5Processor.mjs +82 -0
- package/test/siesta/tests/vdom/VdomRealWorldUpdates.mjs +5 -5
- package/.github/epic-functional-components.md +0 -498
- package/.github/ticket-asymmetric-vdom-updates.md +0 -122
package/ServiceWorker.mjs
CHANGED
@@ -227,7 +227,7 @@ class HelixContainer extends Container {
|
|
227
227
|
}
|
228
228
|
}, {
|
229
229
|
module: BoxLabel,
|
230
|
-
|
230
|
+
html : [
|
231
231
|
'<b>Navigation Concept</b>',
|
232
232
|
'<p>Click on an item to select it. Afterwards you can use the Arrow Keys to walk through the items.</p>',
|
233
233
|
'<p>Hit the Space Key to rotate the currently selected item to the front.</p>',
|
@@ -30,7 +30,7 @@ class MainContainer extends Viewport {
|
|
30
30
|
*/
|
31
31
|
items: [HeaderContainer, {
|
32
32
|
module : TabContainer,
|
33
|
-
activeIndex: null, //
|
33
|
+
activeIndex: null, // mount no items initially
|
34
34
|
flex : 1,
|
35
35
|
reference : 'tab-container',
|
36
36
|
sortable : true,
|
@@ -56,21 +56,21 @@ class WorldMapContainer extends Container {
|
|
56
56
|
handler: 'onSeriesButtonClick',
|
57
57
|
series : 'cases',
|
58
58
|
style : {marginRight: '2px'},
|
59
|
-
text : '
|
59
|
+
text : [{tag: 'span', style: {color: '#bbbbbb'}, text: '●'}, {vtype: 'text', text: ' Cases'}]
|
60
60
|
}, {
|
61
61
|
handler: 'onSeriesButtonClick',
|
62
62
|
series : 'active',
|
63
63
|
style : {marginRight: '2px'},
|
64
|
-
text : '
|
64
|
+
text : [{tag: 'span', style: {color: '#64b5f6'}, text: '●'}, {vtype: 'text', text: ' Active'}]
|
65
65
|
}, {
|
66
66
|
handler: 'onSeriesButtonClick',
|
67
67
|
series : 'recovered',
|
68
68
|
style : {marginRight: '2px'},
|
69
|
-
text : '
|
69
|
+
text : [{tag: 'span', style: {color: '#28ca68'}, text: '●'}, {vtype: 'text', text: ' Recovered'}]
|
70
70
|
}, {
|
71
71
|
handler: 'onSeriesButtonClick',
|
72
72
|
series : 'deaths',
|
73
|
-
text : '
|
73
|
+
text : [{tag: 'span', style: {color: '#fb6767'}, text: '●'}, {vtype: 'text', text: ' Deaths'}]
|
74
74
|
}]
|
75
75
|
}, {
|
76
76
|
module : WorldMapComponent,
|
@@ -67,7 +67,7 @@ class Table extends Container {
|
|
67
67
|
cls : ['neo-country-column', 'neo-table-cell'],
|
68
68
|
html: [
|
69
69
|
'<div style="display: flex; align-items: center">',
|
70
|
-
|
70
|
+
'<img style="height:20px; margin-right:10px; width:20px;" src="' + Util.getCountryFlagUrl(data.value) + '">' + data.value,
|
71
71
|
'</div>'
|
72
72
|
].join('')
|
73
73
|
}
|
package/apps/portal/index.html
CHANGED
@@ -172,7 +172,7 @@ class ContentComponent extends Component {
|
|
172
172
|
path += `${pagesFolder + record.id.replaceAll('.', '/')}.md`;
|
173
173
|
|
174
174
|
if (record.isLeaf && path) {
|
175
|
-
baseConfigs = {appName,
|
175
|
+
baseConfigs = {appName, autoInitVnode: true, autoMount: true, parentComponent: me, windowId};
|
176
176
|
data = await fetch(path);
|
177
177
|
content = await data.text();
|
178
178
|
// Update content sections (modifies markdown content with h1/h2/h3 tags and IDs)
|
@@ -62,10 +62,10 @@ class Base extends CoreBase {
|
|
62
62
|
me.afterConstructed()
|
63
63
|
})
|
64
64
|
} else {
|
65
|
-
if (Neo.apps['RealWorld'].
|
65
|
+
if (Neo.apps['RealWorld'].vnodeInitialized) {
|
66
66
|
me.onAppRendered()
|
67
67
|
} else {
|
68
|
-
Neo.apps['RealWorld'].on('
|
68
|
+
Neo.apps['RealWorld'].on('vnodeInitialized',me.onAppRendered, me)
|
69
69
|
}
|
70
70
|
}
|
71
71
|
}
|
@@ -231,7 +231,7 @@ class HelixContainer extends Container {
|
|
231
231
|
}
|
232
232
|
}, {
|
233
233
|
module: BoxLabel,
|
234
|
-
|
234
|
+
html : [
|
235
235
|
'<b>Navigation Concept</b>',
|
236
236
|
'<p>Click on an item to select it. Afterwards you can use the Arrow Keys to walk through the items.</p>',
|
237
237
|
'<p>Hit the Space Key to rotate the currently selected item to the front.</p>',
|
@@ -31,7 +31,7 @@ class MainContainer extends Viewport {
|
|
31
31
|
items: [HeaderContainer, {
|
32
32
|
module : TabContainer,
|
33
33
|
activateInsertedTabs: true,
|
34
|
-
activeIndex : null, //
|
34
|
+
activeIndex : null, // mount no items initially
|
35
35
|
flex : 1,
|
36
36
|
reference : 'tab-container',
|
37
37
|
sortable : true,
|
@@ -299,7 +299,7 @@ class MainContainerController extends ComponentController {
|
|
299
299
|
if (view) {
|
300
300
|
NeoArray.add(me.connectedApps, name);
|
301
301
|
|
302
|
-
Neo.apps[name].on('
|
302
|
+
Neo.apps[name].on('vnodeInitialized', () => {
|
303
303
|
me.timeout(100).then(() => {
|
304
304
|
me.getMainView(name).add(view)
|
305
305
|
})
|
@@ -56,21 +56,21 @@ class WorldMapContainer extends Container {
|
|
56
56
|
handler: 'onSeriesButtonClick',
|
57
57
|
series : 'cases',
|
58
58
|
style : {marginRight: '2px'},
|
59
|
-
text : '
|
59
|
+
text : [{tag: 'span', style: {color: '#bbbbbb'}, text: '●'}, {vtype: 'text', text: ' Cases'}]
|
60
60
|
}, {
|
61
61
|
handler: 'onSeriesButtonClick',
|
62
62
|
series : 'active',
|
63
63
|
style : {marginRight: '2px'},
|
64
|
-
text : '
|
64
|
+
text : [{tag: 'span', style: {color: '#64b5f6'}, text: '●'}, {vtype: 'text', text: ' Active'}]
|
65
65
|
}, {
|
66
66
|
handler: 'onSeriesButtonClick',
|
67
67
|
series : 'recovered',
|
68
68
|
style : {marginRight: '2px'},
|
69
|
-
text : '
|
69
|
+
text : [{tag: 'span', style: {color: '#28ca68'}, text: '●'}, {vtype: 'text', text: ' Recovered'}]
|
70
70
|
}, {
|
71
71
|
handler: 'onSeriesButtonClick',
|
72
72
|
series : 'deaths',
|
73
|
-
text : '
|
73
|
+
text : [{tag: 'span', style: {color: '#fb6767'}, text: '●'}, {vtype: 'text', text: ' Deaths'}]
|
74
74
|
}]
|
75
75
|
}, {
|
76
76
|
module : WorldMapComponent,
|
@@ -1,13 +1,12 @@
|
|
1
1
|
import fs from 'fs-extra';
|
2
2
|
import path from 'path';
|
3
|
-
import
|
3
|
+
import * as Terser from 'terser';
|
4
4
|
import {minifyHtml} from './util/minifyHtml.mjs';
|
5
|
+
import {processFileContent} from './util/astTemplateProcessor.mjs';
|
5
6
|
|
6
7
|
const
|
7
8
|
outputBasePath = 'dist/esm/',
|
8
|
-
|
9
|
-
// It captures the entire import statement (excluding the leading 'import') and the path itself.
|
10
|
-
regexImport = /(import(?:\s*(?:[\w*{}\n\r\t, ]+from\s*)?|\s*\(\s*)?)(["'`])((?:(?!\2).)*node_modules(?:(?!\2).)*)\2/g,
|
9
|
+
regexImport = /(import(?:\s*(?:[\w*{}\n\r\t, ]+from\s*)?|\s*\(\s*)?)(["`])((?:(?!\2).)*node_modules(?:(?!\2).)*)\2/g,
|
11
10
|
root = path.resolve(),
|
12
11
|
requireJson = path => JSON.parse(fs.readFileSync(path, 'utf-8')),
|
13
12
|
packageJson = requireJson(path.join(root, 'package.json')),
|
@@ -22,76 +21,45 @@ if (insideNeo) {
|
|
22
21
|
inputDirectories = ['apps', 'docs', 'node_modules/neo.mjs/src', 'src']
|
23
22
|
}
|
24
23
|
|
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
|
-
*/
|
32
24
|
function adjustImportPathHandler(match, p1, p2, p3) {
|
33
25
|
let newPath;
|
34
|
-
|
35
26
|
if (p3.includes('/node_modules/neo.mjs/')) {
|
36
27
|
newPath = p3.replace('/node_modules/neo.mjs/', '/')
|
37
28
|
} else {
|
38
|
-
newPath = '../../' + p3;
|
29
|
+
newPath = '../../' + p3;
|
39
30
|
}
|
40
|
-
|
41
|
-
// Reconstruct the import statement with the new path
|
42
31
|
return p1 + p2 + newPath + p2
|
43
32
|
}
|
44
33
|
|
45
|
-
/**
|
46
|
-
*
|
47
|
-
* @param {String} inputDir
|
48
|
-
* @param {String} outputDir
|
49
|
-
* @returns {Promise<void>}
|
50
|
-
*/
|
51
34
|
async function minifyDirectory(inputDir, outputDir) {
|
52
35
|
if (fs.existsSync(inputDir)) {
|
53
36
|
fs.mkdirSync(outputDir, {recursive: true});
|
54
|
-
|
55
37
|
const dirents = fs.readdirSync(inputDir, {recursive: true, withFileTypes: true});
|
56
|
-
|
57
38
|
for (const dirent of dirents) {
|
58
|
-
// Intended to skip the docs/output folder, since the content is already minified
|
59
39
|
if (dirent.path.includes('/docs/output/')) {
|
60
40
|
continue
|
61
41
|
}
|
62
|
-
|
63
42
|
if (dirent.isFile()) {
|
64
43
|
const
|
65
44
|
inputPath = path.join(dirent.path, dirent.name),
|
66
45
|
relativePath = path.relative(inputDir, inputPath),
|
67
46
|
outputPath = path.join(outputDir, relativePath),
|
68
47
|
content = fs.readFileSync(inputPath, 'utf8');
|
69
|
-
|
70
48
|
await minifyFile(content, outputPath)
|
71
|
-
}
|
72
|
-
// Copy resources folders
|
73
|
-
else if (dirent.name === 'resources') {
|
49
|
+
} else if (dirent.name === 'resources') {
|
74
50
|
const
|
75
51
|
inputPath = path.join(dirent.path, dirent.name),
|
76
52
|
relativePath = path.relative(inputDir, inputPath),
|
77
53
|
outputPath = path.join(outputDir, relativePath);
|
78
|
-
|
79
54
|
fs.mkdirSync(path.dirname(outputPath), {recursive: true});
|
80
|
-
|
81
55
|
fs.copySync(inputPath, outputPath);
|
82
|
-
|
83
|
-
// Minify all JSON files inside the copied folder
|
84
56
|
const resourcesEntries = fs.readdirSync(outputPath, {recursive: true, withFileTypes: true});
|
85
|
-
|
86
57
|
for (const resource of resourcesEntries) {
|
87
|
-
if (resource.isFile()) {
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
fs.writeFileSync(resourcePath, JSON.stringify(JSON.parse(content)))
|
94
|
-
}
|
58
|
+
if (resource.isFile() && resource.name.endsWith('.json')) {
|
59
|
+
const
|
60
|
+
resourcePath = path.join(resource.path, resource.name),
|
61
|
+
content = fs.readFileSync(resourcePath, 'utf8');
|
62
|
+
fs.writeFileSync(resourcePath, JSON.stringify(JSON.parse(content)))
|
95
63
|
}
|
96
64
|
}
|
97
65
|
}
|
@@ -99,57 +67,42 @@ async function minifyDirectory(inputDir, outputDir) {
|
|
99
67
|
}
|
100
68
|
}
|
101
69
|
|
102
|
-
/**
|
103
|
-
* @param {String} content
|
104
|
-
* @param {String} outputPath
|
105
|
-
* @returns {Promise<void>}
|
106
|
-
*/
|
107
70
|
async function minifyFile(content, outputPath) {
|
108
71
|
fs.mkdirSync(path.dirname(outputPath), {recursive: true});
|
109
72
|
|
110
73
|
try {
|
111
|
-
// Minify JSON files
|
112
74
|
if (outputPath.endsWith('.json')) {
|
113
75
|
const jsonContent = JSON.parse(content);
|
114
|
-
|
115
76
|
if (outputPath.endsWith('neo-config.json')) {
|
116
77
|
Object.assign(jsonContent, {
|
117
|
-
basePath
|
118
|
-
environment
|
119
|
-
mainPath
|
78
|
+
basePath: '../../' + jsonContent.basePath,
|
79
|
+
environment: 'dist/esm',
|
80
|
+
mainPath: './Main.mjs',
|
120
81
|
workerBasePath: jsonContent.basePath + 'src/worker/'
|
121
82
|
});
|
122
|
-
|
123
83
|
if (!insideNeo) {
|
124
84
|
jsonContent.appPath = jsonContent.appPath.substring(6)
|
125
85
|
}
|
126
86
|
}
|
127
|
-
|
128
87
|
fs.writeFileSync(outputPath, JSON.stringify(jsonContent));
|
129
88
|
console.log(`Minified JSON: ${outputPath}`)
|
130
|
-
}
|
131
|
-
// Minify HTML files
|
132
|
-
else if (outputPath.endsWith('.html')) {
|
89
|
+
} else if (outputPath.endsWith('.html')) {
|
133
90
|
const minifiedContent = await minifyHtml(content);
|
134
|
-
|
135
91
|
fs.writeFileSync(outputPath, minifiedContent);
|
136
92
|
console.log(`Minified HTML: ${outputPath}`)
|
137
|
-
}
|
138
|
-
// Minify JS files
|
139
|
-
else if (outputPath.endsWith('.mjs')) {
|
93
|
+
} else if (outputPath.endsWith('.mjs')) {
|
140
94
|
let adjustedContent = content.replace(regexImport, adjustImportPathHandler);
|
141
95
|
|
142
|
-
|
96
|
+
// AST-based processing for html templates
|
97
|
+
const result = processFileContent(adjustedContent, outputPath);
|
98
|
+
|
99
|
+
const minifiedResult = await Terser.minify(result.content, {
|
143
100
|
module: true,
|
144
|
-
compress: {
|
145
|
-
|
146
|
-
},
|
147
|
-
mangle: {
|
148
|
-
toplevel: true
|
149
|
-
}
|
101
|
+
compress: {dead_code: true},
|
102
|
+
mangle: {toplevel: true}
|
150
103
|
});
|
151
104
|
|
152
|
-
fs.writeFileSync(outputPath,
|
105
|
+
fs.writeFileSync(outputPath, minifiedResult.code);
|
153
106
|
console.log(`Minified JS: ${outputPath}`)
|
154
107
|
}
|
155
108
|
} catch (e) {
|
@@ -161,26 +114,21 @@ const
|
|
161
114
|
swContent = fs.readFileSync(path.resolve(root, 'ServiceWorker.mjs'), 'utf8'),
|
162
115
|
promises = [minifyFile(swContent, path.resolve(root, outputBasePath, 'ServiceWorker.mjs'))];
|
163
116
|
|
164
|
-
// Execute the minification
|
165
117
|
inputDirectories.forEach(folder => {
|
166
118
|
const outputPath = path.resolve(root, outputBasePath, folder.replace('node_modules/neo.mjs/', ''));
|
167
|
-
|
168
119
|
promises.push(minifyDirectory(path.resolve(root, folder), outputPath)
|
169
120
|
.catch(err => {
|
170
121
|
console.error('dist/esm Minification failed:', err);
|
171
|
-
process.exit(1)
|
122
|
+
process.exit(1)
|
172
123
|
})
|
173
124
|
)
|
174
125
|
});
|
175
126
|
|
176
127
|
Promise.all(promises).then(() => {
|
177
|
-
// Copying the already skipped and minified docs/output folder
|
178
128
|
const docsOutputPath = path.resolve(root, 'docs/output');
|
179
|
-
|
180
129
|
if (fs.existsSync(docsOutputPath)) {
|
181
130
|
fs.copySync(docsOutputPath, path.resolve(root, outputBasePath, 'docs/output'))
|
182
131
|
}
|
183
|
-
|
184
132
|
const processTime = (Math.round((new Date - startDate) * 100) / 100000).toFixed(2);
|
185
133
|
console.log(`\nTotal time for dist/esm: ${processTime}s`);
|
186
134
|
process.exit()
|
@@ -0,0 +1,27 @@
|
|
1
|
+
import esbuild from 'esbuild';
|
2
|
+
import path from 'path';
|
3
|
+
import { fileURLToPath } from 'url';
|
4
|
+
|
5
|
+
const __filename = fileURLToPath(import.meta.url);
|
6
|
+
const __dirname = path.dirname(__filename);
|
7
|
+
|
8
|
+
const build = async () => {
|
9
|
+
try {
|
10
|
+
await esbuild.build({
|
11
|
+
entryPoints: ['node_modules/parse5/dist/index.js'],
|
12
|
+
bundle: true,
|
13
|
+
minify: true,
|
14
|
+
format: 'esm',
|
15
|
+
outfile: path.join(__dirname, '../dist/parse5.mjs'),
|
16
|
+
banner: {
|
17
|
+
js: '/* eslint-disable */'
|
18
|
+
}
|
19
|
+
});
|
20
|
+
console.log('Successfully bundled and minified parse5 to dist/parse5.mjs');
|
21
|
+
} catch (error) {
|
22
|
+
console.error('Error bundling parse5:', error);
|
23
|
+
process.exit(1);
|
24
|
+
}
|
25
|
+
};
|
26
|
+
|
27
|
+
build();
|
@@ -0,0 +1,210 @@
|
|
1
|
+
import * as acorn from 'acorn';
|
2
|
+
import {generate} from 'astring';
|
3
|
+
import {processHtmlTemplateLiteral} from './templateBuildProcessor.mjs';
|
4
|
+
|
5
|
+
/**
|
6
|
+
* This module provides a self-contained, reusable pipeline for transforming `html` tagged
|
7
|
+
* template literals within a JavaScript file into standard Neo.mjs VDOM objects.
|
8
|
+
* It is designed to be called from any build script (`dist/esm`, Webpack, etc.) to ensure
|
9
|
+
* consistent template processing across all build environments.
|
10
|
+
*
|
11
|
+
* The core strategy is a full Abstract Syntax Tree (AST) transformation:
|
12
|
+
* 1. A string of JS code is parsed into an AST using `acorn`.
|
13
|
+
* 2. The AST is traversed to find all `html` tagged template expressions.
|
14
|
+
* 3. Each found template is processed by `templateBuildProcessor.mjs`, which converts the
|
15
|
+
* HTML-like syntax into a serializable VDOM object, carefully preserving any
|
16
|
+
* embedded JavaScript expressions as placeholders.
|
17
|
+
* 4. This VDOM object is then converted back into a valid AST `ObjectExpression` node.
|
18
|
+
* 5. The original `TaggedTemplateExpression` node is replaced in the main AST with the
|
19
|
+
* new `ObjectExpression` node.
|
20
|
+
* 6. Finally, the modified AST is converted back into a string of JavaScript code using `astring`.
|
21
|
+
*
|
22
|
+
* This AST-centric approach is robust and correctly handles complex scenarios like nested
|
23
|
+
* templates and varied JavaScript expressions, which are difficult to manage with
|
24
|
+
* simpler methods like regular expressions.
|
25
|
+
*/
|
26
|
+
|
27
|
+
const regexHtml = /html\s*`/;
|
28
|
+
|
29
|
+
/**
|
30
|
+
* Converts a serializable VDOM object into a valid Acorn AST `ObjectExpression`.
|
31
|
+
* This function is the critical bridge between the intermediate VDOM representation and the
|
32
|
+
* final, executable code. It recursively builds the AST, paying special attention to the
|
33
|
+
* `##__NEO_EXPR__...##` placeholders.
|
34
|
+
*
|
35
|
+
* When a placeholder is found, this function extracts the raw expression string and uses
|
36
|
+
* `acorn.parseExpressionAt` to parse it into a proper AST node. This ensures that runtime
|
37
|
+
* expressions are injected directly into the final AST, preserving them perfectly for
|
38
|
+
* when the code is executed in the browser.
|
39
|
+
*
|
40
|
+
* @param {object} json The JSON-like VDOM object from the template processor.
|
41
|
+
* @returns {object} A valid Acorn AST node representing the VDOM.
|
42
|
+
* @private
|
43
|
+
*/
|
44
|
+
function jsonToAst(json) {
|
45
|
+
if (json === null) {
|
46
|
+
return { type: 'Literal', value: null };
|
47
|
+
}
|
48
|
+
switch (typeof json) {
|
49
|
+
case 'string':
|
50
|
+
const exprMatch = json.match(/^##__NEO_EXPR__(.*)##__NEO_EXPR__##$/s);
|
51
|
+
if (exprMatch) {
|
52
|
+
try {
|
53
|
+
return acorn.parseExpressionAt(exprMatch[1], 0, {ecmaVersion: 'latest'});
|
54
|
+
} catch (e) {
|
55
|
+
console.error(`Failed to parse expression: ${exprMatch[1]}`, e);
|
56
|
+
return { type: 'Literal', value: json };
|
57
|
+
}
|
58
|
+
}
|
59
|
+
return { type: 'Literal', value: json };
|
60
|
+
case 'number':
|
61
|
+
case 'boolean':
|
62
|
+
return { type: 'Literal', value: json };
|
63
|
+
case 'object':
|
64
|
+
if (json.__neo_component_name__) {
|
65
|
+
return { type: 'Identifier', name: json.__neo_component_name__ };
|
66
|
+
}
|
67
|
+
if (Array.isArray(json)) {
|
68
|
+
return {
|
69
|
+
type: 'ArrayExpression',
|
70
|
+
elements: json.map(jsonToAst)
|
71
|
+
};
|
72
|
+
}
|
73
|
+
const properties = Object.entries(json).map(([key, value]) => {
|
74
|
+
const keyNode = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)
|
75
|
+
? { type: 'Identifier', name: key }
|
76
|
+
: { type: 'Literal', value: key };
|
77
|
+
return {
|
78
|
+
type: 'Property',
|
79
|
+
key: keyNode,
|
80
|
+
value: jsonToAst(value),
|
81
|
+
kind: 'init',
|
82
|
+
computed: keyNode.type === 'Literal'
|
83
|
+
};
|
84
|
+
});
|
85
|
+
return { type: 'ObjectExpression', properties };
|
86
|
+
default:
|
87
|
+
return { type: 'Literal', value: null };
|
88
|
+
}
|
89
|
+
}
|
90
|
+
|
91
|
+
/**
|
92
|
+
* Performs a true post-order traversal of the AST (children before parent).
|
93
|
+
* This traversal strategy is essential for this transformation. By processing the innermost
|
94
|
+
* `html` templates first, we ensure that a nested template is already converted into an
|
95
|
+
* `ObjectExpression` before its parent template is processed. The parent can then treat
|
96
|
+
* the nested result as just another expression, leading to a clean, recursive solution.
|
97
|
+
* @param {object} node The current AST node to start traversal from.
|
98
|
+
* @param {function} visitor The visitor function to call on each node after its children have been visited.
|
99
|
+
* @private
|
100
|
+
*/
|
101
|
+
function postOrderWalk(node, visitor) {
|
102
|
+
if (!node) return;
|
103
|
+
|
104
|
+
Object.entries(node).forEach(([key, value]) => {
|
105
|
+
if (key === 'parent') return;
|
106
|
+
if (Array.isArray(value)) {
|
107
|
+
value.forEach(child => postOrderWalk(child, visitor));
|
108
|
+
} else if (typeof value === 'object' && value !== null) {
|
109
|
+
postOrderWalk(value, visitor);
|
110
|
+
}
|
111
|
+
});
|
112
|
+
|
113
|
+
visitor(node);
|
114
|
+
}
|
115
|
+
|
116
|
+
/**
|
117
|
+
* Recursively adds parent pointers to each node in the AST.
|
118
|
+
* While ASTs are typically one-way trees, having a back-reference to the parent makes
|
119
|
+
* node replacement trivial. Instead of complex logic to find and splice a node in its
|
120
|
+
* parent's `body` or `expressions` array, we can simply access `node.parent` to perform
|
121
|
+
* the replacement.
|
122
|
+
* @param {object} node The current AST node.
|
123
|
+
* @param {object|null} parent The parent of the current node.
|
124
|
+
* @private
|
125
|
+
*/
|
126
|
+
function addParentLinks(node, parent) {
|
127
|
+
if (!node || typeof node !== 'object') return;
|
128
|
+
node.parent = parent;
|
129
|
+
for (const key in node) {
|
130
|
+
if (key === 'parent') continue;
|
131
|
+
const child = node[key];
|
132
|
+
if (Array.isArray(child)) {
|
133
|
+
child.forEach(c => addParentLinks(c, node));
|
134
|
+
} else {
|
135
|
+
addParentLinks(child, node);
|
136
|
+
}
|
137
|
+
}
|
138
|
+
}
|
139
|
+
|
140
|
+
/**
|
141
|
+
* The main exported function for this module. It orchestrates the entire transformation.
|
142
|
+
* As an optimization, it first performs a quick regex check to see if a template
|
143
|
+
* likely exists, avoiding the expensive AST parsing for the vast majority of files.
|
144
|
+
* @param {string} fileContent The raw source code of the file to process.
|
145
|
+
* @param {string} filePath The path to the file, used for logging errors.
|
146
|
+
* @returns {{content: string, hasChanges: boolean}} An object containing the transformed
|
147
|
+
* code and a flag indicating if any changes were made.
|
148
|
+
*/
|
149
|
+
export function processFileContent(fileContent, filePath) {
|
150
|
+
// Optimization: a quick regex check is much faster than parsing every file.
|
151
|
+
if (!regexHtml.test(fileContent)) {
|
152
|
+
return { content: fileContent, hasChanges: false };
|
153
|
+
}
|
154
|
+
|
155
|
+
try {
|
156
|
+
const ast = acorn.parse(fileContent, {ecmaVersion: 'latest', sourceType: 'module'});
|
157
|
+
addParentLinks(ast, null);
|
158
|
+
|
159
|
+
let hasChanges = false;
|
160
|
+
|
161
|
+
postOrderWalk(ast, (node) => {
|
162
|
+
if (node.type === 'TaggedTemplateExpression' && node.tag.type === 'Identifier' && node.tag.name === 'html') {
|
163
|
+
hasChanges = true;
|
164
|
+
|
165
|
+
// As a quality-of-life feature, if a template is the return value of a method
|
166
|
+
// named `render`, we automatically rename the method to `createVdom`.
|
167
|
+
let current = node;
|
168
|
+
while (current.parent) {
|
169
|
+
const parent = current.parent;
|
170
|
+
if ((parent.type === 'MethodDefinition' || parent.type === 'Property') && parent.key.name === 'render') {
|
171
|
+
parent.key.name = 'createVdom';
|
172
|
+
break;
|
173
|
+
}
|
174
|
+
current = parent;
|
175
|
+
}
|
176
|
+
|
177
|
+
const templateLiteral = node.quasi;
|
178
|
+
const strings = templateLiteral.quasis.map(q => q.value.cooked);
|
179
|
+
const expressionCodeStrings = templateLiteral.expressions.map(exprNode => generate(exprNode));
|
180
|
+
|
181
|
+
const vdom = processHtmlTemplateLiteral(strings, expressionCodeStrings);
|
182
|
+
const vdomAst = jsonToAst(vdom);
|
183
|
+
|
184
|
+
const parent = node.parent;
|
185
|
+
for (const key in parent) {
|
186
|
+
if (parent[key] === node) {
|
187
|
+
parent[key] = vdomAst;
|
188
|
+
return;
|
189
|
+
}
|
190
|
+
if (Array.isArray(parent[key])) {
|
191
|
+
const index = parent[key].indexOf(node);
|
192
|
+
if (index > -1) {
|
193
|
+
parent[key][index] = vdomAst;
|
194
|
+
return;
|
195
|
+
}
|
196
|
+
}
|
197
|
+
}
|
198
|
+
}
|
199
|
+
});
|
200
|
+
|
201
|
+
return {
|
202
|
+
content: hasChanges ? generate(ast) : fileContent,
|
203
|
+
hasChanges
|
204
|
+
};
|
205
|
+
} catch (e) {
|
206
|
+
console.error(`Error processing HTML template in: ${filePath}`);
|
207
|
+
console.error(e);
|
208
|
+
return { content: fileContent, hasChanges: false };
|
209
|
+
}
|
210
|
+
}
|