@nutin/cli 2.1.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +268 -0
- package/README.md +13 -5
- package/bin/add-feature.mjs +1 -1
- package/bin/cli.mjs +1 -1
- package/bin/create-app.mjs +1 -1
- package/bin/update-app.mjs +1 -1
- package/lib/index.mjs +6 -2
- package/lib/src/add-feature/base-template-patcher.mjs +139 -0
- package/lib/src/add-feature/feature-adder.mjs +20 -9
- package/lib/src/add-feature/feature-context-builder.mjs +1 -0
- package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
- package/lib/src/add-feature/package-json-updater.mjs +7 -9
- package/lib/src/common/feature-registry.mjs +4 -0
- package/lib/src/common/file-generator.mjs +25 -3
- package/lib/src/common/git.mjs +31 -0
- package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
- package/lib/src/common/package-json-helper.mjs +14 -4
- package/lib/src/common/project-meta.mjs +2 -2
- package/lib/src/common/template-compiler.mjs +2 -0
- package/lib/src/common/transpile-ts.mjs +94 -0
- package/lib/src/create-app/context-builder.mjs +1 -0
- package/lib/src/create-app/git-manager.mjs +12 -2
- package/lib/src/create-app/json-manager.mjs +21 -18
- package/lib/src/create-app/new-app-prompt.mjs +4 -2
- package/lib/src/create-app/success-message.mjs +1 -1
- package/lib/src/update-app/conflict-report-writer.mjs +33 -21
- package/lib/src/update-app/json-drift.mjs +54 -0
- package/lib/src/update-app/project-updater.mjs +123 -14
- package/lib/src/update-app/template-differ.mjs +5 -5
- package/lib/src/update-app/three-way-merge.mjs +42 -0
- package/lib/src/update-app/update-context-builder.mjs +1 -0
- package/lib/src/update-app/update-summary-printer.mjs +10 -2
- package/package.json +9 -8
- package/templates/base/AGENTS.md.hbs +42 -41
- package/templates/base/GETTING_STARTED.md.hbs +26 -20
- package/templates/base/README.md.hbs +7 -4
- package/templates/base/src/app/globals.d.ts.hbs +6 -3
- package/templates/base/src/app/guards.ts.hbs +1 -3
- package/templates/base/src/app/main.ts.hbs +2 -9
- package/templates/base/src/app/routes.ts.hbs +7 -2
- package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
- package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
- package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
- package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +286 -24
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +66 -4
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +135 -75
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +93 -54
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
- package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
- package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
- package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
- package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
- package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
- package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
- package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
- package/templates/base/src/core/index.ts.hbs +4 -2
- package/templates/base/src/core/internals.d.ts.hbs +199 -20
- package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
- package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
- package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
- package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
- package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
- package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
- package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
- package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
- package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
- package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +183 -4
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +207 -84
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
- package/templates/base/src/core/services/router/router.test.js.hbs +306 -0
- package/templates/base/src/core/services/router/router.ts.hbs +113 -53
- package/templates/base/tools/builder/builder.config.js.hbs +6 -0
- package/templates/base/tools/builder/builder.js.hbs +18 -6
- package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
- package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
- package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
- package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
- package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
- package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
- package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
- package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
- package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
- package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
- package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
- package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
- package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
- package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
- package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
- package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
- package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
- package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
- package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +15 -7
- package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
- package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
- package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +43 -13
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +28 -21
- package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +24 -6
- package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
- package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
- package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +24 -11
- package/templates/base/tools/builder/core/styles/sass.js.hbs +3 -2
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
- package/templates/base/tools/dev/dev-serve.js.hbs +30 -5
- package/templates/base/tools/dev/serve.js.hbs +152 -22
- package/templates/base/tools/dev/watcher.js.hbs +34 -3
- package/templates/base/tools/generator/generator.js.hbs +1 -1
- package/templates/base/tools/generator/handle-file.js.hbs +4 -2
- package/templates/base/tools/generator/handle-file.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
- package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
- package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
- package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
- package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
- package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
- package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
- package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
- package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
- package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
- package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
- package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
- package/templates/base/tools/utils/build-lock.js.hbs +30 -2
- package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
- package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
- package/templates/base/tools/utils/format.js.hbs +1 -1
- package/templates/base/tools/utils/index.js.hbs +2 -1
- package/templates/base/tools/utils/print.js.hbs +2 -2
- package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
- package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
- package/templates/base/tools/utils/run-command.js.hbs +30 -1
- package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
- package/templates/base/tools/utils/run-script.js.hbs +2 -2
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +3 -0
- package/templates/features/docker/tools/docker/nginx.conf.template +6 -4
- package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
- package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
- package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
- package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
- package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +21 -4
- package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
- package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
- package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
- package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
- /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
|
@@ -1,37 +1,62 @@
|
|
|
1
1
|
import chokidar from 'chokidar';
|
|
2
2
|
import { exec } from 'child_process';
|
|
3
3
|
import path from 'path';
|
|
4
|
-
import { print } from './core/index.js'
|
|
4
|
+
import { print } from './core/index.js';
|
|
5
|
+
import config from '#root/nutin.config.js';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
// Tests import the build output (dist/), so every run rebuilds first.
|
|
8
|
+
const COMMAND = '{{packageManager}} run testin-nutin';
|
|
9
|
+
|
|
10
|
+
const watched = ['src'];
|
|
11
|
+
if (config.testinNutin.includeTools) watched.push('tools');
|
|
12
|
+
|
|
13
|
+
const watcher = chokidar.watch(watched, {
|
|
7
14
|
ignored: /(^|[/\\])\../,
|
|
8
15
|
persistent: true,
|
|
16
|
+
ignoreInitial: true,
|
|
9
17
|
});
|
|
10
18
|
|
|
11
|
-
|
|
19
|
+
let isRunning = false;
|
|
20
|
+
let pendingRun = false;
|
|
21
|
+
let runTimeout = null;
|
|
22
|
+
let lastChangedPath = null;
|
|
23
|
+
|
|
24
|
+
function runTests() {
|
|
25
|
+
isRunning = true;
|
|
26
|
+
pendingRun = false;
|
|
27
|
+
|
|
28
|
+
console.clear();
|
|
29
|
+
print.boldInfo(`\n🔄 File changed: ${path.relative(process.cwd(), lastChangedPath)}\n`);
|
|
30
|
+
|
|
31
|
+
exec(COMMAND, { maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
32
|
+
if (stdout) process.stdout.write(stdout);
|
|
33
|
+
if (stderr) process.stderr.write(stderr);
|
|
34
|
+
if (err) print.boldError(`\nTest run failed: ${err.message}`);
|
|
35
|
+
print.boldHead('Watching for test changes...');
|
|
36
|
+
isRunning = false;
|
|
37
|
+
|
|
38
|
+
// A change arrived mid-run — run again instead of dropping it.
|
|
39
|
+
if (pendingRun) runTests();
|
|
40
|
+
});
|
|
41
|
+
}
|
|
12
42
|
|
|
13
43
|
watcher.on('error', (err) => {
|
|
14
44
|
print.boldError(`\nWatcher error: ${err.message}`);
|
|
15
45
|
});
|
|
16
46
|
|
|
17
|
-
|
|
18
|
-
|
|
47
|
+
watcher.on('all', (event, filePath) => {
|
|
48
|
+
if (!['add', 'change', 'unlink'].includes(event)) return;
|
|
49
|
+
lastChangedPath = filePath;
|
|
19
50
|
|
|
20
|
-
|
|
21
|
-
|
|
51
|
+
if (isRunning) {
|
|
52
|
+
pendingRun = true;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
22
55
|
|
|
23
|
-
runTimeout
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
console.clear();
|
|
28
|
-
print.boldInfo(`\n🔄 File changed: ${path.relative(process.cwd(), filePath)}\n`);
|
|
56
|
+
if (runTimeout) clearTimeout(runTimeout);
|
|
57
|
+
runTimeout = setTimeout(runTests, 100);
|
|
58
|
+
});
|
|
29
59
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (stderr) process.stderr.write(stderr);
|
|
33
|
-
if (err) print.boldError(`\nTest run failed: ${err.message}`);
|
|
34
|
-
isRunning = false;
|
|
35
|
-
});
|
|
36
|
-
}, 100);
|
|
60
|
+
watcher.on('ready', () => {
|
|
61
|
+
print.boldHead('Watching for test changes...');
|
|
37
62
|
});
|
|
@@ -25,12 +25,32 @@ function tryAcquire() {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
function isStale() {
|
|
28
|
+
let mtimeMs;
|
|
28
29
|
try {
|
|
29
|
-
|
|
30
|
-
return Date.now() - mtimeMs > STALE_MS;
|
|
30
|
+
({ mtimeMs } = fs.statSync(LOCK_DIR));
|
|
31
31
|
} catch {
|
|
32
32
|
return true; // lock vanished mid-check (released concurrently) — treat as free
|
|
33
33
|
}
|
|
34
|
+
if (Date.now() - mtimeMs > STALE_MS) return true;
|
|
35
|
+
return isHolderDead();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A build killed hard (kill -9, a crash) never releases — free the lock right away
|
|
39
|
+
// when the pid that wrote it is gone instead of waiting out STALE_MS.
|
|
40
|
+
function isHolderDead() {
|
|
41
|
+
let pid;
|
|
42
|
+
try {
|
|
43
|
+
pid = Number(fs.readFileSync(path.join(LOCK_DIR, 'pid'), 'utf-8'));
|
|
44
|
+
} catch {
|
|
45
|
+
return false; // pid not written yet (holder is mid-acquire) — not provably dead
|
|
46
|
+
}
|
|
47
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
48
|
+
try {
|
|
49
|
+
process.kill(pid, 0);
|
|
50
|
+
return false;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return err.code === 'ESRCH';
|
|
53
|
+
}
|
|
34
54
|
}
|
|
35
55
|
|
|
36
56
|
function release() {
|
|
@@ -66,4 +86,12 @@ export async function acquireBuildLock() {
|
|
|
66
86
|
}
|
|
67
87
|
|
|
68
88
|
process.on('exit', release);
|
|
89
|
+
// 'exit' doesn't fire when a signal terminates the process (Ctrl-C, dev-serve's
|
|
90
|
+
// group kill), so release explicitly there too, then exit the way the signal would have.
|
|
91
|
+
for (const [signal, code] of [['SIGINT', 130], ['SIGTERM', 143], ['SIGHUP', 129]]) {
|
|
92
|
+
process.once(signal, () => {
|
|
93
|
+
release();
|
|
94
|
+
process.exit(code);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
69
97
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { print } from './print.js';
|
|
4
|
+
import { errorExit } from './error-exit.js';
|
|
5
|
+
import { runCommand } from './run-command.js';
|
|
6
|
+
import { promptBoolean } from './prompt-boolean.js';
|
|
7
|
+
|
|
8
|
+
export function isInstalled(depName) {
|
|
9
|
+
return fs.existsSync(path.join(process.cwd(), 'node_modules', depName, 'package.json'));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function detectPackageManager() {
|
|
13
|
+
const has = (file) => fs.existsSync(path.join(process.cwd(), file));
|
|
14
|
+
if (has('pnpm-lock.yaml')) return 'pnpm';
|
|
15
|
+
if (has('yarn.lock')) return 'yarn';
|
|
16
|
+
if (has('bun.lock') || has('bun.lockb')) return 'bun';
|
|
17
|
+
return 'npm';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getDevInstallArgs(packageManager, deps) {
|
|
21
|
+
const pkgList = deps.map((dep) => `${dep.name}@${dep.version}`);
|
|
22
|
+
return packageManager === 'npm'
|
|
23
|
+
? ['npm', ['install', '-D', ...pkgList]]
|
|
24
|
+
: [packageManager, ['add', '-D', ...pkgList]];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// builder.js forwards -y/--yes through the env, since runScript doesn't forward argv.
|
|
28
|
+
function assumeYes() {
|
|
29
|
+
return process.env.NUTIN_ASSUME_YES === '1' || process.argv.includes('-y') || process.argv.includes('--yes');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Makes sure an opt-in feature's devDependencies are installed before its build step
|
|
34
|
+
* imports them. Prod builds never install. Dev builds ask on a TTY; elsewhere (CI, the
|
|
35
|
+
* dev watcher) they only install with -y/--yes, never silently.
|
|
36
|
+
*/
|
|
37
|
+
export async function ensureDeps({ feature, deps, isProd, origin }) {
|
|
38
|
+
const missing = deps.filter((dep) => !isInstalled(dep.name));
|
|
39
|
+
if (!missing.length) return;
|
|
40
|
+
|
|
41
|
+
const [command, args] = getDevInstallArgs(detectPackageManager(), missing);
|
|
42
|
+
const installCommand = [command, ...args].join(' ');
|
|
43
|
+
|
|
44
|
+
if (isProd) {
|
|
45
|
+
errorExit(`${feature} is enabled but its dependencies are missing.\nRun "${installCommand}" before rebuilding.`, origin);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
print.warn(`${feature} is enabled but its dependencies are missing.`);
|
|
49
|
+
print.gray('Required packages:');
|
|
50
|
+
missing.forEach((dep) => print.gray(` - ${dep.name}@${dep.version}`));
|
|
51
|
+
|
|
52
|
+
let shouldInstall;
|
|
53
|
+
if (assumeYes()) {
|
|
54
|
+
print.gray(`-y/--yes passed. Installing automatically: ${installCommand}`);
|
|
55
|
+
shouldInstall = true;
|
|
56
|
+
} else if (process.stdin.isTTY) {
|
|
57
|
+
shouldInstall = await promptBoolean('Install them now?');
|
|
58
|
+
} else {
|
|
59
|
+
errorExit(`Non-interactive shell detected. Run "${installCommand}", or rebuild with "-- -y" to install automatically.`, origin);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!shouldInstall) {
|
|
63
|
+
print.boldError(`Aborting. Run manually: ${installCommand}`);
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await runCommand(command, args);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
errorExit(err, `${origin}-install`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { isInstalled, getDevInstallArgs } from './ensure-deps.js';
|
|
2
|
+
|
|
3
|
+
describe('ensure-deps', () => {
|
|
4
|
+
it('getDevInstallArgs uses "install -D" for npm and "add -D" for the others', () => {
|
|
5
|
+
const deps = [{ name: 'a', version: '^1.0.0' }, { name: '@scope/b', version: '^2.0.0' }];
|
|
6
|
+
|
|
7
|
+
expect(getDevInstallArgs('npm', deps)).toEqual(['npm', ['install', '-D', 'a@^1.0.0', '@scope/b@^2.0.0']]);
|
|
8
|
+
expect(getDevInstallArgs('pnpm', deps)).toEqual(['pnpm', ['add', '-D', 'a@^1.0.0', '@scope/b@^2.0.0']]);
|
|
9
|
+
expect(getDevInstallArgs('yarn', deps)[1][0]).toBe('add');
|
|
10
|
+
expect(getDevInstallArgs('bun', deps)[1][0]).toBe('add');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('isInstalled checks node_modules/<name>/package.json', () => {
|
|
14
|
+
expect(isInstalled('esbuild')).toBeTruthy();
|
|
15
|
+
expect(isInstalled('surely-not-an-installed-package-xyz')).toBeFalsy();
|
|
16
|
+
});
|
|
17
|
+
});
|
|
@@ -8,7 +8,7 @@ export function capitalized(input) {
|
|
|
8
8
|
const formatted = input.replace(
|
|
9
9
|
/\w*/g,
|
|
10
10
|
function(txt) {
|
|
11
|
-
return txt.charAt(0).toUpperCase() + txt.
|
|
11
|
+
return txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase();
|
|
12
12
|
})
|
|
13
13
|
return formatted.replace(/,/g, '');
|
|
14
14
|
}
|
|
@@ -4,6 +4,7 @@ export * from './get-files-recursive.js';
|
|
|
4
4
|
export * from './run-command.js';
|
|
5
5
|
export * from './run-script.js';
|
|
6
6
|
export * from './prompt-boolean.js';
|
|
7
|
-
export * from './languages.js';
|
|
8
7
|
export * from './error-exit.js';
|
|
9
8
|
export * from './build-lock.js';
|
|
9
|
+
export * from './resolve-port.js';
|
|
10
|
+
export * from './ensure-deps.js';
|
|
@@ -16,7 +16,7 @@ export const chalk = {
|
|
|
16
16
|
};
|
|
17
17
|
|
|
18
18
|
export const print = {
|
|
19
|
-
head: (s) => console.info(`${chalk.purple(s)}`),
|
|
19
|
+
head: (s) => console.info(`${chalk.purple(s)}`),
|
|
20
20
|
boldHead : (s) => console.info(`${chalk.boldPurple(s)}`),
|
|
21
21
|
section: (s) => console.info(`${chalk.yellow(s)}`),
|
|
22
22
|
boldSection: (s) => console.info(`${chalk.boldYellow(s)}`),
|
|
@@ -32,6 +32,6 @@ export const print = {
|
|
|
32
32
|
boldError: (s) => console.error(`${chalk.boldRed(s)}`),
|
|
33
33
|
grayError: (s) => console.error(`${chalk.boldGray(s)}`),
|
|
34
34
|
warn: (s) => console.warn(`${chalk.yellow(s)}`),
|
|
35
|
-
boldWarn: (s) => console.warn(`${chalk.
|
|
35
|
+
boldWarn: (s) => console.warn(`${chalk.boldYellow(s)}`),
|
|
36
36
|
debug: (s) => console.debug(`${chalk.gray(s)}`),
|
|
37
37
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const DEFAULT_PORT = 9090;
|
|
2
|
+
|
|
3
|
+
// Precedence: --port flag > npm_config_port > PORT env > DEFAULT_PORT.
|
|
4
|
+
// npm_config_port covers older npm versions turning `npm run dev --port=3000`
|
|
5
|
+
// (no `--`) into config instead of argv; npm >= 11 rejects that with EUNKNOWNCONFIG.
|
|
6
|
+
export function resolvePort(argv = process.argv.slice(2), env = process.env) {
|
|
7
|
+
const raw = readPortFlag(argv) ?? env.npm_config_port ?? env.PORT;
|
|
8
|
+
if (raw === undefined || raw === '') return DEFAULT_PORT;
|
|
9
|
+
|
|
10
|
+
const port = Number(raw);
|
|
11
|
+
if (!/^\d+$/.test(String(raw)) || port < 1 || port > 65535) {
|
|
12
|
+
throw new Error(`Invalid port "${raw}" — expected an integer between 1 and 65535.`);
|
|
13
|
+
}
|
|
14
|
+
return port;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readPortFlag(argv) {
|
|
18
|
+
for (let i = 0; i < argv.length; i++) {
|
|
19
|
+
const arg = argv[i];
|
|
20
|
+
if (arg.startsWith('--port=')) return arg.slice('--port='.length);
|
|
21
|
+
if (arg === '--port') {
|
|
22
|
+
const next = argv[i + 1];
|
|
23
|
+
if (next === undefined || next.startsWith('--')) {
|
|
24
|
+
throw new Error('Missing value for --port (e.g. --port 3000).');
|
|
25
|
+
}
|
|
26
|
+
return next;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { resolvePort, DEFAULT_PORT } from './resolve-port.js';
|
|
2
|
+
|
|
3
|
+
describe('resolvePort', () => {
|
|
4
|
+
it('defaults to 9090 with no flag or env', () => {
|
|
5
|
+
expect(resolvePort([], {})).toBe(DEFAULT_PORT);
|
|
6
|
+
expect(DEFAULT_PORT).toBe(9090);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('reads --port <n>', () => {
|
|
10
|
+
expect(resolvePort(['--port', '3000'], {})).toBe(3000);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('reads --port=<n>', () => {
|
|
14
|
+
expect(resolvePort(['--silent', '--port=3001'], {})).toBe(3001);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('reads npm_config_port (npm rewrites --port into config)', () => {
|
|
18
|
+
expect(resolvePort([], { npm_config_port: '3002' })).toBe(3002);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('reads PORT', () => {
|
|
22
|
+
expect(resolvePort([], { PORT: '3003' })).toBe(3003);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('prefers --port over npm_config_port over PORT', () => {
|
|
26
|
+
expect(resolvePort(['--port', '1111'], { npm_config_port: '2222', PORT: '3333' })).toBe(1111);
|
|
27
|
+
expect(resolvePort([], { npm_config_port: '2222', PORT: '3333' })).toBe(2222);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('ignores an empty PORT', () => {
|
|
31
|
+
expect(resolvePort([], { PORT: '' })).toBe(DEFAULT_PORT);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('throws on a non-numeric port', () => {
|
|
35
|
+
expect(() => resolvePort(['--port', 'abc'], {})).toThrow('Invalid port "abc"');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('throws on out-of-range ports', () => {
|
|
39
|
+
expect(() => resolvePort(['--port=0'], {})).toThrow('Invalid port "0"');
|
|
40
|
+
expect(() => resolvePort([], { PORT: '70000' })).toThrow('Invalid port "70000"');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('throws when --port has no value', () => {
|
|
44
|
+
expect(() => resolvePort(['--port'], {})).toThrow('Missing value for --port');
|
|
45
|
+
expect(() => resolvePort(['--port', '--silent'], {})).toThrow('Missing value for --port');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import { spawn } from 'child_process';
|
|
2
2
|
|
|
3
|
+
const isWindows = process.platform === 'win32';
|
|
4
|
+
|
|
5
|
+
// Windows needs a shell to launch .cmd shims (npm, tsc, ...); a single pre-quoted command
|
|
6
|
+
// string avoids Node's deprecated args-array-with-shell form. Elsewhere no shell is
|
|
7
|
+
// involved, so arguments containing spaces or shell characters are passed through as-is.
|
|
8
|
+
function quoteForCmd(arg) {
|
|
9
|
+
return /[\s"&|<>^]/.test(arg) ? `"${arg.replace(/"/g, '""')}"` : arg;
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
export function runCommand(command, args = [], options = {}) {
|
|
4
13
|
return new Promise((resolve, reject) => {
|
|
5
|
-
const child =
|
|
14
|
+
const child = isWindows
|
|
15
|
+
? spawn([command, ...args].map(quoteForCmd).join(' '), { stdio: 'inherit', shell: true, ...options })
|
|
16
|
+
: spawn(command, args, { stdio: 'inherit', ...options });
|
|
6
17
|
|
|
7
18
|
child.on('error', (err) => reject(err));
|
|
8
19
|
|
|
@@ -12,3 +23,21 @@ export function runCommand(command, args = [], options = {}) {
|
|
|
12
23
|
});
|
|
13
24
|
});
|
|
14
25
|
}
|
|
26
|
+
|
|
27
|
+
// Like runCommand(), but collects the child's stdout and stderr (interleaved, in arrival order)
|
|
28
|
+
// instead of printing them, and resolves with its exit code rather than rejecting: for a caller
|
|
29
|
+
// that rewrites the output, e.g. compile-ts mapping tsc errors back to source files.
|
|
30
|
+
export function captureCommand(command, args = [], options = {}) {
|
|
31
|
+
return new Promise((resolve, reject) => {
|
|
32
|
+
const stdio = ['inherit', 'pipe', 'pipe'];
|
|
33
|
+
const child = isWindows
|
|
34
|
+
? spawn([command, ...args].map(quoteForCmd).join(' '), { stdio, shell: true, ...options })
|
|
35
|
+
: spawn(command, args, { stdio, ...options });
|
|
36
|
+
|
|
37
|
+
let output = '';
|
|
38
|
+
child.stdout.on('data', (chunk) => { output += chunk; });
|
|
39
|
+
child.stderr.on('data', (chunk) => { output += chunk; });
|
|
40
|
+
child.on('error', (err) => reject(err));
|
|
41
|
+
child.on('close', (code) => resolve({ code, output }));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { runCommand } from './run-command.js';
|
|
1
|
+
import { runCommand, captureCommand } from './run-command.js';
|
|
2
2
|
|
|
3
3
|
describe('runCommand', () => {
|
|
4
4
|
it('resolves when the child process exits with code 0', async () => {
|
|
@@ -16,4 +16,15 @@ describe('runCommand', () => {
|
|
|
16
16
|
expect(error).toBeDefined();
|
|
17
17
|
expect(error.message).toContain('7');
|
|
18
18
|
});
|
|
19
|
+
|
|
20
|
+
it('passes arguments containing spaces through unsplit', async () => {
|
|
21
|
+
await runCommand('node', ['-e', 'if (process.argv[1] !== "a b") process.exitCode = 1', 'a b']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('captureCommand resolves with the exit code and the collected stdout and stderr', async () => {
|
|
25
|
+
const { code, output } = await captureCommand('node', ['-e', 'console.log("out"); console.error("err"); process.exitCode = 2']);
|
|
26
|
+
expect(code).toBe(2);
|
|
27
|
+
expect(output).toContain('out');
|
|
28
|
+
expect(output).toContain('err');
|
|
29
|
+
});
|
|
19
30
|
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolve } from 'path';
|
|
2
2
|
import { spawnSync } from 'child_process';
|
|
3
3
|
import process from 'process';
|
|
4
|
-
import { print } from './
|
|
4
|
+
import { print } from './print.js';
|
|
5
5
|
|
|
6
6
|
export function runScript(path, message) {
|
|
7
7
|
if (!message) print.boldSection(`Running ${path}...`);
|
|
@@ -9,7 +9,7 @@ export function runScript(path, message) {
|
|
|
9
9
|
|
|
10
10
|
const args = [resolve(path), '--silent'];
|
|
11
11
|
|
|
12
|
-
const result = spawnSync(
|
|
12
|
+
const result = spawnSync(process.execPath, args,
|
|
13
13
|
{
|
|
14
14
|
stdio: 'inherit',
|
|
15
15
|
env: { ...process.env },
|
|
@@ -23,11 +23,14 @@ RUN {{ciCommand}} || pnpm approve-builds --all
|
|
|
23
23
|
{{else}}
|
|
24
24
|
RUN {{ciCommand}}
|
|
25
25
|
{{/if}}
|
|
26
|
+
{{#unless (eq lang "js")}}
|
|
26
27
|
COPY tsconfig.json .
|
|
28
|
+
{{/unless}}
|
|
27
29
|
COPY ./src ./src
|
|
28
30
|
COPY ./public ./public
|
|
29
31
|
COPY ./nutin.config.js .
|
|
30
32
|
COPY ./config ./config
|
|
33
|
+
__MARKDOWN_SOURCES_PLACEHOLDER__
|
|
31
34
|
COPY ./tools/builder ./tools/builder
|
|
32
35
|
COPY ./tools/docker ./tools/docker
|
|
33
36
|
COPY ./tools/utils ./tools/utils
|
|
@@ -10,7 +10,7 @@ map $uri $content_type_options {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
map $uri $xss_protection {
|
|
13
|
-
default "
|
|
13
|
+
default "0";
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
map $uri $referrer_policy {
|
|
@@ -56,8 +56,9 @@ server {
|
|
|
56
56
|
# Hide nginx version
|
|
57
57
|
server_tokens off;
|
|
58
58
|
|
|
59
|
-
# With i18n enabled, redirects
|
|
60
|
-
#
|
|
59
|
+
# With i18n enabled, redirects "/" and unprefixed paths ("/docs") to the default
|
|
60
|
+
# language, so every served URL matches a page's canonical/og:url instead of
|
|
61
|
+
# returning a duplicate SPA shell (filled in by validate-docker).
|
|
61
62
|
__ROOT_REDIRECT_PLACEHOLDER__
|
|
62
63
|
|
|
63
64
|
# SPA routing: serve index.html for unknown paths
|
|
@@ -109,7 +110,8 @@ server {
|
|
|
109
110
|
# ---------------------------
|
|
110
111
|
# HEALTH ENDPOINT
|
|
111
112
|
# ---------------------------
|
|
112
|
-
|
|
113
|
+
# Exact match: it must win over the i18n unprefixed-path redirect (a regex location).
|
|
114
|
+
location = /health {
|
|
113
115
|
access_log off;
|
|
114
116
|
add_header Content-Type text/plain always;
|
|
115
117
|
add_header Cache-Control "no-store" always;
|
|
@@ -31,7 +31,7 @@ export function validateDockerPorts(rawPorts) {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
if (port > MAX_PORT) {
|
|
34
|
-
throw new Error(`nutin.config.js: "dockerPorts[${i}]"
|
|
34
|
+
throw new Error(`nutin.config.js: "dockerPorts[${i}]": use a registered port between ${MIN_PORT} and ${MAX_PORT}.`);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
if (port < MIN_PORT || port > MAX_PORT) {
|
|
@@ -29,8 +29,8 @@ describe('validateDockerPorts', () => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
it('throws for ephemeral ports at or above 49152', () => {
|
|
32
|
-
expect(() => validateDockerPorts([49152])).toThrow('
|
|
33
|
-
expect(() => validateDockerPorts([60000])).toThrow('
|
|
32
|
+
expect(() => validateDockerPorts([49152])).toThrow('use a registered port');
|
|
33
|
+
expect(() => validateDockerPorts([60000])).toThrow('use a registered port');
|
|
34
34
|
});
|
|
35
35
|
|
|
36
36
|
it('throws for duplicate ports', () => {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Language codes end up in an nginx regex and redirect target: letters, digits and "-" only (e.g. "pt-BR").
|
|
2
|
+
const LANGUAGE_CODE = /^[A-Za-z0-9-]+$/;
|
|
3
|
+
|
|
4
|
+
// Only i18n builds serve every page under a language prefix — without it, "/" and every
|
|
5
|
+
// other path are real pages, and nothing is redirected.
|
|
6
|
+
export function localeRedirects({ i18n, languages, defaultLanguage }) {
|
|
7
|
+
if (!i18n) return '';
|
|
8
|
+
|
|
9
|
+
[defaultLanguage, ...languages].forEach((lang) => {
|
|
10
|
+
if (typeof lang !== 'string' || !LANGUAGE_CODE.test(lang)) {
|
|
11
|
+
throw new Error(`config/languages.json: invalid language code ${JSON.stringify(lang)} — use letters, digits and "-" only (e.g. "en", "pt-BR").`);
|
|
12
|
+
}
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
// Unprefixed app paths (old or external links like "/docs") get a 301 to the default
|
|
16
|
+
// language instead of a 200 SPA shell that duplicates the real page. Paths with a "."
|
|
17
|
+
// (robots.txt, sitemap.xml, assets) are left to the other locations. $request_uri is
|
|
18
|
+
// the raw request (query included): the decoded $uri could inject headers via %0D%0A.
|
|
19
|
+
return [
|
|
20
|
+
`location = / {`,
|
|
21
|
+
` return 301 /${defaultLanguage}/$is_args$args;`,
|
|
22
|
+
` }`,
|
|
23
|
+
``,
|
|
24
|
+
` location ~ ^/(?!(?:${languages.join('|')})(?:/|$))[^.]*$ {`,
|
|
25
|
+
` return 301 /${defaultLanguage}$request_uri;`,
|
|
26
|
+
` }`,
|
|
27
|
+
].join('\n');
|
|
28
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { localeRedirects } from './locale-redirects.js';
|
|
2
|
+
|
|
3
|
+
describe('localeRedirects', () => {
|
|
4
|
+
it('redirects nothing without i18n', () => {
|
|
5
|
+
expect(localeRedirects({ i18n: false, languages: ['en'], defaultLanguage: 'en' })).toBe('');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('redirects "/" to the default language', () => {
|
|
9
|
+
const conf = localeRedirects({ i18n: true, languages: ['en', 'fr'], defaultLanguage: 'fr' });
|
|
10
|
+
expect(conf).toContain('location = / {\n return 301 /fr/$is_args$args;\n }');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('redirects unprefixed, extensionless paths with the raw request URI', () => {
|
|
14
|
+
const conf = localeRedirects({ i18n: true, languages: ['en', 'fr'], defaultLanguage: 'en' });
|
|
15
|
+
expect(conf).toContain('location ~ ^/(?!(?:en|fr)(?:/|$))[^.]*$ {\n return 301 /en$request_uri;\n }');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('only matches unprefixed, extensionless paths', () => {
|
|
19
|
+
const conf = localeRedirects({ i18n: true, languages: ['en', 'pt-BR'], defaultLanguage: 'en' });
|
|
20
|
+
const pattern = new RegExp(conf.match(/location ~ (\S+) \{/)[1]);
|
|
21
|
+
|
|
22
|
+
['/docs', '/docs/', '/docs/api/x', '/english', '/enx/docs', '/pt-BRx'].forEach((path) => {
|
|
23
|
+
expect(pattern.test(path)).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
['/en', '/en/', '/en/docs/', '/pt-BR/docs', '/robots.txt', '/main.1a2b.css', '/assets/og.jpg'].forEach((path) => {
|
|
26
|
+
expect(pattern.test(path)).toBe(false);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws on a language code that could break the nginx config', () => {
|
|
31
|
+
expect(() => localeRedirects({ i18n: true, languages: ['en', 'f r'], defaultLanguage: 'en' })).toThrow('invalid language code');
|
|
32
|
+
expect(() => localeRedirects({ i18n: true, languages: ['en'], defaultLanguage: 'en|.*' })).toThrow('invalid language code');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
|
|
3
|
+
// Markdown folders live outside src/ but build:prod compiles them, so the Docker
|
|
4
|
+
// builder stage needs them too. Returns one COPY line per folder ('' without markdown).
|
|
5
|
+
export function markdownSourcesCopy(sourceFolders, cwd = process.cwd()) {
|
|
6
|
+
const folders = (sourceFolders ?? [])
|
|
7
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry?.folder))
|
|
8
|
+
.filter(Boolean);
|
|
9
|
+
|
|
10
|
+
const relativePaths = folders.map((folder) => {
|
|
11
|
+
const relative = path.relative(cwd, path.resolve(cwd, folder));
|
|
12
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
13
|
+
throw new Error(`Markdown folder "${folder}" is outside the project, so it can't be copied into the Docker image — move it inside the project.`);
|
|
14
|
+
}
|
|
15
|
+
return relative.split(path.sep).join('/');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
// JSON form keeps folder names with spaces intact.
|
|
19
|
+
return [...new Set(relativePaths)]
|
|
20
|
+
.map((relative) => {
|
|
21
|
+
const target = `./${relative}`;
|
|
22
|
+
return `COPY ${JSON.stringify([target, target])}`;
|
|
23
|
+
})
|
|
24
|
+
.join('\n');
|
|
25
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { markdownSourcesCopy } from './markdown-sources-copy.js';
|
|
3
|
+
|
|
4
|
+
describe('markdownSourcesCopy', () => {
|
|
5
|
+
const cwd = path.resolve('/project');
|
|
6
|
+
|
|
7
|
+
it('returns an empty string without markdown folders', () => {
|
|
8
|
+
expect(markdownSourcesCopy(undefined, cwd)).toBe('');
|
|
9
|
+
expect(markdownSourcesCopy([], cwd)).toBe('');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('returns one COPY line per folder, for string and object entries', () => {
|
|
13
|
+
expect(markdownSourcesCopy(['markdown-content', { folder: 'docs/guides' }], cwd)).toBe(
|
|
14
|
+
'COPY ["./markdown-content","./markdown-content"]\nCOPY ["./docs/guides","./docs/guides"]'
|
|
15
|
+
);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('keeps folder names with spaces in a single path', () => {
|
|
19
|
+
expect(markdownSourcesCopy(['my docs'], cwd)).toBe('COPY ["./my docs","./my docs"]');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('copies a folder once when it is listed with different spellings', () => {
|
|
23
|
+
expect(markdownSourcesCopy(['docs', './docs/', { folder: 'docs' }], cwd)).toBe('COPY ["./docs","./docs"]');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('skips entries without a folder', () => {
|
|
27
|
+
expect(markdownSourcesCopy([null, {}, 'docs'], cwd)).toBe('COPY ["./docs","./docs"]');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws for a folder outside the project', () => {
|
|
31
|
+
expect(() => markdownSourcesCopy(['../shared-docs'], cwd)).toThrow('"../shared-docs" is outside the project');
|
|
32
|
+
expect(() => markdownSourcesCopy([path.resolve('/elsewhere/docs')], cwd)).toThrow('is outside the project');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -14,7 +14,9 @@ export async function renderFromTemplate(dockerDir, templateName, outputName, re
|
|
|
14
14
|
|
|
15
15
|
for (const [placeholder, value] of replacements) {
|
|
16
16
|
if (!content.includes(placeholder)) {
|
|
17
|
-
|
|
17
|
+
// Nothing to insert: a template from before this token was added still renders.
|
|
18
|
+
if (value === '') continue;
|
|
19
|
+
throw new Error(`tools/docker/${templateName} is missing the "${placeholder}" token — validate-docker has nothing to substitute. Add the token back — "nutin-update" reports the current template's changes.`);
|
|
18
20
|
}
|
|
19
21
|
content = content.replace(placeholder, value);
|
|
20
22
|
}
|
|
@@ -46,6 +46,18 @@ describe('renderFromTemplate', () => {
|
|
|
46
46
|
).toThrow('missing.template');
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
+
it('skips a missing placeholder token when its value is empty', async () => {
|
|
50
|
+
fs.writeFileSync(path.join(tmpDir, 'Dockerfile.template'), 'ARG PORT="__PORTS_PLACEHOLDER__"\n');
|
|
51
|
+
|
|
52
|
+
await renderFromTemplate(tmpDir, 'Dockerfile.template', 'Dockerfile', [
|
|
53
|
+
['__PORTS_PLACEHOLDER__', '8080'],
|
|
54
|
+
['__MARKDOWN_SOURCES_PLACEHOLDER__', ''],
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const output = fs.readFileSync(path.join(tmpDir, 'Dockerfile'), 'utf-8');
|
|
58
|
+
expect(output).toBe('ARG PORT="8080"\n');
|
|
59
|
+
});
|
|
60
|
+
|
|
49
61
|
it('throws when a placeholder token is missing from the template, without writing the output', async () => {
|
|
50
62
|
fs.writeFileSync(path.join(tmpDir, 'nginx.conf.template'), 'listen 9090;\n');
|
|
51
63
|
|