@nutin/cli 2.0.0 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +5 -8
  3. package/lib/src/create-app/success-message.mjs +2 -3
  4. package/lib/src/update-app/project-updater.mjs +6 -0
  5. package/package.json +4 -4
  6. package/templates/base/.gitignore.hbs +1 -0
  7. package/templates/base/AGENTS.md.hbs +3 -3
  8. package/templates/base/GETTING_STARTED.md.hbs +40 -65
  9. package/templates/base/nutin.config.js.hbs +1 -8
  10. package/templates/base/src/app/components/.gitkeep +0 -0
  11. package/templates/base/src/app/main.ts.hbs +0 -1
  12. package/templates/base/src/app/routes.ts.hbs +2 -1
  13. package/templates/base/src/app/services/.gitkeep +0 -0
  14. package/templates/base/src/app/views/home/home.view.ts.hbs +1 -1
  15. package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +1 -1
  16. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +46 -0
  17. package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +5 -2
  18. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +45 -0
  19. package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +15 -1
  20. package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +14 -3
  21. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +10 -3
  22. package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.ts.hbs +4 -1
  23. package/templates/base/src/core/base-classes/view/view.test.js.hbs +11 -14
  24. package/templates/base/src/core/base-classes/view/view.ts.hbs +8 -9
  25. package/templates/base/src/core/config.ts.hbs +3 -0
  26. package/templates/base/src/core/services/globals/globals.test.js.hbs +38 -0
  27. package/templates/base/src/core/services/globals/globals.ts.hbs +19 -10
  28. package/templates/base/src/core/services/http-client/http-client.test.js.hbs +30 -2
  29. package/templates/base/src/core/services/http-client/http-client.ts.hbs +4 -1
  30. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +142 -0
  31. package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +42 -4
  32. package/templates/base/src/core/services/router/router.test.js.hbs +93 -0
  33. package/templates/base/src/core/services/router/router.ts.hbs +26 -15
  34. package/templates/base/src/index.html.hbs +1 -0
  35. package/templates/base/src/styles/_styles.scss.hbs +1 -113
  36. package/templates/base/src/styles/main.scss.hbs +1 -1
  37. package/templates/base/tools/builder/builder.js.hbs +8 -2
  38. package/templates/base/tools/builder/core/html-index/add-tags.js.hbs +1 -4
  39. package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
  40. package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +11 -2
  41. package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +19 -3
  42. package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +24 -1
  43. package/templates/base/tools/builder/core/seo/route-output.js.hbs +2 -2
  44. package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +5 -1
  45. package/templates/base/tools/builder/core/styles/sass.js.hbs +5 -4
  46. package/templates/base/tools/dev/dev-serve.js.hbs +43 -4
  47. package/templates/base/tools/dev/watcher.js.hbs +42 -26
  48. package/templates/base/tools/generator/generator.js.hbs +32 -47
  49. package/templates/base/tools/generator/handle-file.js.hbs +2 -24
  50. package/templates/base/tools/generator/handle-file.test.js.hbs +1 -20
  51. package/templates/base/tools/generator/templates/locale/locale.template.js.hbs +5 -1
  52. package/templates/base/tools/generator/templates/locale/locale.template.test.js.hbs +11 -1
  53. package/templates/base/tools/generator/templates/view/view.template.js.hbs +1 -1
  54. package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +2 -2
  55. package/templates/base/tools/utils/build-lock.js.hbs +69 -0
  56. package/templates/base/tools/utils/index.js.hbs +1 -0
  57. package/templates/features/docker/tools/docker/Dockerfile.template.hbs +2 -2
  58. package/templates/features/docker/tools/docker/nginx.conf.template +15 -0
  59. package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +12 -0
  60. package/templates/base/src/app/components/index.ts.hbs +0 -1
  61. package/templates/base/src/app/services/index.ts.hbs +0 -1
  62. package/templates/base/src/app/views/index.ts.hbs +0 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.1.1
