@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.
- package/CHANGELOG.md +17 -0
- package/README.md +4 -7
- package/lib/src/create-app/success-message.mjs +2 -3
- package/lib/src/update-app/project-updater.mjs +6 -0
- package/package.json +1 -1
- package/templates/base/AGENTS.md.hbs +0 -1
- package/templates/base/GETTING_STARTED.md.hbs +30 -58
- package/templates/base/nutin.config.js.hbs +0 -7
- package/templates/base/src/app/main.ts.hbs +0 -1
- 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 +22 -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 +6 -0
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +1 -1
- 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 +108 -0
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +17 -1
- package/templates/base/src/core/services/router/router.test.js.hbs +78 -0
- package/templates/base/src/core/services/router/router.ts.hbs +7 -4
- package/templates/base/tools/generator/generator.js.hbs +10 -30
- package/templates/base/tools/generator/handle-file.js.hbs +2 -2
- 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/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
|
|
35
|
+
## Add Feature
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
|
|
39
|
-
nutin-add docker
|
|
38
|
+
nutin-add <feature>
|
|
40
39
|
```
|
|
41
40
|
|
|
42
|
-
##
|
|
41
|
+
## Documentation
|
|
43
42
|
|
|
44
|
-
- [
|
|
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
|
@@ -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
|
-
- [
|
|
7
|
-
- [
|
|
8
|
-
- [
|
|
9
|
-
- [
|
|
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
|
-
- [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
##
|
|
150
|
-
|
|
151
|
-
Nutin features are enabled in `nutin.config.js`.
|
|
146
|
+
## Options and configuration
|
|
152
147
|
|
|
153
|
-
###
|
|
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
|
-
|
|
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
|
|
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
|
-
###
|
|
157
|
+
### Specialized files
|
|
169
158
|
|
|
170
|
-
- Register
|
|
171
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
195
|
+
## Go further
|
|
222
196
|
|
|
223
|
-
|
|
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
|
-
- [
|
|
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: {
|
package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs
CHANGED
|
@@ -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('
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -2,6 +2,16 @@ import { localeTemplate } from './locale.template.js';
|
|
|
2
2
|
|
|
3
3
|
describe('localeTemplate', () => {
|
|
4
4
|
it('renders a locale JSON stub with a "default" key', () => {
|
|
5
|
-
expect(localeTemplate({ pascal: 'Widget' })).toBe('{\n "default": "Widget works !"\n}\n');
|
|
5
|
+
expect(localeTemplate({ pascal: 'Widget', capitalized: 'Widget' })).toBe('{\n "default": "Widget works !"\n}\n');
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
it('does not include a "title" key when isView is false', () => {
|
|
9
|
+
expect(localeTemplate({ pascal: 'Widget', capitalized: 'Widget' }, false)).toBe('{\n "default": "Widget works !"\n}\n');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('includes a top-level "title" key seeded from the capitalized name when isView is true', () => {
|
|
13
|
+
expect(localeTemplate({ pascal: 'Widget', capitalized: 'Widget' }, true)).toBe(
|
|
14
|
+
'{\n "title": "Widget",\n "default": "Widget works !"\n}\n'
|
|
15
|
+
);
|
|
6
16
|
});
|
|
7
17
|
});
|
|
@@ -2,7 +2,7 @@ import { viewTemplate } from './view.template.js';
|
|
|
2
2
|
|
|
3
3
|
describe('viewTemplate', () => {
|
|
4
4
|
it('renders a view class importing View via the relative core path', () => {
|
|
5
|
-
const name = { pascal: 'Widget' };
|
|
5
|
+
const name = { kebab: 'widget', pascal: 'Widget' };
|
|
6
6
|
const targetPath = 'src/app/components/widget';
|
|
7
7
|
|
|
8
8
|
// The generator always leaves the html body as a placeholder ā the builder
|
|
@@ -14,7 +14,7 @@ const template = \`__TEMPLATE_PLACEHOLDER__\`;
|
|
|
14
14
|
|
|
15
15
|
export class WidgetView extends View {
|
|
16
16
|
constructor() {
|
|
17
|
-
super({template});
|
|
17
|
+
super({ template, viewName: 'widget' });
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
}
|