@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.
- package/CHANGELOG.md +54 -0
- package/README.md +1 -1
- package/package.json +4 -4
- package/templates/base/.gitignore.hbs +1 -0
- package/templates/base/AGENTS.md.hbs +3 -2
- package/templates/base/GETTING_STARTED.md.hbs +11 -8
- package/templates/base/nutin.config.js.hbs +1 -1
- package/templates/base/src/app/components/.gitkeep +0 -0
- package/templates/base/src/app/routes.ts.hbs +2 -1
- package/templates/base/src/app/services/.gitkeep +0 -0
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +24 -0
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +39 -0
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +14 -0
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +14 -3
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +34 -0
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +25 -3
- package/templates/base/src/core/services/router/router.test.js.hbs +15 -0
- package/templates/base/src/core/services/router/router.ts.hbs +19 -11
- package/templates/base/src/index.html.hbs +1 -0
- package/templates/base/src/styles/_styles.scss.hbs +1 -113
- package/templates/base/src/styles/main.scss.hbs +1 -1
- package/templates/base/tools/builder/builder.js.hbs +8 -2
- package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
- package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +11 -2
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +19 -3
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +24 -1
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +2 -2
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +5 -1
- package/templates/base/tools/builder/core/styles/sass.js.hbs +5 -4
- package/templates/base/tools/dev/dev-serve.js.hbs +43 -4
- package/templates/base/tools/dev/watcher.js.hbs +42 -26
- package/templates/base/tools/generator/generator.js.hbs +22 -17
- package/templates/base/tools/generator/handle-file.js.hbs +0 -22
- package/templates/base/tools/generator/handle-file.test.js.hbs +1 -20
- package/templates/base/tools/utils/build-lock.js.hbs +69 -0
- package/templates/base/tools/utils/index.js.hbs +1 -0
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +2 -2
- package/templates/features/docker/tools/docker/nginx.conf.template +15 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +12 -0
- package/templates/base/src/app/components/index.ts.hbs +0 -1
- package/templates/base/src/app/services/index.ts.hbs +0 -1
- 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:
|
|
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="/
|
|
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="/
|
|
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="/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|