@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +5 -8
  3. package/lib/src/create-app/success-message.mjs +2 -3
  4. package/lib/src/update-app/project-updater.mjs +6 -0
  5. package/package.json +4 -4
  6. package/templates/base/.gitignore.hbs +1 -0
  7. package/templates/base/AGENTS.md.hbs +3 -3
  8. package/templates/base/GETTING_STARTED.md.hbs +40 -65
  9. package/templates/base/nutin.config.js.hbs +1 -8
  10. package/templates/base/src/app/components/.gitkeep +0 -0
  11. package/templates/base/src/app/main.ts.hbs +0 -1
  12. package/templates/base/src/app/routes.ts.hbs +2 -1
  13. package/templates/base/src/app/services/.gitkeep +0 -0
  14. package/templates/base/src/app/views/home/home.view.ts.hbs +1 -1
  15. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +1 -1
  16. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +46 -0
  17. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +5 -2
  18. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +45 -0
  19. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +15 -1
  20. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +14 -3
  21. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +10 -3
  22. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +4 -1
  23. package/templates/base/src/core/base-classes/view/view.test.js.hbs +11 -14
  24. package/templates/base/src/core/base-classes/view/view.ts.hbs +8 -9
  25. package/templates/base/src/core/config.ts.hbs +3 -0
  26. package/templates/base/src/core/services/globals/globals.test.js.hbs +38 -0
  27. package/templates/base/src/core/services/globals/globals.ts.hbs +19 -10
  28. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +30 -2
  29. package/templates/base/src/core/services/http-client/http-client.ts.hbs +4 -1
  30. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +142 -0
  31. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +42 -4
  32. package/templates/base/src/core/services/router/router.test.js.hbs +93 -0
  33. package/templates/base/src/core/services/router/router.ts.hbs +26 -15
  34. package/templates/base/src/index.html.hbs +1 -0
  35. package/templates/base/src/styles/_styles.scss.hbs +1 -113
  36. package/templates/base/src/styles/main.scss.hbs +1 -1
  37. package/templates/base/tools/builder/builder.js.hbs +8 -2
  38. package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
  39. package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
  40. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +11 -2
  41. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +19 -3
  42. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +24 -1
  43. package/templates/base/tools/builder/core/seo/route-output.js.hbs +2 -2
  44. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +5 -1
  45. package/templates/base/tools/builder/core/styles/sass.js.hbs +5 -4
  46. package/templates/base/tools/dev/dev-serve.js.hbs +43 -4
  47. package/templates/base/tools/dev/watcher.js.hbs +42 -26
  48. package/templates/base/tools/generator/generator.js.hbs +32 -47
  49. package/templates/base/tools/generator/handle-file.js.hbs +2 -24
  50. package/templates/base/tools/generator/handle-file.test.js.hbs +1 -20
  51. package/templates/base/tools/generator/templates/locale/locale.template.js.hbs +5 -1
  52. package/templates/base/tools/generator/templates/locale/locale.template.test.js.hbs +11 -1
  53. package/templates/base/tools/generator/templates/view/view.template.js.hbs +1 -1
  54. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  55. package/templates/base/tools/utils/build-lock.js.hbs +69 -0
  56. package/templates/base/tools/utils/index.js.hbs +1 -0
  57. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +2 -2
  58. package/templates/features/docker/tools/docker/nginx.conf.template +15 -0
  59. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +12 -0
  60. package/templates/base/src/app/components/index.ts.hbs +0 -1
  61. package/templates/base/src/app/services/index.ts.hbs +0 -1
  62. 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('stops processing pipes when a pipe segment resolves to an empty name', () => {
78
- container.innerHTML = '<span data-pipe=":arg">hi</span>';
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
- expect(container.querySelector('span').textContent).toBe('hi');
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) return;
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('derives a kebab-case viewName from the class name, stripping a trailing "View"', () => {
20
- const view = new HomeView({});
21
- expect(view.viewName).toBe('home');
18
+ it('throws when viewName is omitted', () => {
19
+ expect(() => new HomeView({})).toThrow();
22
20
  });
23
21
 
24
- it('derives a kebab-case viewName for a multi-word class name', () => {
25
- const view = new MyFancyView({});
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 an explicit viewName option instead of deriving one', () => {
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?: string;
4
+ viewName: string;
5
5
  }
6
6
 
7
7
  export abstract class View<T extends HTMLElement = HTMLElement> extends BaseComponent<T> {
8
- public viewName: string;
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.viewName = viewName || this.getKebabCaseViewName();
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
- this.setDisplay(globalIds, 'none');
55
- }
56
-
57
- public reveal(globalIds: string[]): void {
58
- this.setDisplay(globalIds, 'block');
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
- private setDisplay(globalIds: string[], display: 'none' | 'block'): void {
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) el.style.display = display;
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
- /** Reveal the elements with the given ids (as registered via `registerGlobals`). */
93
- export const revealGlobals = (globalIds: string[]): void => {
94
- Globals.getInstance().reveal(globalIds);
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 = { ...this._defaultHeaders, ...config };
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);
@@ -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
- if (pushState && window.location.pathname !== localizedPath) {
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');