@nutin/cli 2.0.0 → 2.1.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +4 -7
  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 +1 -1
  6. package/templates/base/AGENTS.md.hbs +0 -1
  7. package/templates/base/GETTING_STARTED.md.hbs +30 -58
  8. package/templates/base/nutin.config.js.hbs +0 -7
  9. package/templates/base/src/app/main.ts.hbs +0 -1
  10. package/templates/base/src/app/views/home/home.view.ts.hbs +1 -1
  11. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +1 -1
  12. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +22 -0
  13. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +5 -2
  14. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +6 -0
  15. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +1 -1
  16. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +10 -3
  17. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +4 -1
  18. package/templates/base/src/core/base-classes/view/view.test.js.hbs +11 -14
  19. package/templates/base/src/core/base-classes/view/view.ts.hbs +8 -9
  20. package/templates/base/src/core/config.ts.hbs +3 -0
  21. package/templates/base/src/core/services/globals/globals.test.js.hbs +38 -0
  22. package/templates/base/src/core/services/globals/globals.ts.hbs +19 -10
  23. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +30 -2
  24. package/templates/base/src/core/services/http-client/http-client.ts.hbs +4 -1
  25. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +108 -0
  26. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +17 -1
  27. package/templates/base/src/core/services/router/router.test.js.hbs +78 -0
  28. package/templates/base/src/core/services/router/router.ts.hbs +7 -4
  29. package/templates/base/tools/generator/generator.js.hbs +10 -30
  30. package/templates/base/tools/generator/handle-file.js.hbs +2 -2
  31. package/templates/base/tools/generator/templates/locale/locale.template.js.hbs +5 -1
  32. package/templates/base/tools/generator/templates/locale/locale.template.test.js.hbs +11 -1
  33. package/templates/base/tools/generator/templates/view/view.template.js.hbs +1 -1
  34. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.0
4
+
5
+ ### Breaking Changes
6
+
7
+ - `View`'s `viewName` is now a required constructor option. Every `View` subclass must now pass `viewName` explicitly.
8
+ - Removed the `generator` object from `nutin.config.js`. The generator now always scaffolds a stylesheet, and gates locale/test file generation on the existing `i18n` and `testinNutin.includeApp` flags.
9
+
10
+ ### Fixes
11
+
12
+ - Restored `document.title` updates on route change, regressed silently in 2.0.0's rewrite, default to the view's `viewName`.
13
+ - Fixed leaked-listener/subscription bug that affected every re-rendering component. Previously-mounted child components (single or catalog) are now properly destroyed before a re-render mounts their replacements.
14
+ - `HttpClient`'s constructor `defaultHeaders` are now actually merged into outgoing requests (previously silently dropped — only per-call `config.headers` were sent).
15
+ - `data-optional` elements with literal `"null"` text content (e.g. `<span data-optional>null</span>`) are now correctly removed, matching the existing `"undefined"` handling.
16
+ - `revealGlobals` now restores the element's original `display` value by default (captured automatically by `hideGlobals`), instead of always forcing `block`; an optional second argument still allows an explicit override.
17
+ - A `data-pipe` chain segment with an empty name (e.g. `data-pipe=":arg"`) now logs a warning and is skipped, instead of silently aborting the entire chain with no write-back.
18
+ - Removed a redundant duplicate translation-file fetch on startup when i18n is enabled.
19
+
3
20
  ## 2.0.0
4
21
 
5
22
  Version 2.0.0 focuses on simplicity and clear APIs over feature accumulation: fewer abstractions, clearer responsibilities, and a more focused API.
package/README.md CHANGED
@@ -32,15 +32,12 @@ npx @nutin/cli
32
32
  nutin-update
33
33
  ```
34
34
 
35
- ## Add Docker
35
+ ## Add Feature
36
36
 
37
37
  ```bash
38
- # ready-to-use Dockerfile for Nutin apps
39
- nutin-add docker
38
+ nutin-add <feature>
40
39
  ```
41
40
 
42
- ## Useful links
41
+ ## Documentation
43
42
 
44
- - [Nutin website](https://www.nutin.org)
45
- - [First app tutorial](https://www.nutin.org/tutorial)
46
- - [Repository documentation](https://github.com/LoisKOUNINEF/nutin/tree/main/docs)
43
+ - [Repository documentation](https://github.com/LoisKOUNINEF/nutin/tree/main/docs/API.md)
@@ -3,12 +3,11 @@ import { PACKAGE_HOMEPAGE } from '../common/package-data.mjs';
3
3
 
4
4
  export function displaySuccessMessage(answers) {
5
5
  const { projectName, packageManager, deployHelper } = answers;
6
+ print.info(`Visit https://${PACKAGE_HOMEPAGE}`);
7
+
6
8
  print.boldSuccess('\nšŸŽ‰ Your project is ready!\n');
