@nutin/cli 2.1.1 → 3.0.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 +268 -0
- package/README.md +13 -5
- package/bin/add-feature.mjs +1 -1
- package/bin/cli.mjs +1 -1
- package/bin/create-app.mjs +1 -1
- package/bin/update-app.mjs +1 -1
- package/lib/index.mjs +6 -2
- package/lib/src/add-feature/base-template-patcher.mjs +139 -0
- package/lib/src/add-feature/feature-adder.mjs +20 -9
- package/lib/src/add-feature/feature-context-builder.mjs +1 -0
- package/lib/src/add-feature/nutin-config-updater.mjs +34 -8
- package/lib/src/add-feature/package-json-updater.mjs +7 -9
- package/lib/src/common/feature-registry.mjs +4 -0
- package/lib/src/common/file-generator.mjs +25 -3
- package/lib/src/common/git.mjs +31 -0
- package/lib/src/common/meta-bootstrap-prompt.mjs +28 -2
- package/lib/src/common/package-json-helper.mjs +14 -4
- package/lib/src/common/project-meta.mjs +2 -2
- package/lib/src/common/template-compiler.mjs +2 -0
- package/lib/src/common/transpile-ts.mjs +94 -0
- package/lib/src/create-app/context-builder.mjs +1 -0
- package/lib/src/create-app/git-manager.mjs +12 -2
- package/lib/src/create-app/json-manager.mjs +21 -18
- package/lib/src/create-app/new-app-prompt.mjs +4 -2
- package/lib/src/create-app/success-message.mjs +1 -1
- package/lib/src/update-app/conflict-report-writer.mjs +33 -21
- package/lib/src/update-app/json-drift.mjs +54 -0
- package/lib/src/update-app/project-updater.mjs +123 -14
- package/lib/src/update-app/template-differ.mjs +5 -5
- package/lib/src/update-app/three-way-merge.mjs +42 -0
- package/lib/src/update-app/update-context-builder.mjs +1 -0
- package/lib/src/update-app/update-summary-printer.mjs +10 -2
- package/package.json +9 -8
- package/templates/base/AGENTS.md.hbs +42 -41
- package/templates/base/GETTING_STARTED.md.hbs +26 -20
- package/templates/base/README.md.hbs +7 -4
- package/templates/base/src/app/globals.d.ts.hbs +6 -3
- package/templates/base/src/app/guards.ts.hbs +1 -3
- package/templates/base/src/app/main.ts.hbs +2 -9
- package/templates/base/src/app/routes.ts.hbs +7 -2
- package/templates/base/src/app/views/home/home.view.html.hbs +1 -1
- package/templates/base/src/app/views/home/home.view.ts.hbs +2 -2
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +2 -2
- package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +461 -1
- package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +39 -48
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +36 -1
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +47 -77
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +286 -24
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +144 -26
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +66 -4
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +135 -75
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +85 -4
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +93 -54
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.test.js.hbs +404 -0
- package/templates/base/src/core/base-classes/base-component/helpers/html.helper.ts.hbs +242 -0
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +70 -1
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +54 -15
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +12 -1
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +39 -36
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.test.js.hbs +179 -0
- package/templates/base/src/core/base-classes/base-component/helpers/preservation.helper.ts.hbs +93 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +135 -17
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +199 -81
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +40 -23
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.ts.hbs +67 -65
- package/templates/base/src/core/base-classes/component/component.test.js.hbs +17 -17
- package/templates/base/src/core/base-classes/component/component.ts.hbs +8 -41
- package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +1 -1
- package/templates/base/src/core/base-classes/component/helpers/config.helper.ts.hbs +37 -39
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +14 -1
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.ts.hbs +33 -31
- package/templates/base/src/core/base-classes/service/service.test.js.hbs +32 -0
- package/templates/base/src/core/base-classes/service/service.ts.hbs +21 -13
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +4 -0
- package/templates/base/src/core/base-classes/view/view.ts.hbs +11 -8
- package/templates/base/src/core/index.ts.hbs +4 -2
- package/templates/base/src/core/internals.d.ts.hbs +199 -20
- package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +18 -0
- package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +26 -25
- package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +3 -3
- package/templates/base/src/core/services/globals/globals.ts.hbs +7 -32
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +133 -50
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.ts.hbs +147 -39
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +75 -19
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.ts.hbs +73 -47
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +339 -232
- package/templates/base/src/core/services/http-client/http-client.ts.hbs +37 -25
- package/templates/base/src/core/services/i18n/i18n.test.js.hbs +57 -16
- package/templates/base/src/core/services/i18n/i18n.ts.hbs +42 -42
- package/templates/base/src/core/services/i18n/languages.ts.hbs +0 -2
- package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +4 -6
- package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +10 -0
- package/templates/base/src/core/services/pipe-registry/pipes.ts.hbs +4 -2
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +183 -4
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +207 -84
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +26 -27
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.ts.hbs +38 -60
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +4 -4
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +30 -30
- package/templates/base/src/core/services/router/router.test.js.hbs +306 -0
- package/templates/base/src/core/services/router/router.ts.hbs +113 -53
- package/templates/base/tools/builder/builder.config.js.hbs +6 -0
- package/templates/base/tools/builder/builder.js.hbs +18 -6
- package/templates/base/tools/builder/core/app/compile-ts.js.hbs +81 -6
- package/templates/base/tools/builder/core/app/copy-static.js.hbs +12 -1
- package/templates/base/tools/builder/core/app/handler-refs.js.hbs +114 -0
- package/templates/base/tools/builder/core/app/handler-refs.test.js.hbs +70 -0
- package/templates/base/tools/builder/core/app/paths.js.hbs +17 -0
- package/templates/base/tools/builder/core/app/route-keys.js.hbs +47 -0
- package/templates/base/tools/builder/core/app/route-keys.test.js.hbs +97 -0
- package/templates/base/tools/builder/core/app/ts-errors.js.hbs +58 -0
- package/templates/base/tools/builder/core/app/ts-errors.test.js.hbs +84 -0
- package/templates/base/tools/builder/core/app/validate-routes.js.hbs +21 -55
- package/templates/base/tools/builder/core/finalize-build.js.hbs +6 -0
- package/templates/base/tools/builder/core/html-index/validate-html.js.hbs +10 -2
- package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +111 -23
- package/templates/base/tools/builder/core/html-templates/minify-templates.js.hbs +8 -0
- package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +5 -3
- package/templates/base/tools/builder/core/html-templates/template-literal.js.hbs +193 -0
- package/templates/base/tools/builder/core/html-templates/template-literal.test.js.hbs +69 -0
- package/templates/base/tools/builder/core/i18n/build-i18n.js.hbs +14 -6
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.js.hbs +82 -0
- package/templates/base/tools/builder/core/prod-bundle/bundle-options.test.js.hbs +65 -0
- package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +6 -6
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +15 -7
- package/templates/base/tools/builder/core/prod-bundle/hash-files.js.hbs +4 -4
- package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +31 -13
- package/templates/base/tools/builder/core/seo/generate-robots-txt.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +3 -3
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +43 -13
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +22 -7
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.test.js.hbs +30 -0
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +28 -21
- package/templates/base/tools/builder/core/seo/html-substitutions.test.js.hbs +45 -0
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +24 -6
- package/templates/base/tools/builder/core/seo/route-output.test.js.hbs +31 -0
- package/templates/base/tools/builder/core/seo/seo-routes.js.hbs +29 -0
- package/templates/base/tools/builder/core/seo/seo-routes.test.js.hbs +21 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +6 -7
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +34 -5
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.test.js.hbs +48 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +24 -11
- package/templates/base/tools/builder/core/styles/sass.js.hbs +3 -2
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
- package/templates/base/tools/dev/dev-serve.js.hbs +30 -5
- package/templates/base/tools/dev/serve.js.hbs +152 -22
- package/templates/base/tools/dev/watcher.js.hbs +34 -3
- package/templates/base/tools/generator/generator.js.hbs +1 -1
- package/templates/base/tools/generator/handle-file.js.hbs +4 -2
- package/templates/base/tools/generator/handle-file.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.js.hbs +3 -3
- package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +3 -3
- package/templates/base/tools/generator/templates/service/service.template.js.hbs +1 -1
- package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +1 -1
- package/templates/base/tools/generator/templates/view/view.template.js.hbs +2 -2
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +1 -1
- package/templates/base/tools/testin-nutin/core/globals/assertion-lib.js.hbs +2 -2
- package/templates/base/tools/testin-nutin/core/{tests/assertions.test.js.hbs → globals/assertion-lib.test.js.hbs} +6 -0
- package/templates/base/tools/testin-nutin/core/globals/register-test-globals.js.hbs +6 -7
- package/templates/base/tools/testin-nutin/core/{tests/globals-each-all.test.js.hbs → globals/register-test-globals.test.js.hbs} +12 -0
- package/templates/base/tools/testin-nutin/core/printer.js.hbs +10 -4
- package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +8 -8
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.js.hbs +27 -25
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +30 -22
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.js.hbs +7 -10
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +12 -0
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.js.hbs +47 -22
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +47 -26
- package/templates/base/tools/testin-nutin/mocks/mock-router.js.hbs +19 -11
- package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +24 -7
- package/templates/base/tools/testin-nutin/runner.js.hbs +12 -3
- package/templates/base/tools/testin-nutin/watch-tests.js.hbs +45 -20
- package/templates/base/tools/utils/build-lock.js.hbs +30 -2
- package/templates/base/tools/utils/ensure-deps.js.hbs +72 -0
- package/templates/base/tools/utils/ensure-deps.test.js.hbs +17 -0
- package/templates/base/tools/utils/format.js.hbs +1 -1
- package/templates/base/tools/utils/index.js.hbs +2 -1
- package/templates/base/tools/utils/print.js.hbs +2 -2
- package/templates/base/tools/utils/resolve-port.js.hbs +30 -0
- package/templates/base/tools/utils/resolve-port.test.js.hbs +47 -0
- package/templates/base/tools/utils/run-command.js.hbs +30 -1
- package/templates/base/tools/utils/run-command.test.js.hbs +12 -1
- package/templates/base/tools/utils/run-script.js.hbs +2 -2
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +3 -0
- package/templates/features/docker/tools/docker/nginx.conf.template +6 -4
- package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +1 -1
- package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +2 -2
- package/templates/features/docker/tools/docker/scripts/locale-redirects.js.hbs +28 -0
- package/templates/features/docker/tools/docker/scripts/locale-redirects.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.js.hbs +25 -0
- package/templates/features/docker/tools/docker/scripts/markdown-sources-copy.test.js.hbs +34 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +3 -1
- package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +12 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +21 -4
- package/templates/features/markdown/markdown-content/getting-started.md.hbs +28 -0
- package/templates/features/markdown/markdown-content/writing-pages.md.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.scss.hbs +75 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.test.js.hbs +40 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-content/markdown-content.component.ts.hbs +65 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.test.js.hbs +67 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-landing/markdown-landing.component.ts.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.scss.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.test.js.hbs +59 -0
- package/templates/features/markdown/src/app/markdown/components/markdown-nav/markdown-nav.component.ts.hbs +72 -0
- package/templates/features/markdown/src/app/markdown/locales/en.json.hbs +6 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.test.js.hbs +63 -0
- package/templates/features/markdown/src/app/markdown/markdown-guards.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.test.js.hbs +78 -0
- package/templates/features/markdown/src/app/markdown/markdown-i18n.ts.hbs +31 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.test.js.hbs +98 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifest.ts.hbs +157 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.test.js.hbs +87 -0
- package/templates/features/markdown/src/app/markdown/markdown-manifests.service.ts.hbs +50 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.test.js.hbs +74 -0
- package/templates/features/markdown/src/app/markdown/markdown-routes.ts.hbs +26 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.test.js.hbs +22 -0
- package/templates/features/markdown/src/app/markdown/markdown-sources.ts.hbs +42 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.html.hbs +4 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.scss.hbs +19 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.test.js.hbs +125 -0
- package/templates/features/markdown/src/app/markdown/view/markdown.view.ts.hbs +146 -0
- package/templates/features/markdown/tools/builder/core/markdown/generate-markdown-pages.js.hbs +71 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.js.hbs +582 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-compiler.test.js.hbs +390 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.js.hbs +155 -0
- package/templates/features/markdown/tools/builder/core/markdown/markdown-seo-routes.test.js.hbs +112 -0
- /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs
CHANGED
|
@@ -1,8 +1,30 @@
|
|
|
1
|
-
import
|
|
1
|
+
import * as NavigationManager from '#root/dist/src/core/services/router/helpers/navigation-manager.helper.js';
|
|
2
2
|
import { I18nService } from '#root/dist/src/core/services/index.js';
|
|
3
3
|
import { CONFIG } from '#root/dist/src/core/config.js';
|
|
4
4
|
|
|
5
|
+
// The announcer's delay, run on fake timers.
|
|
6
|
+
const ANNOUNCE_DELAY = 100;
|
|
7
|
+
|
|
8
|
+
// A view element inside a <main>, both in the document.
|
|
9
|
+
function mountView(html) {
|
|
10
|
+
const main = document.createElement('main');
|
|
11
|
+
const element = document.createElement('div');
|
|
12
|
+
element.innerHTML = html;
|
|
13
|
+
main.appendChild(element);
|
|
14
|
+
document.body.appendChild(main);
|
|
15
|
+
return { view: { viewName: 'probe', getElement: () => element }, main, element };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cleanupFocusView() {
|
|
19
|
+
document.querySelectorAll('main, [data-nutin-announcer]').forEach((el) => el.remove());
|
|
20
|
+
}
|
|
21
|
+
|
|
5
22
|
describe('NavigationManager', () => {
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
useRealTimers();
|
|
25
|
+
cleanupFocusView();
|
|
26
|
+
});
|
|
27
|
+
|
|
6
28
|
it('should normalize paths by removing trailing slashes', () => {
|
|
7
29
|
expect(NavigationManager.normalizePath('/about/')).toBe('/about');
|
|
8
30
|
expect(NavigationManager.normalizePath('/about///')).toBe('/about');
|
|
@@ -10,6 +32,12 @@ describe('NavigationManager', () => {
|
|
|
10
32
|
expect(NavigationManager.normalizePath('')).toBe('/');
|
|
11
33
|
});
|
|
12
34
|
|
|
35
|
+
it('never returns a path that starts with "//" (another host)', () => {
|
|
36
|
+
expect(NavigationManager.normalizePath('/./\\evil.example')).toBe('/evil.example');
|
|
37
|
+
expect(NavigationManager.normalizePath('/a/..\\/evil.example')).toBe('/evil.example');
|
|
38
|
+
expect(NavigationManager.normalizePath('/%2e/\\evil.example/x')).toBe('/evil.example/x');
|
|
39
|
+
});
|
|
40
|
+
|
|
13
41
|
it('should call pushState when conditions are met', () => {
|
|
14
42
|
const originalPushState = window.history.pushState;
|
|
15
43
|
let called = false;
|
|
@@ -19,13 +47,13 @@ describe('NavigationManager', () => {
|
|
|
19
47
|
expect(url).toBe('/about');
|
|
20
48
|
};
|
|
21
49
|
|
|
22
|
-
NavigationManager.updateHistory('/about', '/home',
|
|
50
|
+
NavigationManager.updateHistory('/about', '/home', 'push');
|
|
23
51
|
expect(called).toBe(true);
|
|
24
52
|
|
|
25
53
|
window.history.pushState = originalPushState;
|
|
26
54
|
});
|
|
27
55
|
|
|
28
|
-
it(
|
|
56
|
+
it("should not touch history in 'none' mode", () => {
|
|
29
57
|
const originalPushState = window.history.pushState;
|
|
30
58
|
let called = false;
|
|
31
59
|
|
|
@@ -33,12 +61,33 @@ describe('NavigationManager', () => {
|
|
|
33
61
|
called = true;
|
|
34
62
|
};
|
|
35
63
|
|
|
36
|
-
NavigationManager.updateHistory('/about', '/home',
|
|
64
|
+
NavigationManager.updateHistory('/about', '/home', 'none');
|
|
37
65
|
|
|
38
66
|
expect(called).toBe(false);
|
|
39
67
|
window.history.pushState = originalPushState;
|
|
40
68
|
});
|
|
41
69
|
|
|
70
|
+
it("updateHistory in 'replace' mode rewrites the current entry instead of pushing", () => {
|
|
71
|
+
const originalReplaceState = window.history.replaceState;
|
|
72
|
+
const originalPushState = window.history.pushState;
|
|
73
|
+
let pushCalled = false;
|
|
74
|
+
let replacedUrl = null;
|
|
75
|
+
|
|
76
|
+
window.history.pushState = function () {
|
|
77
|
+
pushCalled = true;
|
|
78
|
+
};
|
|
79
|
+
window.history.replaceState = function (state, title, url) {
|
|
80
|
+
replacedUrl = url;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
NavigationManager.updateHistory('/about', '/home', 'replace', 'team');
|
|
84
|
+
|
|
85
|
+
window.history.pushState = originalPushState;
|
|
86
|
+
window.history.replaceState = originalReplaceState;
|
|
87
|
+
expect(pushCalled).toBe(false);
|
|
88
|
+
expect(replacedUrl).toBe('/about#team');
|
|
89
|
+
});
|
|
90
|
+
|
|
42
91
|
it('replaceState calls history.replaceState (not pushState) with the locale-prefixed path', () => {
|
|
43
92
|
const originalReplaceState = window.history.replaceState;
|
|
44
93
|
const originalPushState = window.history.pushState;
|
|
@@ -222,10 +271,47 @@ describe('NavigationManager', () => {
|
|
|
222
271
|
.toEqual({ postId: '1', commentId: '2' });
|
|
223
272
|
});
|
|
224
273
|
|
|
274
|
+
it('matchPattern decodes params and keeps a malformed escape as-is', () => {
|
|
275
|
+
expect(NavigationManager.matchPattern('/users/:name', '/users/J%C3%B6rg')).toEqual({ name: 'Jörg' });
|
|
276
|
+
expect(NavigationManager.matchPattern('/users/:name', '/users/100%')).toEqual({ name: '100%' });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('matchPattern matches static segments literally', () => {
|
|
280
|
+
expect(NavigationManager.matchPattern('/files/report.pdf', '/files/report.pdf')).toEqual({});
|
|
281
|
+
expect(NavigationManager.matchPattern('/files/report.pdf', '/files/reportXpdf')).toBe(null);
|
|
282
|
+
expect(NavigationManager.matchPattern('/v1.0/:id', '/v1.0/7')).toEqual({ id: '7' });
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('updateHistory keeps the query string', () => {
|
|
286
|
+
const originalPushState = window.history.pushState;
|
|
287
|
+
let pushedUrl = null;
|
|
288
|
+
window.history.pushState = function (state, title, url) {
|
|
289
|
+
pushedUrl = url;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
NavigationManager.updateHistory('/list', '/home', 'push', 'top', '?page=2');
|
|
293
|
+
|
|
294
|
+
window.history.pushState = originalPushState;
|
|
295
|
+
expect(pushedUrl).toBe('/list?page=2#top');
|
|
296
|
+
});
|
|
297
|
+
|
|
225
298
|
it('matchPattern returns null when the path does not match the pattern at all', () => {
|
|
226
299
|
expect(NavigationManager.matchPattern('/posts/:id', '/other/123')).toBe(null);
|
|
227
300
|
});
|
|
228
301
|
|
|
302
|
+
it('updateDocumentTitle prefers the view\'s own documentTitle() over seo.json and viewName', () => {
|
|
303
|
+
CONFIG.generateSEOFiles = true;
|
|
304
|
+
CONFIG.seo = { routes: [{ path: '/docs/:slug?', title: 'Docs' }] };
|
|
305
|
+
try {
|
|
306
|
+
NavigationManager.updateDocumentTitle({ viewName: 'docs', documentTitle: () => 'How do I navigate?' }, '/docs/:slug?');
|
|
307
|
+
expect(document.title).toBe('How do I navigate?');
|
|
308
|
+
NavigationManager.updateDocumentTitle({ viewName: 'docs', documentTitle: () => undefined }, '/docs/:slug?');
|
|
309
|
+
expect(document.title).toBe('Docs');
|
|
310
|
+
} finally {
|
|
311
|
+
CONFIG.generateSEOFiles = false;
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
229
315
|
it('updateDocumentTitle falls back to the view\'s viewName when generateSEOFiles is disabled', () => {
|
|
230
316
|
CONFIG.generateSEOFiles = false;
|
|
231
317
|
CONFIG.seo = { routes: [{ path: '/', title: 'Should not be used' }] };
|
|
@@ -260,6 +346,7 @@ describe('NavigationManager', () => {
|
|
|
260
346
|
});
|
|
261
347
|
|
|
262
348
|
it('updateDocumentTitle resolves a per-language seo.json title to the current language', () => {
|
|
349
|
+
CONFIG.i18n = true;
|
|
263
350
|
CONFIG.generateSEOFiles = true;
|
|
264
351
|
CONFIG.seo = { routes: [{ path: '/', title: { en: 'Home', fr: 'Accueil' } }] };
|
|
265
352
|
I18nService['_LANGUAGES'] = ['en', 'fr'];
|
|
@@ -268,12 +355,26 @@ describe('NavigationManager', () => {
|
|
|
268
355
|
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
269
356
|
expect(document.title).toBe('Accueil');
|
|
270
357
|
} finally {
|
|
358
|
+
CONFIG.i18n = false;
|
|
271
359
|
CONFIG.generateSEOFiles = false;
|
|
272
360
|
I18nService['_currentLanguage'] = 'en';
|
|
273
361
|
}
|
|
274
362
|
});
|
|
275
363
|
|
|
364
|
+
it('updateDocumentTitle uses the first value of a per-language seo.json title when i18n is disabled', () => {
|
|
365
|
+
CONFIG.generateSEOFiles = true;
|
|
366
|
+
CONFIG.seo = { routes: [{ path: '/', title: { fr: 'Accueil', en: 'Home' } }] };
|
|
367
|
+
I18nService['_currentLanguage'] = 'en';
|
|
368
|
+
try {
|
|
369
|
+
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
370
|
+
expect(document.title).toBe('Accueil');
|
|
371
|
+
} finally {
|
|
372
|
+
CONFIG.generateSEOFiles = false;
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
276
376
|
it('updateDocumentTitle falls back to the default language, then any available language, then viewName', () => {
|
|
377
|
+
CONFIG.i18n = true;
|
|
277
378
|
CONFIG.generateSEOFiles = true;
|
|
278
379
|
I18nService['_LANGUAGES'] = ['en', 'fr'];
|
|
279
380
|
|
|
@@ -289,6 +390,7 @@ describe('NavigationManager', () => {
|
|
|
289
390
|
NavigationManager.updateDocumentTitle({ viewName: 'home' }, '/');
|
|
290
391
|
expect(document.title).toBe('home');
|
|
291
392
|
} finally {
|
|
393
|
+
CONFIG.i18n = false;
|
|
292
394
|
CONFIG.generateSEOFiles = false;
|
|
293
395
|
I18nService['_currentLanguage'] = 'en';
|
|
294
396
|
}
|
|
@@ -333,4 +435,81 @@ describe('NavigationManager', () => {
|
|
|
333
435
|
I18nService['_translations'] = {};
|
|
334
436
|
}
|
|
335
437
|
});
|
|
438
|
+
|
|
439
|
+
it('focuses the view\'s h1, made focusable with tabindex="-1"', () => {
|
|
440
|
+
const { view, element } = mountView('<p>intro</p><h1>Title</h1>');
|
|
441
|
+
NavigationManager.focusView(view);
|
|
442
|
+
const h1 = element.querySelector('h1');
|
|
443
|
+
expect(document.activeElement).toBe(h1);
|
|
444
|
+
expect(h1.getAttribute('tabindex')).toBe('-1');
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('keeps an h1\'s own tabindex and styling', () => {
|
|
448
|
+
const { view, element } = mountView('<h1 tabindex="0">Title</h1>');
|
|
449
|
+
const h1 = element.querySelector('h1');
|
|
450
|
+
NavigationManager.focusView(view);
|
|
451
|
+
expect(h1.getAttribute('tabindex')).toBe('0');
|
|
452
|
+
expect(h1.style.outline).toBe('');
|
|
453
|
+
expect(document.activeElement).toBe(h1);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it('hides the focus ring of an element it made focusable, and restores it on blur', () => {
|
|
457
|
+
const { view, element } = mountView('<h1 style="color: red">Title</h1>');
|
|
458
|
+
const h1 = element.querySelector('h1');
|
|
459
|
+
NavigationManager.focusView(view);
|
|
460
|
+
expect(h1.style.outline).toBe('none');
|
|
461
|
+
|
|
462
|
+
h1.blur();
|
|
463
|
+
expect(h1.hasAttribute('tabindex')).toBe(false);
|
|
464
|
+
expect(h1.style.outline).toBe('');
|
|
465
|
+
expect(h1.getAttribute('style')).toBe('color: red;');
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('leaves no style attribute behind on an element that had none', () => {
|
|
469
|
+
const { view, element } = mountView('<h1>Title</h1>');
|
|
470
|
+
const h1 = element.querySelector('h1');
|
|
471
|
+
NavigationManager.focusView(view);
|
|
472
|
+
h1.blur();
|
|
473
|
+
expect(h1.hasAttribute('style')).toBe(false);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('falls back to the h1 in <main> when the view has none', () => {
|
|
477
|
+
const { view, main } = mountView('<p>no heading</p>');
|
|
478
|
+
const h1 = document.createElement('h1');
|
|
479
|
+
h1.textContent = 'Layout title';
|
|
480
|
+
main.prepend(h1);
|
|
481
|
+
NavigationManager.focusView(view);
|
|
482
|
+
expect(document.activeElement).toBe(h1);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('focuses the view itself and announces document.title when there is no h1', () => {
|
|
486
|
+
useFakeTimers();
|
|
487
|
+
const { view, element } = mountView('<p>no heading</p>');
|
|
488
|
+
document.title = 'Probe page';
|
|
489
|
+
NavigationManager.focusView(view);
|
|
490
|
+
expect(document.activeElement).toBe(element);
|
|
491
|
+
expect(element.getAttribute('tabindex')).toBe('-1');
|
|
492
|
+
|
|
493
|
+
const region = document.querySelector('[data-nutin-announcer]');
|
|
494
|
+
expect(region.getAttribute('role')).toBe('status');
|
|
495
|
+
expect(region.getAttribute('aria-live')).toBe('polite');
|
|
496
|
+
advanceTimersByTime(ANNOUNCE_DELAY - 1);
|
|
497
|
+
expect(region.textContent).toBe('');
|
|
498
|
+
advanceTimersByTime(1);
|
|
499
|
+
expect(region.textContent).toBe('Probe page');
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
it('reuses one announcer, emptying it first so the same title is announced again', () => {
|
|
503
|
+
useFakeTimers();
|
|
504
|
+
const { view } = mountView('<p>no heading</p>');
|
|
505
|
+
document.title = 'Same';
|
|
506
|
+
NavigationManager.focusView(view);
|
|
507
|
+
advanceTimersByTime(ANNOUNCE_DELAY);
|
|
508
|
+
NavigationManager.focusView(view);
|
|
509
|
+
const regions = document.querySelectorAll('[data-nutin-announcer]');
|
|
510
|
+
expect(regions.length).toBe(1);
|
|
511
|
+
expect(regions[0].textContent).toBe('');
|
|
512
|
+
advanceTimersByTime(ANNOUNCE_DELAY);
|
|
513
|
+
expect(regions[0].textContent).toBe('Same');
|
|
514
|
+
});
|
|
336
515
|
});
|
|
@@ -1,128 +1,251 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import type { View } from "../../../base-classes/view/view.js";
|
|
2
|
+
import { I18nService } from "../../i18n/i18n.js";
|
|
3
3
|
import { CONFIG } from "../../../config.js";
|
|
4
4
|
|
|
5
|
+
// How a navigation affects the history stack: add an entry, rewrite the current one,
|
|
6
|
+
// or leave it untouched (back/forward, reload).
|
|
7
|
+
export type HistoryMode = 'push' | 'replace' | 'none';
|
|
8
|
+
|
|
9
|
+
// Long enough for screen readers to notice the live region's content change.
|
|
10
|
+
const ANNOUNCE_DELAY = 100;
|
|
11
|
+
|
|
12
|
+
// See recoverFromFailedViewLoad().
|
|
13
|
+
const RELOAD_KEY = 'nutin:failed-view-load';
|
|
14
|
+
const RELOAD_WINDOW = 10_000;
|
|
15
|
+
|
|
5
16
|
/**
|
|
6
17
|
* Navigation - handles path normalization and history management
|
|
7
18
|
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
19
|
+
// The i18n guards wrap their branch rather than returning early: esbuild only drops
|
|
20
|
+
// a constant-false block, not code after a constant early return, and I18nService
|
|
21
|
+
// must go unreferenced for prod builds to drop it when i18n is off.
|
|
22
|
+
export function extractLocale(path: string): { locale: Nutin.Language | null; strippedPath: string } {
|
|
23
|
+
if (globalThis.__NUTIN_I18N__ ?? CONFIG.i18n) {
|
|
12
24
|
const segments = path.split('/').filter(Boolean);
|
|
13
|
-
const first = segments[0] as Language;
|
|
25
|
+
const first = segments[0] as Nutin.Language;
|
|
14
26
|
if (first && I18nService.languages.includes(first)) {
|
|
15
27
|
const rest = segments.slice(1).join('/');
|
|
16
28
|
return { locale: first, strippedPath: rest ? `/${rest}` : '/' };
|
|
17
29
|
}
|
|
18
|
-
return { locale: null, strippedPath: path };
|
|
19
30
|
}
|
|
31
|
+
return { locale: null, strippedPath: path };
|
|
32
|
+
}
|
|
20
33
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
34
|
+
export function addLocalePrefix(strippedPath: string): string {
|
|
35
|
+
if (globalThis.__NUTIN_I18N__ ?? CONFIG.i18n) {
|
|
24
36
|
const lang = I18nService.currentLanguage;
|
|
25
37
|
return strippedPath === '/' ? `/${lang}` : `/${lang}${strippedPath}`;
|
|
26
38
|
}
|
|
39
|
+
return strippedPath;
|
|
40
|
+
}
|
|
27
41
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
42
|
+
export function getCurrentLocale(): Nutin.Language | null {
|
|
43
|
+
return extractLocale(window.location.pathname).locale;
|
|
44
|
+
}
|
|
31
45
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
46
|
+
export function updateLocaleInUrl(): void {
|
|
47
|
+
const rawPathname = (new URL(window.location.pathname, window.location.origin).pathname || '/').replace(/\/+$/, '') || '/';
|
|
48
|
+
const { strippedPath } = extractLocale(rawPathname);
|
|
49
|
+
const newUrl = addLocalePrefix(strippedPath);
|
|
50
|
+
if (newUrl !== window.location.pathname) {
|
|
51
|
+
window.history.pushState({}, '', newUrl + window.location.search + window.location.hash);
|
|
39
52
|
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function normalizePath(path: string): string {
|
|
56
|
+
const collapsed = path.replace(/\/\/+/g, '/');
|
|
57
|
+
const url = new URL(collapsed, window.location.origin);
|
|
58
|
+
// Parsing can bring "//" back ("/./\\host" -> "//host"), which the browser reads as another
|
|
59
|
+
// host (pushState throws, a full page load leaves the site): collapse again.
|
|
60
|
+
const pathname = (url.pathname || '/').replace(/\/\/+/g, '/').replace(/\/+$/, '') || '/';
|
|
61
|
+
return extractLocale(pathname).strippedPath;
|
|
62
|
+
}
|
|
40
63
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
64
|
+
export function updateHistory(
|
|
65
|
+
normalizedPath: string,
|
|
66
|
+
currentPath: string,
|
|
67
|
+
mode: HistoryMode,
|
|
68
|
+
hash?: string,
|
|
69
|
+
search: string = ''
|
|
70
|
+
): void {
|
|
71
|
+
if (mode === 'none') return;
|
|
72
|
+
const localizedPath = localizedUrl(normalizedPath, search, hash);
|
|
73
|
+
const currentUrl = window.location.pathname + window.location.search + window.location.hash;
|
|
74
|
+
if (currentUrl === localizedPath) return;
|
|
75
|
+
if (mode === 'push') {
|
|
76
|
+
window.history.pushState({}, '', localizedPath);
|
|
77
|
+
} else {
|
|
78
|
+
window.history.replaceState({}, '', localizedPath);
|
|
46
79
|
}
|
|
80
|
+
}
|
|
47
81
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
82
|
+
// The URL of a path as it appears in the address bar: locale prefix, query and hash.
|
|
83
|
+
export function localizedUrl(normalizedPath: string, search: string = '', hash?: string): string {
|
|
84
|
+
return addLocalePrefix(normalizedPath) + search + (hash ? `#${hash}` : '');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A lazy route's view failed to load: usually a deploy replaced the chunks while this tab
|
|
88
|
+
// was open, or the network dropped. A full page load of the target URL fetches the new
|
|
89
|
+
// build. It happens once per URL within RELOAD_WINDOW (recorded in sessionStorage), so a
|
|
90
|
+
// view that keeps failing (or a broken chunk) can't reload forever: the error is logged
|
|
91
|
+
// and the current page stays. Without sessionStorage, it only logs.
|
|
92
|
+
export function recoverFromFailedViewLoad(url: string, error: unknown): void {
|
|
93
|
+
console.error(`[router] Failed to load the view for "${url}".`, error);
|
|
94
|
+
try {
|
|
95
|
+
const last = JSON.parse(sessionStorage.getItem(RELOAD_KEY) ?? 'null') as { url?: string; at?: number } | null;
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
if (last?.url === url && typeof last.at === 'number' && now - last.at < RELOAD_WINDOW) return;
|
|
98
|
+
sessionStorage.setItem(RELOAD_KEY, JSON.stringify({ url, at: now }));
|
|
99
|
+
} catch {
|
|
100
|
+
return;
|
|
59
101
|
}
|
|
102
|
+
loadPage(url);
|
|
103
|
+
}
|
|
60
104
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
105
|
+
// A full (non-SPA) page load.
|
|
106
|
+
export function loadPage(url: string): void {
|
|
107
|
+
window.location.assign(url);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Rewrites the current history entry's URL to match content already rendered
|
|
111
|
+
// (e.g. a route that fell back to a default sub-page) without adding a
|
|
112
|
+
// new back/forward-navigable entry.
|
|
113
|
+
export function replaceState(path: string): void {
|
|
114
|
+
const localizedPath = addLocalePrefix(path) + window.location.hash;
|
|
115
|
+
window.history.replaceState({}, '', localizedPath);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Scrolls to the element matching `hash` (a bare fragment, no leading "#") if it
|
|
119
|
+
// exists; falls back to the top of the page otherwise (no hash, or a stale/broken
|
|
120
|
+
// id) — matches the browser's own native fallback for an unresolvable fragment.
|
|
121
|
+
export function scrollToHash(hash?: string): void {
|
|
122
|
+
const target = hash ? document.getElementById(hash) : null;
|
|
123
|
+
if (target) {
|
|
124
|
+
target.scrollIntoView({ block: 'start' });
|
|
125
|
+
} else {
|
|
126
|
+
window.scrollTo({ top: 0 });
|
|
67
127
|
}
|
|
128
|
+
}
|
|
68
129
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
130
|
+
// Moves focus to the new view after an in-app navigation, so keyboard and screen-reader
|
|
131
|
+
// users land on it instead of on <body> (the link they used is gone): its <h1>, else the
|
|
132
|
+
// <h1> in <main>; without one, the view itself, with the page title announced instead.
|
|
133
|
+
// preventScroll keeps scrollToHash()'s position.
|
|
134
|
+
export function focusView(view: View): void {
|
|
135
|
+
const viewElement = view.getElement();
|
|
136
|
+
const heading = viewElement.querySelector('h1') ?? document.querySelector('main h1');
|
|
137
|
+
if (heading instanceof HTMLElement) {
|
|
138
|
+
focusElement(heading);
|
|
139
|
+
return;
|
|
79
140
|
}
|
|
141
|
+
focusElement(viewElement);
|
|
142
|
+
announce(document.title);
|
|
143
|
+
}
|
|
80
144
|
|
|
81
|
-
|
|
82
|
-
|
|
145
|
+
// An element made focusable only for this (a heading, the view) gets no focus ring: it isn't
|
|
146
|
+
// interactive, so the ring would only look like a stray highlight. Screen readers still read it
|
|
147
|
+
// and Tab still continues from it. Once it loses focus, it's restored as it was, so it never
|
|
148
|
+
// becomes a click/Tab target. An element with its own tabindex keeps its own styling.
|
|
149
|
+
function focusElement(element: HTMLElement): void {
|
|
150
|
+
if (!element.hasAttribute('tabindex')) {
|
|
151
|
+
const previousOutline = element.style.outline;
|
|
152
|
+
element.setAttribute('tabindex', '-1');
|
|
153
|
+
element.style.outline = 'none';
|
|
154
|
+
element.addEventListener('blur', () => {
|
|
155
|
+
element.removeAttribute('tabindex');
|
|
156
|
+
element.style.outline = previousOutline;
|
|
157
|
+
if (!element.getAttribute('style')) element.removeAttribute('style');
|
|
158
|
+
}, { once: true });
|
|
83
159
|
}
|
|
160
|
+
element.focus({ preventScroll: true });
|
|
161
|
+
}
|
|
84
162
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
163
|
+
let announcer: HTMLElement | null = null;
|
|
164
|
+
|
|
165
|
+
// A visually hidden polite live region, created on first use. Emptied first and filled
|
|
166
|
+
// a moment later, so a title identical to the previous one is still announced (a timer,
|
|
167
|
+
// not requestAnimationFrame: that one doesn't run in a background tab).
|
|
168
|
+
function announce(message: string): void {
|
|
169
|
+
if (!announcer?.isConnected) {
|
|
170
|
+
const region = document.createElement('div');
|
|
171
|
+
region.setAttribute('role', 'status');
|
|
172
|
+
region.setAttribute('aria-live', 'polite');
|
|
173
|
+
region.setAttribute('data-nutin-announcer', '');
|
|
174
|
+
region.style.cssText =
|
|
175
|
+
'position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;' +
|
|
176
|
+
'overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;';
|
|
177
|
+
document.body.appendChild(region);
|
|
178
|
+
announcer = region;
|
|
92
179
|
}
|
|
180
|
+
const region = announcer;
|
|
181
|
+
region.textContent = '';
|
|
182
|
+
setTimeout(() => { region.textContent = message; }, ANNOUNCE_DELAY);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function getCurrentPath(): string {
|
|
186
|
+
return normalizePath(window.location.pathname);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function updateDocumentTitle(view: View, pattern: string): void {
|
|
190
|
+
const route = CONFIG.generateSEOFiles
|
|
191
|
+
? CONFIG.seo?.routes?.find((r) => r.path === pattern)
|
|
192
|
+
: undefined;
|
|
193
|
+
const seoTitle = route?.title ? resolveSeoTitle(route.title) : undefined;
|
|
194
|
+
const localeTitle = (globalThis.__NUTIN_I18N__ ?? CONFIG.i18n) ? I18nService.getTranslationObject<string>(`${view.viewName}.title`) : null;
|
|
195
|
+
document.title = view.documentTitle?.() || seoTitle || localeTitle || view.viewName;
|
|
196
|
+
}
|
|
93
197
|
|
|
94
|
-
|
|
95
|
-
|
|
198
|
+
function resolveSeoTitle(title: string | Record<string, string>): string | undefined {
|
|
199
|
+
if (typeof title !== 'object') return title;
|
|
200
|
+
if (globalThis.__NUTIN_I18N__ ?? CONFIG.i18n) {
|
|
96
201
|
const lang = I18nService.currentLanguage;
|
|
97
202
|
const defaultLang = I18nService.defaultLanguage;
|
|
98
203
|
return title[lang] ?? title[defaultLang] ?? Object.values(title)[0];
|
|
99
204
|
}
|
|
205
|
+
return Object.values(title)[0];
|
|
206
|
+
}
|
|
100
207
|
|
|
101
|
-
|
|
102
|
-
|
|
208
|
+
export function matchPattern(pattern: string, path: string): Record<string, string> | null {
|
|
209
|
+
const paramNames: string[] = [];
|
|
103
210
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
211
|
+
// Splits on "/:param?" and ":param"; the static parts in between match literally.
|
|
212
|
+
const regexPattern = pattern
|
|
213
|
+
.split(/(\/:[^/?]+\?|:[^/]+)/)
|
|
214
|
+
.map((part) => {
|
|
215
|
+
const optional = part.match(/^\/:([^/?]+)\?$/);
|
|
216
|
+
if (optional) {
|
|
217
|
+
paramNames.push(optional[1]!);
|
|
107
218
|
return `(?:/([^/]+))?`; // whole "/param" is optional
|
|
108
|
-
}
|
|
109
|
-
.
|
|
110
|
-
|
|
219
|
+
}
|
|
220
|
+
const required = part.match(/^:([^/]+)$/);
|
|
221
|
+
if (required) {
|
|
222
|
+
paramNames.push(required[1]!);
|
|
111
223
|
return `([^/]+)`;
|
|
112
|
-
}
|
|
224
|
+
}
|
|
225
|
+
return part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
226
|
+
})
|
|
227
|
+
.join('');
|
|
113
228
|
|
|
114
|
-
|
|
115
|
-
|
|
229
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
230
|
+
const match = path.match(regex);
|
|
116
231
|
|
|
117
|
-
|
|
232
|
+
if (!match) return null;
|
|
118
233
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
234
|
+
const params: Record<string, string> = {};
|
|
235
|
+
paramNames.forEach((name, i) => {
|
|
236
|
+
const value = match[i + 1];
|
|
237
|
+
if (value) {
|
|
238
|
+
params[name] = decodeParam(value);
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
return params;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The path comes percent-encoded from the URL; a malformed escape is kept as-is.
|
|
245
|
+
function decodeParam(value: string): string {
|
|
246
|
+
try {
|
|
247
|
+
return decodeURIComponent(value);
|
|
248
|
+
} catch {
|
|
249
|
+
return value;
|
|
127
250
|
}
|
|
128
251
|
}
|