@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
|
@@ -1,23 +1,17 @@
|
|
|
1
|
-
import { View
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import type { View } from '../../base-classes/view/view.js';
|
|
2
|
+
import { Service } from '../../base-classes/service/service.js';
|
|
3
|
+
import { Navigation } from '../event-bus/navigation.facade.js';
|
|
4
|
+
import { AppEventBus } from '../event-bus/event-bus.js';
|
|
5
|
+
import { I18nService } from '../i18n/i18n.js';
|
|
6
|
+
import * as RouteGuardsManager from './helpers/route-guard-manager.helper.js';
|
|
7
|
+
import type { GuardResult } from './helpers/route-guard-manager.helper.js';
|
|
8
|
+
import * as ViewRenderManager from './helpers/view-render-manager.helper.js';
|
|
9
|
+
import * as NavigationManager from './helpers/navigation-manager.helper.js';
|
|
10
|
+
import type { HistoryMode } from './helpers/navigation-manager.helper.js';
|
|
11
|
+
import { CONFIG } from '../../config.js';
|
|
6
12
|
|
|
7
13
|
// centralized export
|
|
8
|
-
export
|
|
9
|
-
export { NavigationManager } from './helpers/navigation-manager.helper.js';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* IRouter is a type alias for the instance of Router, not a true interface
|
|
13
|
-
*/
|
|
14
|
-
export type IRouter = InstanceType<typeof Router>;
|
|
15
|
-
|
|
16
|
-
export interface RouteMatch {
|
|
17
|
-
route: RouteConfig;
|
|
18
|
-
params: Record<string, string>;
|
|
19
|
-
pattern: string;
|
|
20
|
-
}
|
|
14
|
+
export * as NavigationManager from './helpers/navigation-manager.helper.js';
|
|
21
15
|
|
|
22
16
|
class Router extends Service<Router> {
|
|
23
17
|
private _currentView: View | null = null;
|
|
@@ -26,11 +20,16 @@ class Router extends Service<Router> {
|
|
|
26
20
|
private onNavigate = (data: { path: string }) => this.navigate(data.path);
|
|
27
21
|
private onReload = () => this.reload();
|
|
28
22
|
private _busSubscriptions: Array<() => void> = [];
|
|
23
|
+
// Bumped by every navigation: one whose guards resolve after a newer navigation started
|
|
24
|
+
// is dropped instead of rendering over it.
|
|
25
|
+
private _navId = 0;
|
|
26
|
+
// False until the first view is rendered: the first load leaves focus to the browser.
|
|
27
|
+
private _hasRendered = false;
|
|
29
28
|
|
|
30
|
-
constructor(private routes: Routes) {
|
|
29
|
+
constructor(private routes: Nutin.Routes) {
|
|
31
30
|
super();
|
|
32
31
|
this.initializeEventListeners();
|
|
33
|
-
this.navigate(
|
|
32
|
+
this.navigate(this.currentLocation());
|
|
34
33
|
this.registerCleanup(this.removeEventListeners);
|
|
35
34
|
}
|
|
36
35
|
|
|
@@ -42,13 +41,34 @@ class Router extends Service<Router> {
|
|
|
42
41
|
window.removeEventListener('popstate', this.onPopState);
|
|
43
42
|
}
|
|
44
43
|
|
|
44
|
+
// Re-renders the current page, so focus isn't moved (see focusView()).
|
|
45
45
|
public async reload(): Promise<void> {
|
|
46
|
-
|
|
47
|
-
await this.navigate(currentRoute + window.location.hash, false);
|
|
46
|
+
await this.navigateWithMode(this.currentLocation(), 'none', false);
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
public async navigate(path: string | '', pushState: boolean = true): Promise<void> {
|
|
51
|
-
|
|
50
|
+
await this.navigateWithMode(path, pushState ? 'push' : 'none', this._hasRendered);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
public getCurrentParams(): Record<string, string> {
|
|
54
|
+
return { ...this._currentParams };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
public getParam(key: string): string | undefined {
|
|
58
|
+
return this._currentParams[key];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Path, query and hash of the current URL, as navigate() takes them.
|
|
62
|
+
private currentLocation(): string {
|
|
63
|
+
return NavigationManager.getCurrentPath() + window.location.search + window.location.hash;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async navigateWithMode(path: string, mode: HistoryMode, moveFocus: boolean): Promise<void> {
|
|
67
|
+
const navId = ++this._navId;
|
|
68
|
+
const isStale = () => navId !== this._navId;
|
|
69
|
+
const [pathAndQuery = '', hash] = path.split('#');
|
|
70
|
+
const [rawPath = '', query] = pathAndQuery.split('?');
|
|
71
|
+
const search = query ? `?${query}` : '';
|
|
52
72
|
const normalizedPath = NavigationManager.normalizePath(rawPath);
|
|
53
73
|
const currentPath = NavigationManager.getCurrentPath();
|
|
54
74
|
|
|
@@ -56,7 +76,7 @@ class Router extends Service<Router> {
|
|
|
56
76
|
const routeMatch = this.matchRoute(normalizedPath);
|
|
57
77
|
|
|
58
78
|
if (!routeMatch) {
|
|
59
|
-
await this.handleNotFound(normalizedPath, currentPath,
|
|
79
|
+
await this.handleNotFound(normalizedPath, currentPath, mode, isStale, moveFocus);
|
|
60
80
|
return;
|
|
61
81
|
}
|
|
62
82
|
|
|
@@ -64,35 +84,58 @@ class Router extends Service<Router> {
|
|
|
64
84
|
normalizedPath,
|
|
65
85
|
routeMatch.route,
|
|
66
86
|
routeMatch.params,
|
|
67
|
-
|
|
87
|
+
currentPath,
|
|
88
|
+
mode,
|
|
89
|
+
moveFocus
|
|
68
90
|
);
|
|
69
91
|
|
|
70
|
-
if (!guardResult) return;
|
|
92
|
+
if (!guardResult || isStale()) return;
|
|
93
|
+
|
|
94
|
+
// Before the current view is torn down: it stays up while a lazy route's chunk loads.
|
|
95
|
+
const view = await this.resolveView(guardResult.viewConstructor!, NavigationManager.localizedUrl(normalizedPath, search, hash), isStale);
|
|
96
|
+
if (!view) return;
|
|
71
97
|
|
|
72
98
|
this._currentView = await ViewRenderManager.transitionOutCurrentView(this._currentView);
|
|
73
99
|
this._currentParams = routeMatch.params;
|
|
74
100
|
|
|
75
|
-
// Must run before renderNewView(): a view's onEnter() may rewrite the URL via
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
// to already be this route's, not the previous view's, before onEnter() fires.
|
|
79
|
-
NavigationManager.updateHistory(normalizedPath, currentPath, pushState, hash);
|
|
101
|
+
// Must run before renderNewView(): a view's onEnter() may rewrite the URL via NavigationManager.replaceState(),
|
|
102
|
+
// which acts on whatever history entry is current. This ensures current before onEnter() fires.
|
|
103
|
+
NavigationManager.updateHistory(normalizedPath, currentPath, mode, hash, search);
|
|
80
104
|
|
|
81
|
-
this._currentView = ViewRenderManager.renderNewView(
|
|
82
|
-
guardResult.viewConstructor!,
|
|
83
|
-
routeMatch.params
|
|
84
|
-
);
|
|
105
|
+
this._currentView = ViewRenderManager.renderNewView(view, routeMatch.params);
|
|
85
106
|
|
|
86
107
|
NavigationManager.updateDocumentTitle(this._currentView, routeMatch.pattern);
|
|
87
108
|
NavigationManager.scrollToHash(hash);
|
|
109
|
+
this.afterRender(moveFocus);
|
|
88
110
|
}
|
|
89
111
|
|
|
90
|
-
|
|
91
|
-
|
|
112
|
+
// Runs a route's view factory. A sync one behaves as it always did (its errors propagate).
|
|
113
|
+
// A lazy one is awaited: if it fails to load, recoverFromFailedViewLoad() takes over; if a
|
|
114
|
+
// newer navigation started meanwhile, the view (already mounted, empty, by its
|
|
115
|
+
// constructor) is destroyed and dropped.
|
|
116
|
+
private async resolveView(factory: Nutin.ViewFactory, url: string, isStale: () => boolean): Promise<View | null> {
|
|
117
|
+
if (isStale()) return null;
|
|
118
|
+
const result = factory();
|
|
119
|
+
if (!isPromise(result)) return result;
|
|
120
|
+
|
|
121
|
+
let view: View;
|
|
122
|
+
try {
|
|
123
|
+
view = await result;
|
|
124
|
+
} catch (error) {
|
|
125
|
+
if (!isStale()) NavigationManager.recoverFromFailedViewLoad(url, error);
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
if (isStale()) {
|
|
129
|
+
view.destroy();
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return view;
|
|
92
133
|
}
|
|
93
134
|
|
|
94
|
-
|
|
95
|
-
|
|
135
|
+
// After updateDocumentTitle(): focusView() announces document.title when the view has no h1.
|
|
136
|
+
private afterRender(moveFocus: boolean): void {
|
|
137
|
+
if (moveFocus && this._currentView) NavigationManager.focusView(this._currentView);
|
|
138
|
+
this._hasRendered = true;
|
|
96
139
|
}
|
|
97
140
|
|
|
98
141
|
private initializeEventListeners(): void {
|
|
@@ -115,11 +158,13 @@ class Router extends Service<Router> {
|
|
|
115
158
|
}
|
|
116
159
|
|
|
117
160
|
private async handlePopState(): Promise<void> {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
161
|
+
if (globalThis.__NUTIN_I18N__ ?? CONFIG.i18n) {
|
|
162
|
+
const newLocale = NavigationManager.getCurrentLocale();
|
|
163
|
+
if (newLocale && newLocale !== I18nService.currentLanguage) {
|
|
164
|
+
await I18nService.setCurrentLanguage(newLocale as Nutin.Language);
|
|
165
|
+
}
|
|
121
166
|
}
|
|
122
|
-
this.
|
|
167
|
+
this.navigateWithMode(this.currentLocation(), 'none', this._hasRendered);
|
|
123
168
|
}
|
|
124
169
|
|
|
125
170
|
/**
|
|
@@ -128,7 +173,7 @@ class Router extends Service<Router> {
|
|
|
128
173
|
* - '/users/:id?' matches '/users' and '/users/123'
|
|
129
174
|
* - '/posts/:id' matches '/posts/123' but not '/posts'
|
|
130
175
|
*/
|
|
131
|
-
private matchRoute(path: string): RouteMatch | null {
|
|
176
|
+
private matchRoute(path: string): Nutin.RouteMatch | null {
|
|
132
177
|
for (const [pattern, routeConfig] of Object.entries(this.routes)) {
|
|
133
178
|
const match = NavigationManager.matchPattern(pattern, path);
|
|
134
179
|
if (match) {
|
|
@@ -141,7 +186,9 @@ class Router extends Service<Router> {
|
|
|
141
186
|
private async handleNotFound(
|
|
142
187
|
normalizedPath: string,
|
|
143
188
|
currentPath: string,
|
|
144
|
-
|
|
189
|
+
mode: HistoryMode,
|
|
190
|
+
isStale: () => boolean,
|
|
191
|
+
moveFocus: boolean
|
|
145
192
|
): Promise<void> {
|
|
146
193
|
const notFoundRoute = this.routes['/404'];
|
|
147
194
|
|
|
@@ -150,21 +197,26 @@ class Router extends Service<Router> {
|
|
|
150
197
|
return;
|
|
151
198
|
}
|
|
152
199
|
|
|
153
|
-
const
|
|
154
|
-
|
|
200
|
+
const notFoundFactory = RouteGuardsManager.getViewConstructor(notFoundRoute);
|
|
201
|
+
const view = await this.resolveView(notFoundFactory, NavigationManager.localizedUrl(normalizedPath), isStale);
|
|
202
|
+
if (!view) return;
|
|
203
|
+
|
|
155
204
|
this._currentView = await ViewRenderManager.transitionOutCurrentView(this._currentView);
|
|
156
205
|
this._currentParams = {};
|
|
157
|
-
this._currentView = ViewRenderManager.renderNewView(
|
|
206
|
+
this._currentView = ViewRenderManager.renderNewView(view, {});
|
|
158
207
|
|
|
159
208
|
NavigationManager.updateDocumentTitle(this._currentView, '/404');
|
|
160
|
-
NavigationManager.updateHistory(normalizedPath, currentPath,
|
|
209
|
+
NavigationManager.updateHistory(normalizedPath, currentPath, mode);
|
|
210
|
+
this.afterRender(moveFocus);
|
|
161
211
|
}
|
|
162
212
|
|
|
163
213
|
private async handleGuards(
|
|
164
214
|
normalizedPath: string,
|
|
165
|
-
routeConfig: RouteConfig,
|
|
215
|
+
routeConfig: Nutin.RouteConfig,
|
|
166
216
|
params: Record<string, string>,
|
|
167
|
-
|
|
217
|
+
currentPath: string,
|
|
218
|
+
mode: HistoryMode,
|
|
219
|
+
moveFocus: boolean
|
|
168
220
|
): Promise<GuardResult | false> {
|
|
169
221
|
const guardResult = await RouteGuardsManager.processRouteGuards(
|
|
170
222
|
routeConfig,
|
|
@@ -174,7 +226,11 @@ class Router extends Service<Router> {
|
|
|
174
226
|
|
|
175
227
|
if (!guardResult.allowed) {
|
|
176
228
|
if (guardResult.redirectTo) {
|
|
177
|
-
|
|
229
|
+
// A guarded URL that's already the current history entry (back/forward,
|
|
230
|
+
// reload, first load) is replaced by the redirect target, so the address
|
|
231
|
+
// bar matches what's rendered and Back doesn't land on the guarded URL again.
|
|
232
|
+
const redirectMode = mode === 'push' && normalizedPath !== currentPath ? 'push' : 'replace';
|
|
233
|
+
await this.navigateWithMode(guardResult.redirectTo, redirectMode, moveFocus);
|
|
178
234
|
}
|
|
179
235
|
// If no redirect, stay on current route (guard blocked navigation)
|
|
180
236
|
return false;
|
|
@@ -184,4 +240,8 @@ class Router extends Service<Router> {
|
|
|
184
240
|
}
|
|
185
241
|
}
|
|
186
242
|
|
|
187
|
-
|
|
243
|
+
function isPromise<T>(value: T | Promise<T>): value is Promise<T> {
|
|
244
|
+
return typeof (value as Promise<T> | null)?.then === 'function';
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const AppRouter = (routes: Nutin.Routes) => Router.getInstance(routes);
|
|
@@ -19,6 +19,11 @@ function validateConfig(config) {
|
|
|
19
19
|
|
|
20
20
|
validateConfig(nutinConfig);
|
|
21
21
|
|
|
22
|
+
// `build:prod` passes --prod rather than a NODE_ENV=production prefix, which Windows shells
|
|
23
|
+
// don't understand. Every build step is a separate process that only inherits the env (see
|
|
24
|
+
// runScript), so the flag is turned into NODE_ENV here, for them and for this process.
|
|
25
|
+
if (process.argv.includes('--prod')) process.env.NODE_ENV = 'production';
|
|
26
|
+
|
|
22
27
|
const wellKnownNonSEORoutes = [
|
|
23
28
|
'/400',
|
|
24
29
|
'/401',
|
|
@@ -34,6 +39,7 @@ export const builderConfig = {
|
|
|
34
39
|
...nutinConfig.builder,
|
|
35
40
|
isProd: process.env.NODE_ENV === 'production',
|
|
36
41
|
i18n: nutinConfig.i18n,
|
|
42
|
+
markdownSources: nutinConfig.markdownSources,
|
|
37
43
|
tailwind: nutinConfig.tailwind,
|
|
38
44
|
generateSEO: nutinConfig.generateSEOFiles,
|
|
39
45
|
WELL_KNOWN_NON_SEO_ROUTES: wellKnownNonSEORoutes,
|
|
@@ -13,14 +13,25 @@ const scriptsDir = path.join(process.cwd(), 'tools', 'builder', 'core');
|
|
|
13
13
|
|
|
14
14
|
print.boldHead(`\nStarting build...`);
|
|
15
15
|
|
|
16
|
+
// runScript forwards env vars but not argv, so -y/--yes (auto-install an opt-in
|
|
17
|
+
// feature's missing dependencies, see utils/ensure-deps.js) is passed through the env.
|
|
18
|
+
if (process.argv.includes('-y') || process.argv.includes('--yes')) process.env.NUTIN_ASSUME_YES = '1';
|
|
19
|
+
|
|
16
20
|
runScript(path.join(scriptsDir, 'app', 'copy-static.js'), 'Copying files...');
|
|
21
|
+
{{#if markdown}}
|
|
22
|
+
|
|
23
|
+
runScript(path.join(scriptsDir, 'markdown', 'generate-markdown-pages.js'), 'Compiling Markdown pages...');
|
|
24
|
+
{{/if}}
|
|
17
25
|
|
|
18
26
|
runScript(path.join(scriptsDir, 'html-index', 'validate-html.js'), 'Processing index.html...');
|
|
19
27
|
runScript(path.join(scriptsDir, 'app', 'validate-routes.js'), 'Validating app routes...');
|
|
20
28
|
|
|
21
|
-
runScript(path.join(scriptsDir, 'app', 'compile-ts.js'), 'Compiling TypeScript...');
|
|
22
|
-
|
|
23
29
|
runScript(path.join(scriptsDir, 'html-templates', 'process-html-templates.js'), 'Processing HTML templates...');
|
|
30
|
+
{{#unless (eq lang "js")}}
|
|
31
|
+
// After the templates are merged, so they're type-checked too.
|
|
32
|
+
runScript(path.join(scriptsDir, 'app', 'compile-ts.js'), 'Compiling TypeScript...');
|
|
33
|
+
{{/unless}}
|
|
34
|
+
runScript(path.join(scriptsDir, 'html-templates', 'minify-templates.js'), 'Minifying HTML templates...');
|
|
24
35
|
|
|
25
36
|
runScript(path.join(scriptsDir, 'styles', 'sass.js'), 'Compiling styles...');
|
|
26
37
|
if (builderConfig.tailwind) runScript(path.join(scriptsDir, 'styles', 'tailwind.js'), 'Compiling Tailwind CSS...');
|
|
@@ -30,12 +41,13 @@ if (builderConfig.i18n) runScript(path.join(scriptsDir, 'i18n', 'build-i18n.js')
|
|
|
30
41
|
runScript(path.join(scriptsDir, 'prod-bundle', 'esbuild.js'), 'Running esbuild...');
|
|
31
42
|
|
|
32
43
|
if (builderConfig.isProd) {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
runScript(path.join(scriptsDir, 'prod-bundle', 'hash-files.js'), 'Hashing files...');
|
|
45
|
+
if (builderConfig.generateSEO) runScript(path.join(scriptsDir, 'seo', 'generate-seo-files.js'), 'Generating SEO Files...');
|
|
46
|
+
// Last, so the prerendered SEO pages, sitemap.xml and robots.txt get compressed too.
|
|
47
|
+
runScript(path.join(scriptsDir, 'prod-bundle', 'compress-files.js'), 'Compressing files...');
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
runScript(path.join(scriptsDir, 'finalize-build.js'), 'Finalizing build...')
|
|
50
|
+
runScript(path.join(scriptsDir, 'finalize-build.js'), 'Finalizing build...');
|
|
39
51
|
|
|
40
52
|
print.boldSuccess(`\nBuild successful!\n`);
|
|
41
53
|
|
|
@@ -1,14 +1,89 @@
|
|
|
1
|
-
import
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { captureCommand, errorExit } from "../../../utils/index.js";
|
|
2
4
|
import { builderConfig } from '../../builder.config.js';
|
|
5
|
+
import { PATHS } from './paths.js';
|
|
6
|
+
import { readTemplateMap } from '../html-templates/merge-templates.js';
|
|
7
|
+
import { remapTscOutput } from './ts-errors.js';
|
|
8
|
+
import { injectHandlerRefs, stripHandlerRefs } from './handler-refs.js';
|
|
9
|
+
|
|
10
|
+
// Runs after the .html templates are merged (process-html-templates), on dist-build's copies,
|
|
11
|
+
// so templates are type-checked like the rest of the code. A tsconfig generated there extends
|
|
12
|
+
// the project's: dist-build/src mirrors src, so its include/exclude apply as written.
|
|
13
|
+
const BUILD_TSCONFIG = path.join(PATHS.temp, 'tsconfig.json');
|
|
3
14
|
|
|
4
15
|
async function compileTS() {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
|
|
16
|
+
writeBuildTsconfig();
|
|
17
|
+
|
|
18
|
+
// Prod bundles straight from the merged .ts (see prod-bundle/esbuild.js), so tsc only
|
|
19
|
+
// type-checks there. Dev emits each .js next to its .ts, for the dev bundle and testin-nutin.
|
|
20
|
+
const args = ['--project', BUILD_TSCONFIG, '--pretty', 'false'];
|
|
9
21
|
if (builderConfig.isProd) args.push('--noEmit');
|
|
10
22
|
|
|
11
|
-
|
|
23
|
+
// data-event handlers are type-checked through references injected for tsc only, then
|
|
24
|
+
// removed from the .ts copies the prod bundle is built from (see handler-refs.js).
|
|
25
|
+
const handlerRefs = await injectHandlerRefs();
|
|
26
|
+
const { code, output } = await captureCommand('tsc', args);
|
|
27
|
+
await stripHandlerRefs(handlerRefs);
|
|
28
|
+
const report = remapTscOutput(output, {
|
|
29
|
+
cwd: process.cwd(),
|
|
30
|
+
tempDir: PATHS.temp,
|
|
31
|
+
templateMap: await readTemplateMap(),
|
|
32
|
+
handlerRefs,
|
|
33
|
+
}).trim();
|
|
34
|
+
|
|
35
|
+
// On stdout, where tsc itself writes its diagnostics.
|
|
36
|
+
if (report) console.log(report);
|
|
37
|
+
if (code !== 0) throw new Error(`tsc exited with code ${code}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeBuildTsconfig() {
|
|
41
|
+
const project = readTsconfig(path.resolve('tsconfig.json'));
|
|
42
|
+
const config = {
|
|
43
|
+
extends: '../tsconfig.json',
|
|
44
|
+
compilerOptions: { rootDir: 'src', outDir: 'src' },
|
|
45
|
+
include: (project.include ?? ['src']).map(toBuildPath),
|
|
46
|
+
exclude: (project.exclude ?? []).map(toBuildPath),
|
|
47
|
+
};
|
|
48
|
+
if (project.files) config.files = project.files.map(toBuildPath);
|
|
49
|
+
fs.writeFileSync(BUILD_TSCONFIG, JSON.stringify(config, null, 2));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Paths in src/ are mirrored in dist-build/src; anything else stays where it is, one level up.
|
|
53
|
+
function toBuildPath(entry) {
|
|
54
|
+
const normalized = entry.replace(/^\.\//, '');
|
|
55
|
+
return normalized === 'src' || normalized.startsWith('src/') ? normalized : `../${normalized}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// tsconfig.json is JSONC: comments and trailing commas are allowed.
|
|
59
|
+
function readTsconfig(file) {
|
|
60
|
+
const source = fs.readFileSync(file, 'utf-8');
|
|
61
|
+
let json = '';
|
|
62
|
+
for (let i = 0; i < source.length; i++) {
|
|
63
|
+
const ch = source[i];
|
|
64
|
+
if (ch === '"') {
|
|
65
|
+
const end = endOfString(source, i);
|
|
66
|
+
json += source.slice(i, end);
|
|
67
|
+
i = end - 1;
|
|
68
|
+
} else if (ch === '/' && source[i + 1] === '/') {
|
|
69
|
+
while (i < source.length && source[i] !== '\n') i++;
|
|
70
|
+
json += '\n';
|
|
71
|
+
} else if (ch === '/' && source[i + 1] === '*') {
|
|
72
|
+
i = source.indexOf('*/', i + 2) + 1;
|
|
73
|
+
if (i === 0) break;
|
|
74
|
+
} else {
|
|
75
|
+
json += ch;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return JSON.parse(json.replace(/,(\s*[}\]])/g, '$1'));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function endOfString(source, start) {
|
|
82
|
+
for (let i = start + 1; i < source.length; i++) {
|
|
83
|
+
if (source[i] === '\\') i++;
|
|
84
|
+
else if (source[i] === '"') return i + 1;
|
|
85
|
+
}
|
|
86
|
+
return source.length;
|
|
12
87
|
}
|
|
13
88
|
|
|
14
89
|
compileTS().catch((err) => {
|
|
@@ -7,7 +7,13 @@ import { builderConfig } from '../../builder.config.js';
|
|
|
7
7
|
|
|
8
8
|
async function copyStatic() {
|
|
9
9
|
const extensions = [...BINARY_EXTENSIONS, '.html'];
|
|
10
|
-
|
|
10
|
+
{{#if (eq lang "js")}}
|
|
11
|
+
// No compile step emits .js here, so sources are copied in dev and prod alike.
|
|
12
|
+
extensions.push('.js');
|
|
13
|
+
{{else}}
|
|
14
|
+
// Templates are merged into these copies, then tsc checks them (and emits .js in dev).
|
|
15
|
+
extensions.push('.ts');
|
|
16
|
+
{{/if}}
|
|
11
17
|
|
|
12
18
|
// Every build starts from a clean dist-build: a failed/interrupted prior
|
|
13
19
|
// build never gets renamed away by finalize-build.js, so a stale sibling
|
|
@@ -23,6 +29,11 @@ async function copyStatic() {
|
|
|
23
29
|
const files = await getFilesRecursive(PATHS.source, extension);
|
|
24
30
|
|
|
25
31
|
for (const file of files) {
|
|
32
|
+
{{#if (eq lang "js")}}
|
|
33
|
+
// Tests import the build output; they must not end up in it.
|
|
34
|
+
if (file.endsWith('.test.js')) continue;
|
|
35
|
+
|
|
36
|
+
{{/if}}
|
|
26
37
|
const relative = path.relative(PATHS.source, file);
|
|
27
38
|
const dest = path.join(PATHS.tempSource, relative);
|
|
28
39
|
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getFilesRecursive } from '../../../utils/index.js';
|
|
4
|
+
import { findInlineTemplate } from '../html-templates/template-literal.js';
|
|
5
|
+
import { PATHS } from './paths.js';
|
|
6
|
+
|
|
7
|
+
// data-event handlers are looked up by name at runtime, so tsc can't see them: a typo binds
|
|
8
|
+
// nothing, and noUnusedLocals flags every private handler. Before tsc, each component/view's
|
|
9
|
+
// handler names are referenced from its own class, on the class's opening line (no line shifts):
|
|
10
|
+
// class TaskCardComponent extends Component { static { void ((c: InstanceType<typeof TaskCardComponent>) => [c._save]); }
|
|
11
|
+
// A class body may read its own private members, so a missing handler is a tsc error and an
|
|
12
|
+
// existing one counts as used. The arrow is never called; stripHandlerRefs() removes it after tsc.
|
|
13
|
+
|
|
14
|
+
const DATA_EVENT = /\bdata-event\s*=\s*(["'])(.*?)\1/gs;
|
|
15
|
+
const CLASS_OPEN = /\bclass\s+([A-Za-z_$][\w$]*)\b[^{;]*?\bextends\b[^{;]*\{/g;
|
|
16
|
+
const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
|
|
17
|
+
|
|
18
|
+
// Injects the references into every .ts under src/app; returns where they went, by path
|
|
19
|
+
// relative to dist-build (as ts-errors.js receives tsc's paths).
|
|
20
|
+
export async function injectHandlerRefs() {
|
|
21
|
+
const refs = {};
|
|
22
|
+
for (const codePath of await getFilesRecursive(PATHS.tempApp, ['.ts'])) {
|
|
23
|
+
const content = await fs.readFile(codePath, 'utf-8');
|
|
24
|
+
const result = addHandlerRefs(content);
|
|
25
|
+
if (!result) continue;
|
|
26
|
+
await fs.writeFile(codePath, result.content);
|
|
27
|
+
refs[toPosix(path.relative(PATHS.temp, codePath))] = result.entry;
|
|
28
|
+
}
|
|
29
|
+
return refs;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function stripHandlerRefs(refs) {
|
|
33
|
+
for (const [relPath, entry] of Object.entries(refs)) {
|
|
34
|
+
const codePath = path.join(PATHS.temp, relPath);
|
|
35
|
+
const content = await fs.readFile(codePath, 'utf-8');
|
|
36
|
+
await fs.writeFile(codePath, removeHandlerRefs(content, entry));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The template's handler names, referenced from the class that follows (or precedes) it.
|
|
41
|
+
// Returns null when there's nothing to check. entry: { className, line, col, segment, handlers }
|
|
42
|
+
// with 1-based positions; each handler's col is where it sits in the injected line, and its
|
|
43
|
+
// templateLine/templateCol where its name is written in the template.
|
|
44
|
+
export function addHandlerRefs(content) {
|
|
45
|
+
const template = findInlineTemplate(content);
|
|
46
|
+
if (!template || template.body === null) return null;
|
|
47
|
+
|
|
48
|
+
const handlers = collectHandlers(content, template);
|
|
49
|
+
if (!handlers.length) return null;
|
|
50
|
+
|
|
51
|
+
const classOpen = findClassOpen(content, template);
|
|
52
|
+
if (!classOpen) return null;
|
|
53
|
+
|
|
54
|
+
const prefix = ` static { void ((c: InstanceType<typeof ${classOpen.name}>) => [`;
|
|
55
|
+
let segment = prefix;
|
|
56
|
+
const { line, col } = positionOf(content, classOpen.end);
|
|
57
|
+
const located = handlers.map((handler, i) => {
|
|
58
|
+
if (i > 0) segment += ', ';
|
|
59
|
+
const handlerCol = col + segment.length + 2; // past "c."
|
|
60
|
+
segment += `c.${handler.name}`;
|
|
61
|
+
return { ...handler, col: handlerCol };
|
|
62
|
+
});
|
|
63
|
+
segment += ']); }';
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
content: content.slice(0, classOpen.end) + segment + content.slice(classOpen.end),
|
|
67
|
+
entry: { className: classOpen.name, line, col, segment, handlers: located },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function removeHandlerRefs(content, entry) {
|
|
72
|
+
const index = content.indexOf(entry.segment);
|
|
73
|
+
return index === -1 ? content : content.slice(0, index) + content.slice(index + entry.segment.length);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Each handler named in a static data-event (one built with ${} is skipped), once, at its
|
|
77
|
+
// first occurrence.
|
|
78
|
+
function collectHandlers(content, template) {
|
|
79
|
+
const handlers = new Map();
|
|
80
|
+
for (const match of template.body.matchAll(DATA_EVENT)) {
|
|
81
|
+
const value = match[2];
|
|
82
|
+
if (value.includes('${')) continue;
|
|
83
|
+
const name = value.split(':')[1]?.trim();
|
|
84
|
+
if (!name || !IDENTIFIER.test(name) || handlers.has(name)) continue;
|
|
85
|
+
|
|
86
|
+
const valueStart = match.index + match[0].indexOf(match[1]) + 1;
|
|
87
|
+
const afterColon = value.indexOf(':') + 1;
|
|
88
|
+
const nameOffset = template.open + valueStart + afterColon + value.slice(afterColon).indexOf(name);
|
|
89
|
+
const { line, col } = positionOf(content, nameOffset);
|
|
90
|
+
handlers.set(name, { name, templateLine: line, templateCol: col });
|
|
91
|
+
}
|
|
92
|
+
return [...handlers.values()];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The first `class X ... extends ... {` outside the template: after it first, else before it.
|
|
96
|
+
function findClassOpen(content, template) {
|
|
97
|
+
const end = template.end ?? template.open;
|
|
98
|
+
let found = null;
|
|
99
|
+
for (const match of content.matchAll(CLASS_OPEN)) {
|
|
100
|
+
const inTemplate = match.index >= template.open && match.index < end;
|
|
101
|
+
if (inTemplate) continue;
|
|
102
|
+
const candidate = { name: match[1], end: match.index + match[0].length };
|
|
103
|
+
if (match.index >= end) return candidate;
|
|
104
|
+
found ??= candidate;
|
|
105
|
+
}
|
|
106
|
+
return found;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function positionOf(content, offset) {
|
|
110
|
+
const before = content.slice(0, offset);
|
|
111
|
+
return { line: before.split('\n').length, col: offset - before.lastIndexOf('\n') };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const toPosix = (p) => p.split(path.sep).join('/');
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { addHandlerRefs, removeHandlerRefs } from './handler-refs.js';
|
|
2
|
+
|
|
3
|
+
const VIEW = [
|
|
4
|
+
"import { View, html } from '../../../core/index.js';",
|
|
5
|
+
'',
|
|
6
|
+
'const template = html`<div>',
|
|
7
|
+
' <button data-event="click:_save:@value">Save</button>',
|
|
8
|
+
' <a data-event=\'click:_open\' data-event-extra="x">Open</a>',
|
|
9
|
+
' <button data-event="click:_save">Again</button>',
|
|
10
|
+
'</div>`;',
|
|
11
|
+
'',
|
|
12
|
+
'export class TaskView extends View {',
|
|
13
|
+
' private _save(): void {}',
|
|
14
|
+
' private _open(): void {}',
|
|
15
|
+
'}',
|
|
16
|
+
].join('\n');
|
|
17
|
+
|
|
18
|
+
describe('handler-refs', () => {
|
|
19
|
+
it('references each data-event handler once, from the class\'s opening line', () => {
|
|
20
|
+
const { content, entry } = addHandlerRefs(VIEW);
|
|
21
|
+
const classLine = content.split('\n')[8];
|
|
22
|
+
|
|
23
|
+
expect(classLine).toBe(
|
|
24
|
+
'export class TaskView extends View { static { void ((c: InstanceType<typeof TaskView>) => [c._save, c._open]); }'
|
|
25
|
+
);
|
|
26
|
+
expect(content.split('\n').length).toBe(VIEW.split('\n').length);
|
|
27
|
+
expect(entry.className).toBe('TaskView');
|
|
28
|
+
expect(entry.line).toBe(9);
|
|
29
|
+
expect(entry.handlers.map((h) => h.name)).toEqual(['_save', '_open']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('records where each handler is named in the template and in the injected line', () => {
|
|
33
|
+
const { content, entry } = addHandlerRefs(VIEW);
|
|
34
|
+
const [save, open] = entry.handlers;
|
|
35
|
+
|
|
36
|
+
expect(save.templateLine).toBe(4);
|
|
37
|
+
expect(VIEW.split('\n')[3].slice(save.templateCol - 1).startsWith('_save:@value')).toBeTruthy();
|
|
38
|
+
expect(open.templateLine).toBe(5);
|
|
39
|
+
expect(VIEW.split('\n')[4].slice(open.templateCol - 1).startsWith('_open\'')).toBeTruthy();
|
|
40
|
+
|
|
41
|
+
const classLine = content.split('\n')[8];
|
|
42
|
+
expect(classLine.slice(save.col - 1).startsWith('_save,')).toBeTruthy();
|
|
43
|
+
expect(classLine.slice(open.col - 1).startsWith('_open]')).toBeTruthy();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('skips data-event values built with ${}, and files without handlers', () => {
|
|
47
|
+
const dynamic = VIEW
|
|
48
|
+
.replace('click:_save:@value', 'click:${handler}')
|
|
49
|
+
.replace("'click:_open'", "'click:${other}'")
|
|
50
|
+
.replace('"click:_save"', '"${evt}:_save"');
|
|
51
|
+
expect(addHandlerRefs(dynamic)).toBe(null);
|
|
52
|
+
expect(addHandlerRefs("export class A extends View {}")).toBe(null);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('finds the class after the template, even when the markup mentions a class', () => {
|
|
56
|
+
const source = [
|
|
57
|
+
'const templateFn = (t: T) => html`<p class="x">the class Foo extends Bar { }</p><i data-event="click:_go"></i>`;',
|
|
58
|
+
'export class CardComponent<T = string> extends Component<T> {',
|
|
59
|
+
'}',
|
|
60
|
+
].join('\n');
|
|
61
|
+
const { content, entry } = addHandlerRefs(source);
|
|
62
|
+
expect(entry.className).toBe('CardComponent');
|
|
63
|
+
expect(content.split('\n')[1]).toContain('extends Component<T> { static { void ((c: InstanceType<typeof CardComponent>) => [c._go]); }');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('removes exactly the injected references', () => {
|
|
67
|
+
const { content, entry } = addHandlerRefs(VIEW);
|
|
68
|
+
expect(removeHandlerRefs(content, entry)).toBe(VIEW);
|
|
69
|
+
});
|
|
70
|
+
});
|