@nutin/cli 2.0.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/README.md +5 -8
- package/lib/src/create-app/success-message.mjs +2 -3
- package/lib/src/update-app/project-updater.mjs +6 -0
- package/package.json +4 -4
- package/templates/base/.gitignore.hbs +1 -0
- package/templates/base/AGENTS.md.hbs +3 -3
- package/templates/base/GETTING_STARTED.md.hbs +40 -65
- package/templates/base/nutin.config.js.hbs +1 -8
- package/templates/base/src/app/components/.gitkeep +0 -0
- package/templates/base/src/app/main.ts.hbs +0 -1
- package/templates/base/src/app/routes.ts.hbs +2 -1
- package/templates/base/src/app/services/.gitkeep +0 -0
- package/templates/base/src/app/views/home/home.view.ts.hbs +1 -1
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +1 -1
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +46 -0
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +5 -2
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +45 -0
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +15 -1
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +14 -3
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +10 -3
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +4 -1
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +11 -14
- package/templates/base/src/core/base-classes/view/view.ts.hbs +8 -9
- package/templates/base/src/core/config.ts.hbs +3 -0
- package/templates/base/src/core/services/globals/globals.test.js.hbs +38 -0
- package/templates/base/src/core/services/globals/globals.ts.hbs +19 -10
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +30 -2
- package/templates/base/src/core/services/http-client/http-client.ts.hbs +4 -1
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +142 -0
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +42 -4
- package/templates/base/src/core/services/router/router.test.js.hbs +93 -0
- package/templates/base/src/core/services/router/router.ts.hbs +26 -15
- 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 +32 -47
- package/templates/base/tools/generator/handle-file.js.hbs +2 -24
- package/templates/base/tools/generator/handle-file.test.js.hbs +1 -20
- package/templates/base/tools/generator/templates/locale/locale.template.js.hbs +5 -1
- package/templates/base/tools/generator/templates/locale/locale.template.test.js.hbs +11 -1
- package/templates/base/tools/generator/templates/view/view.template.js.hbs +1 -1
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
- 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
|
@@ -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,8 +14,7 @@ const creators = {
|
|
|
26
14
|
print.section(`Generating service: ${name.capitalized}`);
|
|
27
15
|
try {
|
|
28
16
|
generateFile({ name, targetPath, templateFn: serviceTemplate, suffix: suffix });
|
|
29
|
-
|
|
30
|
-
if (nutinConfig.generator.generateTest) await generateTest({ name, targetPath, suffix: suffix });
|
|
17
|
+
await generateTest({ name, targetPath, suffix: suffix });
|
|
31
18
|
} catch (err) {
|
|
32
19
|
handleError("Failed to generate service", err);
|
|
33
20
|
}
|
|
@@ -39,10 +26,9 @@ const creators = {
|
|
|
39
26
|
try {
|
|
40
27
|
generateFile({ name, targetPath, templateFn: componentTemplate, suffix: suffix });
|
|
41
28
|
generateFile({ name, targetPath, templateFn: htmlTemplate, suffix: suffix, extension: 'html' });
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (nutinConfig.generator.generateTest) await generateTest({ name, targetPath, suffix });
|
|
29
|
+
generateFile({ name, targetPath, templateFn: scssTemplate, suffix: suffix, extension: 'scss' });
|
|
30
|
+
await generateLocales({ targetPath, name, isView: false });
|
|
31
|
+
await generateTest({ name, targetPath, suffix });
|
|
46
32
|
} catch (err) {
|
|
47
33
|
handleError("Failed to generate component", err);
|
|
48
34
|
}
|
|
@@ -54,20 +40,39 @@ const creators = {
|
|
|
54
40
|
try {
|
|
55
41
|
generateFile({ name, targetPath, templateFn: viewTemplate, suffix: suffix });
|
|
56
42
|
generateFile({ name, targetPath, templateFn: htmlTemplate, suffix: suffix, extension: 'html' });
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (nutinConfig.generator.generateTest) await generateTest({ name, targetPath, suffix });
|
|
43
|
+
generateFile({ name, targetPath, templateFn: scssTemplate, suffix: suffix, extension: 'scss' });
|
|
44
|
+
await generateLocales({ targetPath, name, isView: true });
|
|
45
|
+
await generateTest({ name, targetPath, suffix });
|
|
61
46
|
} catch (err) {
|
|
62
47
|
handleError("Failed to generate view", err);
|
|
63
48
|
}
|
|
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 {
|
|
@@ -77,31 +82,11 @@ if (create) {
|
|
|
77
82
|
|
|
78
83
|
// Helper Functions
|
|
79
84
|
async function generateTest({ targetPath, name, suffix }) {
|
|
80
|
-
|
|
81
|
-
if (!nutinConfig.testinNutin.includeApp) {
|
|
82
|
-
print.warn('⚠️ Enable includeApp in nutin.config.js testinNutin object to use test files.');
|
|
83
|
-
if (!process.stdin.isTTY) {
|
|
84
|
-
print.warn('Non-interactive shell detected — skipping test file generation.');
|
|
85
|
-
isGenerate = false;
|
|
86
|
-
} else {
|
|
87
|
-
isGenerate = await promptBoolean('Do you want to generate the test file anyway ?');
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
if (isGenerate) generateFile({ targetPath, name, templateFn: testTemplate, extension: 'test.js', suffix: suffix });
|
|
85
|
+
if (nutinConfig.testinNutin.includeApp) generateFile({ targetPath, name, templateFn: testTemplate, extension: 'test.js', suffix: suffix });
|
|
91
86
|
}
|
|
92
87
|
|
|
93
|
-
async function generateLocales({ targetPath, name }) {
|
|
94
|
-
|
|
95
|
-
if (!nutinConfig.i18n) {
|
|
96
|
-
print.warn('⚠️ Enable i18n in nutin.config.js to use json-based content per language.');
|
|
97
|
-
if (!process.stdin.isTTY) {
|
|
98
|
-
print.warn('Non-interactive shell detected — skipping locales file generation.');
|
|
99
|
-
isGenerate = false;
|
|
100
|
-
} else {
|
|
101
|
-
isGenerate = await promptBoolean('Do you want to generate the locales file(s) anyway ?');
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
if (isGenerate) generateLocalesJson({ targetPath, name });
|
|
88
|
+
async function generateLocales({ targetPath, name, isView }) {
|
|
89
|
+
if (nutinConfig.i18n) generateLocalesJson({ targetPath, name, isView });
|
|
105
90
|
}
|
|
106
91
|
|
|
107
92
|
function showUsageAndExit(message) {
|
|
@@ -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,32 +21,11 @@ export function generateFile({
|
|
|
22
21
|
fs.writeFileSync(filePath, template);
|
|
23
22
|
}
|
|
24
23
|
|
|
25
|
-
export function
|
|
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
|
-
export function generateLocalesJson({ targetPath, name }) {
|
|
24
|
+
export function generateLocalesJson({ targetPath, name, isView }) {
|
|
47
25
|
const localesDir = `${targetPath}/locales`;
|
|
48
26
|
fs.mkdirSync(localesDir, { recursive: true });
|
|
49
27
|
|
|
50
|
-
const template = localeTemplate(name);
|
|
28
|
+
const template = localeTemplate(name, isView);
|
|
51
29
|
|
|
52
30
|
const failedLangs = [];
|
|
53
31
|
for (const lang of LANGUAGES) {
|
|
@@ -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
|
});
|
|
@@ -2,6 +2,16 @@ import { localeTemplate } from './locale.template.js';
|
|
|
2
2
|
|
|
3
3
|
describe('localeTemplate', () => {
|
|
4
4
|
it('renders a locale JSON stub with a "default" key', () => {
|
|
5
|
-
expect(localeTemplate({ pascal: 'Widget' })).toBe('{\n "default": "Widget works !"\n}\n');
|
|
5
|
+
expect(localeTemplate({ pascal: 'Widget', capitalized: 'Widget' })).toBe('{\n "default": "Widget works !"\n}\n');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('does not include a "title" key when isView is false', () => {
|
|
9
|
+
expect(localeTemplate({ pascal: 'Widget', capitalized: 'Widget' }, false)).toBe('{\n "default": "Widget works !"\n}\n');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('includes a top-level "title" key seeded from the capitalized name when isView is true', () => {
|
|
13
|
+
expect(localeTemplate({ pascal: 'Widget', capitalized: 'Widget' }, true)).toBe(
|
|
14
|
+
'{\n "title": "Widget",\n "default": "Widget works !"\n}\n'
|
|
15
|
+
);
|
|
6
16
|
});
|
|
7
17
|
});
|
|
@@ -2,7 +2,7 @@ import { viewTemplate } from './view.template.js';
|
|
|
2
2
|
|
|
3
3
|
describe('viewTemplate', () => {
|
|
4
4
|
it('renders a view class importing View via the relative core path', () => {
|
|
5
|
-
const name = { pascal: 'Widget' };
|
|
5
|
+
const name = { kebab: 'widget', pascal: 'Widget' };
|
|
6
6
|
const targetPath = 'src/app/components/widget';
|
|
7
7
|
|
|
8
8
|
// The generator always leaves the html body as a placeholder — the builder
|
|
@@ -14,7 +14,7 @@ const template = \`__TEMPLATE_PLACEHOLDER__\`;
|
|
|
14
14
|
|
|
15
15
|
export class WidgetView extends View {
|
|
16
16
|
constructor() {
|
|
17
|
-
super({template});
|
|
17
|
+
super({ template, viewName: 'widget' });
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
}
|
|
@@ -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
|
+
}
|
|
@@ -42,6 +42,11 @@ server {
|
|
|
42
42
|
__PORTS_PLACEHOLDER__
|
|
43
43
|
server_name _;
|
|
44
44
|
|
|
45
|
+
# Keep nginx's own automatic redirects (e.g. the missing-trailing-slash 301 on a
|
|
46
|
+
# prerendered route directory) relative, so they never leak this container's
|
|
47
|
+
# internal port/scheme when served behind a reverse proxy or a remapped port.
|
|
48
|
+
absolute_redirect off;
|
|
49
|
+
|
|
45
50
|
# ---------------------------
|
|
46
51
|
# ROOT + ROUTING
|
|
47
52
|
# ---------------------------
|
|
@@ -51,6 +56,10 @@ server {
|
|
|
51
56
|
# Hide nginx version
|
|
52
57
|
server_tokens off;
|
|
53
58
|
|
|
59
|
+
# With i18n enabled, redirects bare "/" to the default language so the served URL
|
|
60
|
+
# always matches each page's canonical/og:url (filled in by validate-docker).
|
|
61
|
+
__ROOT_REDIRECT_PLACEHOLDER__
|
|
62
|
+
|
|
54
63
|
# SPA routing: serve index.html for unknown paths
|
|
55
64
|
location / {
|
|
56
65
|
try_files $uri $uri/ /index.html;
|
|
@@ -112,6 +121,12 @@ server {
|
|
|
112
121
|
# --------------------------
|
|
113
122
|
error_page 404 /404.html;
|
|
114
123
|
|
|
124
|
+
# A route directory that exists on disk but has no index.html of its own (e.g. a
|
|
125
|
+
# parent route whose only prerendered pages are its children) makes try_files'
|
|
126
|
+
# $uri/ candidate match the directory and stop there, returning 403 instead of
|
|
127
|
+
# falling through to the SPA shell. Serve the shell for that case too.
|
|
128
|
+
error_page 403 = /index.html;
|
|
129
|
+
|
|
115
130
|
location = /404.html {
|
|
116
131
|
internal;
|
|
117
132
|
try_files /404.html =404;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
1
2
|
import path from 'path';
|
|
2
3
|
import { errorExit } from '../../utils/index.js';
|
|
3
4
|
import nutinConfig from '../../../nutin.config.js';
|
|
@@ -6,6 +7,16 @@ import { renderFromTemplate } from './render-docker-template.js';
|
|
|
6
7
|
|
|
7
8
|
const DOCKER_DIR = path.join(process.cwd(), 'tools', 'docker');
|
|
8
9
|
const PLACEHOLDER = '__PORTS_PLACEHOLDER__';
|
|
10
|
+
const ROOT_REDIRECT_PLACEHOLDER = '__ROOT_REDIRECT_PLACEHOLDER__';
|
|
11
|
+
|
|
12
|
+
// Only i18n builds serve every page under a language prefix — without it, "/" is a real page.
|
|
13
|
+
function rootRedirect() {
|
|
14
|
+
if (!nutinConfig.i18n) return '';
|
|
15
|
+
|
|
16
|
+
const languagesConfigPath = path.join(process.cwd(), 'config', 'languages.json');
|
|
17
|
+
const { defaultLanguage } = JSON.parse(fs.readFileSync(languagesConfigPath, 'utf-8'));
|
|
18
|
+
return `location = / {\n return 301 /${defaultLanguage}/$is_args$args;\n }`;
|
|
19
|
+
}
|
|
9
20
|
|
|
10
21
|
async function validateDocker() {
|
|
11
22
|
let ports;
|
|
@@ -22,6 +33,7 @@ async function validateDocker() {
|
|
|
22
33
|
|
|
23
34
|
await renderFromTemplate(DOCKER_DIR, 'nginx.conf.template', 'nginx.conf', [
|
|
24
35
|
[PLACEHOLDER, ports.map((port) => `listen ${port};`).join('\n ')],
|
|
36
|
+
[ROOT_REDIRECT_PLACEHOLDER, rootRedirect()],
|
|
25
37
|
]).catch((err) => errorExit(err.message));
|
|
26
38
|
}
|
|
27
39
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
// global components export
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
// global services export
|