@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
|
@@ -74,9 +74,16 @@ describe('PipeHelper', () => {
|
|
|
74
74
|
expect(container.querySelector('input').value).toBe('FROM SOURCE');
|
|
75
75
|
});
|
|
76
76
|
|
|
77
|
-
it('
|
|
78
|
-
|
|
77
|
+
it('warns and skips a pipe segment that resolves to an empty name, continuing the rest of the chain', () => {
|
|
78
|
+
const warnSpy = spyOn(console, 'warn');
|
|
79
|
+
warnSpy.andCallFake(() => {});
|
|
80
|
+
|
|
81
|
+
container.innerHTML = '<span data-pipe=":arg|upper">hi</span>';
|
|
79
82
|
PipeHelper.parsePipeAttributes(container);
|
|
80
|
-
|
|
83
|
+
|
|
84
|
+
expect(container.querySelector('span').textContent).toBe('HI');
|
|
85
|
+
expect(warnSpy.callCount).toBe(1);
|
|
86
|
+
|
|
87
|
+
warnSpy.restore();
|
|
81
88
|
});
|
|
82
89
|
});
|
|
@@ -29,7 +29,10 @@ export class PipeHelper {
|
|
|
29
29
|
for (const pipe of pipes) {
|
|
30
30
|
const [pipeName, ...argParts] = pipe.split(':');
|
|
31
31
|
|
|
32
|
-
if(!pipeName)
|
|
32
|
+
if (!pipeName) {
|
|
33
|
+
console.warn(`Empty pipe name in segment "${pipe}" - skipping.`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
33
36
|
|
|
34
37
|
const args = argParts.length ? argParts.join(':').split(',') : [];
|
|
35
38
|
value = AppPipeRegistry.apply(pipeName.trim(), value, args.map(a => a.trim()));
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { View } from '#root/dist/src/core/index.js';
|
|
2
2
|
|
|
3
3
|
class HomeView extends View {}
|
|
4
|
-
class MyFancyView extends View {}
|
|
5
4
|
class NamedView extends View {}
|
|
6
5
|
|
|
7
6
|
describe('View', () => {
|
|
@@ -16,38 +15,36 @@ describe('View', () => {
|
|
|
16
15
|
app = null;
|
|
17
16
|
});
|
|
18
17
|
|
|
19
|
-
it('
|
|
20
|
-
|
|
21
|
-
expect(view.viewName).toBe('home');
|
|
18
|
+
it('throws when viewName is omitted', () => {
|
|
19
|
+
expect(() => new HomeView({})).toThrow();
|
|
22
20
|
});
|
|
23
21
|
|
|
24
|
-
it('
|
|
25
|
-
|
|
26
|
-
expect(view.viewName).toBe('my-fancy');
|
|
22
|
+
it('throws when viewName is an empty string', () => {
|
|
23
|
+
expect(() => new HomeView({ viewName: '' })).toThrow();
|
|
27
24
|
});
|
|
28
25
|
|
|
29
|
-
it('uses
|
|
26
|
+
it('uses the explicit viewName option', () => {
|
|
30
27
|
const view = new NamedView({ viewName: 'custom-name' });
|
|
31
28
|
expect(view.viewName).toBe('custom-name');
|
|
32
29
|
});
|
|
33
30
|
|
|
34
31
|
it('generateTemplate() returns the constructor template', () => {
|
|
35
|
-
const view = new HomeView({ template: '<p>hi</p>' });
|
|
32
|
+
const view = new HomeView({ viewName: 'home', template: '<p>hi</p>' });
|
|
36
33
|
expect(view.render().innerHTML).toBe('<p>hi</p>');
|
|
37
34
|
});
|
|
38
35
|
|
|
39
36
|
it('defaults the template to an empty string', () => {
|
|
40
|
-
const view = new HomeView({});
|
|
37
|
+
const view = new HomeView({ viewName: 'home' });
|
|
41
38
|
expect(view.render().innerHTML).toBe('');
|
|
42
39
|
});
|
|
43
40
|
|
|
44
41
|
it('defaults the tagName to "section"', () => {
|
|
45
|
-
const view = new HomeView({});
|
|
42
|
+
const view = new HomeView({ viewName: 'home' });
|
|
46
43
|
expect(view.getElement().tagName).toBe('SECTION');
|
|
47
44
|
});
|
|
48
45
|
|
|
49
46
|
it('setRouteParams/getRouteParams/getRouteParam/hasRouteParam manage route params', () => {
|
|
50
|
-
const view = new HomeView({});
|
|
47
|
+
const view = new HomeView({ viewName: 'home' });
|
|
51
48
|
expect(view.hasRouteParam('id')).toBe(false);
|
|
52
49
|
expect(view.getRouteParam('id')).toBeUndefined();
|
|
53
50
|
|
|
@@ -59,7 +56,7 @@ describe('View', () => {
|
|
|
59
56
|
});
|
|
60
57
|
|
|
61
58
|
it('getRouteParams() returns a copy, not a live reference', () => {
|
|
62
|
-
const view = new HomeView({});
|
|
59
|
+
const view = new HomeView({ viewName: 'home' });
|
|
63
60
|
view.setRouteParams({ id: '1' });
|
|
64
61
|
const params = view.getRouteParams();
|
|
65
62
|
params.id = 'mutated';
|
|
@@ -67,7 +64,7 @@ describe('View', () => {
|
|
|
67
64
|
});
|
|
68
65
|
|
|
69
66
|
it('onEnter and onExit are present as no-op hooks', () => {
|
|
70
|
-
const view = new HomeView({});
|
|
67
|
+
const view = new HomeView({ viewName: 'home' });
|
|
71
68
|
expect(() => view.onEnter()).not.toThrow();
|
|
72
69
|
expect(() => view.onExit()).not.toThrow();
|
|
73
70
|
});
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { BaseComponent, BaseComponentOptions } from '../../index.js';
|
|
2
2
|
|
|
3
3
|
export interface ViewOptions extends BaseComponentOptions {
|
|
4
|
-
viewName
|
|
4
|
+
viewName: string;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
7
|
export abstract class View<T extends HTMLElement = HTMLElement> extends BaseComponent<T> {
|
|
8
|
-
|
|
8
|
+
private _viewName: string;
|
|
9
9
|
protected routeParams: Record<string, string> = {};
|
|
10
10
|
private _template: string;
|
|
11
11
|
|
|
@@ -17,8 +17,13 @@ export abstract class View<T extends HTMLElement = HTMLElement> extends BaseComp
|
|
|
17
17
|
trustLevel,
|
|
18
18
|
}: ViewOptions) {
|
|
19
19
|
super({ mountTarget, tagName, trustLevel });
|
|
20
|
+
if (!viewName) throw new Error('View requires a viewName.');
|
|
20
21
|
this._template = template ?? '';
|
|
21
|
-
this.
|
|
22
|
+
this._viewName = viewName;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
public get viewName(): string {
|
|
26
|
+
return this._viewName;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
protected override generateTemplate(): string {
|
|
@@ -41,12 +46,6 @@ export abstract class View<T extends HTMLElement = HTMLElement> extends BaseComp
|
|
|
41
46
|
return key in this.routeParams && this.routeParams[key] !== undefined;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
private getKebabCaseViewName(): string {
|
|
45
|
-
const className = this.constructor.name;
|
|
46
|
-
const baseName = className.replace(/View$/, '');
|
|
47
|
-
return baseName.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
48
|
-
}
|
|
49
|
-
|
|
50
49
|
// Navigation hooks — called by router only, never by render lifecycle
|
|
51
50
|
public onEnter(): void {}
|
|
52
51
|
public onExit(): void {}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import langs from '../../config/languages.json' with { type: 'json' };
|
|
2
|
+
import seo from '../../config/seo.json' with { type: 'json' };
|
|
2
3
|
import nutinConfig from '../../nutin.config.js';
|
|
3
4
|
|
|
4
5
|
export const CONFIG = {
|
|
5
6
|
langs,
|
|
7
|
+
seo,
|
|
6
8
|
i18n: nutinConfig.i18n,
|
|
9
|
+
generateSEOFiles: nutinConfig.generateSEOFiles,
|
|
7
10
|
} as const;
|
|
@@ -106,6 +106,9 @@ describe('globals (hideGlobals/revealGlobals)', () => {
|
|
|
106
106
|
[...document.body.children].forEach((el) => {
|
|
107
107
|
if (el.id !== 'app') el.remove();
|
|
108
108
|
});
|
|
109
|
+
// Globals is a process-wide singleton - drop its captured-display state so
|
|
110
|
+
// a later test's ids can't inherit an earlier test's original display value.
|
|
111
|
+
Globals.testingResetAll();
|
|
109
112
|
});
|
|
110
113
|
|
|
111
114
|
it('toggle display for every given id that is present', () => {
|
|
@@ -146,4 +149,39 @@ describe('globals (hideGlobals/revealGlobals)', () => {
|
|
|
146
149
|
expect(() => hideGlobals(['missing'])).not.toThrow();
|
|
147
150
|
expect(() => revealGlobals(['missing'])).not.toThrow();
|
|
148
151
|
});
|
|
152
|
+
|
|
153
|
+
it('revealGlobals restores the display value captured when hideGlobals was called', () => {
|
|
154
|
+
const header = document.createElement('div');
|
|
155
|
+
header.id = 'header';
|
|
156
|
+
header.style.display = 'flex';
|
|
157
|
+
document.body.appendChild(header);
|
|
158
|
+
|
|
159
|
+
hideGlobals(['header']);
|
|
160
|
+
expect(header.style.display).toBe('none');
|
|
161
|
+
|
|
162
|
+
revealGlobals(['header']);
|
|
163
|
+
expect(header.style.display).toBe('flex');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('revealGlobals accepts an explicit display override that wins over the captured value', () => {
|
|
167
|
+
const header = document.createElement('div');
|
|
168
|
+
header.id = 'header';
|
|
169
|
+
header.style.display = 'flex';
|
|
170
|
+
document.body.appendChild(header);
|
|
171
|
+
|
|
172
|
+
hideGlobals(['header']);
|
|
173
|
+
revealGlobals(['header'], 'inline-block');
|
|
174
|
+
|
|
175
|
+
expect(header.style.display).toBe('inline-block');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('revealGlobals falls back to "block" when there is no prior hideGlobals call for that id', () => {
|
|
179
|
+
const header = document.createElement('div');
|
|
180
|
+
header.id = 'header';
|
|
181
|
+
document.body.appendChild(header);
|
|
182
|
+
|
|
183
|
+
revealGlobals(['header']);
|
|
184
|
+
|
|
185
|
+
expect(header.style.display).toBe('block');
|
|
186
|
+
});
|
|
149
187
|
});
|
|
@@ -21,6 +21,7 @@ export interface RegisterGlobalsOptions {
|
|
|
21
21
|
|
|
22
22
|
export class Globals extends Service<Globals> {
|
|
23
23
|
private _mounted: Record<string, GlobalMountable> = {};
|
|
24
|
+
private _originalDisplay: Record<string, string> = {};
|
|
24
25
|
|
|
25
26
|
constructor() { super(); }
|
|
26
27
|
|
|
@@ -51,23 +52,28 @@ export class Globals extends Service<Globals> {
|
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
public hide(globalIds: string[]): void {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
globalIds.forEach((id) => {
|
|
56
|
+
const el = document.getElementById(id) as HTMLElement | null;
|
|
57
|
+
if (!el) return;
|
|
58
|
+
if (!(id in this._originalDisplay)) {
|
|
59
|
+
this._originalDisplay[id] = el.style.display || getComputedStyle(el).display;
|
|
60
|
+
}
|
|
61
|
+
el.style.display = 'none';
|
|
62
|
+
});
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
public reveal(globalIds: string[], display?: string): void {
|
|
62
66
|
globalIds.forEach((id) => {
|
|
63
67
|
const el = document.getElementById(id) as HTMLElement | null;
|
|
64
|
-
if (el)
|
|
68
|
+
if (!el) return;
|
|
69
|
+
el.style.display = display ?? this._originalDisplay[id] ?? 'block';
|
|
65
70
|
});
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
protected onDestroy(): void {
|
|
69
74
|
Object.values(this._mounted).forEach((instance) => instance.destroy?.());
|
|
70
75
|
this._mounted = {};
|
|
76
|
+
this._originalDisplay = {};
|
|
71
77
|
}
|
|
72
78
|
}
|
|
73
79
|
|
|
@@ -89,7 +95,10 @@ export const hideGlobals = (globalIds: string[]): void => {
|
|
|
89
95
|
Globals.getInstance().hide(globalIds);
|
|
90
96
|
};
|
|
91
97
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
/**
|
|
99
|
+
* Reveal the elements with the given ids (as registered via `registerGlobals`), restoring
|
|
100
|
+
* each element's display value as captured by `hideGlobals` — pass `display` to override.
|
|
101
|
+
*/
|
|
102
|
+
export const revealGlobals = (globalIds: string[], display?: string): void => {
|
|
103
|
+
Globals.getInstance().reveal(globalIds, display);
|
|
95
104
|
};
|
|
@@ -197,13 +197,41 @@ describe('HttpClient', () => {
|
|
|
197
197
|
|
|
198
198
|
it('should handle custom headers', async () => {
|
|
199
199
|
fetchMock.mockJsonResponse('https://api.example.com/users', []);
|
|
200
|
-
|
|
200
|
+
|
|
201
201
|
await httpClient.get('https://api.example.com/users', { headers: { 'Authorization': 'Bearer custom-token' } });
|
|
202
|
-
|
|
202
|
+
|
|
203
203
|
const lastCall = fetchMock.getLastCall();
|
|
204
204
|
expect(lastCall.options.headers['Authorization']).toBe('Bearer custom-token');
|
|
205
205
|
});
|
|
206
206
|
|
|
207
|
+
it('should send the constructor default headers on a request with no custom headers', async () => {
|
|
208
|
+
fetchMock.mockJsonResponse('https://api.example.com/users', []);
|
|
209
|
+
|
|
210
|
+
await httpClient.get('https://api.example.com/users');
|
|
211
|
+
|
|
212
|
+
const lastCall = fetchMock.getLastCall();
|
|
213
|
+
expect(lastCall.options.headers['Content-Type']).toBe('application/json');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should merge default headers with per-call headers on different keys', async () => {
|
|
217
|
+
fetchMock.mockJsonResponse('https://api.example.com/users', []);
|
|
218
|
+
|
|
219
|
+
await httpClient.get('https://api.example.com/users', { headers: { 'Authorization': 'Bearer custom-token' } });
|
|
220
|
+
|
|
221
|
+
const lastCall = fetchMock.getLastCall();
|
|
222
|
+
expect(lastCall.options.headers['Content-Type']).toBe('application/json');
|
|
223
|
+
expect(lastCall.options.headers['Authorization']).toBe('Bearer custom-token');
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should let a per-call header override a default header on the same key', async () => {
|
|
227
|
+
fetchMock.mockJsonResponse('https://api.example.com/users', []);
|
|
228
|
+
|
|
229
|
+
await httpClient.get('https://api.example.com/users', { headers: { 'Content-Type': 'text/plain' } });
|
|
230
|
+
|
|
231
|
+
const lastCall = fetchMock.getLastCall();
|
|
232
|
+
expect(lastCall.options.headers['Content-Type']).toBe('text/plain');
|
|
233
|
+
});
|
|
234
|
+
|
|
207
235
|
it('should invoke request interceptors with the final url and options before sending', async () => {
|
|
208
236
|
fetchMock.mockJsonResponse('https://api.example.com/users', []);
|
|
209
237
|
const calls = [];
|
|
@@ -56,7 +56,10 @@ export class HttpClient extends Service<HttpClient> {
|
|
|
56
56
|
): Promise<T> {
|
|
57
57
|
const { controller, timeoutId } = HttpManager.createAbortController(config?.timeout);
|
|
58
58
|
const fullUrl = `${this._baseUrl}${endpoint}`;
|
|
59
|
-
const mergedConfig = {
|
|
59
|
+
const mergedConfig: IRequestConfig = {
|
|
60
|
+
...config,
|
|
61
|
+
headers: { ...this._defaultHeaders, ...config?.headers },
|
|
62
|
+
};
|
|
60
63
|
|
|
61
64
|
try {
|
|
62
65
|
const url = HttpBuilder.buildRequestUrl(fullUrl, config?.queryParams);
|
package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs
CHANGED
|
@@ -39,6 +39,40 @@ describe('NavigationManager', () => {
|
|
|
39
39
|
window.history.pushState = originalPushState;
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it('replaceState calls history.replaceState (not pushState) with the locale-prefixed path', () => {
|
|
43
|
+
const originalReplaceState = window.history.replaceState;
|
|
44
|
+
const originalPushState = window.history.pushState;
|
|
45
|
+
let pushCalled = false;
|
|
46
|
+
let replaceArgs = null;
|
|
47
|
+
|
|
48
|
+
window.history.pushState = () => { pushCalled = true; };
|
|
49
|
+
window.history.replaceState = (state, title, url) => { replaceArgs = url; };
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
NavigationManager.replaceState('/section/first-page');
|
|
53
|
+
expect(replaceArgs).toBe('/section/first-page');
|
|
54
|
+
expect(pushCalled).toBe(false);
|
|
55
|
+
} finally {
|
|
56
|
+
window.history.replaceState = originalReplaceState;
|
|
57
|
+
window.history.pushState = originalPushState;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('replaceState preserves an existing hash fragment', () => {
|
|
62
|
+
const originalReplaceState = window.history.replaceState;
|
|
63
|
+
window.history.pushState({}, '', '/section#some-heading');
|
|
64
|
+
let replaceArgs = null;
|
|
65
|
+
window.history.replaceState = (state, title, url) => { replaceArgs = url; };
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
NavigationManager.replaceState('/section/first-page');
|
|
69
|
+
expect(replaceArgs).toBe('/section/first-page#some-heading');
|
|
70
|
+
} finally {
|
|
71
|
+
window.history.replaceState = originalReplaceState;
|
|
72
|
+
window.history.pushState({}, '', '/');
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
42
76
|
it('should return current path from location', () => {
|
|
43
77
|
// This test assumes a DOM-like environment
|
|
44
78
|
const currentPath = window.location.pathname;
|
|
@@ -191,4 +225,112 @@ describe('NavigationManager', () => {
|
|
|
191
225
|
it('matchPattern returns null when the path does not match the pattern at all', () => {
|
|
192
226
|
expect(NavigationManager.matchPattern('/posts/:id', '/other/123')).toBe(null);
|
|
193
227
|
});
|
|
228
|
+
|
|
229
|
+
it('updateDocumentTitle falls back to the view\'s viewName when generateSEOFiles is disabled', () => {
|
|
230
|
+
CONFIG.generateSEOFiles = false;
|
|
231
|
+
CONFIG.seo = { routes: [{ path: '/', title: 'Should not be used' }] };
|
|
232
|
+
try {
|
|
233
|
+
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
234
|
+
expect(document.title).toBe('home');
|
|
235
|
+
} finally {
|
|
236
|
+
CONFIG.generateSEOFiles = false;
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('updateDocumentTitle falls back to the view\'s viewName when no seo.json route matches the pattern', () => {
|
|
241
|
+
CONFIG.generateSEOFiles = true;
|
|
242
|
+
CONFIG.seo = { routes: [{ path: '/other', title: 'Other' }] };
|
|
243
|
+
try {
|
|
244
|
+
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
245
|
+
expect(document.title).toBe('home');
|
|
246
|
+
} finally {
|
|
247
|
+
CONFIG.generateSEOFiles = false;
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('updateDocumentTitle uses a flat seo.json title when generateSEOFiles is enabled and the pattern matches', () => {
|
|
252
|
+
CONFIG.generateSEOFiles = true;
|
|
253
|
+
CONFIG.seo = { routes: [{ path: '/', title: 'My App — Home' }] };
|
|
254
|
+
try {
|
|
255
|
+
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
256
|
+
expect(document.title).toBe('My App — Home');
|
|
257
|
+
} finally {
|
|
258
|
+
CONFIG.generateSEOFiles = false;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('updateDocumentTitle resolves a per-language seo.json title to the current language', () => {
|
|
263
|
+
CONFIG.generateSEOFiles = true;
|
|
264
|
+
CONFIG.seo = { routes: [{ path: '/', title: { en: 'Home', fr: 'Accueil' } }] };
|
|
265
|
+
I18nService['_LANGUAGES'] = ['en', 'fr'];
|
|
266
|
+
I18nService['_currentLanguage'] = 'fr';
|
|
267
|
+
try {
|
|
268
|
+
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
269
|
+
expect(document.title).toBe('Accueil');
|
|
270
|
+
} finally {
|
|
271
|
+
CONFIG.generateSEOFiles = false;
|
|
272
|
+
I18nService['_currentLanguage'] = 'en';
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('updateDocumentTitle falls back to the default language, then any available language, then viewName', () => {
|
|
277
|
+
CONFIG.generateSEOFiles = true;
|
|
278
|
+
I18nService['_LANGUAGES'] = ['en', 'fr'];
|
|
279
|
+
|
|
280
|
+
CONFIG.seo = { routes: [{ path: '/', title: { fr: 'Accueil' } }] };
|
|
281
|
+
I18nService['_currentLanguage'] = 'en';
|
|
282
|
+
try {
|
|
283
|
+
// current lang ('en') missing -> falls back to default lang's value, present here as 'fr' only,
|
|
284
|
+
// so it falls further to the first available value
|
|
285
|
+
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
286
|
+
expect(document.title).toBe('Accueil');
|
|
287
|
+
|
|
288
|
+
CONFIG.seo = { routes: [{ path: '/', title: {} }] };
|
|
289
|
+
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
290
|
+
expect(document.title).toBe('home');
|
|
291
|
+
} finally {
|
|
292
|
+
CONFIG.generateSEOFiles = false;
|
|
293
|
+
I18nService['_currentLanguage'] = 'en';
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('updateDocumentTitle uses the view\'s locale title when i18n is enabled and no seo.json route matches', () => {
|
|
298
|
+
CONFIG.i18n = true;
|
|
299
|
+
CONFIG.generateSEOFiles = false;
|
|
300
|
+
I18nService['_translations'] = { home: { title: 'Accueil' } };
|
|
301
|
+
try {
|
|
302
|
+
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
303
|
+
expect(document.title).toBe('Accueil');
|
|
304
|
+
} finally {
|
|
305
|
+
CONFIG.i18n = false;
|
|
306
|
+
I18nService['_translations'] = {};
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('updateDocumentTitle prefers seo.json title over the locale title when both are present', () => {
|
|
311
|
+
CONFIG.i18n = true;
|
|
312
|
+
CONFIG.generateSEOFiles = true;
|
|
313
|
+
CONFIG.seo = { routes: [{ path: '/', title: 'From seo.json' }] };
|
|
314
|
+
I18nService['_translations'] = { home: { title: 'From locale' } };
|
|
315
|
+
try {
|
|
316
|
+
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
317
|
+
expect(document.title).toBe('From seo.json');
|
|
318
|
+
} finally {
|
|
319
|
+
CONFIG.i18n = false;
|
|
320
|
+
CONFIG.generateSEOFiles = false;
|
|
321
|
+
I18nService['_translations'] = {};
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('updateDocumentTitle ignores the locale title when i18n is disabled, falling back to viewName', () => {
|
|
326
|
+
CONFIG.i18n = false;
|
|
327
|
+
CONFIG.generateSEOFiles = false;
|
|
328
|
+
I18nService['_translations'] = { home: { title: 'Should not be used' } };
|
|
329
|
+
try {
|
|
330
|
+
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
331
|
+
expect(document.title).toBe('home');
|
|
332
|
+
} finally {
|
|
333
|
+
I18nService['_translations'] = {};
|
|
334
|
+
}
|
|
335
|
+
});
|
|
194
336
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { I18nService } from "../../../index.js";
|
|
1
|
+
import { I18nService, View } from "../../../index.js";
|
|
2
2
|
import { Language } from "../../i18n/languages.js";
|
|
3
3
|
import { CONFIG } from "../../../config.js";
|
|
4
4
|
|
|
@@ -48,18 +48,56 @@ export class NavigationManager {
|
|
|
48
48
|
public static updateHistory(
|
|
49
49
|
normalizedPath: string,
|
|
50
50
|
currentPath: string,
|
|
51
|
-
pushState: boolean
|
|
51
|
+
pushState: boolean,
|
|
52
|
+
hash?: string
|
|
52
53
|
): void {
|
|
53
|
-
const localizedPath = this.addLocalePrefix(normalizedPath);
|
|
54
|
-
|
|
54
|
+
const localizedPath = this.addLocalePrefix(normalizedPath) + (hash ? `#${hash}` : '');
|
|
55
|
+
const currentUrl = window.location.pathname + window.location.hash;
|
|
56
|
+
if (pushState && currentUrl !== localizedPath) {
|
|
55
57
|
window.history.pushState({}, '', localizedPath);
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
// Rewrites the current history entry's URL to match content already rendered
|
|
62
|
+
// (e.g. a route that fell back to a default sub-page) without adding a
|
|
63
|
+
// new back/forward-navigable entry.
|
|
64
|
+
public static replaceState(path: string): void {
|
|
65
|
+
const localizedPath = this.addLocalePrefix(path) + window.location.hash;
|
|
66
|
+
window.history.replaceState({}, '', localizedPath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Scrolls to the element matching `hash` (a bare fragment, no leading "#") if it
|
|
70
|
+
// exists; falls back to the top of the page otherwise (no hash, or a stale/broken
|
|
71
|
+
// id) — matches the browser's own native fallback for an unresolvable fragment.
|
|
72
|
+
public static scrollToHash(hash?: string): void {
|
|
73
|
+
const target = hash ? document.getElementById(hash) : null;
|
|
74
|
+
if (target) {
|
|
75
|
+
target.scrollIntoView({ block: 'start' });
|
|
76
|
+
} else {
|
|
77
|
+
window.scrollTo({ top: 0 });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
public static getCurrentPath(): string {
|
|
60
82
|
return this.normalizePath(window.location.pathname);
|
|
61
83
|
}
|
|
62
84
|
|
|
85
|
+
public static updateDocumentTitle(view: View, pattern: string): void {
|
|
86
|
+
const route = CONFIG.generateSEOFiles
|
|
87
|
+
? CONFIG.seo?.routes?.find((r) => r.path === pattern)
|
|
88
|
+
: undefined;
|
|
89
|
+
const seoTitle = route?.title ? this.resolveSeoTitle(route.title) : undefined;
|
|
90
|
+
const localeTitle = CONFIG.i18n ? I18nService.getTranslationObject<string>(`${view.viewName}.title`) : null;
|
|
91
|
+
document.title = seoTitle || localeTitle || view.viewName;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private static resolveSeoTitle(title: string | Record<string, string>): string | undefined {
|
|
95
|
+
if (typeof title !== 'object') return title;
|
|
96
|
+
const lang = I18nService.currentLanguage;
|
|
97
|
+
const defaultLang = I18nService.defaultLanguage;
|
|
98
|
+
return title[lang] ?? title[defaultLang] ?? Object.values(title)[0];
|
|
99
|
+
}
|
|
100
|
+
|
|
63
101
|
public static matchPattern(pattern: string, path: string): Record<string, string> | null {
|
|
64
102
|
const paramNames: string[] = [];
|
|
65
103
|
|
|
@@ -21,6 +21,7 @@ describe('Router', () => {
|
|
|
21
21
|
|
|
22
22
|
beforeEach(() => {
|
|
23
23
|
CONFIG.i18n = false;
|
|
24
|
+
CONFIG.generateSEOFiles = false;
|
|
24
25
|
window.history.pushState({}, '', '/');
|
|
25
26
|
});
|
|
26
27
|
|
|
@@ -30,6 +31,7 @@ describe('Router', () => {
|
|
|
30
31
|
router = null;
|
|
31
32
|
}
|
|
32
33
|
CONFIG.i18n = false;
|
|
34
|
+
CONFIG.generateSEOFiles = false;
|
|
33
35
|
window.history.pushState({}, '', '/');
|
|
34
36
|
});
|
|
35
37
|
|
|
@@ -58,6 +60,21 @@ describe('Router', () => {
|
|
|
58
60
|
]);
|
|
59
61
|
});
|
|
60
62
|
|
|
63
|
+
it('navigate() applies a view\'s onEnter() history rewrite without it being overwritten by the router\'s own history update', async () => {
|
|
64
|
+
const home = makeView('home');
|
|
65
|
+
const section = makeView('section');
|
|
66
|
+
section.onEnter = () => {
|
|
67
|
+
section.calls.push(['onEnter']);
|
|
68
|
+
window.history.replaceState({}, '', '/section/first-page');
|
|
69
|
+
};
|
|
70
|
+
router = AppRouter({ '/': () => home, '/section': () => section });
|
|
71
|
+
await flushPromises();
|
|
72
|
+
|
|
73
|
+
await router.navigate('/section');
|
|
74
|
+
|
|
75
|
+
expect(window.location.pathname).toBe('/section/first-page');
|
|
76
|
+
});
|
|
77
|
+
|
|
61
78
|
it('navigate() passes matched route params to the rendered view', async () => {
|
|
62
79
|
const home = makeView('home');
|
|
63
80
|
const userView = makeView('user');
|
|
@@ -211,6 +228,82 @@ describe('Router', () => {
|
|
|
211
228
|
expect(home.calls.some(c => c[0] === 'render')).toBe(true);
|
|
212
229
|
});
|
|
213
230
|
|
|
231
|
+
it('sets document.title to the view\'s viewName when generateSEOFiles is disabled', async () => {
|
|
232
|
+
CONFIG.generateSEOFiles = false;
|
|
233
|
+
CONFIG.seo = { routes: [{ path: '/', title: 'Should not be used' }] };
|
|
234
|
+
|
|
235
|
+
const home = makeView('home');
|
|
236
|
+
router = AppRouter({ '/': () => home });
|
|
237
|
+
await flushPromises();
|
|
238
|
+
|
|
239
|
+
expect(document.title).toBe('home');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('falls back to the view\'s viewName when generateSEOFiles is enabled but no seo.json route matches', async () => {
|
|
243
|
+
CONFIG.generateSEOFiles = true;
|
|
244
|
+
CONFIG.seo = { routes: [] };
|
|
245
|
+
|
|
246
|
+
const home = makeView('home');
|
|
247
|
+
router = AppRouter({ '/': () => home });
|
|
248
|
+
await flushPromises();
|
|
249
|
+
|
|
250
|
+
expect(document.title).toBe('home');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('sets document.title from config/seo.json when generateSEOFiles is enabled and a route matches', async () => {
|
|
254
|
+
CONFIG.generateSEOFiles = true;
|
|
255
|
+
CONFIG.seo = { routes: [{ path: '/', title: 'My App — Home' }] };
|
|
256
|
+
|
|
257
|
+
const home = makeView('home');
|
|
258
|
+
router = AppRouter({ '/': () => home });
|
|
259
|
+
await flushPromises();
|
|
260
|
+
|
|
261
|
+
expect(document.title).toBe('My App — Home');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('sets document.title for the /404 route', async () => {
|
|
265
|
+
CONFIG.generateSEOFiles = true;
|
|
266
|
+
CONFIG.seo = { routes: [{ path: '/404', title: 'Not Found' }] };
|
|
267
|
+
|
|
268
|
+
const home = makeView('home');
|
|
269
|
+
const notFound = makeView('not-found');
|
|
270
|
+
router = AppRouter({ '/': () => home, '/404': () => notFound });
|
|
271
|
+
await flushPromises();
|
|
272
|
+
|
|
273
|
+
await router.navigate('/nope');
|
|
274
|
+
|
|
275
|
+
expect(document.title).toBe('Not Found');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('resolves a per-language title when i18n is enabled', async () => {
|
|
279
|
+
CONFIG.i18n = true;
|
|
280
|
+
CONFIG.generateSEOFiles = true;
|
|
281
|
+
CONFIG.seo = { routes: [{ path: '/', title: { en: 'Home', fr: 'Accueil' } }] };
|
|
282
|
+
I18nService['_LANGUAGES'] = ['en', 'fr'];
|
|
283
|
+
I18nService['_currentLanguage'] = 'fr';
|
|
284
|
+
|
|
285
|
+
const home = makeView('home');
|
|
286
|
+
router = AppRouter({ '/': () => home });
|
|
287
|
+
await flushPromises();
|
|
288
|
+
|
|
289
|
+
expect(document.title).toBe('Accueil');
|
|
290
|
+
|
|
291
|
+
I18nService['_currentLanguage'] = 'en';
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('falls back to the view\'s locale title when i18n is enabled and no seo.json route matches', async () => {
|
|
295
|
+
CONFIG.i18n = true;
|
|
296
|
+
I18nService['_translations'] = { home: { title: 'Accueil' } };
|
|
297
|
+
|
|
298
|
+
const home = makeView('home');
|
|
299
|
+
router = AppRouter({ '/': () => home });
|
|
300
|
+
await flushPromises();
|
|
301
|
+
|
|
302
|
+
expect(document.title).toBe('Accueil');
|
|
303
|
+
|
|
304
|
+
I18nService['_translations'] = {};
|
|
305
|
+
});
|
|
306
|
+
|
|
214
307
|
it('removeEventListeners() stops the router from responding to further navigate/popstate events', async () => {
|
|
215
308
|
const home = makeView('home');
|
|
216
309
|
const about = makeView('about');
|