@nutin/cli 2.1.0 → 2.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +54 -0
- package/README.md +1 -1
- package/package.json +4 -4
- package/templates/base/.gitignore.hbs +1 -0
- package/templates/base/AGENTS.md.hbs +3 -2
- package/templates/base/GETTING_STARTED.md.hbs +11 -8
- package/templates/base/nutin.config.js.hbs +1 -1
- package/templates/base/src/app/components/.gitkeep +0 -0
- package/templates/base/src/app/routes.ts.hbs +2 -1
- package/templates/base/src/app/services/.gitkeep +0 -0
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +24 -0
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +39 -0
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +14 -0
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +14 -3
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +34 -0
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +25 -3
- package/templates/base/src/core/services/router/router.test.js.hbs +15 -0
- package/templates/base/src/core/services/router/router.ts.hbs +19 -11
- package/templates/base/src/index.html.hbs +1 -0
- package/templates/base/src/styles/_styles.scss.hbs +1 -113
- package/templates/base/src/styles/main.scss.hbs +1 -1
- package/templates/base/tools/builder/builder.js.hbs +8 -2
- package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
- package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +11 -2
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +19 -3
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +24 -1
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +2 -2
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +5 -1
- package/templates/base/tools/builder/core/styles/sass.js.hbs +5 -4
- package/templates/base/tools/dev/dev-serve.js.hbs +43 -4
- package/templates/base/tools/dev/watcher.js.hbs +42 -26
- package/templates/base/tools/generator/generator.js.hbs +22 -17
- package/templates/base/tools/generator/handle-file.js.hbs +0 -22
- package/templates/base/tools/generator/handle-file.test.js.hbs +1 -20
- package/templates/base/tools/utils/build-lock.js.hbs +69 -0
- package/templates/base/tools/utils/index.js.hbs +1 -0
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +2 -2
- package/templates/features/docker/tools/docker/nginx.conf.template +15 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +12 -0
- package/templates/base/src/app/components/index.ts.hbs +0 -1
- package/templates/base/src/app/services/index.ts.hbs +0 -1
- package/templates/base/src/app/views/index.ts.hbs +0 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 2.1.1
|
|
4
|
+
|
|
5
|
+
### Changes
|
|
6
|
+
|
|
7
|
+
- `src/app` no longer has an imposed structure.
|
|
8
|
+
|
|
9
|
+
The generator now creates elements in `src/app/<path>/` instead of `src/app/<type>s/<path>/` (e.g. `generate view user/user-view` → `src/app/user/user-view/`).
|
|
10
|
+
|
|
11
|
+
New projects still ship `components/`, `services/` (empty) and `views/` as the suggested layout, so pass the folder explicitly to keep it (`generate component components/my-component`).
|
|
12
|
+
|
|
13
|
+
- Removed the `src/app/{components,views,services}/index.ts` barrel files from new projects, and the generator no longer appends exports to them.
|
|
14
|
+
|
|
15
|
+
Import app elements by their file path.
|
|
16
|
+
|
|
17
|
+
Import Nutin elements by the framework's barrel (`src/core/index.ts`).
|
|
18
|
+
|
|
19
|
+
- Sass now compiles every `.scss` file under `src/app` instead of only `src/app/components` and `src/app/views`.
|
|
20
|
+
|
|
21
|
+
- The generator now exits before writing anything if the target folder already exists and isn't empty, since folder names carry no type suffix.
|
|
22
|
+
|
|
23
|
+
- Dev builds (`dev`/`serve`/`build`) now bundle with esbuild too, unminified with a sourcemap and `console`/`debugger` kept, so npm runtime dependencies work in dev without import maps or vendoring. tsc's per-file output is still emitted for testin-nutin.
|
|
24
|
+
|
|
25
|
+
- Sass now resolves `@use "pkg:<package>/<path>"` imports through `node_modules`.
|
|
26
|
+
|
|
27
|
+
- The default `builder.esbuild.target` in `nutin.config.js` is now `es2020` (was `es2015`), which libraries relying on native `async` functions (e.g. Alpine) require. Existing projects keep their own `nutin.config.js` value.
|
|
28
|
+
|
|
29
|
+
- Generated SEO HTML now includes `hreflang` alternates (one per language plus `x-default`) when i18n is enabled, `og:type` and `twitter:card` (`summary_large_image`) meta tags, and an absolute `og:image`/`twitter:image` URL (a relative `ogImage` in `config/seo.json` is now prefixed with `baseUrl`).
|
|
30
|
+
|
|
31
|
+
- The router now supports `#hash` fragments: navigating to `path#id` keeps the hash in the URL and scrolls to the matching element, falling back to the top of the page. The hash is also kept on reload and on language change.
|
|
32
|
+
|
|
33
|
+
- New `NavigationManager.replaceState(path)` (exported from `core/index.ts`) rewrites the current URL without adding a history entry. The router now updates history before rendering the new view, so a view's `onEnter()` can call it without the change being overwritten.
|
|
34
|
+
|
|
35
|
+
- Docker feature: `tools/docker/Dockerfile.template` now defaults to Node `24.21.0` (was `22.23.2`) Alpine `3.24` (was `3.20`, end-of-life) for the nginx runtime image (nginx `1.26` → `1.30`).
|
|
36
|
+
|
|
37
|
+
- Docker feature: in i18n projects, nginx now redirects a bare `/` to the default language (generated into `nginx.conf` through a new `__ROOT_REDIRECT_PLACEHOLDER__` token).
|
|
38
|
+
|
|
39
|
+
### Fixes
|
|
40
|
+
|
|
41
|
+
- `generate` with a missing argument now prints its usage message instead of crashing.
|
|
42
|
+
|
|
43
|
+
- Attributes set on a `data-component` placeholder (`class`, `id`, `aria-*`, `data-*`, …) are now kept on the mounted component's element instead of being dropped. Classes are merged with the component's own.
|
|
44
|
+
|
|
45
|
+
- Builds now take a lock (`.build-lock/`, gitignored) before touching `dist-build`, so concurrent builds (a manual build during `dev`, or stray watcher processes) no longer corrupt each other's output.
|
|
46
|
+
|
|
47
|
+
- The dev watcher no longer drops a file change made while a rebuild is running. It now rebuilds again once the current build finishes.
|
|
48
|
+
|
|
49
|
+
- Stopping `dev` now kills the whole process group of live-server and the watcher (including an in-flight build), so no orphaned watcher processes are left behind. On Windows, the process tree is killed with `taskkill` instead.
|
|
50
|
+
|
|
51
|
+
- SSR now renders views into a real `<main id="app">`, so unset `data-optional` fields are removed from prerendered HTML instead of leaking the literal text `undefined`.
|
|
52
|
+
|
|
53
|
+
- Ctrl/Cmd/Shift/Alt-clicks and middle-clicks on `<a href>` elements with a `data-event` click handler now fall through to the browser's native behaviour (e.g. open in a new tab), instead of being intercepted.
|
|
54
|
+
|
|
55
|
+
- Docker feature: nginx now issues relative redirects (`absolute_redirect off`), so the trailing-slash redirect on prerendered routes no longer leaks the container's internal port or scheme behind a proxy. A route directory without its own `index.html` now serves the SPA shell instead of a 403.
|
|
56
|
+
|
|
3
57
|
## 2.1.0
|
|
4
58
|
|
|
5
59
|
### Breaking Changes
|
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nutin/cli",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "The structure of a framework. The freedom of vanilla.",
|
|
5
5
|
"preferGlobal": true,
|
|
6
6
|
"main": "lib/index.mjs",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"minor": "npm version minor -m 'CI/CD: Bump version to %s'",
|
|
18
18
|
"major": "npm version major -m 'CI/CD: Bump version to %s'",
|
|
19
19
|
"test": "echo \"Error: no test specified\" && exit 1",
|
|
20
|
-
"
|
|
20
|
+
"release": "npm publish --access public"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
23
23
|
"Nutin",
|
|
@@ -42,10 +42,10 @@
|
|
|
42
42
|
},
|
|
43
43
|
"repository": {
|
|
44
44
|
"type": "git",
|
|
45
|
-
"url": "https://github.com/
|
|
45
|
+
"url": "git+https://github.com/LoisKOUNINEF/nutin.git"
|
|
46
46
|
},
|
|
47
47
|
"bugs": {
|
|
48
|
-
"url": "https://github.com/
|
|
48
|
+
"url": "https://github.com/LoisKOUNINEF/nutin/issues"
|
|
49
49
|
},
|
|
50
50
|
"homepage": "www.nutin.org"
|
|
51
51
|
}
|
|
@@ -19,13 +19,14 @@ nutin.config.js build/feature config — see below
|
|
|
19
19
|
|
|
20
20
|
- Favor small, composable components.
|
|
21
21
|
- One `View` = one route.
|
|
22
|
-
- Import
|
|
22
|
+
- Import framework code from the `core/index.js` barrel — do not deep-import into `core/`. App elements have no barrels: import them by file path.
|
|
23
|
+
- `src/app/` layout is free; `components/`, `views/`, `services/` is the default suggestion.
|
|
23
24
|
|
|
24
25
|
## Commands
|
|
25
26
|
|
|
26
27
|
- Serve: `{{packageManager}} run serve` (build + serve), `{{packageManager}} run serve:only` (no rebuild), `{{packageManager}} run dev` (watch mode).
|
|
27
28
|
- Build: `{{packageManager}} run build` (dev) or `{{packageManager}} run build:prod` (production).
|
|
28
|
-
- Generate: `{{packageManager}} run generate ELEMENT ELEMENT_NAME` (`ELEMENT` = `component`/`view`/`service
|
|
29
|
+
- Generate: `{{packageManager}} run generate ELEMENT ELEMENT_NAME` (`ELEMENT` = `component`/`view`/`service`; `ELEMENT_NAME` may be a nested path, generated in `src/app/<path>/`; fails if that folder already exists and isn't empty).
|
|
29
30
|
- Test: `{{packageManager}} run testin-nutin` (build + run), `{{packageManager}} run testin-nutin:only` (no rebuild), `{{packageManager}} run testin-nutin:watch`.
|
|
30
31
|
|
|
31
32
|
## Base classes
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
```
|
|
17
17
|
src/
|
|
18
|
-
app/
|
|
18
|
+
app/ -- suggested layout: organize src/app however you like
|
|
19
19
|
components/
|
|
20
20
|
services/
|
|
21
21
|
views/
|
|
@@ -32,12 +32,14 @@ nutin.config.js -- features and configuration
|
|
|
32
32
|
|
|
33
33
|
### Imports
|
|
34
34
|
|
|
35
|
-
Imports use **relative paths** and end with `.js` extension.
|
|
35
|
+
- Imports use **relative paths** and end with `.js` extension.
|
|
36
36
|
|
|
37
37
|
```js
|
|
38
38
|
import { Foo } from 'relative/path/to/foo.js';
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
- Use Nutin's code barrel file (`src/core/index.js`) to import Nutin's modules.
|
|
42
|
+
|
|
41
43
|
## How do I run my app?
|
|
42
44
|
|
|
43
45
|
```bash
|
|
@@ -52,18 +54,19 @@ import { Foo } from 'relative/path/to/foo.js';
|
|
|
52
54
|
|
|
53
55
|
## How do I generate components, views and services?
|
|
54
56
|
|
|
55
|
-
|
|
56
|
-
`src/app/<components|views|services>/index.ts`.
|
|
57
|
-
|
|
57
|
+
Elements are generated in `src/app/<path>/`, so you choose the project structure. The generator stops if the target folder already exists and isn't empty.
|
|
58
58
|
|
|
59
59
|
```bash
|
|
60
|
-
{{packageManager}} run generate <component|view|service> ELEMENT_NAME
|
|
60
|
+
{{packageManager}} run generate <component|view|service> PATH/TO/ELEMENT_NAME
|
|
61
61
|
|
|
62
62
|
# Examples
|
|
63
|
-
{{packageManager}} run generate component my-component # Target dir: src/app/components/my-component/
|
|
64
|
-
{{packageManager}} run generate
|
|
63
|
+
{{packageManager}} run generate component components/my-component # Target dir: src/app/components/my-component/
|
|
64
|
+
{{packageManager}} run generate view user/user-view # Target dir: src/app/user/user-view/
|
|
65
|
+
{{packageManager}} run generate service user/user-service # Target dir: src/app/user/user-service/
|
|
65
66
|
```
|
|
66
67
|
|
|
68
|
+
Import generated elements by their file path (e.g. `./views/home/home.view.js`).
|
|
69
|
+
|
|
67
70
|
## What does a typical component look like?
|
|
68
71
|
|
|
69
72
|
```bash
|
|
File without changes
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Routes } from '../core/index.js';
|
|
2
|
-
import { HomeView
|
|
2
|
+
import { HomeView } from './views/home/home.view.js';
|
|
3
|
+
import { NotFoundView } from './views/not-found/not-found.view.js';
|
|
3
4
|
|
|
4
5
|
export const appRoutes: Routes = {
|
|
5
6
|
'/': () => new HomeView(),
|
|
File without changes
|
package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ChildrenHelper } from '#root/dist/src/core/base-classes/base-component/helpers/children.helper.js';
|
|
2
|
+
import { BaseComponent } from '#root/dist/src/core/index.js';
|
|
2
3
|
|
|
3
4
|
describe('ChildrenHelper', () => {
|
|
4
5
|
let element;
|
|
@@ -55,6 +56,29 @@ describe('ChildrenHelper', () => {
|
|
|
55
56
|
expect(children.length).toBe(2);
|
|
56
57
|
});
|
|
57
58
|
|
|
59
|
+
it('keeps the placeholder attributes (except data-component) on the mounted child element', () => {
|
|
60
|
+
class Widget extends BaseComponent {
|
|
61
|
+
generateTemplate() { return '<p>widget</p>'; }
|
|
62
|
+
}
|
|
63
|
+
element.innerHTML = '<div data-component="widget" class="my-class" id="my-widget"></div>';
|
|
64
|
+
const component = {
|
|
65
|
+
registerChildren: () => [{
|
|
66
|
+
selector: 'widget',
|
|
67
|
+
factory: (el) => new Widget({ mountTarget: el }),
|
|
68
|
+
}],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const children = [];
|
|
72
|
+
ChildrenHelper.addChildren(component, element, children);
|
|
73
|
+
|
|
74
|
+
const childEl = children[0].getElement();
|
|
75
|
+
expect(element.contains(childEl)).toBe(true);
|
|
76
|
+
expect(childEl.classList.contains('my-class')).toBe(true);
|
|
77
|
+
expect(childEl.id).toBe('my-widget');
|
|
78
|
+
expect(childEl.hasAttribute('data-component')).toBe(false);
|
|
79
|
+
expect(childEl.innerHTML).toBe('<p>widget</p>');
|
|
80
|
+
});
|
|
81
|
+
|
|
58
82
|
it('does nothing when there are no registerChildren', () => {
|
|
59
83
|
const component = { registerChildren: () => [] };
|
|
60
84
|
const children = [];
|
|
@@ -39,6 +39,45 @@ describe('DomHelper', () => {
|
|
|
39
39
|
expect(container.contains(el)).toBe(true);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it('mountElement carries the placeholder attributes onto the element, merging classes', () => {
|
|
43
|
+
const placeholder = document.createElement('div');
|
|
44
|
+
placeholder.className = 'my-class other-class';
|
|
45
|
+
placeholder.id = 'foo';
|
|
46
|
+
placeholder.setAttribute('aria-label', 'Label');
|
|
47
|
+
placeholder.setAttribute('data-extra', 'bar');
|
|
48
|
+
container.appendChild(placeholder);
|
|
49
|
+
|
|
50
|
+
const el = document.createElement('span');
|
|
51
|
+
el.className = 'own-class';
|
|
52
|
+
DomHelper.mountElement(el, placeholder);
|
|
53
|
+
|
|
54
|
+
expect(el.classList.contains('own-class')).toBe(true);
|
|
55
|
+
expect(el.classList.contains('my-class')).toBe(true);
|
|
56
|
+
expect(el.classList.contains('other-class')).toBe(true);
|
|
57
|
+
expect(el.id).toBe('foo');
|
|
58
|
+
expect(el.getAttribute('aria-label')).toBe('Label');
|
|
59
|
+
expect(el.getAttribute('data-extra')).toBe('bar');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('mountElement does not carry data-component over from the placeholder', () => {
|
|
63
|
+
const placeholder = document.createElement('div');
|
|
64
|
+
placeholder.setAttribute('data-component', 'widget');
|
|
65
|
+
container.appendChild(placeholder);
|
|
66
|
+
|
|
67
|
+
const el = document.createElement('span');
|
|
68
|
+
DomHelper.mountElement(el, placeholder);
|
|
69
|
+
|
|
70
|
+
expect(el.hasAttribute('data-component')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('mountElement leaves the element attributes untouched in append mode', () => {
|
|
74
|
+
container.className = 'container-class';
|
|
75
|
+
const el = document.createElement('span');
|
|
76
|
+
DomHelper.mountElement(el, '#mount-target');
|
|
77
|
+
|
|
78
|
+
expect(el.attributes.length).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
42
81
|
it('createElement creates an element with the given tag name', () => {
|
|
43
82
|
const el = DomHelper.createElement('section');
|
|
44
83
|
expect(el.tagName).toBe('SECTION');
|
|
@@ -72,10 +72,24 @@ export class DomHelper {
|
|
|
72
72
|
config.target.appendChild(config.element);
|
|
73
73
|
} else {
|
|
74
74
|
// Replace placeholder mode
|
|
75
|
+
this.copyAttributes(config.target, config.element);
|
|
75
76
|
config.target.replaceWith(config.element);
|
|
76
77
|
}
|
|
77
78
|
} else {
|
|
78
79
|
return;
|
|
79
80
|
}
|
|
80
81
|
}
|
|
82
|
+
|
|
83
|
+
// Carries placeholder attributes (class, id, aria-*, data-*...) onto the mounted
|
|
84
|
+
// element. data-component is skipped so an ancestor's child lookup can't re-match it.
|
|
85
|
+
private static copyAttributes(from: HTMLElement, to: HTMLElement): void {
|
|
86
|
+
Array.from(from.attributes).forEach(attr => {
|
|
87
|
+
if (attr.name === 'data-component') return;
|
|
88
|
+
if (attr.name === 'class') {
|
|
89
|
+
to.classList.add(...Array.from(from.classList));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
to.setAttribute(attr.name, attr.value);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
81
95
|
}
|
|
@@ -32,18 +32,29 @@ export class EventHelper {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
private static createBoundHandler(
|
|
35
|
-
el: Element,
|
|
36
|
-
component: BaseComponent,
|
|
37
|
-
handler: (...args: any[]) => void,
|
|
35
|
+
el: Element,
|
|
36
|
+
component: BaseComponent,
|
|
37
|
+
handler: (...args: any[]) => void,
|
|
38
38
|
rawArgs: string[]
|
|
39
39
|
): EventListener {
|
|
40
40
|
return (event: Event) => {
|
|
41
|
+
if (this.isModifiedAnchorClick(el, event)) return;
|
|
42
|
+
|
|
41
43
|
const resolvedArgs = rawArgs.map(arg => TokenHelper.resolve(arg.trim(), el, event));
|
|
42
44
|
|
|
43
45
|
handler.call(component, ...resolvedArgs);
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
// Lets a real <a href> fall through to native browser behavior (open in new tab, etc.)
|
|
50
|
+
// on a modified or non-primary click, instead of always intercepting via preventDefault.
|
|
51
|
+
private static isModifiedAnchorClick(el: Element, event: Event): boolean {
|
|
52
|
+
if (event.type !== 'click' || el.tagName !== 'A' || !el.getAttribute('href')) return false;
|
|
53
|
+
|
|
54
|
+
const mouseEvent = event as MouseEvent;
|
|
55
|
+
return mouseEvent.ctrlKey || mouseEvent.metaKey || mouseEvent.shiftKey || mouseEvent.altKey || mouseEvent.button !== 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
47
58
|
private static addEvent(
|
|
48
59
|
target: EventTarget,
|
|
49
60
|
event: string,
|
package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs
CHANGED
|
@@ -39,6 +39,40 @@ describe('NavigationManager', () => {
|
|
|
39
39
|
window.history.pushState = originalPushState;
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it('replaceState calls history.replaceState (not pushState) with the locale-prefixed path', () => {
|
|
43
|
+
const originalReplaceState = window.history.replaceState;
|
|
44
|
+
const originalPushState = window.history.pushState;
|
|
45
|
+
let pushCalled = false;
|
|
46
|
+
let replaceArgs = null;
|
|
47
|
+
|
|
48
|
+
window.history.pushState = () => { pushCalled = true; };
|
|
49
|
+
window.history.replaceState = (state, title, url) => { replaceArgs = url; };
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
NavigationManager.replaceState('/section/first-page');
|
|
53
|
+
expect(replaceArgs).toBe('/section/first-page');
|
|
54
|
+
expect(pushCalled).toBe(false);
|
|
55
|
+
} finally {
|
|
56
|
+
window.history.replaceState = originalReplaceState;
|
|
57
|
+
window.history.pushState = originalPushState;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('replaceState preserves an existing hash fragment', () => {
|
|
62
|
+
const originalReplaceState = window.history.replaceState;
|
|
63
|
+
window.history.pushState({}, '', '/section#some-heading');
|
|
64
|
+
let replaceArgs = null;
|
|
65
|
+
window.history.replaceState = (state, title, url) => { replaceArgs = url; };
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
NavigationManager.replaceState('/section/first-page');
|
|
69
|
+
expect(replaceArgs).toBe('/section/first-page#some-heading');
|
|
70
|
+
} finally {
|
|
71
|
+
window.history.replaceState = originalReplaceState;
|
|
72
|
+
window.history.pushState({}, '', '/');
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
42
76
|
it('should return current path from location', () => {
|
|
43
77
|
// This test assumes a DOM-like environment
|
|
44
78
|
const currentPath = window.location.pathname;
|
|
@@ -48,14 +48,36 @@ export class NavigationManager {
|
|
|
48
48
|
public static updateHistory(
|
|
49
49
|
normalizedPath: string,
|
|
50
50
|
currentPath: string,
|
|
51
|
-
pushState: boolean
|
|
51
|
+
pushState: boolean,
|
|
52
|
+
hash?: string
|
|
52
53
|
): void {
|
|
53
|
-
const localizedPath = this.addLocalePrefix(normalizedPath);
|
|
54
|
-
|
|
54
|
+
const localizedPath = this.addLocalePrefix(normalizedPath) + (hash ? `#${hash}` : '');
|
|
55
|
+
const currentUrl = window.location.pathname + window.location.hash;
|
|
56
|
+
if (pushState && currentUrl !== localizedPath) {
|
|
55
57
|
window.history.pushState({}, '', localizedPath);
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
// Rewrites the current history entry's URL to match content already rendered
|
|
62
|
+
// (e.g. a route that fell back to a default sub-page) without adding a
|
|
63
|
+
// new back/forward-navigable entry.
|
|
64
|
+
public static replaceState(path: string): void {
|
|
65
|
+
const localizedPath = this.addLocalePrefix(path) + window.location.hash;
|
|
66
|
+
window.history.replaceState({}, '', localizedPath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Scrolls to the element matching `hash` (a bare fragment, no leading "#") if it
|
|
70
|
+
// exists; falls back to the top of the page otherwise (no hash, or a stale/broken
|
|
71
|
+
// id) — matches the browser's own native fallback for an unresolvable fragment.
|
|
72
|
+
public static scrollToHash(hash?: string): void {
|
|
73
|
+
const target = hash ? document.getElementById(hash) : null;
|
|
74
|
+
if (target) {
|
|
75
|
+
target.scrollIntoView({ block: 'start' });
|
|
76
|
+
} else {
|
|
77
|
+
window.scrollTo({ top: 0 });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
59
81
|
public static getCurrentPath(): string {
|
|
60
82
|
return this.normalizePath(window.location.pathname);
|
|
61
83
|
}
|
|
@@ -60,6 +60,21 @@ describe('Router', () => {
|
|
|
60
60
|
]);
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
it('navigate() applies a view\'s onEnter() history rewrite without it being overwritten by the router\'s own history update', async () => {
|
|
64
|
+
const home = makeView('home');
|
|
65
|
+
const section = makeView('section');
|
|
66
|
+
section.onEnter = () => {
|
|
67
|
+
section.calls.push(['onEnter']);
|
|
68
|
+
window.history.replaceState({}, '', '/section/first-page');
|
|
69
|
+
};
|
|
70
|
+
router = AppRouter({ '/': () => home, '/section': () => section });
|
|
71
|
+
await flushPromises();
|
|
72
|
+
|
|
73
|
+
await router.navigate('/section');
|
|
74
|
+
|
|
75
|
+
expect(window.location.pathname).toBe('/section/first-page');
|
|
76
|
+
});
|
|
77
|
+
|
|
63
78
|
it('navigate() passes matched route params to the rendered view', async () => {
|
|
64
79
|
const home = makeView('home');
|
|
65
80
|
const userView = makeView('user');
|
|
@@ -6,6 +6,7 @@ import { NavigationManager } from './helpers/navigation-manager.helper.js';
|
|
|
6
6
|
|
|
7
7
|
// centralized export
|
|
8
8
|
export { Routes, RouteGuard } from './helpers/route-guard-manager.helper.js';
|
|
9
|
+
export { NavigationManager } from './helpers/navigation-manager.helper.js';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* IRouter is a type alias for the instance of Router, not a true interface
|
|
@@ -29,7 +30,7 @@ class Router extends Service<Router> {
|
|
|
29
30
|
constructor(private routes: Routes) {
|
|
30
31
|
super();
|
|
31
32
|
this.initializeEventListeners();
|
|
32
|
-
this.navigate(NavigationManager.getCurrentPath());
|
|
33
|
+
this.navigate(NavigationManager.getCurrentPath() + window.location.hash);
|
|
33
34
|
this.registerCleanup(this.removeEventListeners);
|
|
34
35
|
}
|
|
35
36
|
|
|
@@ -43,13 +44,14 @@ class Router extends Service<Router> {
|
|
|
43
44
|
|
|
44
45
|
public async reload(): Promise<void> {
|
|
45
46
|
const currentRoute = NavigationManager.getCurrentPath();
|
|
46
|
-
await this.navigate(currentRoute, false);
|
|
47
|
+
await this.navigate(currentRoute + window.location.hash, false);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
public async navigate(path: string | '', pushState: boolean = true): Promise<void> {
|
|
50
|
-
const
|
|
51
|
+
const [rawPath = '', hash] = path.split('#');
|
|
52
|
+
const normalizedPath = NavigationManager.normalizePath(rawPath);
|
|
51
53
|
const currentPath = NavigationManager.getCurrentPath();
|
|
52
|
-
|
|
54
|
+
|
|
53
55
|
// Try to match the route with parameters
|
|
54
56
|
const routeMatch = this.matchRoute(normalizedPath);
|
|
55
57
|
|
|
@@ -59,24 +61,30 @@ class Router extends Service<Router> {
|
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
const guardResult = await this.handleGuards(
|
|
62
|
-
normalizedPath,
|
|
63
|
-
routeMatch.route,
|
|
64
|
-
routeMatch.params,
|
|
64
|
+
normalizedPath,
|
|
65
|
+
routeMatch.route,
|
|
66
|
+
routeMatch.params,
|
|
65
67
|
pushState
|
|
66
68
|
);
|
|
67
|
-
|
|
69
|
+
|
|
68
70
|
if (!guardResult) return;
|
|
69
71
|
|
|
70
72
|
this._currentView = await ViewRenderManager.transitionOutCurrentView(this._currentView);
|
|
71
73
|
this._currentParams = routeMatch.params;
|
|
74
|
+
|
|
75
|
+
// Must run before renderNewView(): a view's onEnter() may rewrite the URL via
|
|
76
|
+
// NavigationManager.replaceState (e.g. canonicalizing a bare route to a default
|
|
77
|
+
// sub-page), which acts on whatever history entry is current — so that entry needs
|
|
78
|
+
// to already be this route's, not the previous view's, before onEnter() fires.
|
|
79
|
+
NavigationManager.updateHistory(normalizedPath, currentPath, pushState, hash);
|
|
80
|
+
|
|
72
81
|
this._currentView = ViewRenderManager.renderNewView(
|
|
73
82
|
guardResult.viewConstructor!,
|
|
74
83
|
routeMatch.params
|
|
75
84
|
);
|
|
76
85
|
|
|
77
86
|
NavigationManager.updateDocumentTitle(this._currentView, routeMatch.pattern);
|
|
78
|
-
NavigationManager.
|
|
79
|
-
window.scrollTo({ top: 0 });
|
|
87
|
+
NavigationManager.scrollToHash(hash);
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
public getCurrentParams(): Record<string, string> {
|
|
@@ -111,7 +119,7 @@ class Router extends Service<Router> {
|
|
|
111
119
|
if (newLocale && newLocale !== I18nService.currentLanguage) {
|
|
112
120
|
await I18nService.setCurrentLanguage(newLocale as Language);
|
|
113
121
|
}
|
|
114
|
-
this.navigate(NavigationManager.getCurrentPath(), false);
|
|
122
|
+
this.navigate(NavigationManager.getCurrentPath() + window.location.hash, false);
|
|
115
123
|
}
|
|
116
124
|
|
|
117
125
|
/**
|
|
@@ -1,113 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
/* ===========
|
|
4
|
-
CSS Reset
|
|
5
|
-
=========== */
|
|
6
|
-
|
|
7
|
-
/* Box sizing */
|
|
8
|
-
|
|
9
|
-
*,
|
|
10
|
-
*::before,
|
|
11
|
-
*::after {
|
|
12
|
-
box-sizing: border-box;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/* Remove default spacing */
|
|
16
|
-
|
|
17
|
-
* {
|
|
18
|
-
margin: 0;
|
|
19
|
-
padding: 0;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/* Document */
|
|
23
|
-
|
|
24
|
-
html {
|
|
25
|
-
font-size: 100%;
|
|
26
|
-
-webkit-text-size-adjust: 100%;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
body {
|
|
30
|
-
min-height: 100vh;
|
|
31
|
-
font: inherit;
|
|
32
|
-
line-height: 1.5;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/* Media */
|
|
36
|
-
|
|
37
|
-
img,
|
|
38
|
-
picture,
|
|
39
|
-
svg,
|
|
40
|
-
canvas,
|
|
41
|
-
video {
|
|
42
|
-
display: block;
|
|
43
|
-
max-width: 100%;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/* Forms */
|
|
47
|
-
|
|
48
|
-
button,
|
|
49
|
-
input,
|
|
50
|
-
select,
|
|
51
|
-
textarea {
|
|
52
|
-
font: inherit;
|
|
53
|
-
color: inherit;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
button {
|
|
57
|
-
background: none;
|
|
58
|
-
border: none;
|
|
59
|
-
cursor: pointer;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
textarea {
|
|
63
|
-
resize: vertical;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/* Tables */
|
|
67
|
-
|
|
68
|
-
table {
|
|
69
|
-
border-collapse: collapse;
|
|
70
|
-
border-spacing: 0;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/* Lists */
|
|
74
|
-
|
|
75
|
-
ul,
|
|
76
|
-
ol {
|
|
77
|
-
list-style: none;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/* Links */
|
|
81
|
-
|
|
82
|
-
a {
|
|
83
|
-
color: inherit;
|
|
84
|
-
text-decoration: none;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/* Quotes */
|
|
88
|
-
|
|
89
|
-
blockquote,
|
|
90
|
-
q {
|
|
91
|
-
quotes: none;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
blockquote::before,
|
|
95
|
-
blockquote::after,
|
|
96
|
-
q::before,
|
|
97
|
-
q::after {
|
|
98
|
-
content: "";
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/* Miscellaneous */
|
|
102
|
-
|
|
103
|
-
hr {
|
|
104
|
-
border: none;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
fieldset {
|
|
108
|
-
border: none;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
:where([hidden]) {
|
|
112
|
-
display: none !important;
|
|
113
|
-
}
|
|
1
|
+
// Global styles
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Global styles
|
|
2
2
|
@use "styles";
|