@nutin/cli 2.1.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 (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -4
  4. package/templates/base/.gitignore.hbs +1 -0
  5. package/templates/base/AGENTS.md.hbs +3 -2
  6. package/templates/base/GETTING_STARTED.md.hbs +11 -8
  7. package/templates/base/nutin.config.js.hbs +1 -1
  8. package/templates/base/src/app/components/.gitkeep +0 -0
  9. package/templates/base/src/app/routes.ts.hbs +2 -1
  10. package/templates/base/src/app/services/.gitkeep +0 -0
  11. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +24 -0
  12. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +39 -0
  13. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +14 -0
  14. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +14 -3
  15. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +34 -0
  16. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +25 -3
  17. package/templates/base/src/core/services/router/router.test.js.hbs +15 -0
  18. package/templates/base/src/core/services/router/router.ts.hbs +19 -11
  19. package/templates/base/src/index.html.hbs +1 -0
  20. package/templates/base/src/styles/_styles.scss.hbs +1 -113
  21. package/templates/base/src/styles/main.scss.hbs +1 -1
  22. package/templates/base/tools/builder/builder.js.hbs +8 -2
  23. package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
  24. package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
  25. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +11 -2
  26. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +19 -3
  27. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +24 -1
  28. package/templates/base/tools/builder/core/seo/route-output.js.hbs +2 -2
  29. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +5 -1
  30. package/templates/base/tools/builder/core/styles/sass.js.hbs +5 -4
  31. package/templates/base/tools/dev/dev-serve.js.hbs +43 -4
  32. package/templates/base/tools/dev/watcher.js.hbs +42 -26
  33. package/templates/base/tools/generator/generator.js.hbs +22 -17
  34. package/templates/base/tools/generator/handle-file.js.hbs +0 -22
  35. package/templates/base/tools/generator/handle-file.test.js.hbs +1 -20
  36. package/templates/base/tools/utils/build-lock.js.hbs +69 -0
  37. package/templates/base/tools/utils/index.js.hbs +1 -0
  38. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +2 -2
  39. package/templates/features/docker/tools/docker/nginx.conf.template +15 -0
  40. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +12 -0
  41. package/templates/base/src/app/components/index.ts.hbs +0 -1
  42. package/templates/base/src/app/services/index.ts.hbs +0 -1
  43. package/templates/base/src/app/views/index.ts.hbs +0 -3
@@ -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', () => {
@@ -2,23 +2,11 @@
2
2
 
3
3
  import path from "path";
4
4
  import { allFormats, getLastWord, print, promptBoolean, errorExit } from "../utils/index.js";
5
- import { generateFile, appendToIndex, generateLocalesJson } from "./handle-file.js";
5
+ import fs from "fs";
6
+ import { generateFile, generateLocalesJson } from "./handle-file.js";
6
7
  import { serviceTemplate, componentTemplate, viewTemplate, htmlTemplate, scssTemplate, testTemplate } from "./templates/index.js";
7
8
  import nutinConfig from "../../nutin.config.js";
8
9
 
9
- // Constants and Setup
10
- const [, , rawType, rawFullPath] = process.argv;
11
-
12
- if (!rawType || !rawFullPath) {
13
- showUsageAndExit("Missing arguments.");
14
- process.exit(1);
15
- }
16
-
17
- const type = allFormats(rawType);
18
- const fullPath = allFormats(rawFullPath);
19
- const name = allFormats(getLastWord(fullPath.kebab));
20
- const targetPath = path.join('src', 'app', `${type.kebab}s/${fullPath.kebab}`);
21
-
22
10
  // Creator Mapping
23
11
  const creators = {
24
12
  service: async (name, targetPath) => {
@@ -26,7 +14,6 @@ const creators = {
26
14
  print.section(`Generating service: ${name.capitalized}`);
27
15
  try {
28
16
  generateFile({ name, targetPath, templateFn: serviceTemplate, suffix: suffix });
29
- appendToIndex({ name, targetPath, suffix: suffix });
30
17
  await generateTest({ name, targetPath, suffix: suffix });
31
18
  } catch (err) {
32
19
  handleError("Failed to generate service", err);
@@ -41,7 +28,6 @@ const creators = {
41
28
  generateFile({ name, targetPath, templateFn: htmlTemplate, suffix: suffix, extension: 'html' });
42
29
  generateFile({ name, targetPath, templateFn: scssTemplate, suffix: suffix, extension: 'scss' });
43
30
  await generateLocales({ targetPath, name, isView: false });
44
- appendToIndex({ name, targetPath, suffix: suffix });
45
31
  await generateTest({ name, targetPath, suffix });
46
32
  } catch (err) {
47
33
  handleError("Failed to generate component", err);
@@ -56,7 +42,6 @@ const creators = {
56
42
  generateFile({ name, targetPath, templateFn: htmlTemplate, suffix: suffix, extension: 'html' });
57
43
  generateFile({ name, targetPath, templateFn: scssTemplate, suffix: suffix, extension: 'scss' });
58
44
  await generateLocales({ targetPath, name, isView: true });
59
- appendToIndex({ name, targetPath, suffix: suffix });
60
45
  await generateTest({ name, targetPath, suffix });
61
46
  } catch (err) {
62
47
  handleError("Failed to generate view", err);
@@ -64,10 +49,30 @@ const creators = {
64
49
  },
65
50
  };
66
51
 
52
+ // Constants and Setup
53
+ const [, , rawType, rawFullPath] = process.argv;
54
+
55
+ if (!rawType || !rawFullPath) {
56
+ showUsageAndExit("Missing arguments.");
57
+ process.exit(1);
58
+ }
59
+
60
+ const type = allFormats(rawType);
61
+ const fullPath = allFormats(rawFullPath);
62
+ const name = allFormats(getLastWord(fullPath.kebab));
63
+ const targetPath = path.join('src', 'app', fullPath.kebab);
64
+
67
65
  // Main Execution
68
66
  const create = creators[type.kebab];
69
67
 
70
68
  if (create) {
69
+ // Folder names carry no type suffix, so a view and a component with the same path would
70
+ // share one folder (and clobber each other's locales/) — refuse before writing anything.
71
+ if (fs.existsSync(targetPath) && fs.readdirSync(targetPath).length > 0) {
72
+ print.boldError(`\nA folder with this name already exists: ${targetPath}`);
73
+ print.warn("Pick another path, e.g. user/user-view and user/user-card.");
74
+ process.exit(1);
75
+ }
71
76
  await create(name, targetPath);
72
77
  print.boldSuccess(`\n${type.capitalized} ${name.capitalized} generated in ${targetPath}.\n`)
73
78
  } else {
@@ -1,5 +1,4 @@
1
1
  import fs from 'fs';
2
- import path from 'path';
3
2
  import { print, LANGUAGES } from '../utils/index.js';
4
3
  import { localeTemplate } from './templates/index.js';
5
4
 
@@ -22,27 +21,6 @@ export function generateFile({
22
21
  fs.writeFileSync(filePath, template);
23
22
  }
24
23
 
25
- export function appendToIndex({ name, targetPath, suffix }) {
26
- const absTargetPath = path.resolve(process.cwd(), targetPath);
27
-
28
- const parts = targetPath.split(path.sep);
29
- const basePath = parts.slice(0, 3).join(path.sep);
30
-
31
- const indexFilePath = path.join(basePath, 'index.ts');
32
-
33
- const absFilePath = path.join(absTargetPath, `${name.kebab}.${suffix}.js`);
34
- const relPath = path.relative(basePath, absFilePath).replace(/\\/g, '/');
35
-
36
- const lineToAppend = `export * from './${relPath}';\n`;
37
-
38
- try {
39
- fs.appendFileSync(indexFilePath, lineToAppend, 'utf8');
40
- print.gray(`${suffix}s/index.ts updated.`);
41
- } catch (err) {
42
- throw new Error(`Failed to update ${indexFilePath}: ${err.message}`, { cause: err });
43
- }
44
- }
45
-
46
24
  export function generateLocalesJson({ targetPath, name, isView }) {
47
25
  const localesDir = `${targetPath}/locales`;
48
26
  fs.mkdirSync(localesDir, { recursive: true });
@@ -1,22 +1,19 @@
1
1
  import fs from 'fs';
2
2
  import os from 'os';
3
3
  import path from 'path';
4
- import { generateFile, appendToIndex } from './handle-file.js';
4
+ import { generateFile } from './handle-file.js';
5
5
 
6
6
  describe('handle-file', () => {
7
7
  let tmpDir;
8
- let originalCwd;
9
8
  let exitSpy;
10
9
 
11
10
  beforeEach(() => {
12
11
  tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'nutin-handle-file-'));
13
- originalCwd = process.cwd();
14
12
  exitSpy = spyOn(process, 'exit').andCallFake(() => {});
15
13
  });
16
14
 
17
15
  afterEach(() => {
18
16
  exitSpy.restore();
19
- process.chdir(originalCwd);
20
17
  fs.rmSync(tmpDir, { recursive: true, force: true });
21
18
  });
22
19
 
@@ -53,20 +50,4 @@ describe('handle-file', () => {
53
50
  expect(exitSpy.callCount).toBe(1);
54
51
  expect(exitSpy.lastCall).toEqual([1]);
55
52
  });
56
-
57
- it('appendToIndex appends an export line to the barrel three segments up from targetPath', () => {
58
- process.chdir(tmpDir);
59
- const basePath = path.join('src', 'app', 'components');
60
- fs.mkdirSync(basePath, { recursive: true });
61
- fs.writeFileSync(path.join(basePath, 'index.ts'), "export * from './existing.js';\n");
62
-
63
- const targetPath = path.join('src', 'app', 'components', 'widget');
64
- const name = { kebab: 'widget', pascal: 'Widget', camel: 'widget', capitalized: 'Widget' };
65
-
66
- // appendToIndex prints a "index.ts updated" log via console.info
67
- silenceConsole('info', () => appendToIndex({ name, targetPath, suffix: 'component' }));
68
-
69
- const indexContent = fs.readFileSync(path.join(basePath, 'index.ts'), 'utf8');
70
- expect(indexContent).toBe("export * from './existing.js';\nexport * from './widget/widget.component.js';\n");
71
- });
72
53
  });
@@ -0,0 +1,69 @@
1
+ import * as fs from 'fs';
2
+ import path from 'path';
3
+ import { print } from './print.js';
4
+
5
+ // Guards the whole build pipeline against concurrent invocations — e.g. a manual
6
+ // `{{packageManager}} run build` running alongside the dev watcher's auto-rebuild, or (as actually
7
+ // happened) multiple orphaned watcher.js processes each kicking off their own build.
8
+ // copy-static.js starts every run by wiping and recreating dist-build; two builds
9
+ // doing that at once corrupt each other's output (missing/non-empty dist-build,
10
+ // merge-templates.js "has both an inline and external template" conflicts, etc).
11
+ const LOCK_DIR = path.resolve('.build-lock');
12
+ const STALE_MS = 5 * 60 * 1000; // a full build takes seconds — anything older is a dead leftover
13
+ const POLL_MS = 200;
14
+ const MAX_WAIT_MS = 2 * 60 * 1000;
15
+
16
+ function tryAcquire() {
17
+ try {
18
+ fs.mkdirSync(LOCK_DIR);
19
+ fs.writeFileSync(path.join(LOCK_DIR, 'pid'), String(process.pid));
20
+ return true;
21
+ } catch (err) {
22
+ if (err.code === 'EEXIST') return false;
23
+ throw err;
24
+ }
25
+ }
26
+
27
+ function isStale() {
28
+ try {
29
+ const { mtimeMs } = fs.statSync(LOCK_DIR);
30
+ return Date.now() - mtimeMs > STALE_MS;
31
+ } catch {
32
+ return true; // lock vanished mid-check (released concurrently) — treat as free
33
+ }
34
+ }
35
+
36
+ function release() {
37
+ try {
38
+ fs.rmSync(LOCK_DIR, { recursive: true, force: true });
39
+ } catch {
40
+ // already released
41
+ }
42
+ }
43
+
44
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
45
+
46
+ export async function acquireBuildLock() {
47
+ const start = Date.now();
48
+ let warned = false;
49
+
50
+ while (!tryAcquire()) {
51
+ if (isStale()) {
52
+ release();
53
+ continue;
54
+ }
55
+
56
+ if (!warned) {
57
+ print.boldInfo('\nAnother build is already in progress — waiting for it to finish...');
58
+ warned = true;
59
+ }
60
+
61
+ if (Date.now() - start > MAX_WAIT_MS) {
62
+ throw new Error(`Timed out waiting for the build lock (${LOCK_DIR}) held by another process.`);
63
+ }
64
+
65
+ await sleep(POLL_MS);
66
+ }
67
+
68
+ process.on('exit', release);
69
+ }
@@ -6,3 +6,4 @@ export * from './run-script.js';
6
6
  export * from './prompt-boolean.js';
7
7
  export * from './languages.js';
8
8
  export * from './error-exit.js';
9
+ export * from './build-lock.js';
@@ -1,5 +1,5 @@
1
- ARG NODE_VERSION=22.23.2
2
- ARG ALPINE_VERSION=3.20
1
+ ARG NODE_VERSION=24.21.0
2
+ ARG ALPINE_VERSION=3.24
3
3
  ARG PORT="__PORTS_PLACEHOLDER__"
4
4
 
5
5
  FROM node:${NODE_VERSION}-alpine AS builder