@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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import * as fsExtra from 'fs-extra';
|
|
3
|
+
import { JsonManager } from '../create-app/json-manager.mjs';
|
|
4
|
+
|
|
5
|
+
const fs = fsExtra.default;
|
|
6
|
+
|
|
7
|
+
// package.json and tsconfig.json aren't rendered from templates (JsonManager writes them
|
|
8
|
+
// once, at creation), so the template diff never sees them. They're compared with what
|
|
9
|
+
// this CLI would generate instead, and only reported.
|
|
10
|
+
// Only the entries nutin generates are compared; anything the user added is ignored.
|
|
11
|
+
const PACKAGE_JSON_SECTIONS = ['scripts', 'devDependencies'];
|
|
12
|
+
|
|
13
|
+
function same(a, b) {
|
|
14
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function compareEntries(file, section, yours = {}, generated = {}) {
|
|
18
|
+
return Object.entries(generated)
|
|
19
|
+
.filter(([key, value]) => !same(yours[key], value))
|
|
20
|
+
.map(([key, value]) => ({ file, key: section ? `${section}.${key}` : key, yours: yours[key], nutin: value }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function readJsonIfExists(filePath) {
|
|
24
|
+
if (!(await fs.pathExists(filePath))) return null;
|
|
25
|
+
try {
|
|
26
|
+
return await fs.readJSON(filePath);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function findJsonDrift(projectPath, context) {
|
|
33
|
+
const jsonManager = new JsonManager();
|
|
34
|
+
const drift = [];
|
|
35
|
+
|
|
36
|
+
const packageJson = await readJsonIfExists(path.join(projectPath, 'package.json'));
|
|
37
|
+
if (packageJson) {
|
|
38
|
+
const generated = jsonManager.buildPackageJson(context);
|
|
39
|
+
for (const section of PACKAGE_JSON_SECTIONS) {
|
|
40
|
+
drift.push(...compareEntries('package.json', section, packageJson[section], generated[section]));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (context.lang !== 'js') {
|
|
45
|
+
const tsconfig = await readJsonIfExists(path.join(projectPath, 'tsconfig.json'));
|
|
46
|
+
if (tsconfig) {
|
|
47
|
+
const generated = jsonManager.buildTsconfigJson();
|
|
48
|
+
drift.push(...compareEntries('tsconfig.json', 'compilerOptions', tsconfig.compilerOptions, generated.compilerOptions));
|
|
49
|
+
drift.push(...compareEntries('tsconfig.json', null, { include: tsconfig.include, exclude: tsconfig.exclude }, { include: generated.include, exclude: generated.exclude }));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return drift;
|
|
54
|
+
}
|
|
@@ -8,12 +8,16 @@ import { fetchOldTemplates } from './fetch-old-templates.mjs';
|
|
|
8
8
|
import { parseVersion, compareVersions } from './version-compare.mjs';
|
|
9
9
|
import { bootstrapProjectMeta } from '../common/meta-bootstrap-prompt.mjs';
|
|
10
10
|
import { printUpdateSummary } from './update-summary-printer.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { writeUpdateReport, REPORT_FILE_NAME } from './conflict-report-writer.mjs';
|
|
12
|
+
import { mergeFile, hasConflictMarkers, CONFLICT_MARKER } from './three-way-merge.mjs';
|
|
13
|
+
import { isGitAvailable, getWorkingTreeStatus } from '../common/git.mjs';
|
|
12
14
|
import { UpdateContextBuilder } from './update-context-builder.mjs';
|
|
13
15
|
import { TemplateDiffer } from './template-differ.mjs';
|
|
16
|
+
import { findJsonDrift } from './json-drift.mjs';
|
|
14
17
|
|
|
15
18
|
const fs = fsExtra.default;
|
|
16
|
-
|
|
19
|
+
|
|
20
|
+
const UNDO_COMMAND = 'git checkout -- . && git clean -fd';
|
|
17
21
|
|
|
18
22
|
export class ProjectUpdater {
|
|
19
23
|
constructor() {
|
|
@@ -22,8 +26,12 @@ export class ProjectUpdater {
|
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
async updateProject(projectPath, options = {}) {
|
|
25
|
-
const { yes = false, from } = options;
|
|
29
|
+
const { yes = false, from, allowDirty = false } = options;
|
|
30
|
+
// Read before anything here writes .nutin-meta.json (bootstrap, resolved conflicts), so only your
|
|
31
|
+
// own changes count. Enforced right before the first file write.
|
|
32
|
+
const gitStatus = allowDirty ? null : await getWorkingTreeStatus(projectPath);
|
|
26
33
|
const meta = await this.resolveMeta(projectPath);
|
|
34
|
+
await this.assertNoConflictMarkers(projectPath, meta);
|
|
27
35
|
|
|
28
36
|
if (this.isUpToDate(meta)) {
|
|
29
37
|
print.boldSuccess(`✅ Already up to date (nutin v${packageVersion}).`);
|
|
@@ -37,7 +45,7 @@ export class ProjectUpdater {
|
|
|
37
45
|
|
|
38
46
|
try {
|
|
39
47
|
const changeSet = await this.computeChangeSet(projectPath, meta, oldTemplatesRoot);
|
|
40
|
-
await this.applyChangeSet(projectPath, meta, changeSet, { yes });
|
|
48
|
+
await this.applyChangeSet(projectPath, meta, changeSet, { yes, gitStatus });
|
|
41
49
|
} finally {
|
|
42
50
|
await cleanup();
|
|
43
51
|
}
|
|
@@ -62,6 +70,28 @@ export class ProjectUpdater {
|
|
|
62
70
|
}
|
|
63
71
|
}
|
|
64
72
|
|
|
73
|
+
// Files the last update left with conflict markers (meta.unmergedFiles): nothing is updated until
|
|
74
|
+
// they're resolved, so a merge never runs on top of markers and a re-run still reports them.
|
|
75
|
+
async assertNoConflictMarkers(projectPath, meta) {
|
|
76
|
+
const unmerged = meta.unmergedFiles ?? [];
|
|
77
|
+
if (unmerged.length === 0) return;
|
|
78
|
+
|
|
79
|
+
const stillMarked = [];
|
|
80
|
+
for (const relPath of unmerged) {
|
|
81
|
+
if (await hasConflictMarkers(path.join(projectPath, relPath))) stillMarked.push(relPath);
|
|
82
|
+
}
|
|
83
|
+
if (stillMarked.length > 0) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${stillMarked.length} file(s) still have conflict markers from the last update — the project won't build until ` +
|
|
86
|
+
`they're resolved. Search for "${CONFLICT_MARKER}", merge, then run nutin-update again:\n` +
|
|
87
|
+
stillMarked.map((relPath) => ` - ${relPath}`).join('\n'),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await updateProjectMeta(projectPath, { unmergedFiles: [] });
|
|
92
|
+
print.info('Conflict markers from the last update are resolved.');
|
|
93
|
+
}
|
|
94
|
+
|
|
65
95
|
isUpToDate(meta) {
|
|
66
96
|
return compareVersions(meta.version, packageVersion) === 0;
|
|
67
97
|
}
|
|
@@ -84,12 +114,13 @@ export class ProjectUpdater {
|
|
|
84
114
|
|
|
85
115
|
async computeChangeSet(projectPath, meta, oldTemplatesRoot) {
|
|
86
116
|
const { oldContext, newContext } = await this.contextBuilder.buildContexts(projectPath, meta);
|
|
87
|
-
|
|
117
|
+
const changeSet = await this.differ.diff(projectPath, oldTemplatesRoot, oldContext, newContext);
|
|
118
|
+
return { ...changeSet, jsonDrift: await findJsonDrift(projectPath, newContext) };
|
|
88
119
|
}
|
|
89
120
|
|
|
90
|
-
async applyChangeSet(projectPath, meta, changeSet, { yes }) {
|
|
91
|
-
const { toUpdate, toAdd, conflicts, unknown, removedByUser, noLongerGenerated } = changeSet;
|
|
92
|
-
printUpdateSummary({ toUpdate, toAdd, conflicts, unknown, removedByUser, noLongerGenerated });
|
|
121
|
+
async applyChangeSet(projectPath, meta, changeSet, { yes, gitStatus = null }) {
|
|
122
|
+
const { toUpdate, toAdd, conflicts, unknown, removedByUser, noLongerGenerated, jsonDrift } = changeSet;
|
|
123
|
+
printUpdateSummary({ toUpdate, toAdd, conflicts, unknown, removedByUser, noLongerGenerated, jsonDrift });
|
|
93
124
|
|
|
94
125
|
if (toUpdate.length === 0 && toAdd.length === 0 && conflicts.length === 0) {
|
|
95
126
|
print.boldSuccess('\n✅ Nothing to update.');
|
|
@@ -97,18 +128,32 @@ export class ProjectUpdater {
|
|
|
97
128
|
return;
|
|
98
129
|
}
|
|
99
130
|
|
|
100
|
-
|
|
131
|
+
// Binary files can't be merged: they're kept as they are and listed in the report.
|
|
132
|
+
const toMerge = conflicts.filter(({ newEntry, actualContent }) => !newEntry.isBinary && !Buffer.isBuffer(actualContent));
|
|
133
|
+
const binaryConflicts = conflicts.filter((conflict) => !toMerge.includes(conflict));
|
|
134
|
+
|
|
135
|
+
if (gitStatus) this.assertPristine(gitStatus);
|
|
136
|
+
|
|
137
|
+
if (toMerge.length > 0 && !(await isGitAvailable())) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`nutin-update needs git to merge the ${toMerge.length} file(s) you edited, but git was not found. ` +
|
|
140
|
+
'Install git and run nutin-update again. No files were changed.',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!yes && (toUpdate.length > 0 || toAdd.length > 0 || toMerge.length > 0)) {
|
|
101
145
|
if (!process.stdin.isTTY) {
|
|
102
146
|
print.warn('\n⚠️ Non-interactive shell detected — skipping the confirmation prompt.');
|
|
103
147
|
print.boldInfo('Re-run with --yes to apply these updates automatically. No files were changed.');
|
|
104
148
|
return;
|
|
105
149
|
}
|
|
106
150
|
|
|
151
|
+
const merges = toMerge.length > 0 ? ` and merge ${toMerge.length} file(s) you edited` : '';
|
|
107
152
|
const { proceed } = await inquirer.prompt([
|
|
108
153
|
{
|
|
109
154
|
type: 'confirm',
|
|
110
155
|
name: 'proceed',
|
|
111
|
-
message: `\nApply ${toUpdate.length + toAdd.length}
|
|
156
|
+
message: `\nApply ${toUpdate.length + toAdd.length} file update(s)${merges}?`,
|
|
112
157
|
default: true,
|
|
113
158
|
},
|
|
114
159
|
]);
|
|
@@ -124,15 +169,79 @@ export class ProjectUpdater {
|
|
|
124
169
|
await fs.writeFile(outputPath, newEntry.content);
|
|
125
170
|
}
|
|
126
171
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
172
|
+
const { merged, conflicted } = await this.mergeEditedFiles(projectPath, meta, toMerge);
|
|
173
|
+
|
|
174
|
+
if (merged.length > 0 || conflicted.length > 0 || binaryConflicts.length > 0) {
|
|
175
|
+
await writeUpdateReport(projectPath, { merged, conflicted, binaryConflicts }, meta.version, packageVersion);
|
|
130
176
|
}
|
|
131
177
|
|
|
132
|
-
await updateProjectMeta(projectPath, {
|
|
178
|
+
await updateProjectMeta(projectPath, {
|
|
179
|
+
version: packageVersion,
|
|
180
|
+
unmergedFiles: conflicted.map(({ relPath }) => relPath),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
if (merged.length > 0) {
|
|
184
|
+
print.info(`\n${merged.length} file(s) you edited were merged with the new version — review them (listed in ${REPORT_FILE_NAME}).`);
|
|
185
|
+
}
|
|
186
|
+
if (binaryConflicts.length > 0) {
|
|
187
|
+
print.boldError(`\n⚠️ ${binaryConflicts.length} binary file(s) you modified were left as they are — see ${REPORT_FILE_NAME}`);
|
|
188
|
+
}
|
|
189
|
+
if (conflicted.length > 0) {
|
|
190
|
+
print.boldError(
|
|
191
|
+
`\n⚠️ ${conflicted.length} file(s) have conflict markers — the project won't build until they're resolved ` +
|
|
192
|
+
`(search for "${CONFLICT_MARKER}"):`,
|
|
193
|
+
);
|
|
194
|
+
conflicted.forEach(({ relPath }) => print.boldError(` - ${relPath}`));
|
|
195
|
+
}
|
|
196
|
+
if (gitStatus) {
|
|
197
|
+
print.info(`\nTo undo this update, run from the project folder: ${UNDO_COMMAND}`);
|
|
198
|
+
print.info(`Important: this will also remove any untracked folder.`);
|
|
199
|
+
}
|
|
133
200
|
|
|
134
201
|
print.boldSuccess(`\n✅ Updated to nutin v${packageVersion}.`);
|
|
135
202
|
}
|
|
203
|
+
|
|
204
|
+
// The update must be undoable with git: no uncommitted changes and no untracked files in the
|
|
205
|
+
// project folder, so checkout + clean only reverts what the update wrote. --allow-dirty skips this.
|
|
206
|
+
assertPristine({ repo, gitFound, changes }) {
|
|
207
|
+
if (!gitFound) {
|
|
208
|
+
throw new Error('git was not found: nutin-update uses it to make the update undoable. Install git, or pass --allow-dirty. No files were changed.');
|
|
209
|
+
}
|
|
210
|
+
if (!repo) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
'This project isn\'t in a git repository, so the update couldn\'t be undone. Run "git init" and commit, ' +
|
|
213
|
+
'or pass --allow-dirty. No files were changed.',
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (changes.length > 0) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
'Commit or stash your changes first (untracked files included), so the update can be undone with git, ' +
|
|
219
|
+
`or pass --allow-dirty. No files were changed:\n${changes.map((line) => ` ${line}`).join('\n')}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Three-way merge of each file you edited: the old nutin version is the base, so your edits and
|
|
225
|
+
// nutin's changes are both kept, and only overlapping edits get conflict markers.
|
|
226
|
+
async mergeEditedFiles(projectPath, meta, toMerge) {
|
|
227
|
+
const merged = [];
|
|
228
|
+
const conflicted = [];
|
|
229
|
+
const labels = { base: `nutin v${meta.version}`, theirs: `nutin v${packageVersion}` };
|
|
230
|
+
|
|
231
|
+
for (const { relPath, actualContent, baseContent, newEntry } of toMerge) {
|
|
232
|
+
const { content, conflictCount } = await mergeFile({
|
|
233
|
+
base: baseContent,
|
|
234
|
+
yours: actualContent,
|
|
235
|
+
theirs: newEntry.content,
|
|
236
|
+
labels,
|
|
237
|
+
});
|
|
238
|
+
await fs.writeFile(path.join(projectPath, relPath), content);
|
|
239
|
+
if (conflictCount > 0) conflicted.push({ relPath, conflictCount });
|
|
240
|
+
else merged.push({ relPath });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { merged, conflicted };
|
|
244
|
+
}
|
|
136
245
|
}
|
|
137
246
|
|
|
138
247
|
export const projectUpdater = new ProjectUpdater();
|
|
@@ -11,8 +11,8 @@ function contentEquals(a, b) {
|
|
|
11
11
|
return a === b;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
// Paths the user is expected to hand-edit right after `create-app
|
|
15
|
-
// content is just placeholder/starter scaffolding, so nutin-update must never rewrite
|
|
14
|
+
// Paths the user is expected to hand-edit right after `create-app`.
|
|
15
|
+
// Their generated content is just placeholder/starter scaffolding, so nutin-update must never rewrite
|
|
16
16
|
// or conflict-flag them.
|
|
17
17
|
const FULLY_EXCLUDED_PATTERNS = [
|
|
18
18
|
/^config\/[^/]+\.json$/,
|
|
@@ -21,7 +21,7 @@ const FULLY_EXCLUDED_PATTERNS = [
|
|
|
21
21
|
];
|
|
22
22
|
|
|
23
23
|
// Like FULLY_EXCLUDED_PATTERNS, but only for files that already existed before this
|
|
24
|
-
// update
|
|
24
|
+
// update. A brand new file introduced by a newer template version should still be added.
|
|
25
25
|
const ADD_ONLY_PATTERNS = [
|
|
26
26
|
/^src\/styles(\/|$)/,
|
|
27
27
|
];
|
|
@@ -114,8 +114,8 @@ export class TemplateDiffer {
|
|
|
114
114
|
if (contentEquals(actualContent, oldEntry.content)) {
|
|
115
115
|
toUpdate.push({ relPath, newEntry });
|
|
116
116
|
} else if (!contentEquals(actualContent, newEntry.content)) {
|
|
117
|
-
// Differs from both baselines
|
|
118
|
-
conflicts.push({ relPath, actualContent, newEntry });
|
|
117
|
+
// Differs from both baselines: a real conflict, merged from the old baseline at apply time.
|
|
118
|
+
conflicts.push({ relPath, actualContent, baseContent: oldEntry.content, newEntry });
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import os from 'os';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import * as fsExtra from 'fs-extra';
|
|
4
|
+
import { execFileAsync } from '../common/git.mjs';
|
|
5
|
+
|
|
6
|
+
const fs = fsExtra.default;
|
|
7
|
+
|
|
8
|
+
export const CONFLICT_MARKER = '<<<<<<< yours';
|
|
9
|
+
|
|
10
|
+
// Merges nutin's changes (base -> theirs) into your file with `git merge-file`, which needs the git
|
|
11
|
+
// binary but no repository. Overlapping edits get diff3 markers: yours, the old nutin version, the new one.
|
|
12
|
+
export async function mergeFile({ base, yours, theirs, labels }) {
|
|
13
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'nutin-merge-'));
|
|
14
|
+
try {
|
|
15
|
+
const [yoursPath, basePath, theirsPath] = ['yours', 'base', 'theirs'].map((name) => path.join(tmpDir, name));
|
|
16
|
+
await Promise.all([
|
|
17
|
+
fs.writeFile(yoursPath, yours),
|
|
18
|
+
fs.writeFile(basePath, base),
|
|
19
|
+
fs.writeFile(theirsPath, theirs),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const args = ['merge-file', '-p', '--diff3', '-L', 'yours', '-L', labels.base, '-L', labels.theirs, yoursPath, basePath, theirsPath];
|
|
23
|
+
try {
|
|
24
|
+
const { stdout } = await execFileAsync('git', args, { maxBuffer: 1024 * 1024 * 50 });
|
|
25
|
+
return { content: stdout, conflictCount: 0 };
|
|
26
|
+
} catch (error) {
|
|
27
|
+
// A positive exit code below 255 is the number of conflicts; anything else is a real failure.
|
|
28
|
+
if (Number.isInteger(error.code) && error.code > 0 && error.code < 255) {
|
|
29
|
+
return { content: error.stdout, conflictCount: error.code };
|
|
30
|
+
}
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
} finally {
|
|
34
|
+
await fs.remove(tmpDir);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function hasConflictMarkers(filePath) {
|
|
39
|
+
if (!(await fs.pathExists(filePath))) return false;
|
|
40
|
+
const content = await fs.readFile(filePath, 'utf8');
|
|
41
|
+
return content.split('\n').some((line) => line.startsWith(CONFLICT_MARKER));
|
|
42
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { print } from '../utils/print.mjs';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
const describeValue = (value) => (value === undefined ? '(missing)' : JSON.stringify(value));
|
|
4
|
+
|
|
5
|
+
export function printUpdateSummary({ toUpdate, toAdd, conflicts, unknown, removedByUser, noLongerGenerated, jsonDrift = [] }) {
|
|
4
6
|
print.section('\nSummary:');
|
|
5
7
|
print.info(` ${toUpdate.length} file(s) will be updated (untouched since generation)`);
|
|
6
8
|
print.info(` ${toAdd.length} new file(s) will be added`);
|
|
7
9
|
if (conflicts.length > 0) {
|
|
8
|
-
print.boldError(` ${conflicts.length} file(s) you
|
|
10
|
+
print.boldError(` ${conflicts.length} file(s) you edited will be merged with the new version`);
|
|
9
11
|
}
|
|
10
12
|
if (noLongerGenerated.length > 0) {
|
|
11
13
|
print.boldError(` ${noLongerGenerated.length} file(s) nutin no longer generates — left in place, review manually:`);
|
|
@@ -19,4 +21,10 @@ export function printUpdateSummary({ toUpdate, toAdd, conflicts, unknown, remove
|
|
|
19
21
|
print.section(` ${removedByUser.length} file(s) nutin used to generate but you removed — not recreated:`);
|
|
20
22
|
removedByUser.forEach(({ relPath }) => print.section(` - ${relPath}`));
|
|
21
23
|
}
|
|
24
|
+
if (jsonDrift.length > 0) {
|
|
25
|
+
print.section(` ${jsonDrift.length} package.json/tsconfig.json value(s) differ from what this nutin version generates — never changed automatically, apply by hand if wanted:`);
|
|
26
|
+
jsonDrift.forEach(({ file, key, yours, nutin }) =>
|
|
27
|
+
print.section(` - ${file} ${key}: yours ${describeValue(yours)}, nutin ${describeValue(nutin)}`)
|
|
28
|
+
);
|
|
29
|
+
}
|
|
22
30
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nutin/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "The structure of a framework. The freedom of vanilla.",
|
|
5
5
|
"preferGlobal": true,
|
|
6
6
|
"main": "lib/index.mjs",
|
|
@@ -30,15 +30,16 @@
|
|
|
30
30
|
"author": "Loïs Kouninef <loiskouninef@gmail.com>",
|
|
31
31
|
"license": "MIT",
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"commander": "^
|
|
33
|
+
"commander": "^15.0.0",
|
|
34
34
|
"diff": "^9.0.0",
|
|
35
|
-
"fs-extra": "^11.
|
|
36
|
-
"handlebars": "^4.7.
|
|
37
|
-
"inquirer": "^
|
|
38
|
-
"tar": "^7.5.22"
|
|
35
|
+
"fs-extra": "^11.4.1",
|
|
36
|
+
"handlebars": "^4.7.10",
|
|
37
|
+
"inquirer": "^14.2.3",
|
|
38
|
+
"tar": "^7.5.22",
|
|
39
|
+
"typescript": "5.9.3"
|
|
39
40
|
},
|
|
40
41
|
"engines": {
|
|
41
|
-
"node": ">=
|
|
42
|
+
"node": ">=24"
|
|
42
43
|
},
|
|
43
44
|
"repository": {
|
|
44
45
|
"type": "git",
|
|
@@ -47,5 +48,5 @@
|
|
|
47
48
|
"bugs": {
|
|
48
49
|
"url": "https://github.com/LoisKOUNINEF/nutin/issues"
|
|
49
50
|
},
|
|
50
|
-
"homepage": "www.nutin.
|
|
51
|
+
"homepage": "www.nutin.dev"
|
|
51
52
|
}
|
|
@@ -20,14 +20,18 @@ nutin.config.js build/feature config — see below
|
|
|
20
20
|
- Favor small, composable components.
|
|
21
21
|
- One `View` = one route.
|
|
22
22
|
- Import framework code from the `core/index.js` barrel — do not deep-import into `core/`. App elements have no barrels: import them by file path.
|
|
23
|
+
{{#if (eq lang "ts")}}
|
|
24
|
+
- Framework types (`Nutin.ComponentConfig`, `Nutin.ComponentOptions`, `Nutin.CatalogConfig`, `Nutin.Template`, `Nutin.Routes`, `Nutin.RouteGuard`, `Nutin.RequestConfig`, …) live in the global `Nutin` namespace declared in `core/internals.d.ts` — use them without importing. App events extend it in `src/app/globals.d.ts` (`declare namespace Nutin { interface AppEventMap { … } }`).
|
|
25
|
+
{{/if}}
|
|
23
26
|
- `src/app/` layout is free; `components/`, `views/`, `services/` is the default suggestion.
|
|
24
27
|
|
|
25
28
|
## Commands
|
|
26
29
|
|
|
27
|
-
- Serve: `{{packageManager}} run serve` (build + serve), `{{packageManager}} run serve:only` (no rebuild), `{{packageManager}} run dev` (watch mode).
|
|
28
|
-
- Build: `{{packageManager}} run build` (dev) or `{{packageManager}} run build:prod` (production).
|
|
30
|
+
- Serve: `{{packageManager}} run serve` (build + serve), `{{packageManager}} run serve:prod` (prod build + serve), `{{packageManager}} run serve:only` (no rebuild), `{{packageManager}} run dev` (watch mode, reloads the page after each rebuild). Port defaults to 9090; override with `-- --port <n>` or `PORT=<n>`.
|
|
31
|
+
- Build: `{{packageManager}} run build` (dev) or `{{packageManager}} run build:prod` (production). Opt-in dependencies (Tailwind, `markdown`) are only installed automatically in an interactive terminal; elsewhere pass `-- -y` (or set `NUTIN_ASSUME_YES`).
|
|
29
32
|
- Generate: `{{packageManager}} run generate ELEMENT ELEMENT_NAME` (`ELEMENT` = `component`/`view`/`service`; `ELEMENT_NAME` may be a nested path, generated in `src/app/<path>/`; fails if that folder already exists and isn't empty).
|
|
30
|
-
- Test: `{{packageManager}} run testin-nutin` (build + run), `{{packageManager}} run testin-nutin:only` (no rebuild), `{{packageManager}} run testin-nutin:watch`.
|
|
33
|
+
- Test: `{{packageManager}} run testin-nutin` (build + run), `{{packageManager}} run testin-nutin:only` (no rebuild), `{{packageManager}} run testin-nutin:watch`, `{{packageManager}} run testin-nutin:coverage`, `{{packageManager}} run testin-nutin:verbose`.
|
|
34
|
+
- Update Nutin: `nutin-update` refuses to run unless the project is committed (no changes, no untracked files; `--allow-dirty` skips the check). Files edited here that also changed upstream are merged three-way; overlapping edits get `diff3` conflict markers, and the project won't build, nor will `nutin-update` run again, until they're resolved (listed in `.nutin-meta.json`'s `unmergedFiles` and `NUTIN-UPDATE-REPORT.md`).
|
|
31
35
|
|
|
32
36
|
## Base classes
|
|
33
37
|
|
|
@@ -38,15 +42,16 @@ BaseComponent<T> — render/destroy engine, data-* parsing, children
|
|
|
38
42
|
└── View — one per route; mounted/unmounted by the Router
|
|
39
43
|
```
|
|
40
44
|
|
|
41
|
-
- `render()`: `onBeforeRender()` → sanitize
|
|
45
|
+
- `render()`: `onBeforeRender()` → sanitize the template and insert the resulting nodes (`SecurityHelper.sanitizeToFragment()`, never assigned to `innerHTML`) → mount children (`data-component`/`data-catalog`) → parse `data-i18n`/`data-pipe` → bind `data-event` listeners → `onAfterRender()`. Runs in full on every `render()` call — no virtual DOM/diffing; only children with `keep`/`keepBy` are kept (see "Children components").
|
|
42
46
|
- `destroy()`: unbinds events, unsubscribes bus listeners, destroys children, removes the element.
|
|
43
|
-
- `Service`: direct `new` throws — always use `MyService.getInstance()` (or the exported const). Auto-binds all methods to the instance. `registerCleanup(fn)`
|
|
44
|
-
- Overridable hooks (all no-op by default): `onBeforeRender`, `onAfterRender`, `onBeforeDestroy`, `onAfterDestroy`, `generateTemplate()`, `registerChildren()`. `View` adds router-only `onEnter()`/`onExit()`.
|
|
47
|
+
- `Service`: direct `new` throws — always use `MyService.getInstance()` (or the exported const). Auto-binds all methods to the instance. `registerCleanup(fn)` callbacks run on `dispose()`, `MyService.destroy()` and `Service.destroyAll()` (tests, SSR) — nothing is torn down on page unload.
|
|
48
|
+
- Overridable hooks (all no-op by default): `onBeforeRender`, `onAfterRender`, `onBeforeDestroy`, `onAfterDestroy`, `onKept`, `generateTemplate()`, `registerChildren()`. `View` adds router-only `onEnter()`/`onExit()`.
|
|
45
49
|
|
|
46
50
|
## Children components
|
|
47
51
|
|
|
48
|
-
- Override `registerChildren(): ComponentConfig[]` to declare children — matched against `[data-component="selector"]` elements in the rendered template. ComponentConfig: `{ selector: 'my-selector', factory: (el) => new MyComponent(el, {...} )}`.
|
|
49
|
-
-
|
|
52
|
+
- Override `registerChildren(): Nutin.ComponentConfig[]` to declare children — matched against `[data-component="selector"]` elements in the rendered template. ComponentConfig: `{ selector: 'my-selector', factory: (el) => new MyComponent(el, {...} ), keep?: string | number }`.
|
|
53
|
+
- Children are destroyed and recreated on every parent render unless they have a `keep` value: with the same value on the next render, the child is kept as-is (factory not called, not re-rendered, DOM state preserved) and only its `onKept()` hook runs. Put in `keep` whatever should force a recreate.
|
|
54
|
+
- For repeated lists, use the inherited `createCatalogComponents({ items, elementName, selector, component })` inside `registerChildren()` instead of hand-writing one entry per item — targets `[data-catalog="selector"]`. Add `keepBy: (item, index) => id` to keep items whose id is unchanged and whose item is shallow-equal; without it the catalog is rebuilt every render.
|
|
50
55
|
|
|
51
56
|
## `data-*` attributes
|
|
52
57
|
|
|
@@ -63,7 +68,7 @@ BaseComponent<T> — render/destroy engine, data-* parsing, children
|
|
|
63
68
|
|
|
64
69
|
## DOM events binding
|
|
65
70
|
|
|
66
|
-
Handlers are (re-)bound on every `render()`, not at build time. Always use private methods prefixed with underscore:
|
|
71
|
+
Handlers are (re-)bound on every `render()`, not at build time. Always use private methods prefixed with underscore{{#if (eq lang "ts")}} (`protected` for a handler a subclass's template uses){{/if}}:
|
|
67
72
|
|
|
68
73
|
```html
|
|
69
74
|
<div data-event="event:_myFunction:@value,@id">
|
|
@@ -72,17 +77,21 @@ Handlers are (re-)bound on every `render()`, not at build time. Always use priva
|
|
|
72
77
|
private _myFunction(value: string, id: string) { doStuff() }
|
|
73
78
|
```
|
|
74
79
|
|
|
75
|
-
|
|
80
|
+
{{#if (eq lang "ts")}}The build type-checks each handler name written in a template against the class: a missing one fails `tsc` at the template line, and handlers count as used for `noUnusedLocals` (on in this project). Names built with `${}` aren't checked. {{/if}}At runtime, a missing handler binds nothing and logs a `console.warn` in development.
|
|
81
|
+
|
|
82
|
+
Tokens: `@value`, `@id`, `@class`, `@textContent`, `@event`, `@target`, `@attr:name`, `@dataset:key`, plus string/number literals (quoted literals may contain commas). Token values are raw (not HTML-escaped): escaping happens when they are rendered through an `html` template.
|
|
83
|
+
|
|
84
|
+
Only defaults that leave the page are cancelled: link clicks (`<a href>`, unless modified, e.g. ctrl+click), form `submit` and submit-button clicks inside a form. Other events keep their default (typing, checking a box); take `@event` and call `preventDefault()` yourself if needed. A component only binds its own `data-event`/`data-bind`/`data-i18n` elements, not those inside nested child components.
|
|
76
85
|
|
|
77
86
|
## Core services
|
|
78
87
|
|
|
79
|
-
- `AppEventBus` — `subscribe`/`once`/`emit`/`off`. Inside a component, prefer `this.listen(event, cb)` (auto-unsubscribes on `destroy()`) or `this.listenToRenderEvents([event1, event2, ...])` (re-runs `this.render()` whenever any of the given events fire) over calling the bus directly. Typed facades — each `on*` subscriber returns a closure you call directly to unsubscribe (`const off = Navigation.onNavigate(cb); ...; off();`), no need to keep the original callback reference around for `off()`:
|
|
88
|
+
- `AppEventBus` — `subscribe`/`once`/`emit`/`off`.{{#if (eq lang "ts")}} Declare app events in `src/app/globals.d.ts`'s `Nutin.AppEventMap`: `emit()` requires the declared payload, and an event declared `undefined` takes none.{{/if}} Inside a component, prefer `this.listen(event, cb)` (auto-unsubscribes on `destroy()`) or `this.listenToRenderEvents([event1, event2, ...])` (re-runs `this.render()` whenever any of the given events fire) over calling the bus directly. Typed facades — each `on*` subscriber returns a closure you call directly to unsubscribe (`const off = Navigation.onNavigate(cb); ...; off();`), no need to keep the original callback reference around for `off()`:
|
|
80
89
|
- `Navigation` — `navigateTo(path)`/`onNavigate`, `reload()`/`onReload`.
|
|
81
90
|
- `Lifecycle` — `viewMount(viewName)`/`onViewMount`, `viewUnmount(viewName)`/`onViewUnmount`, plus generic `beforeRender`/`afterRender`/`beforeDestroy`/`afterDestroy`.
|
|
82
|
-
- `AppRouter(routes)` — call once in `main.ts`; routes support `:param`/`:param
|
|
91
|
+
- `AppRouter(routes)` — call once in `main.ts` (optional: without it, mount components into any element with `new X(el).render()`; the build then skips the `routes` and `#app` checks, unless `generateSEOFiles` is on; a mount target that isn't found logs a `console.error` in development; testin-nutin's framework tests still mount into `#app`, see "Testing"); routes support `:param`/`:param?`, per-route guards, and lazy views: a factory returning `import('./views/x/x.view.js').then((m) => new m.XView())` loads the view on first visit (import it by its own file, not through a barrel{{#if (eq lang "ts")}}; factory type: global `Nutin.ViewFactory`{{/if}}). Keep `/` and `/404` eager. After each in-app navigation (not the first load or `Navigation.reload()`), focus moves to the view's `<h1>`, else the `<h1>` in `<main>`, else the view element, with `document.title` announced in a polite live region in that last case only (a focused heading is read by screen readers). Give each view an `<h1>`. `document.title` comes from `View.documentTitle()` if overridden, else `config/seo.json`'s title (with `generateSEOFiles`), the `<viewName>.title` translation, then `viewName`.
|
|
83
92
|
- `registerGlobals({ before?, after? })` — each entry is `{ component, id }` (a bare class, constructed internally); mounts them outside `<main id="app">` (`before`/`after` control placement relative to it), stamping each root element with `id`. `hideGlobals(ids)`/`revealGlobals(ids)` (same module) toggle visibility by those ids from any component or view. Call once in `main.ts`.
|
|
84
|
-
- `AppHttpClient` — `get/post/put/patch/delete<T>(endpoint, data?, config?)`; non-OK responses throw `HttpError`.
|
|
85
|
-
- `I18nService` — `translate(key, fallback?)`, `setCurrentLanguage(lang)`; language list comes from `config/languages.json`.
|
|
93
|
+
- `AppHttpClient` — `get/post/put/patch/delete<T>(endpoint, data?, config?)`; non-OK responses throw `HttpError`. Relative endpoints resolve against the page origin; a subclass with a base URL rejects endpoints leaving it. Build paths from values with ``path`/users/${id}` `` (encoded `/` and `\` in paths are rejected unless the API is in the `trustedAPIs` option). FormData/Blob bodies are sent as-is; 204 → `undefined`.
|
|
94
|
+
- `I18nService` — `translate(key, fallback?)`, `setCurrentLanguage(lang)`; language list comes from `config/languages.json`. `initI18n()` (same module) loads the translations when `i18n` is enabled and does nothing otherwise; `main.ts` awaits it before starting the app. With `i18n: false`, prod builds drop the i18n code unless app code references `I18nService`.
|
|
86
95
|
- `AppPipeRegistry` — `register(name, fn)` / `apply(name, value, args)`; backs `data-pipe`.
|
|
87
96
|
|
|
88
97
|
## Styling
|
|
@@ -92,40 +101,27 @@ Tokens: `@value`, `@id`, `@class`, `@textContent`, `@event`, `@target`, `@attr:n
|
|
|
92
101
|
|
|
93
102
|
## Pipes
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
// number (toFixed)
|
|
103
|
-
AppPipeRegistry.register('number');
|
|
104
|
-
|
|
105
|
-
// string pipes
|
|
106
|
-
AppPipeRegistry.register('uppercase');
|
|
107
|
-
AppPipeRegistry.register('lowercase');
|
|
108
|
-
AppPipeRegistry.register('capitalize');
|
|
109
|
-
AppPipeRegistry.register('capitalizeAll');
|
|
110
|
-
AppPipeRegistry.register('truncate');
|
|
111
|
-
|
|
112
|
-
// utility pipes
|
|
113
|
-
AppPipeRegistry.register('default');
|
|
114
|
-
AppPipeRegistry.register('json');
|
|
115
|
-
```
|
|
104
|
+
Built-in pipes, registered by `registerPipes()` in `main.ts` (args in `data-pipe="name:arg1,arg2"`):
|
|
105
|
+
|
|
106
|
+
- `currency:currency,locale` (defaults `USD`, `en-US`), `date:locale,format,time` (defaults browser language, `long`), `number:decimals` (`toFixed`, default `0`)
|
|
107
|
+
- `uppercase`, `lowercase`, `capitalize`, `capitalizeAll`, `truncate:length,suffix` (defaults `50`, `...`)
|
|
108
|
+
- `default:fallback`, `json`
|
|
109
|
+
|
|
110
|
+
Add your own with `AppPipeRegistry.register(name, (value, ...args) => string)`; args arrive as strings.
|
|
116
111
|
|
|
117
112
|
## Configuration
|
|
118
113
|
|
|
119
114
|
### `nutin.config.js`
|
|
120
115
|
|
|
121
116
|
Central options/tools config (see the inline comments for details):
|
|
122
|
-
- `i18n`, `tailwind`, `
|
|
117
|
+
- `i18n`, `tailwind`, `generateSEOFiles` — top-level feature toggles.
|
|
118
|
+
- `dockerPorts`, `markdownSources` — options of the `docker`/`markdown` features, only present once added with `nutin-add <feature>`.
|
|
123
119
|
- `builder.{sass.paths, esbuild}` — build pipeline behavior.
|
|
124
120
|
- `testinNutin.{includeFramework, includeTools, includeApp, jsdomOptions}` — test discovery/environment.
|
|
125
121
|
|
|
126
122
|
### `config/seo.json`
|
|
127
123
|
|
|
128
|
-
Consumed by builder (production only) to generate static HTML per route. Unused if `nutin.config.js ->
|
|
124
|
+
Consumed by builder (production only) to generate static HTML per route. Unused if `nutin.config.js -> generateSEOFiles` is disabled. Dynamic routes (`/blog/:slug`) are written at their real URL from `mockParams` (or `outputPath`). Route guards run first, as an anonymous visitor: a route they block or redirect isn't prerendered nor listed in `sitemap.xml`.
|
|
129
125
|
|
|
130
126
|
### `config/languages.json`
|
|
131
127
|
|
|
@@ -133,17 +129,22 @@ Consumed by i18nService and builder. Unused if i18n is disabled.
|
|
|
133
129
|
|
|
134
130
|
## Security
|
|
135
131
|
|
|
136
|
-
`
|
|
132
|
+
Write every template with the `html` tag (`import { html, raw } from` core): `templateFn = (task) => html\`<li>${task.name}</li>\``. Every `${}` is HTML-escaped, whatever the data's source (input, HTTP, localStorage). Nested `html` results are inserted as-is, and arrays are inserted item by item (no `.join('')`). For lists, use `createCatalogComponents()` (see "Children components") rather than mapping items to markup in the template: each item becomes a child component with its own events and state, kept across renders with `keepBy`. Unquoted attribute values (`title=${x}`) are quoted automatically. A plain value in tag position (`<input ${x}>`) is filtered like `raw()` there: plain attributes stay, handlers, bindings and script URLs are dropped. Wrap HTML you want rendered in `raw()`: it's parsed in place at render (works inside `<svg>`/`<table>`) and loses Nutin's binding attributes (`data-event`, `data-component`, `data-catalog`, `data-bind`, `data-i18n`, `data-pipe*`). `trustedRaw()` keeps them, for markup you wrote yourself (e.g. compiled Markdown). An untagged template literal escapes nothing, and the build warns about each one containing `${}`.
|
|
133
|
+
|
|
134
|
+
Every `render()` also sanitizes the template (`SecurityHelper.sanitizeToFragment()`) and inserts the resulting nodes directly — never re-parsed from a string. `trustLevel` (`'strict' | 'normal' | 'trusted'`, default `'normal'`) controls how much is stripped (`<script>`/`<style>`/`<link>`/`<base>`/`<meta>`, inline handlers, `srcdoc`, `javascript:` URLs, `data:` frames and SVG `<animate>`/`<set>` targeting URL attributes from `normal` up; `<iframe>`/`<object>`/`<embed>` and all `data:` URLs under `strict` only). Don't interpolate data into `data-event` args or `style`: use `data-x="${v}"` + `@dataset:x`. URL attributes (`href`, `src`, `action`, …) whose interpolated value makes them a `javascript:` URL get `about:invalid#nutin-blocked` instead, at every trust level. It is a second layer for `raw()` content, not a substitute for `html`.
|
|
137
135
|
|
|
138
136
|
## Testing (testin-nutin)
|
|
139
137
|
|
|
140
|
-
Nutin's own hand-built test runner + jsdom - not Jest. `describe`/`it` only (**no `test()` alias**), `beforeAll`/`beforeEach`/`afterEach`/`afterAll` (one of each per suite, no stacking - inside `describe` statement, not top-level). All globals — no imports needed in test files.
|
|
138
|
+
Nutin's own hand-built test runner + jsdom - not Jest. Tests are plain JavaScript (`*.test.js` importing the compiled `#root/dist/...` output){{#if (eq lang "ts")}}, so they aren't type-checked{{/if}}. `describe`/`it` only (**no `test()` alias**), `beforeAll`/`beforeEach`/`afterEach`/`afterAll` (one of each per suite, no stacking - anywhere inside the `describe` statement, not top-level). `it.todo(name)` lists a pending test. All globals — no imports needed in test files.
|
|
141
139
|
|
|
142
140
|
- Matchers: `toBe`, `toEqual`, `toContain`, `toBeTruthy`/`toBeFalsy`, `toBeDefined`/`toBeUndefined`, `toBeInstanceOf`, `toHaveBeenCalled`/`toHaveBeenCalledWith`, `toBeLessThan`/`toBeGreaterThan`, `toThrow` — each has a `.not` counterpart.
|
|
143
141
|
- `spyOn(obj, 'method')` returns `.calls`/`.callCount`/`.lastCall`, `.andCallFake(fn)`/`.andReturn(v)`, `.restore()` — unlike mocks, calls pass through by default.
|
|
144
142
|
- `tools/testin-nutin/mocks/` has ready-made `MockEventBus`/`MockHttpClient`/`MockI18n`/`MockRouter` for constructor injection.
|
|
145
|
-
- `useFakeTimers()`/`useRealTimers()`/`advanceTimersByTime(ms)`/`runAllTimers()` for time-dependent code.
|
|
143
|
+
- `useFakeTimers()`/`useRealTimers()`/`advanceTimersByTime(ms)`/`runAllTimers()` for time-dependent code; `flushPromises()` for pending promises.
|
|
144
|
+
- `silenceConsole(method | methods, fn)` runs `fn` with those `console` methods muted (sync or async). Use it in any test that triggers an expected warning/error.
|
|
145
|
+
- `$(selector)`/`$$(selector)` — `document.querySelector`/`querySelectorAll` (as an array).
|
|
146
146
|
- Test discovery/scope controlled by `nutin.config.js`'s `testinNutin.includeApp`/`includeFramework`.
|
|
147
|
+
- Tests run against the built `dist/src/index.html`, and the framework tests (`includeFramework`) mount into its `#app` element. An app without the router that has no `#app` keeps an empty `<div id="app"></div>` in `src/index.html` (not `hidden`: focus tests need it focusable), or sets `includeFramework: false`.
|
|
147
148
|
|
|
148
149
|
## Nutin
|
|
149
150
|
|
|
@@ -166,4 +167,4 @@ When modifying Nutin `core/` files:
|
|
|
166
167
|
|
|
167
168
|
## Documentation
|
|
168
169
|
|
|
169
|
-
[Nutin Documentation](https://
|
|
170
|
+
[Nutin Documentation](https://www.nutin.dev/docs)
|