neo.mjs 10.2.1 → 10.3.1

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.
Files changed (80) hide show
  1. package/.github/CONCEPT.md +2 -4
  2. package/.github/GETTING_STARTED.md +72 -51
  3. package/.github/RELEASE_NOTES/v10.3.0.md +71 -0
  4. package/.github/RELEASE_NOTES/v10.3.1.md +14 -0
  5. package/.github/epic-string-based-templates.md +690 -0
  6. package/ServiceWorker.mjs +2 -2
  7. package/apps/covid/view/MainContainer.mjs +1 -1
  8. package/apps/covid/view/country/Table.mjs +1 -1
  9. package/apps/portal/index.html +1 -1
  10. package/apps/portal/view/home/FooterContainer.mjs +1 -1
  11. package/apps/portal/view/learn/ContentComponent.mjs +1 -1
  12. package/apps/realworld/api/Base.mjs +2 -2
  13. package/apps/sharedcovid/view/MainContainer.mjs +1 -1
  14. package/apps/sharedcovid/view/MainContainerController.mjs +1 -1
  15. package/buildScripts/buildAll.mjs +4 -0
  16. package/buildScripts/buildESModules.mjs +23 -75
  17. package/buildScripts/bundleParse5.mjs +27 -0
  18. package/buildScripts/util/astTemplateProcessor.mjs +210 -0
  19. package/buildScripts/util/templateBuildProcessor.mjs +331 -0
  20. package/buildScripts/webpack/development/webpack.config.appworker.mjs +11 -0
  21. package/buildScripts/webpack/loader/template-loader.mjs +21 -0
  22. package/buildScripts/webpack/production/webpack.config.appworker.mjs +11 -0
  23. package/examples/README.md +1 -1
  24. package/examples/component/wrapper/googleMaps/MarkerDialog.mjs +2 -2
  25. package/examples/form/field/email/MainContainer.mjs +0 -1
  26. package/examples/form/field/number/MainContainer.mjs +0 -1
  27. package/examples/form/field/picker/MainContainer.mjs +0 -1
  28. package/examples/form/field/time/MainContainer.mjs +0 -1
  29. package/examples/form/field/trigger/copyToClipboard/MainContainer.mjs +0 -1
  30. package/examples/form/field/url/MainContainer.mjs +0 -1
  31. package/examples/functional/nestedTemplateComponent/Component.mjs +100 -0
  32. package/examples/functional/nestedTemplateComponent/MainContainer.mjs +48 -0
  33. package/examples/functional/nestedTemplateComponent/app.mjs +6 -0
  34. package/examples/functional/nestedTemplateComponent/index.html +11 -0
  35. package/examples/functional/nestedTemplateComponent/neo-config.json +6 -0
  36. package/examples/functional/templateComponent/Component.mjs +61 -0
  37. package/examples/functional/templateComponent/MainContainer.mjs +48 -0
  38. package/examples/functional/templateComponent/app.mjs +6 -0
  39. package/examples/functional/templateComponent/index.html +11 -0
  40. package/examples/functional/templateComponent/neo-config.json +6 -0
  41. package/learn/gettingstarted/Setup.md +29 -12
  42. package/learn/guides/fundamentals/ApplicationBootstrap.md +2 -2
  43. package/learn/guides/fundamentals/InstanceLifecycle.md +5 -5
  44. package/learn/guides/uibuildingblocks/HtmlTemplates.md +191 -0
  45. package/learn/guides/uibuildingblocks/HtmlTemplatesUnderTheHood.md +156 -0
  46. package/learn/guides/uibuildingblocks/WorkingWithVDom.md +1 -1
  47. package/learn/tree.json +2 -0
  48. package/package.json +61 -56
  49. package/src/DefaultConfig.mjs +3 -3
  50. package/src/calendar/view/calendars/List.mjs +1 -1
  51. package/src/calendar/view/month/Component.mjs +1 -1
  52. package/src/calendar/view/week/Component.mjs +1 -1
  53. package/src/component/Abstract.mjs +1 -1
  54. package/src/component/Base.mjs +33 -27
  55. package/src/container/Base.mjs +5 -5
  56. package/src/controller/Application.mjs +5 -5
  57. package/src/dialog/Base.mjs +6 -6
  58. package/src/draggable/DragProxyComponent.mjs +4 -4
  59. package/src/form/field/ComboBox.mjs +1 -1
  60. package/src/functional/_export.mjs +2 -1
  61. package/src/functional/component/Base.mjs +142 -93
  62. package/src/functional/util/HtmlTemplateProcessor.mjs +243 -0
  63. package/src/functional/util/html.mjs +24 -67
  64. package/src/list/Base.mjs +2 -2
  65. package/src/manager/Toast.mjs +1 -1
  66. package/src/menu/List.mjs +1 -1
  67. package/src/mixin/VdomLifecycle.mjs +87 -90
  68. package/src/tab/Container.mjs +2 -2
  69. package/src/tooltip/Base.mjs +1 -1
  70. package/src/tree/Accordion.mjs +2 -2
  71. package/src/worker/App.mjs +7 -7
  72. package/test/components/files/component/Base.mjs +1 -1
  73. package/test/siesta/siesta.js +2 -0
  74. package/test/siesta/tests/classic/Button.mjs +5 -5
  75. package/test/siesta/tests/functional/Button.mjs +6 -6
  76. package/test/siesta/tests/functional/HtmlTemplateComponent.mjs +193 -33
  77. package/test/siesta/tests/functional/Parse5Processor.mjs +82 -0
  78. package/test/siesta/tests/vdom/VdomRealWorldUpdates.mjs +5 -5
  79. package/.github/epic-functional-components.md +0 -498
  80. package/.github/ticket-asymmetric-vdom-updates.md +0 -122