7
9
 
8
10
  print.boldInfo('Next steps:');
9
11
  print.info(`cd ${projectName}`);
10
12
  print.info(`${packageManager} run serve\n`);
11
-
12
- print.boldInfo('Documentation:');
13
- print.info(`https://${PACKAGE_HOMEPAGE}\n`);
14
13
  }
@@ -98,6 +98,12 @@ export class ProjectUpdater {
98
98
  }
99
99
 
100
100
  if (!yes && (toUpdate.length > 0 || toAdd.length > 0)) {
101
+ if (!process.stdin.isTTY) {
102
+ print.warn('\nāš ļø Non-interactive shell detected — skipping the confirmation prompt.');
103
+ print.boldInfo('Re-run with --yes to apply these updates automatically. No files were changed.');
104
+ return;
105
+ }
106
+
101
107
  const { proceed } = await inquirer.prompt([
102
108
  {
103
109
  type: 'confirm',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nutin/cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "The structure of a framework. The freedom of vanilla.",
5
5
  "preferGlobal": true,
6
6
  "main": "lib/index.mjs",
@@ -119,7 +119,6 @@ AppPipeRegistry.register('json');
119
119
 
120
120
  Central options/tools config (see the inline comments for details):
121
121
  - `i18n`, `tailwind`, `generateSEO`, `dockerPorts` — top-level feature toggles (`dockerPorts` only applies once the docker feature is added).
122
- - `generator.{generateStylesheet, generateLocales, generateTest}` — what the generator scaffolds alongside new elements.
123
122
  - `builder.{sass.paths, esbuild}` — build pipeline behavior.
124
123
  - `testinNutin.{includeFramework, includeTools, includeApp, jsdomOptions}` — test discovery/environment.
125
124
 
@@ -3,20 +3,17 @@
3
3
  ## Table of contents
4
4
 
5
5
  - [Project structure](#project-structure)
6
- - [Running your app](#running-your-app)
7
- - [Generate components, views and services](#generate-components-views-and-services)
8
- - [A typical component](#a-typical-component)
9
- - [Features and configuration](#features-and-configuration)
6
+ - [How do I run my app?](#running-your-app)
7
+ - [How do I generate components, views and services?](#generate-components-views-and-services)
8
+ - [What does a typical component look like?](#a-typical-component)
9
+ - [Options and configuration](#options-and-configuration)
10
10
  - [Styling](#styling)
11
- - [Global layout components](#global-layout-components)
12
11
  - [Testing](#testing)
13
- - [Useful links](#useful-links)
12
+ - [Go further](#go-further)
14
13
 
15
14
  ## Project structure
16
15
 
17
16
  ```
18
- config/ -- configure i18n and SEO files
19
-
20
17
  src/
21
18
  app/
22
19
  components/
@@ -33,7 +30,15 @@ tools/ -- build, development, generator and testing
33
30
  nutin.config.js -- features and configuration
34
31
  ```
35
32
 
36
- ## Running your app
33
+ ### Imports
34
+
35
+ Imports use **relative paths** and end with `.js` extension.
36
+
37
+ ```js
38
+ import { Foo } from 'relative/path/to/foo.js';
39
+ ```
40
+
41
+ ## How do I run my app?
37
42
 
38
43
  ```bash
39
44
  # Development
@@ -45,15 +50,7 @@ nutin.config.js -- features and configuration
45
50
  {{packageManager}} run build:prod # build the application for production
46
51
  ```
47
52
 
48
- ### Imports
49
-
50
- Imports must use **relative paths** and end with `.js` extension.
51
-
52
- ```js
53
- import { Foo } from 'relative/path/to/foo.js';
54
- ```
55
-
56
- ## Generate components, views and services
53
+ ## How do I generate components, views and services?
57
54
 
58
55
  Generated elements are automatically exported from the corresponding barrel file:
59
56
  `src/app/<components|views|services>/index.ts`.
@@ -67,7 +64,7 @@ Generated elements are automatically exported from the corresponding barrel file
67
64
  {{packageManager}} run generate component path/to/my-component/ # Target dir: src/app/components/path/to/my-component/
68
65
  ```
69
66
 
70
- ## A typical component
67
+ ## What does a typical component look like?
71
68
 
72
69
  ```bash
73
70
  components/
@@ -146,29 +143,21 @@ A template can also be written directly inline (skip the `.html` file and the pl
146
143
 
147
144
  Defining both an inline template and an external `.html` file, or neither, fails the build.
148
145
 
149
- ## Features and configuration
150
-
151
- Nutin features are enabled in `nutin.config.js`.
146
+ ## Options and configuration
152
147
 
153
- ### `nutin.config.js`
148
+ ### Main file
154
149
 
155
- Main Nutin configuration and tool options. See the inline comments for details.
150
+ `nutin.config.js` - Main Nutin configuration and tool options. See the inline comments for details.
156
151
 
157
- - `tailwind`, `i18n`, `generateSEOFiles` — top-level features, `false` by default.
158
- - `generator.*` — controls what the generator scaffolds.
152
+ - `tailwind`, `i18n`, `generateSEOFiles` — top-level features, `false` by default.
153
+ See [Options and features](https://github.com/LoisKOUNINEF/nutin/tree/main/docs/OPTIONS_AND_FEATURES.md).
159
154
  - `builder.*` — controls build behavior.
160
- - `testinNutin.*` — controls TestinNutin.
161
-
162
- ### `config/seo.json`
163
-
164
- - Register your routes' description, title and ogImage here.
165
- - Data is used to generate static `index.html` per route, `robots.txt`, and `sitemap.xml`.
166
- - Unused if `nutin.config.js -> generateSEOFiles` is disabled.
155
+ - `testinNutin.*` — controls Nutin's built-in testing toolkit.
167
156
 
168
- ### `config/languages.json`
157
+ ### Specialized files
169
158
 
170
- - Register supported languages for i18n here.
171
- - Unused if `nutin.config.js -> i18n` is disabled.
159
+ - `config/seo.json` - with `generateSEOFiles` enabled. Register your routes' description, title and ogImage here.
160
+ - `config/languages.json` - with `i18n` enabled. Register supported languages here.
172
161
 
173
162
  ## Styling
174
163
 
@@ -178,26 +167,11 @@ Main Nutin configuration and tool options. See the inline comments for details.
178
167
 
179
168
  **Note:** Installing Tailwind CSS dependencies may print `npm warn install-scripts`. This is expected with npm 12's default install-script allowlisting and is safe to ignore.
180
169
 
181
- ## Global layout components
182
-
183
- If you need components to render outside `<main>` (such as a header or footer), register them with `registerGlobals()` in `main.ts` constructor.
184
-
185
- Global components are mounted directly in `<body>` and remain present across route changes. Use `before` for components that should appear before `<main>` and `after` for components that should appear after it.
186
-
187
- ```ts
188
- registerGlobals({
189
- before: [{ component: HeaderComponent, id: 'header' }],
190
- after: [{ component: FooterComponent, id: 'footer' }],
191
- });
192
- ```
193
-
194
- **Note**: `hideGlobals(globalIds: string[])` / `revealGlobals(globalIds: string[])` are exported alongside `registerGlobals` and can be called to toggle visibility of the ids you registered.
195
-
196
170
  ## Testing
197
171
 
198
172
  You can use any testing tool you prefer for application code.
199
173
 
200
- This project includes TestinNutin, Nutin's lightweight testing toolkit. Nutin itself is tested exclusively with it, and the tests are included in the generated project so you can:
174
+ This project includes [testin-nutin](https://github.com/LoisKOUNINEF/nutin/tree/main/docs/TESTING.md), Nutin's lightweight testing toolkit. Nutin itself is tested exclusively with it, and the tests are included in the generated project so you can:
201
175
  - use them as executable documentation;
202
176
  - run and modify Nutin's tests when modifying its code.
203
177
 
@@ -205,7 +179,7 @@ Run the tests with:
205
179
 
206
180
  `{{packageManager}} run testin-nutin`
207
181
 
208
- To use TestinNutin for application tests, enable:
182
+ To use testin-nutin for application tests, enable:
209
183
 
210
184
  ```js
211
185
  // nutin.config.js
@@ -218,10 +192,8 @@ testinNutin: {
218
192
  import { MyModule } from '#root/dist/src/path/to/my-module.js';
219
193
  ```
220
194
 
221
- See **[TestinNutin docs](https://nutin.org/docs/testin-nutin)**.
195
+ ## Go further
222
196
 
223
- ## Useful links
224
-
225
- - [Nutin website](https://www.nutin.org)
197
+ - [Repository documentation](https://github.com/LoisKOUNINEF/nutin/tree/main/docs/API.md)
226
198
  - [First app tutorial](https://www.nutin.org/tutorial)
227
- - [Repository documentation](https://github.com/LoisKOUNINEF/nutin/tree/main/docs)
199
+ - [Nutin website](https://www.nutin.org)
@@ -3,13 +3,6 @@ export default {
3
3
  tailwind: false, // Enable Tailwind CSS v4
4
4
  i18n: false, // Enable i18n; configure languages in config/languages.json
5
5
  generateSEOFiles: false, // Generate per-route HTML, robots.txt and sitemap.xml; configure SEO data in config/seo.json
6
-
7
- // Components / Views scaffolding
8
- generator: {
9
- generateStylesheet: false, // Generate a .scss file alongside each component or view
10
- generateLocales: false, // Generate locale files alongside components and views (requires i18n)
11
- generateTest: false, // Generate test files alongside components, views and services (requires testinNutin.includeApp)
12
- },
13
6
 
14
7
  // Build pipeline
15
8
  builder: {
@@ -12,7 +12,6 @@ class App {
12
12
  document.addEventListener('DOMContentLoaded', async () => {
13
13
  if (nutinConfig.i18n) {
14
14
  await I18nService.initTranslations();
15
- await I18nService.loadTranslations(I18nService.currentLanguage);
16
15
  }
17
16
  new App();
18
17
  });
@@ -4,6 +4,6 @@ const template = `__TEMPLATE_PLACEHOLDER__`;
4
4
 
5
5
  export class HomeView extends View {
6
6
  constructor() {
7
- super({template});
7
+ super({ template, viewName: 'home' });
8
8
  }
9
9
  }
@@ -4,7 +4,7 @@ const template = `__TEMPLATE_PLACEHOLDER__`;
4
4
 
5
5
  export class NotFoundView extends View {
6
6
  constructor() {
7
- super({template});
7
+ super({ template, viewName: 'not-found' });
8
8
  }
9
9
 
10
10
  private _handleHome(): void {
@@ -62,6 +62,28 @@ describe('ChildrenHelper', () => {
62
62
  expect(children.length).toBe(0);
63
63
  });
64
64
 
65
+ it('destroys previously-tracked children and resets the array before mounting new ones on a second call', () => {
66
+ let destroyCount = 0;
67
+ element.innerHTML = '<div data-component="widget"></div>';
68
+ const component = {
69
+ registerChildren: () => [{
70
+ selector: 'widget',
71
+ factory: () => ({ render: () => {}, destroy: () => destroyCount++ }),
72
+ }],
73
+ };
74
+
75
+ const children = [];
76
+ ChildrenHelper.addChildren(component, element, children);
77
+ expect(children.length).toBe(1);
78
+ expect(destroyCount).toBe(0);
79
+
80
+ element.innerHTML = '<div data-component="widget"></div>';
81
+ ChildrenHelper.addChildren(component, element, children);
82
+
83
+ expect(destroyCount).toBe(1);
84
+ expect(children.length).toBe(1);
85
+ });
86
+
65
87
  it('destroyChildren calls destroy on every child', () => {
66
88
  let destroyCount = 0;
67
89
  const children = [
@@ -2,10 +2,13 @@ import { BaseComponent, ComponentConfig } from '../base-component.js';
2
2
 
3
3
  export class ChildrenHelper {
4
4
  public static addChildren(
5
- component: BaseComponent,
6
- element: HTMLElement,
5
+ component: BaseComponent,
6
+ element: HTMLElement,
7
7
  children: BaseComponent[]
8
8
  ): void {
9
+ this.destroyChildren(children);
10
+ children.length = 0;
11
+
9
12
  const configs: ComponentConfig[] = component.registerChildren();
10
13
  configs.forEach(config => {
11
14
  element.querySelectorAll(`[data-component="${config.selector}"]`).forEach(el => {
@@ -109,6 +109,12 @@ describe('DomHelper', () => {
109
109
  expect(container.querySelector('span')).toBe(null);
110
110
  });
111
111
 
112
+ it('cleanupOptionalContent removes an optional element whose textContent is the literal string "null"', () => {
113
+ container.innerHTML = '<span data-optional>null</span>';
114
+ DomHelper.cleanupOptionalContent();
115
+ expect(container.querySelector('span')).toBe(null);
116
+ });
117
+
112
118
  it('cleanupOptionalContent keeps an optional element with real textContent', () => {
113
119
  container.innerHTML = '<span data-optional>hello</span>';
114
120
  DomHelper.cleanupOptionalContent();
@@ -40,7 +40,7 @@ export class DomHelper {
40
40
  }
41
41
 
42
42
  const content = el.textContent?.trim();
43
- return !content || content === "undefined";
43
+ return !content || content === "undefined" || content === "null";
44
44
  };
45
45
 
46
46
  const isValueUndefined = (el: HTMLElement): boolean => {
@@ -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);
@@ -191,4 +191,112 @@ describe('NavigationManager', () => {
191
191
  it('matchPattern returns null when the path does not match the pattern at all', () => {
192
192
  expect(NavigationManager.matchPattern('/posts/:id', '/other/123')).toBe(null);
193
193
  });
194
+
195
+ it('updateDocumentTitle falls back to the view\'s viewName when generateSEOFiles is disabled', () => {
196
+ CONFIG.generateSEOFiles = false;
197
+ CONFIG.seo = { routes: [{ path: '/', title: 'Should not be used' }] };
198
+ try {
199
+ NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
200
+ expect(document.title).toBe('home');
201
+ } finally {
202
+ CONFIG.generateSEOFiles = false;
203
+ }
204
+ });
205
+
206
+ it('updateDocumentTitle falls back to the view\'s viewName when no seo.json route matches the pattern', () => {
207
+ CONFIG.generateSEOFiles = true;
208
+ CONFIG.seo = { routes: [{ path: '/other', title: 'Other' }] };
209
+ try {
210
+ NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
211
+ expect(document.title).toBe('home');
212
+ } finally {
213
+ CONFIG.generateSEOFiles = false;
214
+ }
215
+ });
216
+
217
+ it('updateDocumentTitle uses a flat seo.json title when generateSEOFiles is enabled and the pattern matches', () => {
218
+ CONFIG.generateSEOFiles = true;
219
+ CONFIG.seo = { routes: [{ path: '/', title: 'My App — Home' }] };
220
+ try {
221
+ NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
222
+ expect(document.title).toBe('My App — Home');
223
+ } finally {
224
+ CONFIG.generateSEOFiles = false;
225
+ }
226
+ });
227
+
228
+ it('updateDocumentTitle resolves a per-language seo.json title to the current language', () => {
229
+ CONFIG.generateSEOFiles = true;
230
+ CONFIG.seo = { routes: [{ path: '/', title: { en: 'Home', fr: 'Accueil' } }] };
231
+ I18nService['_LANGUAGES'] = ['en', 'fr'];
232
+ I18nService['_currentLanguage'] = 'fr';
233
+ try {
234
+ NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
235
+ expect(document.title).toBe('Accueil');
236
+ } finally {
237
+ CONFIG.generateSEOFiles = false;
238
+ I18nService['_currentLanguage'] = 'en';
239
+ }
240
+ });
241
+
242
+ it('updateDocumentTitle falls back to the default language, then any available language, then viewName', () => {
243
+ CONFIG.generateSEOFiles = true;
244
+ I18nService['_LANGUAGES'] = ['en', 'fr'];
245
+
246
+ CONFIG.seo = { routes: [{ path: '/', title: { fr: 'Accueil' } }] };
247
+ I18nService['_currentLanguage'] = 'en';
248
+ try {
249
+ // current lang ('en') missing -> falls back to default lang's value, present here as 'fr' only,
250
+ // so it falls further to the first available value
251
+ NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
252
+ expect(document.title).toBe('Accueil');
253
+
254
+ CONFIG.seo = { routes: [{ path: '/', title: {} }] };
255
+ NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
256
+ expect(document.title).toBe('home');
257
+ } finally {
258
+ CONFIG.generateSEOFiles = false;
259
+ I18nService['_currentLanguage'] = 'en';
260
+ }
261
+ });
262
+
263
+ it('updateDocumentTitle uses the view\'s locale title when i18n is enabled and no seo.json route matches', () => {
264
+ CONFIG.i18n = true;
265
+ CONFIG.generateSEOFiles = false;
266
+ I18nService['_translations'] = { home: { title: 'Accueil' } };
267
+ try {
268
+ NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
269
+ expect(document.title).toBe('Accueil');
270
+ } finally {
271
+ CONFIG.i18n = false;
272
+ I18nService['_translations'] = {};
273
+ }
274
+ });
275
+
276
+ it('updateDocumentTitle prefers seo.json title over the locale title when both are present', () => {
277
+ CONFIG.i18n = true;
278
+ CONFIG.generateSEOFiles = true;
279
+ CONFIG.seo = { routes: [{ path: '/', title: 'From seo.json' }] };
280
+ I18nService['_translations'] = { home: { title: 'From locale' } };
281
+ try {
282
+ NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
283
+ expect(document.title).toBe('From seo.json');
284
+ } finally {
285
+ CONFIG.i18n = false;
286
+ CONFIG.generateSEOFiles = false;
287
+ I18nService['_translations'] = {};
288
+ }
289
+ });
290
+
291
+ it('updateDocumentTitle ignores the locale title when i18n is disabled, falling back to viewName', () => {
292
+ CONFIG.i18n = false;
293
+ CONFIG.generateSEOFiles = false;
294
+ I18nService['_translations'] = { home: { title: 'Should not be used' } };
295
+ try {
296
+ NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
297
+ expect(document.title).toBe('home');
298
+ } finally {
299
+ I18nService['_translations'] = {};
300
+ }
301
+ });
194
302
  });
@@ -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
 
@@ -60,6 +60,22 @@ export class NavigationManager {
60
60
  return this.normalizePath(window.location.pathname);
61
61
  }
62
62
 
63
+ public static updateDocumentTitle(view: View, pattern: string): void {
64
+ const route = CONFIG.generateSEOFiles
65
+ ? CONFIG.seo?.routes?.find((r) => r.path === pattern)
66
+ : undefined;
67
+ const seoTitle = route?.title ? this.resolveSeoTitle(route.title) : undefined;
68
+ const localeTitle = CONFIG.i18n ? I18nService.getTranslationObject<string>(`${view.viewName}.title`) : null;
69
+ document.title = seoTitle || localeTitle || view.viewName;
70
+ }
71
+
72
+ private static resolveSeoTitle(title: string | Record<string, string>): string | undefined {
73
+ if (typeof title !== 'object') return title;
74
+ const lang = I18nService.currentLanguage;
75
+ const defaultLang = I18nService.defaultLanguage;
76
+ return title[lang] ?? title[defaultLang] ?? Object.values(title)[0];
77
+ }
78
+
63
79
  public static matchPattern(pattern: string, path: string): Record<string, string> | null {
64
80
  const paramNames: string[] = [];
65
81
 
@@ -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
 
@@ -211,6 +213,82 @@ describe('Router', () => {
211
213
  expect(home.calls.some(c => c[0] === 'render')).toBe(true);
212
214
  });
213
215
 
216
+ it('sets document.title to the view\'s viewName when generateSEOFiles is disabled', async () => {
217
+ CONFIG.generateSEOFiles = false;
218
+ CONFIG.seo = { routes: [{ path: '/', title: 'Should not be used' }] };
219
+
220
+ const home = makeView('home');
221
+ router = AppRouter({ '/': () => home });
222
+ await flushPromises();
223
+
224
+ expect(document.title).toBe('home');
225
+ });
226
+
227
+ it('falls back to the view\'s viewName when generateSEOFiles is enabled but no seo.json route matches', async () => {
228
+ CONFIG.generateSEOFiles = true;
229
+ CONFIG.seo = { routes: [] };
230
+
231
+ const home = makeView('home');
232
+ router = AppRouter({ '/': () => home });
233
+ await flushPromises();
234
+
235
+ expect(document.title).toBe('home');
236
+ });
237
+
238
+ it('sets document.title from config/seo.json when generateSEOFiles is enabled and a route matches', async () => {
239
+ CONFIG.generateSEOFiles = true;
240
+ CONFIG.seo = { routes: [{ path: '/', title: 'My App — Home' }] };
241
+
242
+ const home = makeView('home');
243
+ router = AppRouter({ '/': () => home });
244
+ await flushPromises();
245
+
246
+ expect(document.title).toBe('My App — Home');
247
+ });
248
+
249
+ it('sets document.title for the /404 route', async () => {
250
+ CONFIG.generateSEOFiles = true;
251
+ CONFIG.seo = { routes: [{ path: '/404', title: 'Not Found' }] };
252
+
253
+ const home = makeView('home');
254
+ const notFound = makeView('not-found');
255
+ router = AppRouter({ '/': () => home, '/404': () => notFound });
256
+ await flushPromises();
257
+
258
+ await router.navigate('/nope');
259
+
260
+ expect(document.title).toBe('Not Found');
261
+ });
262
+
263
+ it('resolves a per-language title when i18n is enabled', async () => {
264
+ CONFIG.i18n = true;
265
+ CONFIG.generateSEOFiles = true;
266
+ CONFIG.seo = { routes: [{ path: '/', title: { en: 'Home', fr: 'Accueil' } }] };
267
+ I18nService['_LANGUAGES'] = ['en', 'fr'];
268
+ I18nService['_currentLanguage'] = 'fr';
269
+
270
+ const home = makeView('home');
271
+ router = AppRouter({ '/': () => home });
272
+ await flushPromises();
273
+
274
+ expect(document.title).toBe('Accueil');
275
+
276
+ I18nService['_currentLanguage'] = 'en';
277
+ });
278
+
279
+ it('falls back to the view\'s locale title when i18n is enabled and no seo.json route matches', async () => {
280
+ CONFIG.i18n = true;
281
+ I18nService['_translations'] = { home: { title: 'Accueil' } };
282
+
283
+ const home = makeView('home');
284
+ router = AppRouter({ '/': () => home });
285
+ await flushPromises();
286
+
287
+ expect(document.title).toBe('Accueil');
288
+
289
+ I18nService['_translations'] = {};
290
+ });
291
+
214
292
  it('removeEventListeners() stops the router from responding to further navigate/popstate events', async () => {
215
293
  const home = makeView('home');
216
294
  const about = makeView('about');
@@ -15,6 +15,7 @@ export type IRouter = InstanceType<typeof Router>;
15
15
  export interface RouteMatch {
16
16
  route: RouteConfig;
17
17
  params: Record<string, string>;
18
+ pattern: string;
18
19
  }
19
20
 
20
21
  class Router extends Service<Router> {
@@ -69,10 +70,11 @@ class Router extends Service<Router> {
69
70
  this._currentView = await ViewRenderManager.transitionOutCurrentView(this._currentView);
70
71
  this._currentParams = routeMatch.params;
71
72
  this._currentView = ViewRenderManager.renderNewView(
72
- guardResult.viewConstructor!,
73
+ guardResult.viewConstructor!,
73
74
  routeMatch.params
74
75
  );
75
-
76
+
77
+ NavigationManager.updateDocumentTitle(this._currentView, routeMatch.pattern);
76
78
  NavigationManager.updateHistory(normalizedPath, currentPath, pushState);
77
79
  window.scrollTo({ top: 0 });
78
80
  }
@@ -122,7 +124,7 @@ class Router extends Service<Router> {
122
124
  for (const [pattern, routeConfig] of Object.entries(this.routes)) {
123
125
  const match = NavigationManager.matchPattern(pattern, path);
124
126
  if (match) {
125
- return { route: routeConfig, params: match };
127
+ return { route: routeConfig, params: match, pattern };
126
128
  }
127
129
  }
128
130
  return null;
@@ -145,7 +147,8 @@ class Router extends Service<Router> {
145
147
  this._currentView = await ViewRenderManager.transitionOutCurrentView(this._currentView);
146
148
  this._currentParams = {};
147
149
  this._currentView = ViewRenderManager.renderNewView(notFoundConstructor, {});
148
-
150
+
151
+ NavigationManager.updateDocumentTitle(this._currentView, '/404');
149
152
  NavigationManager.updateHistory(normalizedPath, currentPath, pushState);
150
153
  }
151
154
 
@@ -27,7 +27,7 @@ const creators = {
27
27
  try {
28
28
  generateFile({ name, targetPath, templateFn: serviceTemplate, suffix: suffix });
29
29
  appendToIndex({ name, targetPath, suffix: suffix });
30
- if (nutinConfig.generator.generateTest) await generateTest({ name, targetPath, suffix: suffix });
30
+ await generateTest({ name, targetPath, suffix: suffix });
31
31
  } catch (err) {
32
32
  handleError("Failed to generate service", err);
33
33
  }
@@ -39,10 +39,10 @@ const creators = {
39
39
  try {
40
40
  generateFile({ name, targetPath, templateFn: componentTemplate, suffix: suffix });
41
41
  generateFile({ name, targetPath, templateFn: htmlTemplate, suffix: suffix, extension: 'html' });
42
- if (nutinConfig.generator.generateStylesheet) generateFile({ name, targetPath, templateFn: scssTemplate, suffix: suffix, extension: 'scss' });
43
- if (nutinConfig.generator.generateLocales) await generateLocales({ targetPath, name });
42
+ generateFile({ name, targetPath, templateFn: scssTemplate, suffix: suffix, extension: 'scss' });
43
+ await generateLocales({ targetPath, name, isView: false });
44
44
  appendToIndex({ name, targetPath, suffix: suffix });
45
- if (nutinConfig.generator.generateTest) await generateTest({ name, targetPath, suffix });
45
+ await generateTest({ name, targetPath, suffix });
46
46
  } catch (err) {
47
47
  handleError("Failed to generate component", err);
48
48
  }
@@ -54,10 +54,10 @@ const creators = {
54
54
  try {
55
55
  generateFile({ name, targetPath, templateFn: viewTemplate, suffix: suffix });
56
56
  generateFile({ name, targetPath, templateFn: htmlTemplate, suffix: suffix, extension: 'html' });
57
- if (nutinConfig.generator.generateStylesheet) generateFile({ name, targetPath, templateFn: scssTemplate, suffix: suffix, extension: 'scss' });
58
- if (nutinConfig.generator.generateLocales) await generateLocales({ targetPath, name });
57
+ generateFile({ name, targetPath, templateFn: scssTemplate, suffix: suffix, extension: 'scss' });
58
+ await generateLocales({ targetPath, name, isView: true });
59
59
  appendToIndex({ name, targetPath, suffix: suffix });
60
- if (nutinConfig.generator.generateTest) await generateTest({ name, targetPath, suffix });
60
+ await generateTest({ name, targetPath, suffix });
61
61
  } catch (err) {
62
62
  handleError("Failed to generate view", err);
63
63
  }
@@ -77,31 +77,11 @@ if (create) {
77
77
 
78
78
  // Helper Functions
79
79
  async function generateTest({ targetPath, name, suffix }) {
80
- let isGenerate = true;
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 });
80
+ if (nutinConfig.testinNutin.includeApp) generateFile({ targetPath, name, templateFn: testTemplate, extension: 'test.js', suffix: suffix });
91
81
  }
92
82
 
93
- async function generateLocales({ targetPath, name }) {
94
- let isGenerate = true;
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 });
83
+ async function generateLocales({ targetPath, name, isView }) {
84
+ if (nutinConfig.i18n) generateLocalesJson({ targetPath, name, isView });
105
85
  }
106
86
 
107
87
  function showUsageAndExit(message) {
@@ -43,11 +43,11 @@ export function appendToIndex({ name, targetPath, suffix }) {
43
43
  }
44
44
  }
45
45
 
46
- export function generateLocalesJson({ targetPath, name }) {
46
+ export function generateLocalesJson({ targetPath, name, isView }) {
47
47
  const localesDir = `${targetPath}/locales`;
48
48
  fs.mkdirSync(localesDir, { recursive: true });
49
49
 
50
- const template = localeTemplate(name);
50
+ const template = localeTemplate(name, isView);
51
51
 
52
52
  const failedLangs = [];
53
53
  for (const lang of LANGUAGES) {
@@ -1,4 +1,8 @@
1
- export const localeTemplate = (name) => `{
1
+ export const localeTemplate = (name, isView) => isView ? `{
2
+ "title": "${name.capitalized}",
3
+ "default": "${name.pascal} works !"
4
+ }
5
+ ` : `{
2
6
  "default": "${name.pascal} works !"
3
7
  }
4
8
  `;
@@ -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
  });
@@ -10,7 +10,7 @@ const template = \`${htmlTemplate}\`;
10
10
 
11
11
  export class ${name.pascal}View extends View {
12
12
  constructor() {
13
- super({template});
13
+ super({ template, viewName: '${name.kebab}' });
14
14
  }
15
15
 
16
16
  }
@@ -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
  }