4
+
5
+ ### Changes
6
+
7
+ - `src/app` no longer has an imposed structure.
8
+
9
+ The generator now creates elements in `src/app/<path>/` instead of `src/app/<type>s/<path>/` (e.g. `generate view user/user-view` → `src/app/user/user-view/`).
10
+
11
+ New projects still ship `components/`, `services/` (empty) and `views/` as the suggested layout, so pass the folder explicitly to keep it (`generate component components/my-component`).
12
+
13
+ - Removed the `src/app/{components,views,services}/index.ts` barrel files from new projects, and the generator no longer appends exports to them.
14
+
15
+ Import app elements by their file path.
16
+
17
+ Import Nutin elements by the framework's barrel (`src/core/index.ts`).
18
+
19
+ - Sass now compiles every `.scss` file under `src/app` instead of only `src/app/components` and `src/app/views`.
20
+
21
+ - The generator now exits before writing anything if the target folder already exists and isn't empty, since folder names carry no type suffix.
22
+
23
+ - Dev builds (`dev`/`serve`/`build`) now bundle with esbuild too, unminified with a sourcemap and `console`/`debugger` kept, so npm runtime dependencies work in dev without import maps or vendoring. tsc's per-file output is still emitted for testin-nutin.
24
+
25
+ - Sass now resolves `@use "pkg:<package>/<path>"` imports through `node_modules`.
26
+
27
+ - The default `builder.esbuild.target` in `nutin.config.js` is now `es2020` (was `es2015`), which libraries relying on native `async` functions (e.g. Alpine) require. Existing projects keep their own `nutin.config.js` value.
28
+
29
+ - Generated SEO HTML now includes `hreflang` alternates (one per language plus `x-default`) when i18n is enabled, `og:type` and `twitter:card` (`summary_large_image`) meta tags, and an absolute `og:image`/`twitter:image` URL (a relative `ogImage` in `config/seo.json` is now prefixed with `baseUrl`).
30
+
31
+ - The router now supports `#hash` fragments: navigating to `path#id` keeps the hash in the URL and scrolls to the matching element, falling back to the top of the page. The hash is also kept on reload and on language change.
32
+
33
+ - New `NavigationManager.replaceState(path)` (exported from `core/index.ts`) rewrites the current URL without adding a history entry. The router now updates history before rendering the new view, so a view's `onEnter()` can call it without the change being overwritten.
34
+
35
+ - Docker feature: `tools/docker/Dockerfile.template` now defaults to Node `24.21.0` (was `22.23.2`) Alpine `3.24` (was `3.20`, end-of-life) for the nginx runtime image (nginx `1.26` → `1.30`).
36
+
37
+ - Docker feature: in i18n projects, nginx now redirects a bare `/` to the default language (generated into `nginx.conf` through a new `__ROOT_REDIRECT_PLACEHOLDER__` token).
38
+
39
+ ### Fixes
40
+
41
+ - `generate` with a missing argument now prints its usage message instead of crashing.
42
+
43
+ - Attributes set on a `data-component` placeholder (`class`, `id`, `aria-*`, `data-*`, …) are now kept on the mounted component's element instead of being dropped. Classes are merged with the component's own.
44
+
45
+ - Builds now take a lock (`.build-lock/`, gitignored) before touching `dist-build`, so concurrent builds (a manual build during `dev`, or stray watcher processes) no longer corrupt each other's output.
46
+
47
+ - The dev watcher no longer drops a file change made while a rebuild is running. It now rebuilds again once the current build finishes.
48
+
49
+ - Stopping `dev` now kills the whole process group of live-server and the watcher (including an in-flight build), so no orphaned watcher processes are left behind. On Windows, the process tree is killed with `taskkill` instead.
50
+
51
+ - SSR now renders views into a real `<main id="app">`, so unset `data-optional` fields are removed from prerendered HTML instead of leaking the literal text `undefined`.
52
+
53
+ - Ctrl/Cmd/Shift/Alt-clicks and middle-clicks on `<a href>` elements with a `data-event` click handler now fall through to the browser's native behaviour (e.g. open in a new tab), instead of being intercepted.
54
+
55
+ - Docker feature: nginx now issues relative redirects (`absolute_redirect off`), so the trailing-slash redirect on prerendered routes no longer leaks the container's internal port or scheme behind a proxy. A route directory without its own `index.html` now serves the SPA shell instead of a 403.
56
+
57
+ ## 2.1.0
58
+
59
+ ### Breaking Changes
60
+
61
+ - `View`'s `viewName` is now a required constructor option. Every `View` subclass must now pass `viewName` explicitly.
62
+ - Removed the `generator` object from `nutin.config.js`. The generator now always scaffolds a stylesheet, and gates locale/test file generation on the existing `i18n` and `testinNutin.includeApp` flags.
63
+
64
+ ### Fixes
65
+
66
+ - Restored `document.title` updates on route change, regressed silently in 2.0.0's rewrite, default to the view's `viewName`.
67
+ - Fixed leaked-listener/subscription bug that affected every re-rendering component. Previously-mounted child components (single or catalog) are now properly destroyed before a re-render mounts their replacements.
68
+ - `HttpClient`'s constructor `defaultHeaders` are now actually merged into outgoing requests (previously silently dropped — only per-call `config.headers` were sent).
69
+ - `data-optional` elements with literal `"null"` text content (e.g. `<span data-optional>null</span>`) are now correctly removed, matching the existing `"undefined"` handling.
70
+ - `revealGlobals` now restores the element's original `display` value by default (captured automatically by `hideGlobals`), instead of always forcing `block`; an optional second argument still allows an explicit override.
71
+ - A `data-pipe` chain segment with an empty name (e.g. `data-pipe=":arg"`) now logs a warning and is skipped, instead of silently aborting the entire chain with no write-back.
72
+ - Removed a redundant duplicate translation-file fetch on startup when i18n is enabled.
73
+
3
74
  ## 2.0.0
