@nutin/cli 2.0.0 → 2.1.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 (62) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +5 -8
  3. package/lib/src/create-app/success-message.mjs +2 -3
  4. package/lib/src/update-app/project-updater.mjs +6 -0
  5. package/package.json +4 -4
  6. package/templates/base/.gitignore.hbs +1 -0
  7. package/templates/base/AGENTS.md.hbs +3 -3
  8. package/templates/base/GETTING_STARTED.md.hbs +40 -65
  9. package/templates/base/nutin.config.js.hbs +1 -8
  10. package/templates/base/src/app/components/.gitkeep +0 -0
  11. package/templates/base/src/app/main.ts.hbs +0 -1
  12. package/templates/base/src/app/routes.ts.hbs +2 -1
  13. package/templates/base/src/app/services/.gitkeep +0 -0
  14. package/templates/base/src/app/views/home/home.view.ts.hbs +1 -1
  15. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +1 -1
  16. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +46 -0
  17. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +5 -2
  18. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +45 -0
  19. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +15 -1
  20. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +14 -3
  21. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +10 -3
  22. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +4 -1
  23. package/templates/base/src/core/base-classes/view/view.test.js.hbs +11 -14
  24. package/templates/base/src/core/base-classes/view/view.ts.hbs +8 -9
  25. package/templates/base/src/core/config.ts.hbs +3 -0
  26. package/templates/base/src/core/services/globals/globals.test.js.hbs +38 -0
  27. package/templates/base/src/core/services/globals/globals.ts.hbs +19 -10
  28. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +30 -2
  29. package/templates/base/src/core/services/http-client/http-client.ts.hbs +4 -1
  30. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +142 -0
  31. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +42 -4
  32. package/templates/base/src/core/services/router/router.test.js.hbs +93 -0
  33. package/templates/base/src/core/services/router/router.ts.hbs +26 -15
  34. package/templates/base/src/index.html.hbs +1 -0
  35. package/templates/base/src/styles/_styles.scss.hbs +1 -113
  36. package/templates/base/src/styles/main.scss.hbs +1 -1
  37. package/templates/base/tools/builder/builder.js.hbs +8 -2
  38. package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
  39. package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
  40. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +11 -2
  41. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +19 -3
  42. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +24 -1
  43. package/templates/base/tools/builder/core/seo/route-output.js.hbs +2 -2
  44. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +5 -1
  45. package/templates/base/tools/builder/core/styles/sass.js.hbs +5 -4
  46. package/templates/base/tools/dev/dev-serve.js.hbs +43 -4
  47. package/templates/base/tools/dev/watcher.js.hbs +42 -26
  48. package/templates/base/tools/generator/generator.js.hbs +32 -47
  49. package/templates/base/tools/generator/handle-file.js.hbs +2 -24
  50. package/templates/base/tools/generator/handle-file.test.js.hbs +1 -20
  51. package/templates/base/tools/generator/templates/locale/locale.template.js.hbs +5 -1
  52. package/templates/base/tools/generator/templates/locale/locale.template.test.js.hbs +11 -1
  53. package/templates/base/tools/generator/templates/view/view.template.js.hbs +1 -1
  54. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  55. package/templates/base/tools/utils/build-lock.js.hbs +69 -0
  56. package/templates/base/tools/utils/index.js.hbs +1 -0
  57. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +2 -2
  58. package/templates/features/docker/tools/docker/nginx.conf.template +15 -0
  59. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +12 -0
  60. package/templates/base/src/app/components/index.ts.hbs +0 -1
  61. package/templates/base/src/app/services/index.ts.hbs +0 -1
  62. package/templates/base/src/app/views/index.ts.hbs +0 -3
@@ -6,6 +6,7 @@ import { NavigationManager } from './helpers/navigation-manager.helper.js';
6
6
 
7
7
  // centralized export
8
8
  export { Routes, RouteGuard } from './helpers/route-guard-manager.helper.js';
9
+ export { NavigationManager } from './helpers/navigation-manager.helper.js';
9
10
 
