@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/package.json +4 -4
  4. package/templates/base/.gitignore.hbs +1 -0
  5. package/templates/base/AGENTS.md.hbs +3 -2
  6. package/templates/base/GETTING_STARTED.md.hbs +11 -8
  7. package/templates/base/nutin.config.js.hbs +1 -1
  8. package/templates/base/src/app/components/.gitkeep +0 -0
  9. package/templates/base/src/app/routes.ts.hbs +2 -1
  10. package/templates/base/src/app/services/.gitkeep +0 -0
  11. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +24 -0
  12. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +39 -0
  13. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +14 -0
  14. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +14 -3
  15. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +34 -0
  16. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +25 -3
  17. package/templates/base/src/core/services/router/router.test.js.hbs +15 -0
  18. package/templates/base/src/core/services/router/router.ts.hbs +19 -11
  19. package/templates/base/src/index.html.hbs +1 -0
  20. package/templates/base/src/styles/_styles.scss.hbs +1 -113
  21. package/templates/base/src/styles/main.scss.hbs +1 -1
  22. package/templates/base/tools/builder/builder.js.hbs +8 -2
  23. package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
  24. package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
  25. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +11 -2
  26. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +19 -3
  27. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +24 -1
  28. package/templates/base/tools/builder/core/seo/route-output.js.hbs +2 -2
  29. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +5 -1
  30. package/templates/base/tools/builder/core/styles/sass.js.hbs +5 -4
  31. package/templates/base/tools/dev/dev-serve.js.hbs +43 -4
  32. package/templates/base/tools/dev/watcher.js.hbs +42 -26
  33. package/templates/base/tools/generator/generator.js.hbs +22 -17
  34. package/templates/base/tools/generator/handle-file.js.hbs +0 -22
  35. package/templates/base/tools/generator/handle-file.test.js.hbs +1 -20
  36. package/templates/base/tools/utils/build-lock.js.hbs +69 -0
  37. package/templates/base/tools/utils/index.js.hbs +1 -0
  38. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +2 -2
  39. package/templates/features/docker/tools/docker/nginx.conf.template +15 -0
  40. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +12 -0
  41. package/templates/base/src/app/components/index.ts.hbs +0 -1
  42. package/templates/base/src/app/services/index.ts.hbs +0 -1
  43. 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
@@ -28,7 +28,7 @@ npx @nutin/cli
28
28
  ## Update Nutin
29
29
 
30
30
  ```bash
31
- # update Nutin while preserving your changes
31
+ # update to latest version while preserving your changes
32
32
  nutin-update
33
33
  ```
34
34
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nutin/cli",
3
- "version": "2.1.0",
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
- "publish": "npm publish --access public"
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/loiskouninef/nutin.git"
45
+ "url": "git+https://github.com/LoisKOUNINEF/nutin.git"
46
46
  },
47
47
  "bugs": {
48
- "url": "https://github.com/loiskouninef/nutin/issues"
48
+ "url": "https://github.com/LoisKOUNINEF/nutin/issues"
49
49
  },
50
50
  "homepage": "www.nutin.org"
51
51
  }
@@ -7,6 +7,7 @@ pnpm-debug.log*
7
7
 
8
8
  /dist/
9
9
  /dist-build/
10
+ /.build-lock/
10
11
  /coverage/
11
12
 
12
13
  # Generated by "validate-docker" (run via docker:build) from tools/docker/*.template — only present when the docker feature is added
@@ -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 everything from barrel files (`core/index.js`, `components/index.js`, `views/index.js`, `services/index.js`) — do not deep-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`, supports nested paths, updates the nearest `index.ts` barrel).
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
- Generated elements are automatically exported from the corresponding barrel file:
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 component path/to/my-component/ # Target dir: src/app/components/path/to/my-component/
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
@@ -16,7 +16,7 @@ export default {
16
16
  bundle: true,
17
17
  minify: true,
18
18
  sourcemap: false,
19
- target: ['es2015'],
19
+ target: ['es2020'],
20
20
  drop: ['console', 'debugger'],
21
21
  },
22
22
  },
File without changes
@@ -1,5 +1,6 @@
1
1
  import { Routes } from '../core/index.js';
2
- import { HomeView, NotFoundView } from './views/index.js';
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
@@ -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,
@@ -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
- if (pushState && window.location.pathname !== localizedPath) {
54
+ const localizedPath = this.addLocalePrefix(normalizedPath) + (hash ? `#${hash}` : '');
55
+ const currentUrl = window.location.pathname + window.location.hash;
56
+ if (pushState && currentUrl !== localizedPath) {
55
57
  window.history.pushState({}, '', localizedPath);
56
58
  }
57
59
  }
58
60
 
61
+ // Rewrites the current history entry's URL to match content already rendered
62
+ // (e.g. a route that fell back to a default sub-page) without adding a
63
+ // new back/forward-navigable entry.
64
+ public static replaceState(path: string): void {
65
+ const localizedPath = this.addLocalePrefix(path) + window.location.hash;
66
+ window.history.replaceState({}, '', localizedPath);
67
+ }
68
+
69
+ // Scrolls to the element matching `hash` (a bare fragment, no leading "#") if it
70
+ // exists; falls back to the top of the page otherwise (no hash, or a stale/broken
71
+ // id) — matches the browser's own native fallback for an unresolvable fragment.
72
+ public static scrollToHash(hash?: string): void {
73
+ const target = hash ? document.getElementById(hash) : null;
74
+ if (target) {
75
+ target.scrollIntoView({ block: 'start' });
76
+ } else {
77
+ window.scrollTo({ top: 0 });
78
+ }
79
+ }
80
+
59
81
  public static getCurrentPath(): string {
60
82
  return this.normalizePath(window.location.pathname);
61
83
  }
@@ -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 normalizedPath = NavigationManager.normalizePath(path);
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.updateHistory(normalizedPath, currentPath, pushState);
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
  /**
@@ -2,6 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
5
6
  <link rel="icon" type="image/x-icon" href="/favicon.ico" />
6
7
  </head>
7
8
  <body>
@@ -1,113 +1 @@
1
- @use "variables" as *;
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
- // CSS reset & global styles
1
+ // Global styles
2
2
  @use "styles";