package/ServiceWorker.mjs CHANGED
@@ -20,9 +20,9 @@ class ServiceWorker extends ServiceBase {
20
20
  */
21
21
  singleton: true,
22
22
  /**
23
- * @member {String} version='10.2.1'
23
+ * @member {String} version='10.3.1'
24
24
  */
25
- version: '10.2.1'
25
+ version: '10.3.1'
26
26
  }
27
27
 
28
28
  /**
@@ -30,7 +30,7 @@ class MainContainer extends Viewport {
30
30
  */
31
31
  items: [HeaderContainer, {
32
32
  module : TabContainer,
33
- activeIndex: null, // render no items initially
33
+ activeIndex: null, // mount no items initially
34
34
  flex : 1,
35
35
  reference : 'tab-container',
36
36
  sortable : true,
@@ -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
- '<img style="height:20px; margin-right:10px; width:20px;" src="' + Util.getCountryFlagUrl(data.value) + '">' + data.value,
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
  }
@@ -16,7 +16,7 @@
16
16
  "@type": "Organization",
17
17
  "name": "Neo.mjs"
18
18
  },
19
- "datePublished": "2025-07-30",
19
+ "datePublished": "2025-08-02",
20
20
  "publisher": {
21
21
  "@type": "Organization",
22
22
  "name": "Neo.mjs"
@@ -108,7 +108,7 @@ class FooterContainer extends Container {
108
108
  }, {
109
109
  module: Component,
110
110
  cls : ['neo-version'],
111
- text : 'v10.2.1'
111
+ text : 'v10.3.1'
112
112
  }]
113
113
  }],
114
114
  /**
@@ -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, autoMount: true, autoRender: true, parentComponent: me, windowId};
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'].rendered) {
65
+ if (Neo.apps['RealWorld'].vnodeInitialized) {
66
66
  me.onAppRendered()
67
67
  } else {
68
- Neo.apps['RealWorld'].on('render',me.onAppRendered, me)
68
+ Neo.apps['RealWorld'].on('vnodeInitialized',me.onAppRendered, me)
69
69
  }
70
70
  }
71
71
  }
@@ -31,7 +31,7 @@ class MainContainer extends Viewport {
31
31
  items: [HeaderContainer, {
32
32
  module : TabContainer,
33
33
  activateInsertedTabs: true,
34
- activeIndex : null, // render no items initially
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('render', () => {
302
+ Neo.apps[name].on('vnodeInitialized', () => {
303
303
  me.timeout(100).then(() => {
304
304
  me.getMainView(name).add(view)
305
305
  })
@@ -129,6 +129,10 @@ if (programOpts.info) {
129
129
  childProcess.status && process.exit(childProcess.status);
130
130
  }
131
131
 
132
+ console.log(chalk.blue('Bundling parse5...'));
133
+ childProcess = spawnSync('node', [`${neoPath}/buildScripts/bundleParse5.mjs`], cpOpts);
134
+ childProcess.status && process.exit(childProcess.status);
135
+
132
136
  if (themes === 'yes') {
133
137
  childProcess = spawnSync('node', [`${neoPath}/buildScripts/buildThemes.mjs`].concat(cpArgs), cpOpts);
134
138
  childProcess.status && process.exit(childProcess.status);
@@ -1,13 +1,12 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
- import {minify as minifyJs} from 'terser';
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
- // 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.
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; // Prepend 2 levels up
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
- 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
- }
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 : '../../' + jsonContent.basePath,
118
- environment : 'dist/esm',
119
- mainPath : './Main.mjs',
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
- const result = await minifyJs(adjustedContent, {
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
- dead_code: true
146
- },
147
- mangle: {
148
- toplevel: true
149
- }
101
+ compress: {dead_code: true},
102
+ mangle: {toplevel: true}
150
103
  });
151
104
 
152
- fs.writeFileSync(outputPath, result.code);
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) // Exit with error code
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
+ }