10
11
  /**
11
12
  * IRouter is a type alias for the instance of Router, not a true interface
@@ -15,6 +16,7 @@ export type IRouter = InstanceType<typeof Router>;
15
16
  export interface RouteMatch {
16
17
  route: RouteConfig;
17
18
  params: Record<string, string>;
19
+ pattern: string;
18
20
  }
19
21
 
20
22
  class Router extends Service<Router> {
@@ -28,7 +30,7 @@ class Router extends Service<Router> {
28
30
  constructor(private routes: Routes) {
29
31
  super();
30
32
  this.initializeEventListeners();
31
- this.navigate(NavigationManager.getCurrentPath());
33
+ this.navigate(NavigationManager.getCurrentPath() + window.location.hash);
32
34
  this.registerCleanup(this.removeEventListeners);
33
35
  }
34
36
 
@@ -42,13 +44,14 @@ class Router extends Service<Router> {
42
44
 
43
45
  public async reload(): Promise<void> {
44
46
  const currentRoute = NavigationManager.getCurrentPath();
45
- await this.navigate(currentRoute, false);
47
+ await this.navigate(currentRoute + window.location.hash, false);
46
48
  }
47
49
 
48
50
  public async navigate(path: string | '', pushState: boolean = true): Promise<void> {
49
- const normalizedPath = NavigationManager.normalizePath(path);
51
+ const [rawPath = '', hash] = path.split('#');
52
+ const normalizedPath = NavigationManager.normalizePath(rawPath);
50
53
  const currentPath = NavigationManager.getCurrentPath();
51
-
54
+
52
55
  // Try to match the route with parameters
53
56
  const routeMatch = this.matchRoute(normalizedPath);
54
57
 
@@ -58,23 +61,30 @@ class Router extends Service<Router> {
58
61
  }
59
62
 
60
63
  const guardResult = await this.handleGuards(
61
- normalizedPath,
62
- routeMatch.route,
63
- routeMatch.params,
64
+ normalizedPath,
65
+ routeMatch.route,
66
+ routeMatch.params,
64
67
  pushState
65
68
  );
66
-
69
+
67
70
  if (!guardResult) return;
68
71
 
69
72
  this._currentView = await ViewRenderManager.transitionOutCurrentView(this._currentView);
70
73
  this._currentParams = routeMatch.params;
74
+
75
+ // Must run before renderNewView(): a view's onEnter() may rewrite the URL via
76
+ // NavigationManager.replaceState (e.g. canonicalizing a bare route to a default
77
+ // sub-page), which acts on whatever history entry is current — so that entry needs
78
+ // to already be this route's, not the previous view's, before onEnter() fires.
79
+ NavigationManager.updateHistory(normalizedPath, currentPath, pushState, hash);
80
+
71
81
  this._currentView = ViewRenderManager.renderNewView(
72
- guardResult.viewConstructor!,
82
+ guardResult.viewConstructor!,
73
83
  routeMatch.params
74
84
  );
75
-
76
- NavigationManager.updateHistory(normalizedPath, currentPath, pushState);
77
- window.scrollTo({ top: 0 });
85
+
86
+ NavigationManager.updateDocumentTitle(this._currentView, routeMatch.pattern);
87
+ NavigationManager.scrollToHash(hash);
78
88
  }
79
89
 
80
90
  public getCurrentParams(): Record<string, string> {
@@ -109,7 +119,7 @@ class Router extends Service<Router> {
109
119
  if (newLocale && newLocale !== I18nService.currentLanguage) {
110
120
  await I18nService.setCurrentLanguage(newLocale as Language);
111
121
  }
112
- this.navigate(NavigationManager.getCurrentPath(), false);
122
+ this.navigate(NavigationManager.getCurrentPath() + window.location.hash, false);
113
123
  }
114
124
 
115
125
  /**
@@ -122,7 +132,7 @@ class Router extends Service<Router> {
122
132
  for (const [pattern, routeConfig] of Object.entries(this.routes)) {
123
133
  const match = NavigationManager.matchPattern(pattern, path);
124
134
  if (match) {
125
- return { route: routeConfig, params: match };
135
+ return { route: routeConfig, params: match, pattern };
126
136
  }
127
137
  }
128
138
  return null;
@@ -145,7 +155,8 @@ class Router extends Service<Router> {
145
155
  this._currentView = await ViewRenderManager.transitionOutCurrentView(this._currentView);
146
156
  this._currentParams = {};
147
157
  this._currentView = ViewRenderManager.renderNewView(notFoundConstructor, {});
148
-
158
+
159
+ NavigationManager.updateDocumentTitle(this._currentView, '/404');
149
160
  NavigationManager.updateHistory(normalizedPath, currentPath, pushState);
150
161
  }
151
162
 
@@ -2,6 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5
6
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
6
7
  </head>
7
8
  <body>
@@ -1,113 +1 @@
1
- @use "variables" as *;
2
-
3
- /* ===========
4
- CSS Reset
5
- =========== */
6
-
7
- /* Box sizing */
8
-
9
- *,
10
- *::before,
11
- *::after {
12
- box-sizing: border-box;
13
- }
14
-
15
- /* Remove default spacing */
16
-
17
- * {
18
- margin: 0;
19
- padding: 0;
20
- }
21
-
22
- /* Document */
23
-
24
- html {
25
- font-size: 100%;
26
- -webkit-text-size-adjust: 100%;
27
- }
28
-
29
- body {
30
- min-height: 100vh;
31
- font: inherit;
32
- line-height: 1.5;
33
- }
34
-
35
- /* Media */
36
-
37
- img,
38
- picture,
39
- svg,
40
- canvas,
41
- video {
42
- display: block;
43
- max-width: 100%;
44
- }
45
-
46
- /* Forms */
47
-
48
- button,
49
- input,
50
- select,
51
- textarea {
52
- font: inherit;
53
- color: inherit;
54
- }
55
-
56
- button {
57
- background: none;
58
- border: none;
59
- cursor: pointer;
60
- }
61
-
62
- textarea {
63
- resize: vertical;
64
- }
65
-
66
- /* Tables */
67
-
68
- table {
69
- border-collapse: collapse;
70
- border-spacing: 0;
71
- }
72
-
73
- /* Lists */
74
-
75
- ul,
76
- ol {
77
- list-style: none;
78
- }
79
-
80
- /* Links */
81
-
82
- a {
83
- color: inherit;
84
- text-decoration: none;
85
- }
86
-
87
- /* Quotes */
88
-
89
- blockquote,
90
- q {
91
- quotes: none;
92
- }
93
-
94
- blockquote::before,
95
- blockquote::after,
96
- q::before,
97
- q::after {
98
- content: "";
99
- }
100
-
101
- /* Miscellaneous */
102
-
103
- hr {
104
- border: none;
105
- }
106
-
107
- fieldset {
108
- border: none;
109
- }
110
-
111
- :where([hidden]) {
112
- display: none !important;
113
- }
1
+ // Global styles
@@ -1,2 +1,2 @@
1
- // CSS reset & global styles
1
+ // Global styles
2
2
  @use "styles";