4
75
 
5
76
  Version 2.0.0 focuses on simplicity and clear APIs over feature accumulation: fewer abstractions, clearer responsibilities, and a more focused API.
package/README.md CHANGED
@@ -28,19 +28,16 @@ npx @nutin/cli
28
28
  ## Update Nutin
29
29
 
30
30
  ```bash
31
- # update Nutin while preserving your changes
31
+ # update to latest version while preserving your changes
32
32
  nutin-update
33
33
  ```
34
34
 
35
- ## Add Docker
35
+ ## Add Feature
36
36
 
37
37
  ```bash
38
- # ready-to-use Dockerfile for Nutin apps
39
- nutin-add docker
38
+ nutin-add <feature>
40
39
  ```
41
40
 
42
- ## Useful links
41
+ ## Documentation
43
42
 
44
- - [Nutin website](https://www.nutin.org)
45
- - [First app tutorial](https://www.nutin.org/tutorial)
46
- - [Repository documentation](https://github.com/LoisKOUNINEF/nutin/tree/main/docs)
43
+ - [Repository documentation](https://github.com/LoisKOUNINEF/nutin/tree/main/docs/API.md)
@@ -3,12 +3,11 @@ import { PACKAGE_HOMEPAGE } from '../common/package-data.mjs';
3
3
 
4
4
  export function displaySuccessMessage(answers) {
5
5
  const { projectName, packageManager, deployHelper } = answers;
6
+ print.info(`Visit https://${PACKAGE_HOMEPAGE}`);
7
+
6
8
  print.boldSuccess('\n🎉 Your project is ready!\n');
7
9
 
8
10
  print.boldInfo('Next steps:');
9
11
  print.info(`cd ${projectName}`);
10
12
  print.info(`${packageManager} run serve\n`);
11
-
12
- print.boldInfo('Documentation:');
13
- print.info(`https://${PACKAGE_HOMEPAGE}\n`);
14
13
  }
@@ -98,6 +98,12 @@ export class ProjectUpdater {
98
98
  }
99
99
 
100
100
  if (!yes && (toUpdate.length > 0 || toAdd.length > 0)) {
101
+ if (!process.stdin.isTTY) {
102
+ print.warn('\n⚠️ Non-interactive shell detected — skipping the confirmation prompt.');
103
+ print.boldInfo('Re-run with --yes to apply these updates automatically. No files were changed.');
104
+ return;
105
+ }
106
+
101
107
  const { proceed } = await inquirer.prompt([
102
108
  {
103
109
  type: 'confirm',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nutin/cli",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "The structure of a framework. The freedom of vanilla.",
5
5
  "preferGlobal": true,
6
6
  "main": "lib/index.mjs",
@@ -17,7 +17,7 @@
17
17
  "minor": "npm version minor -m 'CI/CD: Bump version to %s'",
18
18
  "major": "npm version major -m 'CI/CD: Bump version to %s'",
19
19
  "test": "echo \"Error: no test specified\" && exit 1",
20
- "publish": "npm publish --access public"
20
+ "release": "npm publish --access public"
21
21
  },
22
22
  "keywords": [
23
23
  "Nutin",
@@ -42,10 +42,10 @@
42
42
  },
43
43
  "repository": {
44
44
  "type": "git",
45
- "url": "https://github.com/loiskouninef/nutin.git"
45
+ "url": "git+https://github.com/LoisKOUNINEF/nutin.git"
46
46
  },
47
47
  "bugs": {
48
- "url": "https://github.com/loiskouninef/nutin/issues"
48
+ "url": "https://github.com/LoisKOUNINEF/nutin/issues"
49
49
  },
50
50
  "homepage": "www.nutin.org"
51
51
  }
@@ -7,6 +7,7 @@ pnpm-debug.log*
7
7
 
8
8
  /dist/
9
9
  /dist-build/
10
+ /.build-lock/
10
11
  /coverage/
11
12
 
12
13
  # Generated by "validate-docker" (run via docker:build) from tools/docker/*.template — only present when the docker feature is added
@@ -19,13 +19,14 @@ nutin.config.js build/feature config — see below
19
19
 
20
20
  - Favor small, composable components.
21
21
  - One `View` = one route.
22
- - Import everything from barrel files (`core/index.js`, `components/index.js`, `views/index.js`, `services/index.js`) — do not deep-import.
22
+ - Import framework code from the `core/index.js` barrel — do not deep-import into `core/`. App elements have no barrels: import them by file path.
23
+ - `src/app/` layout is free; `components/`, `views/`, `services/` is the default suggestion.
23
24
 
24
25
  ## Commands
25
26
 
26
27
  - Serve: `{{packageManager}} run serve` (build + serve), `{{packageManager}} run serve:only` (no rebuild), `{{packageManager}} run dev` (watch mode).
27
28
  - Build: `{{packageManager}} run build` (dev) or `{{packageManager}} run build:prod` (production).
28
- - Generate: `{{packageManager}} run generate ELEMENT ELEMENT_NAME` (`ELEMENT` = `component`/`view`/`service`, supports nested paths, updates the nearest `index.ts` barrel).
29
+ - Generate: `{{packageManager}} run generate ELEMENT ELEMENT_NAME` (`ELEMENT` = `component`/`view`/`service`; `ELEMENT_NAME` may be a nested path, generated in `src/app/<path>/`; fails if that folder already exists and isn't empty).
29
30
  - Test: `{{packageManager}} run testin-nutin` (build + run), `{{packageManager}} run testin-nutin:only` (no rebuild), `{{packageManager}} run testin-nutin:watch`.
30
31
 
31
32
  ## Base classes
@@ -119,7 +120,6 @@ AppPipeRegistry.register('json');
119
120
 
120
121
  Central options/tools config (see the inline comments for details):
121
122
  - `i18n`, `tailwind`, `generateSEO`, `dockerPorts` — top-level feature toggles (`dockerPorts` only applies once the docker feature is added).
122
- - `generator.{generateStylesheet, generateLocales, generateTest}` — what the generator scaffolds alongside new elements.
123
123
  - `builder.{sass.paths, esbuild}` — build pipeline behavior.
124
124
  - `testinNutin.{includeFramework, includeTools, includeApp, jsdomOptions}` — test discovery/environment.
125
125
 
@@ -3,22 +3,19 @@
3
3
  ## Table of contents
4
4
 
5
5
  - [Project structure](#project-structure)
6
- - [Running your app](#running-your-app)
7
- - [Generate components, views and services](#generate-components-views-and-services)
8
- - [A typical component](#a-typical-component)
9
- - [Features and configuration](#features-and-configuration)
6
+ - [How do I run my app?](#running-your-app)
7
+ - [How do I generate components, views and services?](#generate-components-views-and-services)
8
+ - [What does a typical component look like?](#a-typical-component)
9
+ - [Options and configuration](#options-and-configuration)
10
10
  - [Styling](#styling)
11
- - [Global layout components](#global-layout-components)
12
11
  - [Testing](#testing)
13
- - [Useful links](#useful-links)
12
+ - [Go further](#go-further)
14
13
 
15
14
  ## Project structure
16
15
 
17
16
  ```
18
- config/ -- configure i18n and SEO files
19
-
20
17
  src/
21
- app/
18
+ app/ -- suggested layout: organize src/app however you like
22
19
  components/
23
20
  services/
24
21
  views/
@@ -33,7 +30,17 @@ tools/ -- build, development, generator and testing
33
30
  nutin.config.js -- features and configuration
34
31
  ```
35
32
 
36
- ## Running your app
33
+ ### Imports
34
+
35
+ - Imports use **relative paths** and end with `.js` extension.
36
+
37
+ ```js
38
+ import { Foo } from 'relative/path/to/foo.js';
39
+ ```
40
+
41
+ - Use Nutin's code barrel file (`src/core/index.js`) to import Nutin's modules.
42
+
43
+ ## How do I run my app?
37
44
 
38
45
  ```bash
39
46
  # Development
@@ -45,29 +52,22 @@ nutin.config.js -- features and configuration
45
52
  {{packageManager}} run build:prod # build the application for production
46
53
  ```
47
54
 
48
- ### Imports
49
-
50
- Imports must use **relative paths** and end with `.js` extension.
51
-
52
- ```js
53
- import { Foo } from 'relative/path/to/foo.js';
54
- ```
55
-
56
- ## Generate components, views and services
57
-
58
- Generated elements are automatically exported from the corresponding barrel file:
59
- `src/app/<components|views|services>/index.ts`.
55
+ ## How do I generate components, views and services?
60
56
 
57
+ Elements are generated in `src/app/<path>/`, so you choose the project structure. The generator stops if the target folder already exists and isn't empty.
61
58
 
62
59
  ```bash
63
- {{packageManager}} run generate <component|view|service> ELEMENT_NAME
60
+ {{packageManager}} run generate <component|view|service> PATH/TO/ELEMENT_NAME
64
61
 
65
62
  # Examples
66
- {{packageManager}} run generate component my-component # Target dir: src/app/components/my-component/
67
- {{packageManager}} run generate component path/to/my-component/ # Target dir: src/app/components/path/to/my-component/
63
+ {{packageManager}} run generate component components/my-component # Target dir: src/app/components/my-component/
64
+ {{packageManager}} run generate view user/user-view # Target dir: src/app/user/user-view/
65
+ {{packageManager}} run generate service user/user-service # Target dir: src/app/user/user-service/
68
66
  ```
69
67
 
70
- ## A typical component
68
+ Import generated elements by their file path (e.g. `./views/home/home.view.js`).
69
+
70
+ ## What does a typical component look like?
71
71
 
72
72
  ```bash
73
73
  components/
@@ -146,29 +146,21 @@ A template can also be written directly inline (skip the `.html` file and the pl
146
146
 
147
147
  Defining both an inline template and an external `.html` file, or neither, fails the build.
148
148
 
149
- ## Features and configuration
149
+ ## Options and configuration
150
150
 
151
- Nutin features are enabled in `nutin.config.js`.
151
+ ### Main file
152
152
 
153
- ### `nutin.config.js`
153
+ `nutin.config.js` - Main Nutin configuration and tool options. See the inline comments for details.
154
154
 
155
- Main Nutin configuration and tool options. See the inline comments for details.
156
-
157
- - `tailwind`, `i18n`, `generateSEOFiles` — top-level features, `false` by default.
158
- - `generator.*` — controls what the generator scaffolds.
155
+ - `tailwind`, `i18n`, `generateSEOFiles` — top-level features, `false` by default.
156
+ See [Options and features](https://github.com/LoisKOUNINEF/nutin/tree/main/docs/OPTIONS_AND_FEATURES.md).
159
157
  - `builder.*` — controls build behavior.
160
- - `testinNutin.*` — controls TestinNutin.
161
-
162
- ### `config/seo.json`
158
+ - `testinNutin.*` — controls Nutin's built-in testing toolkit.
163
159
 
164
- - Register your routes' description, title and ogImage here.
165
- - Data is used to generate static `index.html` per route, `robots.txt`, and `sitemap.xml`.
166
- - Unused if `nutin.config.js -> generateSEOFiles` is disabled.
160
+ ### Specialized files
167
161
 
168
- ### `config/languages.json`
169
-
170
- - Register supported languages for i18n here.
171
- - Unused if `nutin.config.js -> i18n` is disabled.
162
+ - `config/seo.json` - with `generateSEOFiles` enabled. Register your routes' description, title and ogImage here.
163
+ - `config/languages.json` - with `i18n` enabled. Register supported languages here.
172
164
 
173
165
  ## Styling
174
166
 
@@ -178,26 +170,11 @@ Main Nutin configuration and tool options. See the inline comments for details.
178
170
 
179
171
  **Note:** Installing Tailwind CSS dependencies may print `npm warn install-scripts`. This is expected with npm 12's default install-script allowlisting and is safe to ignore.
180
172
 
181
- ## Global layout components
182
-
183
- If you need components to render outside `<main>` (such as a header or footer), register them with `registerGlobals()` in `main.ts` constructor.
184
-
185
- Global components are mounted directly in `<body>` and remain present across route changes. Use `before` for components that should appear before `<main>` and `after` for components that should appear after it.
186
-
187
- ```ts
188
- registerGlobals({
189
- before: [{ component: HeaderComponent, id: 'header' }],
190
- after: [{ component: FooterComponent, id: 'footer' }],
191
- });
192
- ```
193
-
194
- **Note**: `hideGlobals(globalIds: string[])` / `revealGlobals(globalIds: string[])` are exported alongside `registerGlobals` and can be called to toggle visibility of the ids you registered.
195
-
196
173
  ## Testing
197
174
 
198
175
  You can use any testing tool you prefer for application code.
199
176
 
200
- This project includes TestinNutin, Nutin's lightweight testing toolkit. Nutin itself is tested exclusively with it, and the tests are included in the generated project so you can:
177
+ This project includes [testin-nutin](https://github.com/LoisKOUNINEF/nutin/tree/main/docs/TESTING.md), Nutin's lightweight testing toolkit. Nutin itself is tested exclusively with it, and the tests are included in the generated project so you can:
201
178
  - use them as executable documentation;
202
179
  - run and modify Nutin's tests when modifying its code.
203
180
 
@@ -205,7 +182,7 @@ Run the tests with:
205
182
 
206
183
  `{{packageManager}} run testin-nutin`
207
184
 
208
- To use TestinNutin for application tests, enable:
185
+ To use testin-nutin for application tests, enable:
209
186
 
210
187
  ```js
211
188
  // nutin.config.js
@@ -218,10 +195,8 @@ testinNutin: {
218
195
  import { MyModule } from '#root/dist/src/path/to/my-module.js';
219
196
  ```
220
197
 
221
- See **[TestinNutin docs](https://nutin.org/docs/testin-nutin)**.
198
+ ## Go further
222
199
 
223
- ## Useful links
224
-
225
- - [Nutin website](https://www.nutin.org)
200
+ - [Repository documentation](https://github.com/LoisKOUNINEF/nutin/tree/main/docs/API.md)
226
201
  - [First app tutorial](https://www.nutin.org/tutorial)
227
- - [Repository documentation](https://github.com/LoisKOUNINEF/nutin/tree/main/docs)
202
+ - [Nutin website](https://www.nutin.org)
@@ -3,13 +3,6 @@ export default {
3
3
  tailwind: false, // Enable Tailwind CSS v4
4
4
  i18n: false, // Enable i18n; configure languages in config/languages.json
5
5
  generateSEOFiles: false, // Generate per-route HTML, robots.txt and sitemap.xml; configure SEO data in config/seo.json
6
-
7
- // Components / Views scaffolding
8
- generator: {
9
- generateStylesheet: false, // Generate a .scss file alongside each component or view
10
- generateLocales: false, // Generate locale files alongside components and views (requires i18n)
11
- generateTest: false, // Generate test files alongside components, views and services (requires testinNutin.includeApp)
12
- },
13
6
 
14
7
  // Build pipeline
15
8
  builder: {
@@ -23,7 +16,7 @@ export default {
23
16
  bundle: true,
24
17
  minify: true,
25
18
  sourcemap: false,
26
- target: ['es2015'],
19
+ target: ['es2020'],
27
20
  drop: ['console', 'debugger'],
28
21
  },
29
22
  },
File without changes
@@ -12,7 +12,6 @@ class App {
12
12
  document.addEventListener('DOMContentLoaded', async () => {
13
13
  if (nutinConfig.i18n) {
14
14
  await I18nService.initTranslations();
15
- await I18nService.loadTranslations(I18nService.currentLanguage);
16
15
  }
17
16
  new App();
18
17
  });
@@ -1,5 +1,6 @@
1
1
  import { Routes } from '../core/index.js';
2
- import { HomeView, NotFoundView } from './views/index.js';
2
+ import { HomeView } from './views/home/home.view.js';
3
+ import { NotFoundView } from './views/not-found/not-found.view.js';
3
4
 
4
5
  export const appRoutes: Routes = {
5
6
  '/': () => new HomeView(),
File without changes
@@ -4,6 +4,6 @@ const template = `__TEMPLATE_PLACEHOLDER__`;
4
4
 
5
5
  export class HomeView extends View {
6
6
  constructor() {
7
- super({template});
7
+ super({ template, viewName: 'home' });
8
8
  }
9
9
  }
@@ -4,7 +4,7 @@ const template = `__TEMPLATE_PLACEHOLDER__`;
4
4
 
5
5
  export class NotFoundView extends View {
6
6
  constructor() {
7
- super({template});
7
+ super({ template, viewName: 'not-found' });
8
8
  }
9
9
 
10
10
  private _handleHome(): void {
@@ -1,4 +1,5 @@
1
1
  import { ChildrenHelper } from '#root/dist/src/core/base-classes/base-component/helpers/children.helper.js';
2
+ import { BaseComponent } from '#root/dist/src/core/index.js';
2
3
 
3
4
  describe('ChildrenHelper', () => {
4
5
  let element;
@@ -55,6 +56,29 @@ describe('ChildrenHelper', () => {
55
56
  expect(children.length).toBe(2);
56
57
  });
57
58
 
59
+ it('keeps the placeholder attributes (except data-component) on the mounted child element', () => {
60
+ class Widget extends BaseComponent {
61
+ generateTemplate() { return '<p>widget</p>'; }
62
+ }
63
+ element.innerHTML = '<div data-component="widget" class="my-class" id="my-widget"></div>';
64
+ const component = {
65
+ registerChildren: () => [{
66
+ selector: 'widget',
67
+ factory: (el) => new Widget({ mountTarget: el }),
68
+ }],
69
+ };
70
+
71
+ const children = [];
72
+ ChildrenHelper.addChildren(component, element, children);
73
+
74
+ const childEl = children[0].getElement();
75
+ expect(element.contains(childEl)).toBe(true);
76
+ expect(childEl.classList.contains('my-class')).toBe(true);
77
+ expect(childEl.id).toBe('my-widget');
78
+ expect(childEl.hasAttribute('data-component')).toBe(false);
79
+ expect(childEl.innerHTML).toBe('<p>widget</p>');
80
+ });
81
+
58
82
  it('does nothing when there are no registerChildren', () => {
59
83
  const component = { registerChildren: () => [] };
60
84
  const children = [];
@@ -62,6 +86,28 @@ describe('ChildrenHelper', () => {
62
86
  expect(children.length).toBe(0);
63
87
  });
64
88
 
89
+ it('destroys previously-tracked children and resets the array before mounting new ones on a second call', () => {
90
+ let destroyCount = 0;
91
+ element.innerHTML = '<div data-component="widget"></div>';
92
+ const component = {
93
+ registerChildren: () => [{
94
+ selector: 'widget',
95
+ factory: () => ({ render: () => {}, destroy: () => destroyCount++ }),
96
+ }],
97
+ };
98
+
99
+ const children = [];
100
+ ChildrenHelper.addChildren(component, element, children);
101
+ expect(children.length).toBe(1);
102
+ expect(destroyCount).toBe(0);
103
+
104
+ element.innerHTML = '<div data-component="widget"></div>';
105
+ ChildrenHelper.addChildren(component, element, children);
106
+
107
+ expect(destroyCount).toBe(1);
108
+ expect(children.length).toBe(1);
109
+ });
110
+
65
111
  it('destroyChildren calls destroy on every child', () => {
66
112
  let destroyCount = 0;
67
113
  const children = [
@@ -2,10 +2,13 @@ import { BaseComponent, ComponentConfig } from '../base-component.js';
2
2
 
3
3
  export class ChildrenHelper {
4
4
  public static addChildren(
5
- component: BaseComponent,
6
- element: HTMLElement,
5
+ component: BaseComponent,
6
+ element: HTMLElement,
7
7
  children: BaseComponent[]
8
8
  ): void {
9
+ this.destroyChildren(children);
10
+ children.length = 0;
11
+
9
12
  const configs: ComponentConfig[] = component.registerChildren();
10
13
  configs.forEach(config => {
11
14
  element.querySelectorAll(`[data-component="${config.selector}"]`).forEach(el => {
@@ -39,6 +39,45 @@ describe('DomHelper', () => {
39
39
  expect(container.contains(el)).toBe(true);
40
40
  });
41
41
 
42
+ it('mountElement carries the placeholder attributes onto the element, merging classes', () => {
43
+ const placeholder = document.createElement('div');
44
+ placeholder.className = 'my-class other-class';
45
+ placeholder.id = 'foo';
46
+ placeholder.setAttribute('aria-label', 'Label');
47
+ placeholder.setAttribute('data-extra', 'bar');
48
+ container.appendChild(placeholder);
49
+
50
+ const el = document.createElement('span');
51
+ el.className = 'own-class';
52
+ DomHelper.mountElement(el, placeholder);
53
+
54
+ expect(el.classList.contains('own-class')).toBe(true);
55
+ expect(el.classList.contains('my-class')).toBe(true);
56
+ expect(el.classList.contains('other-class')).toBe(true);
57
+ expect(el.id).toBe('foo');
58
+ expect(el.getAttribute('aria-label')).toBe('Label');
59
+ expect(el.getAttribute('data-extra')).toBe('bar');
60
+ });
61
+
62
+ it('mountElement does not carry data-component over from the placeholder', () => {
63
+ const placeholder = document.createElement('div');
64
+ placeholder.setAttribute('data-component', 'widget');
65
+ container.appendChild(placeholder);
66
+
67
+ const el = document.createElement('span');
68
+ DomHelper.mountElement(el, placeholder);
69
+
70
+ expect(el.hasAttribute('data-component')).toBe(false);
71
+ });
72
+
73
+ it('mountElement leaves the element attributes untouched in append mode', () => {
74
+ container.className = 'container-class';
75
+ const el = document.createElement('span');
76
+ DomHelper.mountElement(el, '#mount-target');
77
+
78
+ expect(el.attributes.length).toBe(0);
79
+ });
80
+
42
81
  it('createElement creates an element with the given tag name', () => {
43
82
  const el = DomHelper.createElement('section');
44
83
  expect(el.tagName).toBe('SECTION');
@@ -109,6 +148,12 @@ describe('DomHelper', () => {
109
148
  expect(container.querySelector('span')).toBe(null);
110
149
  });
111
150
 
151
+ it('cleanupOptionalContent removes an optional element whose textContent is the literal string "null"', () => {
152
+ container.innerHTML = '<span data-optional>null</span>';
153
+ DomHelper.cleanupOptionalContent();
154
+ expect(container.querySelector('span')).toBe(null);
155
+ });
156
+
112
157
  it('cleanupOptionalContent keeps an optional element with real textContent', () => {
113
158
  container.innerHTML = '<span data-optional>hello</span>';
114
159
  DomHelper.cleanupOptionalContent();
@@ -40,7 +40,7 @@ export class DomHelper {
40
40
  }
41
41
 
42
42
  const content = el.textContent?.trim();
43
- return !content || content === "undefined";
43
+ return !content || content === "undefined" || content === "null";
44
44
  };
45
45
 
46
46
  const isValueUndefined = (el: HTMLElement): boolean => {
@@ -72,10 +72,24 @@ export class DomHelper {
72
72
  config.target.appendChild(config.element);
73
73
  } else {
74
74
  // Replace placeholder mode
75
+ this.copyAttributes(config.target, config.element);
75
76
  config.target.replaceWith(config.element);
76
77
  }
77
78
  } else {
78
79
  return;
79
80
  }
80
81
  }
82
+
83
+ // Carries placeholder attributes (class, id, aria-*, data-*...) onto the mounted
84
+ // element. data-component is skipped so an ancestor's child lookup can't re-match it.
85
+ private static copyAttributes(from: HTMLElement, to: HTMLElement): void {
86
+ Array.from(from.attributes).forEach(attr => {
87
+ if (attr.name === 'data-component') return;
88
+ if (attr.name === 'class') {
89
+ to.classList.add(...Array.from(from.classList));
90
+ return;
91
+ }
92
+ to.setAttribute(attr.name, attr.value);
93
+ });
94
+ }
81
95
  }
@@ -32,18 +32,29 @@ export class EventHelper {
32
32
  }
33
33
 
34
34
  private static createBoundHandler(
35
- el: Element,
36
- component: BaseComponent,
37
- handler: (...args: any[]) => void,
35
+ el: Element,
36
+ component: BaseComponent,
37
+ handler: (...args: any[]) => void,
38
38
  rawArgs: string[]
39
39
  ): EventListener {
40
40
  return (event: Event) => {
41
+ if (this.isModifiedAnchorClick(el, event)) return;
42
+
41
43
  const resolvedArgs = rawArgs.map(arg => TokenHelper.resolve(arg.trim(), el, event));
42
44
 
43
45
  handler.call(component, ...resolvedArgs);
44
46
  };
45
47
  }
46
48
 
49
+ // Lets a real <a href> fall through to native browser behavior (open in new tab, etc.)
50
+ // on a modified or non-primary click, instead of always intercepting via preventDefault.
51
+ private static isModifiedAnchorClick(el: Element, event: Event): boolean {
52
+ if (event.type !== 'click' || el.tagName !== 'A' || !el.getAttribute('href')) return false;
53
+
54
+ const mouseEvent = event as MouseEvent;
55
+ return mouseEvent.ctrlKey || mouseEvent.metaKey || mouseEvent.shiftKey || mouseEvent.altKey || mouseEvent.button !== 0;
56
+ }
57
+
47
58
  private static addEvent(
48
59
  target: EventTarget,
49
60
  event: string,