@@ -1,9 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import path from 'path';
4
- import { print, runScript } from '../utils/index.js';
4
+ import { print, runScript, acquireBuildLock } from '../utils/index.js';
5
5
  import { builderConfig } from './builder.config.js';
6
6
 
7
+ // Must happen before copy-static.js wipes/recreates dist-build — a second build
8
+ // starting mid-run (a manual build, another dev watcher instance, ...) would
9
+ // otherwise race it. See tools/utils/build-lock.js.
10
+ await acquireBuildLock();
11
+
7
12
  const scriptsDir = path.join(process.cwd(), 'tools', 'builder', 'core');
8
13
 
9
14
  print.boldHead(`\nStarting build...`);
@@ -22,8 +27,9 @@ if (builderConfig.tailwind) runScript(path.join(scriptsDir, 'styles', 'tailwind.
22
27
 
23
28
  if (builderConfig.i18n) runScript(path.join(scriptsDir, 'i18n', 'build-i18n.js'), 'Combining locales...');
24
29
 
30
+ runScript(path.join(scriptsDir, 'prod-bundle', 'esbuild.js'), 'Running esbuild...');
31
+
25
32
  if (builderConfig.isProd) {
26
- runScript(path.join(scriptsDir, 'prod-bundle', 'esbuild.js'), 'Running esbuild...');
27
33
  runScript(path.join(scriptsDir, 'prod-bundle', 'hash-files.js'), 'Hashing files...');
28
34
  runScript(path.join(scriptsDir, 'prod-bundle', 'compress-files.js'), 'Compressing files...');
29
35
  if (builderConfig.generateSEO) runScript(path.join(scriptsDir, 'seo', 'generate-seo-files.js'), 'Generating SEO Files...');
@@ -1,12 +1,9 @@
1
1
  import { writeFile } from 'fs/promises';
2
- import { builderConfig } from '../../builder.config.js';
3
2
 
4
3
  export async function addTags(htmlContent, filePath) {
5
- const scriptSrc = builderConfig.isProd ? "bundle.js" : "app/main.js";
6
-
7
4
  const tags = [
8
5
  { type: 'stylesheet', href: 'main.css', position: 'head' },
9
- { type: 'script', src: scriptSrc, position: 'body' }
6
+ { type: 'script', src: 'bundle.js', position: 'body' }
10
7
  ];
11
8
 
12
9
  let modifiedHtml = htmlContent;
@@ -22,7 +22,7 @@ describe('addTags', () => {
22
22
  const result = await addTags(html, filePath);
23
23
 
24
24
  expect(result).toBe(
25
- '<html><head><title>T</title><link rel="stylesheet" href="/main.css"></head><body><script type="module" src="/app/main.js"></script></body></html>'
25
+ '<html><head><title>T</title><link rel="stylesheet" href="/main.css"></head><body><script type="module" src="/bundle.js"></script></body></html>'
26
26
  );
27
27
  });
28
28
 
@@ -32,7 +32,7 @@ describe('addTags', () => {
32
32
  const result = await addTags(html, filePath);
33
33
 
34
34
  expect(result).toBe(
35
- '<html><link rel="stylesheet" href="/main.css"><title>T</title><body><script type="module" src="/app/main.js"></script></body></html>'
35
+ '<html><link rel="stylesheet" href="/main.css"><title>T</title><body><script type="module" src="/bundle.js"></script></body></html>'
36
36
  );
37
37
  });
38
38
 
@@ -42,7 +42,7 @@ describe('addTags', () => {
42
42
  const result = await addTags(html, filePath);
43
43
 
44
44
  expect(result).toBe(
45
- '<html></html><link rel="stylesheet" href="/main.css"><script type="module" src="/app/main.js"></script>'
45
+ '<html></html><link rel="stylesheet" href="/main.css"><script type="module" src="/bundle.js"></script>'
46
46
  );
47
47
  });
48
48
 
@@ -4,12 +4,21 @@ import { print, errorExit } from '../../../utils/index.js';
4
4
  import { PATHS } from '../app/paths.js';
5
5
  import { builderConfig } from '../../builder.config.js';
6
6
 
7
- const ENTRY_FILE = path.join(PATHS.tempApp, 'main.ts');
7
+ // Prod bundles straight from the copied .ts sources (tsc runs --noEmit there);
8
+ // dev bundles tsc's emitted .js, which is kept on disk for testin-nutin.
9
+ const ENTRY_FILE = path.join(PATHS.tempApp, builderConfig.isProd ? 'main.ts' : 'main.js');
8
10
  const OUT_FILE = path.join(PATHS.tempSource, 'bundle.js');
9
11
 
12
+ const DEV_OVERRIDES = {
13
+ minify: false,
14
+ sourcemap: true,
15
+ drop: [],
16
+ };
17
+
10
18
  async function build() {
11
19
  await esbuild.build({
12
20
  ...builderConfig.esbuild,
21
+ ...(builderConfig.isProd ? {} : DEV_OVERRIDES),
13
22
  platform: 'browser',
14
23
  format: 'esm',
15
24
  legalComments: 'none',
@@ -17,7 +26,7 @@ async function build() {
17
26
  '.json': 'json',
18
27
  },
19
28
  define: {
20
- 'process.env.NODE_ENV': '"production"',
29
+ 'process.env.NODE_ENV': builderConfig.isProd ? '"production"' : '"development"',
21
30
  },
22
31
  entryPoints: [ENTRY_FILE],
23
32
  outfile: OUT_FILE,
@@ -8,16 +8,32 @@ import { builderConfig } from '../../builder.config.js';
8
8
  import { resolveLocaleValue, valueForLangWithFallback, collectI18nSeoIssues } from './i18n-resolution.js';
9
9
  import { segmentsOf, validateMockParams, writeRouteHtml, warnForRoutesMissingSeoConfig } from './route-output.js';
10
10
 
11
+ // og:image/twitter:image require an absolute URL for social platforms to resolve them;
12
+ // every other absolute field (og:url, canonical) is already baseUrl-prefixed, so ogImage
13
+ // (a repo-relative path in config/seo.json) needs the same treatment.
14
+ function toAbsoluteUrl(urlOrPath, baseUrl) {
15
+ if (!urlOrPath) return urlOrPath;
16
+ return /^https?:\/\//.test(urlOrPath) ? urlOrPath : `${baseUrl}${urlOrPath}`;
17
+ }
18
+
11
19
  async function processRoute(template, baseUrl, defaultLanguage, languages, route, bundleUrl) {
12
20
  validateMockParams(route);
13
21
 
14
22
  const routeSuffix = route.path === '/' ? '' : route.path;
15
23
 
16
24
  if (builderConfig.i18n) {
25
+ // Same for every language variant of this route — lets crawlers know these URLs
26
+ // are alternates of one page rather than duplicate/competing content. x-default
27
+ // points at the default language.
28
+ const hreflangLinks = [
29
+ ...languages.map((l) => ({ hreflang: l, href: `${baseUrl}/${l}${routeSuffix}/` })),
30
+ { hreflang: 'x-default', href: `${baseUrl}/${defaultLanguage}${routeSuffix}/` },
31
+ ];
32
+
17
33
  for (const lang of languages) {
18
34
  const title = valueForLangWithFallback(route.title, lang, defaultLanguage);
19
35
  const description = valueForLangWithFallback(route.description, lang, defaultLanguage);
20
- const ogImage = valueForLangWithFallback(route.ogImage, lang, defaultLanguage);
36
+ const ogImage = toAbsoluteUrl(valueForLangWithFallback(route.ogImage, lang, defaultLanguage), baseUrl);
21
37
 
22
38
  const pageUrl = `${baseUrl}/${lang}${routeSuffix}`;
23
39
  const body = await renderRoute({
@@ -31,7 +47,7 @@ async function processRoute(template, baseUrl, defaultLanguage, languages, route
31
47
  });
32
48
 
33
49
  await writeRouteHtml({
34
- template, lang, title, description, pageUrl, ogImage, body,
50
+ template, lang, title, description, pageUrl, ogImage, body, hreflangLinks,
35
51
  outputSegments: [lang, ...segmentsOf(routeSuffix)],
36
52
  routePath: route.path,
37
53
  });
@@ -39,7 +55,7 @@ async function processRoute(template, baseUrl, defaultLanguage, languages, route
39
55
  } else {
40
56
  const title = resolveLocaleValue(route.title, defaultLanguage);
41
57
  const description = resolveLocaleValue(route.description, defaultLanguage);
42
- const ogImage = resolveLocaleValue(route.ogImage, defaultLanguage);
58
+ const ogImage = toAbsoluteUrl(resolveLocaleValue(route.ogImage, defaultLanguage), baseUrl);
43
59
 
44
60
  if (!title || !description) {
45
61
  errorExit(`Missing title or description for route "${route.path}" in seo.json`, 'generate-seo-html');
@@ -14,7 +14,7 @@ export function upsertInHead(html, pattern, replacement, fullTag) {
14
14
  return html.replace('</head>', `\n ${fullTag}\n </head>`);
15
15
  }
16
16
 
17
- export function applySubstitutions(template, lang, title, description, pageUrl, ogImage) {
17
+ export function applySubstitutions(template, lang, title, description, pageUrl, ogImage, hreflangLinks = []) {
18
18
  let html = template;
19
19
 
20
20
  // lang attribute on <html>
@@ -53,6 +53,15 @@ export function applySubstitutions(template, lang, title, description, pageUrl,
53
53
  `<meta property="og:url" content="${pageUrl}/" />`
54
54
  );
55
55
 
56
+ // hreflang alternates — one per active language plus x-default, so crawlers see
57
+ // these URLs as language variants of the same page rather than duplicate content
58
+ if (hreflangLinks.length > 0) {
59
+ const altTags = hreflangLinks
60
+ .map(({ hreflang, href }) => `<link rel="alternate" hreflang="${hreflang}" href="${href}" />`)
61
+ .join('\n ');
62
+ html = html.replace('</head>', `\n ${altTags}\n </head>`);
63
+ }
64
+
56
65
  // og:title
57
66
  html = upsertInHead(html,
58
67
  /(<meta\s+property=["']og:title["']\s+content=)["'][^"']*["']/,
@@ -81,6 +90,20 @@ export function applySubstitutions(template, lang, title, description, pageUrl,
81
90
  `<meta name="twitter:description" content="${escape(description)}" />`
82
91
  );
83
92
 
93
+ // og:type
94
+ html = upsertInHead(html,
95
+ /(<meta\s+property=["']og:type["']\s+content=)["'][^"']*["']/,
96
+ `$1"website"`,
97
+ `<meta property="og:type" content="website" />`
98
+ );
99
+
100
+ // twitter:card — required for Twitter/X to render an image card at all
101
+ html = upsertInHead(html,
102
+ /(<meta\s+name=["']twitter:card["']\s+content=)["'][^"']*["']/,
103
+ `$1"summary_large_image"`,
104
+ `<meta name="twitter:card" content="summary_large_image" />`
105
+ );
106
+
84
107
  // og:image + twitter:image — only when explicitly provided in route config
85
108
  if (ogImage) {
86
109
  html = upsertInHead(html,
@@ -29,8 +29,8 @@ export function validateMockParams(route) {
29
29
  }
30
30
  }
31
31
 
32
- export async function writeRouteHtml({ template, lang, title, description, pageUrl, ogImage, body, outputSegments, routePath }) {
33
- let html = applySubstitutions(template, lang, title, description, pageUrl, ogImage);
32
+ export async function writeRouteHtml({ template, lang, title, description, pageUrl, ogImage, body, outputSegments, routePath, hreflangLinks }) {
33
+ let html = applySubstitutions(template, lang, title, description, pageUrl, ogImage, hreflangLinks);
34
34
 
35
35
  if (html === template) {
36
36
  errorExit(`Failed to apply any changes for ${routePath} (lang "${lang}") — index.html may be missing a </head> tag`, 'generate-seo-html');
@@ -28,7 +28,11 @@ export async function getAppRoutePaths(bundleUrl, lang, pageUrl) {
28
28
  export async function renderRoute({ bundleUrl, appRoutesKey, mockParams, mockFetch, lang, pageUrl, i18nEnabled }) {
29
29
  // Passing a real URL instance as `location` gives `.pathname`/`.href`
30
30
  // which is what I18n's getLocaleFromUrl() actually reads.
31
- const { window } = parseHTML('<!doctype html><html><body></body></html>', { location: new URL(pageUrl) });
31
+ // <main id="app"> mirrors index.html's real mount target — View defaults to mounting
32
+ // there (view.ts), and without it the View tree is never attached to `document`, so
33
+ // DomHelper.cleanupOptionalContent()'s document-wide `[data-optional]` query silently
34
+ // misses it, leaking raw "undefined" text for any unset optional field.
35
+ const { window } = parseHTML('<!doctype html><html><body><main id="app"></main></body></html>', { location: new URL(pageUrl) });
32
36
  const { trackedFetches } = installGlobals(window, {
33
37
  lang,
34
38
  mockFetch: mockFetch ?? {},
@@ -13,24 +13,25 @@ const scssOrigins = builderConfig.sass.paths;
13
13
  const scssPath = (origin) => path.join(stylesPath, origin);
14
14
  const stylesInput = scssOrigins.map(origin => scssPath(origin));
15
15
  const pathsToLoad = [ ...stylesInput ];
16
+ // resolves `@use "pkg:<package>/<subpath>"` through node_modules (package exports)
17
+ const importers = [ new sass.NodePackageImporter() ];
16
18
 
17
19
  try {
18
20
  const mainResult = await sass.compileAsync(path.join(stylesPath, 'main.scss'), {
19
21
  loadPaths: [ ...pathsToLoad ],
22
+ importers,
20
23
  style: 'compressed'
21
24
  });
22
25
  fs.writeFileSync(stylesOutput, mainResult.css);
23
26
 
24
27
  // features styles
25
28
  const appInput = path.join(PATHS.source, 'app');
26
- const appStyles = [
27
- ...getFilesRecursive(path.join(appInput, 'components'), 'scss'),
28
- ...getFilesRecursive(path.join(appInput, 'views'), 'scss')
29
- ];
29
+ const appStyles = getFilesRecursive(appInput, 'scss');
30
30
 
31
31
  for (const style of appStyles) {
32
32
  const result = await sass.compileAsync(style, {
33
33
  loadPaths: [ ...pathsToLoad ],
34
+ importers,
34
35
  style: 'compressed'
35
36
  })
36
37
  fs.appendFileSync(stylesOutput, result.css);
@@ -1,8 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn } from 'child_process';
3
+ import { spawn, spawnSync } from 'child_process';
4
4
  import { print, runCommand } from '../utils/index.js';
5
5
 
6
+ const isWindows = process.platform === 'win32';
7
+
6
8
  async function startDev() {
7
9
  console.clear();
8
10
  print.blue('šŸš€ Starting Dev Environment...\n');
@@ -14,22 +16,59 @@ async function startDev() {
14
16
  process.exit(1);
15
17
  }
16
18
 
17
- const serve = spawn(['{{packageManager}}', 'run', 'serve:only', '--silent'].join(' '), { stdio: 'inherit', shell: true });
18
- const watcher = spawn(['node', 'tools/dev/watcher.js', '--silent'].join(' '), { stdio: 'inherit', shell: true });
19
+ // detached: true makes each child the leader of its own process group, so its
20
+ // descendants (watcher.js's own `exec('{{packageManager}} run build')` included) can be
21
+ // killed as a group below. Without this, a shutdown that doesn't cleanly propagate
22
+ // SIGINT down the shell layers (backgrounded, launched by a task runner, a closed
23
+ // terminal tab instead of Ctrl-C, ...) leaves them running as orphans — multiple
24
+ // stray watcher.js processes then race each other's builds.
25
+ // Not on Windows: there, detached children leave the console, so Ctrl-C would no
26
+ // longer reach them, and negative-pid group kills aren't supported — taskkill /T
27
+ // walks the process tree instead.
28
+ const serve = spawn(['{{packageManager}}', 'run', 'serve:only', '--silent'].join(' '), { stdio: 'inherit', shell: true, detached: !isWindows });
29
+ const watcher = spawn(['node', 'tools/dev/watcher.js', '--silent'].join(' '), { stdio: 'inherit', shell: true, detached: !isWindows });
30
+
31
+ const children = [serve, watcher];
32
+ let shuttingDown = false;
33
+
34
+ function killGroup(child, signal) {
35
+ if (!child.pid) return;
36
+ if (isWindows) {
37
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
38
+ return;
39
+ }
40
+ try {
41
+ process.kill(-child.pid, signal);
42
+ } catch {
43
+ // group already gone
44
+ }
45
+ }
46
+
47
+ function shutdown(signal, exitCode = 0) {
48
+ if (shuttingDown) return;
49
+ shuttingDown = true;
50
+ children.forEach((child) => killGroup(child, signal));
51
+ process.exit(exitCode);
52
+ }
53
+
54
+ process.on('SIGINT', () => shutdown('SIGINT'));
55
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
19
56
 
20
57
  serve.on('error', (err) => {
21
58
  print.boldError(`live-server failed to start: ${err.message}`);
22
59
  process.exit(1);
23
60
  });
24
61
  serve.on('close', (code) => {
62
+ if (shuttingDown) return;
25
63
  print.error(`live-server exited with code ${code}`);
26
- process.exit(code ?? 1);
64
+ shutdown('SIGTERM', code ?? 1);
27
65
  });
28
66
  watcher.on('error', (err) => {
29
67
  print.boldError(`watcher failed to start: ${err.message}`);
30
68
  process.exit(1);
31
69
  });
32
70
  watcher.on('close', (code) => {
71
+ if (shuttingDown) return;
33
72
  print.error(`watcher exited with code ${code}`);
34
73
  });
35
74
  }
@@ -9,42 +9,58 @@ const watcher = chokidar.watch(['src'], {
9
9
  });
10
10
 
11
11
  let isBuilding = false;
12
+ let pendingRebuild = false;
12
13
  let buildTimeout = null;
14
+ let lastChangedPath = null;
15
+
16
+ function runBuild() {
17
+ isBuilding = true;
18
+ pendingRebuild = false;
19
+
20
+ console.clear();
21
+ print.boldInfo(`\nšŸ”„ File changed: ${path.relative(process.cwd(), lastChangedPath)}\n`);
22
+ print.info('\nRebuilding...');
23
+
24
+ const command = '{{packageManager}} run build --silent';
25
+
26
+ exec(command, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
27
+ if (stdout) process.stdout.write(stdout);
28
+ if (stderr) process.stderr.write(stderr);
29
+ if (err) {
30
+ if (err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
31
+ print.boldError(`\nBuild output exceeded the buffer size — raise maxBuffer in tools/dev/watcher.js.`);
32
+ } else {
33
+ print.boldError(`\nBuild failed: ${err.message}`);
34
+ }
35
+ } else {
36
+ print.boldBlue('Watching for changes...');
37
+ }
38
+ isBuilding = false;
39
+
40
+ // A change arrived while this build was running — it was never picked up by
41
+ // any debounce timer (isBuilding used to make those just silently return,
42
+ // dropping the change for good), so build again now instead of missing it.
43
+ if (pendingRebuild) runBuild();
44
+ });
45
+ }
13
46
 
14
47
  watcher.on('error', (err) => {
15
48
  print.boldError(`\nWatcher error: ${err.message}`);
16
49
  });
17
50
 
18
51
  watcher.on('change', (filePath) => {
52
+ lastChangedPath = filePath;
53
+
54
+ if (isBuilding) {
55
+ pendingRebuild = true;
56
+ return;
57
+ }
58
+
19
59
  if (buildTimeout) {
20
60
  clearTimeout(buildTimeout);
21
61
  }
22
-
23
- buildTimeout = setTimeout(() => {
24
- if (isBuilding) return;
25
- isBuilding = true;
26
-
27
- console.clear();
28
- print.boldInfo(`\nšŸ”„ File changed: ${path.relative(process.cwd(), filePath)}\n`);
29
- print.info('\nRebuilding...');
30
-
31
- const command = '{{packageManager}} run build --silent';
32
-
33
- exec(command, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
34
- if (stdout) process.stdout.write(stdout);
35
- if (stderr) process.stderr.write(stderr);
36
- if (err) {
37
- if (err.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') {
38
- print.boldError(`\nBuild output exceeded the buffer size — raise maxBuffer in tools/dev/watcher.js.`);
39
- } else {
40
- print.boldError(`\nBuild failed: ${err.message}`);
41
- }
42
- } else {
43
- print.boldBlue('Watching for changes...');
44
- }
45
- isBuilding = false;
46
- });
47
- }, 100);
62
+
63
+ buildTimeout = setTimeout(runBuild, 100);
48
64
  });
49
65
 
50
66
  watcher.on('ready', () => {