@nutin/cli 1.3.1 → 2.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 +89 -9
- package/README.md +20 -35
- package/bin/add-feature.mjs +9 -0
- package/bin/cli.mjs +8 -0
- package/bin/create-app.mjs +5 -4
- package/bin/update-app.mjs +9 -0
- package/lib/index.mjs +76 -25
- package/lib/src/add-feature/feature-adder.mjs +83 -0
- package/lib/src/add-feature/feature-context-builder.mjs +25 -0
- package/lib/src/add-feature/nutin-config-updater.mjs +29 -0
- package/lib/src/add-feature/package-json-updater.mjs +49 -0
- package/lib/src/common/feature-registry.mjs +11 -0
- package/lib/src/common/file-generator.mjs +159 -0
- package/lib/src/common/meta-bootstrap-prompt.mjs +33 -0
- package/lib/src/common/package-data.mjs +10 -0
- package/lib/src/common/package-json-helper.mjs +103 -0
- package/lib/src/common/project-meta.mjs +54 -0
- package/lib/src/create-app/context-builder.mjs +21 -0
- package/lib/src/{git-manager.mjs → create-app/git-manager.mjs} +2 -2
- package/lib/src/create-app/json-manager.mjs +73 -0
- package/lib/src/{prompts.mjs → create-app/new-app-prompt.mjs} +6 -6
- package/lib/src/{project-generator.mjs → create-app/project-generator.mjs} +10 -8
- package/lib/src/create-app/success-message.mjs +14 -0
- package/lib/src/update-app/conflict-report-writer.mjs +31 -0
- package/lib/src/update-app/fetch-old-templates.mjs +61 -0
- package/lib/src/update-app/project-updater.mjs +136 -0
- package/lib/src/update-app/template-differ.mjs +124 -0
- package/lib/src/update-app/update-context-builder.mjs +25 -0
- package/lib/src/update-app/update-summary-printer.mjs +22 -0
- package/lib/src/update-app/version-compare.mjs +12 -0
- package/lib/src/utils/print.mjs +36 -0
- package/lib/src/utils/promise-exec-alias.mjs +4 -0
- package/package.json +17 -12
- package/templates/base/.gitignore.hbs +6 -1
- package/templates/base/AGENTS.md.hbs +169 -0
- package/templates/base/GETTING_STARTED.md.hbs +227 -0
- package/templates/base/README.md.hbs +8 -17
- package/templates/base/config/languages.json.hbs +6 -0
- package/templates/base/config/seo.json.hbs +12 -0
- package/templates/base/nutin.config.js.hbs +51 -0
- package/templates/base/src/app/components/index.ts.hbs +1 -2
- package/templates/base/src/app/globals.d.ts.hbs +3 -19
- package/templates/base/src/{libs/guards → app}/guards.ts.hbs +1 -5
- package/templates/base/src/app/main.ts.hbs +7 -26
- package/templates/base/src/app/services/index.ts.hbs +1 -1
- package/templates/base/src/app/views/home/home.view.html.hbs +7 -0
- package/templates/base/src/app/views/home/home.view.ts.hbs +1 -56
- package/templates/base/src/app/views/index.ts.hbs +1 -0
- package/templates/base/src/app/views/not-found/not-found.view.html.hbs +8 -0
- package/templates/base/src/app/views/not-found/not-found.view.ts.hbs +4 -37
- package/templates/base/src/core/base-classes/base-component/base-component.test.js.hbs +158 -0
- package/templates/base/src/core/base-classes/base-component/base-component.ts.hbs +68 -35
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.test.js.hbs +124 -0
- package/templates/base/src/core/base-classes/base-component/helpers/catalog.helper.ts.hbs +9 -8
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.test.js.hbs +74 -0
- package/templates/base/src/core/base-classes/base-component/helpers/children.helper.ts.hbs +1 -1
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.test.js.hbs +145 -0
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +7 -1
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.test.js.hbs +88 -0
- package/templates/base/src/core/base-classes/base-component/helpers/event.helper.ts.hbs +2 -2
- package/templates/base/src/core/base-classes/base-component/helpers/i18n.helper.test.js.hbs +62 -0
- package/templates/base/src/core/base-classes/base-component/helpers/pipe.helper.test.js.hbs +82 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs +142 -0
- package/templates/base/src/core/base-classes/base-component/helpers/security.helper.ts.hbs +67 -22
- package/templates/base/src/core/base-classes/base-component/helpers/token.helper.test.js.hbs +183 -0
- package/templates/base/src/core/base-classes/component/component.test.js.hbs +85 -0
- package/templates/base/src/core/base-classes/component/component.ts.hbs +24 -33
- package/templates/base/src/core/base-classes/component/helpers/config.helper.test.js.hbs +58 -0
- package/templates/base/src/core/base-classes/component/helpers/data-binding.helper.test.js.hbs +75 -0
- package/templates/base/src/core/base-classes/service/service.test.js.hbs +150 -0
- package/templates/base/src/core/base-classes/service/service.ts.hbs +26 -20
- package/templates/base/src/core/base-classes/view/view.test.js.hbs +74 -0
- package/templates/base/src/core/base-classes/view/view.ts.hbs +17 -41
- package/templates/base/src/core/config.ts.hbs +7 -0
- package/templates/base/src/core/index.ts.hbs +0 -3
- package/templates/base/src/core/internals.d.ts.hbs +27 -0
- package/templates/base/src/core/nutin-config.d.ts.hbs +4 -0
- package/templates/base/src/core/services/event-bus/event-bus.test.js.hbs +67 -3
- package/templates/base/src/core/services/event-bus/event-bus.ts.hbs +9 -4
- package/templates/base/src/core/services/event-bus/index.ts.hbs +3 -0
- package/templates/base/src/core/services/event-bus/lifecycle.facade.test.js.hbs +123 -0
- package/templates/base/src/core/services/event-bus/lifecycle.facade.ts.hbs +61 -0
- package/templates/base/src/core/services/event-bus/navigation.facade.test.js.hbs +46 -0
- package/templates/base/src/core/services/event-bus/navigation.facade.ts.hbs +25 -0
- package/templates/base/src/core/services/globals/globals.test.js.hbs +149 -0
- package/templates/base/src/core/services/globals/globals.ts.hbs +95 -0
- package/templates/base/src/core/services/http-client/helpers/http-builder.helper.test.js.hbs +25 -0
- package/templates/base/src/core/services/http-client/helpers/http-manager.helper.test.js.hbs +20 -0
- package/templates/base/src/core/services/http-client/http-client.test.js.hbs +43 -0
- package/templates/base/src/core/services/i18n/i18n.test.js.hbs +270 -0
- package/templates/{features/i18n → base}/src/core/services/i18n/i18n.ts.hbs +22 -4
- package/templates/base/src/core/services/index.ts.hbs +5 -6
- package/templates/base/src/core/services/pipe-registry/pipe-registry.test.js.hbs +60 -1
- package/templates/base/src/core/services/pipe-registry/pipe-registry.ts.hbs +1 -1
- package/templates/base/src/core/services/pipe-registry/pipes.test.js.hbs +128 -0
- package/templates/base/src/{libs/pipes → core/services/pipe-registry}/pipes.ts.hbs +8 -5
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.test.js.hbs +149 -6
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +40 -29
- package/templates/base/src/core/services/router/helpers/route-guard-manager.helper.test.js.hbs +27 -0
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.test.js.hbs +83 -17
- package/templates/base/src/core/services/router/helpers/view-render-manager.helper.ts.hbs +14 -38
- package/templates/base/src/core/services/router/router.test.js.hbs +226 -2
- package/templates/base/src/core/services/router/router.ts.hbs +40 -8
- package/templates/base/src/index.html.hbs +2 -6
- package/templates/base/src/styles/_styles.scss.hbs +102 -43
- package/templates/base/src/styles/base/_variables.scss.hbs +1 -86
- package/templates/base/src/styles/main.scss.hbs +1 -18
- package/templates/base/src/styles/tailwind.css.hbs +2 -0
- package/templates/base/tools/builder/builder.config.js.hbs +40 -0
- package/templates/base/tools/builder/builder.js.hbs +24 -26
- package/templates/base/tools/builder/core/app/binary-extensions.test.js.hbs +16 -0
- package/templates/base/tools/builder/core/app/compile-ts.js.hbs +16 -0
- package/templates/base/tools/builder/core/{copy-static.js.hbs → app/copy-static.js.hbs} +27 -34
- package/templates/base/tools/builder/core/app/validate-routes.js.hbs +77 -0
- package/templates/base/tools/builder/core/finalize-build.js.hbs +16 -7
- package/templates/base/tools/builder/core/{add-tags.js.hbs → html-index/add-tags.js.hbs} +2 -2
- package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +56 -0
- package/templates/base/tools/builder/core/{validate-html.js.hbs → html-index/validate-html.js.hbs} +5 -11
- package/templates/base/tools/builder/core/html-templates/merge-templates.js.hbs +152 -0
- package/templates/base/tools/builder/core/html-templates/minify-html.js.hbs +15 -0
- package/templates/base/tools/builder/core/html-templates/process-html-templates.js.hbs +8 -0
- package/templates/{features/i18n/tools/builder/core → base/tools/builder/core/i18n}/build-i18n.js.hbs +6 -17
- package/templates/base/tools/builder/core/prod-bundle/compress-files.js.hbs +37 -0
- package/templates/base/tools/builder/core/prod-bundle/esbuild.js.hbs +30 -0
- package/templates/base/tools/builder/core/{hash-files.js.hbs → prod-bundle/hash-files.js.hbs} +14 -11
- package/templates/base/tools/builder/core/seo/generate-robots-txt.js.hbs +51 -0
- package/templates/base/tools/builder/core/seo/generate-seo-files.js.hbs +16 -0
- package/templates/base/tools/builder/core/seo/generate-seo-html.js.hbs +128 -0
- package/templates/base/tools/builder/core/seo/generate-sitemap-xml.js.hbs +43 -0
- package/templates/base/tools/builder/core/seo/html-substitutions.js.hbs +99 -0
- package/templates/base/tools/builder/core/seo/i18n-resolution.js.hbs +66 -0
- package/templates/base/tools/builder/core/seo/route-output.js.hbs +64 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-bundle.js.hbs +54 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-polyfills.js.hbs +132 -0
- package/templates/base/tools/builder/core/seo/ssr/ssr-render.js.hbs +95 -0
- package/templates/base/tools/builder/core/styles/sass.js.hbs +40 -0
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +84 -0
- package/templates/base/tools/dev/dev-serve.js.hbs +26 -18
- package/templates/base/tools/dev/serve.js.hbs +11 -1
- package/templates/base/tools/dev/watcher.js.hbs +14 -6
- package/templates/base/tools/generator/generator.js.hbs +64 -47
- package/templates/base/tools/generator/handle-file.js.hbs +17 -44
- package/templates/base/tools/generator/handle-file.test.js.hbs +72 -0
- package/templates/base/tools/generator/templates/{component.template.js.hbs → component/component.template.js.hbs} +3 -2
- package/templates/base/tools/generator/templates/component/component.template.test.js.hbs +23 -0
- package/templates/base/tools/generator/templates/html/html.template.js.hbs +2 -0
- package/templates/base/tools/generator/templates/html/html.template.test.js.hbs +7 -0
- package/templates/base/tools/generator/templates/index.js.hbs +7 -12
- package/templates/base/tools/generator/templates/locale/locale.template.js.hbs +4 -0
- package/templates/base/tools/generator/templates/locale/locale.template.test.js.hbs +7 -0
- package/templates/base/tools/generator/templates/scss/scss.template.js.hbs +1 -0
- package/templates/base/tools/generator/templates/scss/scss.template.test.js.hbs +7 -0
- package/templates/base/tools/generator/templates/service/service.template.js.hbs +17 -0
- package/templates/base/tools/generator/templates/service/service.template.test.js.hbs +22 -0
- package/templates/base/tools/generator/templates/test/test.template.js.hbs +10 -0
- package/templates/base/tools/generator/templates/test/test.template.test.js.hbs +27 -0
- package/templates/base/tools/generator/templates/{view.template.js.hbs → view/view.template.js.hbs} +3 -2
- package/templates/base/tools/generator/templates/view/view.template.test.js.hbs +24 -0
- package/templates/base/tools/testin-nutin/core/coverage/collect-coverage.js.hbs +39 -0
- package/templates/base/tools/testin-nutin/core/coverage/compute-coverage.js.hbs +115 -0
- package/templates/base/tools/testin-nutin/core/coverage/compute-coverage.test.js.hbs +74 -0
- package/templates/base/tools/testin-nutin/core/coverage/write-summary-report.js.hbs +54 -0
- package/templates/base/tools/testin-nutin/core/coverage/write-uncovered-report.js.hbs +44 -0
- package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/assertion-lib.js.hbs +62 -24
- package/templates/base/tools/testin-nutin/core/globals/clock.js.hbs +206 -0
- package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/jsdom-setup.js.hbs +19 -6
- package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/register-test-globals.js.hbs +13 -4
- package/templates/base/tools/testin-nutin/core/globals/silence-console.js.hbs +23 -0
- package/templates/{features/testinNutin/testin-nutin/core/src → base/tools/testin-nutin/core}/globals/spyon.js.hbs +2 -0
- package/templates/base/tools/testin-nutin/core/index.js.hbs +9 -0
- package/templates/base/tools/testin-nutin/core/printer.js.hbs +85 -0
- package/templates/base/tools/testin-nutin/core/queue/queue.test.js.hbs +46 -0
- package/templates/base/tools/testin-nutin/core/queue/test-discovery.test.js.hbs +46 -0
- package/templates/base/tools/testin-nutin/core/queue/test-queue.js.hbs +154 -0
- package/templates/{features/testinNutin → base/tools}/testin-nutin/core/tests/assertions.test.js.hbs +27 -0
- package/templates/base/tools/testin-nutin/core/tests/clock.test.js.hbs +118 -0
- package/templates/base/tools/testin-nutin/core/tests/globals-each-all.test.js.hbs +28 -0
- package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/create-mock-method.js.hbs +2 -1
- package/templates/base/tools/testin-nutin/mocks/create-mock-method.test.js.hbs +64 -0
- package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-event-bus.js.hbs +3 -2
- package/templates/base/tools/testin-nutin/mocks/mock-event-bus.test.js.hbs +66 -0
- package/templates/base/tools/testin-nutin/mocks/mock-http-client.test.js.hbs +26 -0
- package/templates/base/tools/testin-nutin/mocks/mock-i18n.test.js.hbs +61 -0
- package/templates/base/tools/testin-nutin/mocks/mock-router.test.js.hbs +29 -0
- package/templates/base/tools/testin-nutin/runner.js.hbs +100 -0
- package/templates/base/tools/testin-nutin/watch-tests.js.hbs +37 -0
- package/templates/base/tools/utils/error-exit.js.hbs +8 -0
- package/templates/base/tools/utils/format.js.hbs +10 -10
- package/templates/base/tools/utils/format.test.js.hbs +45 -0
- package/templates/base/tools/utils/get-files-recursive.js.hbs +6 -1
- package/templates/base/tools/utils/get-files-recursive.test.js.hbs +49 -0
- package/templates/base/tools/utils/get-rel-to-core.test.js.hbs +18 -0
- package/templates/base/tools/utils/index.js.hbs +1 -3
- package/templates/base/tools/utils/languages.js.hbs +15 -0
- package/templates/base/tools/utils/languages.test.js.hbs +16 -0
- package/templates/base/tools/utils/print.js.hbs +19 -13
- package/templates/base/tools/utils/print.test.js.hbs +60 -0
- package/templates/base/tools/utils/prompt-boolean.js.hbs +7 -0
- package/templates/base/tools/utils/prompt-boolean.test.js.hbs +54 -0
- package/templates/base/tools/utils/run-command.js.hbs +4 -2
- package/templates/base/tools/utils/run-command.test.js.hbs +19 -0
- package/templates/base/tools/utils/run-script.js.hbs +14 -3
- package/templates/base/tools/utils/run-script.test.js.hbs +40 -0
- package/templates/features/{deployHelper → docker}/.dockerignore.hbs +4 -4
- package/templates/features/docker/tools/docker/Dockerfile.template.hbs +51 -0
- package/templates/features/{deployHelper/tools/deployment/nginx.conf.hbs → docker/tools/docker/nginx.conf.template} +4 -2
- package/templates/features/docker/tools/docker/scripts/docker-build.js.hbs +20 -0
- package/templates/features/docker/tools/docker/scripts/docker-ports.js.hbs +48 -0
- package/templates/features/docker/tools/docker/scripts/docker-ports.test.js.hbs +52 -0
- package/templates/features/docker/tools/docker/scripts/docker-run.js.hbs +28 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.js.hbs +23 -0
- package/templates/features/docker/tools/docker/scripts/render-docker-template.test.js.hbs +60 -0
- package/templates/features/docker/tools/docker/scripts/validate-docker.js.hbs +30 -0
- package/lib/src/context-builder.mjs +0 -40
- package/lib/src/file-generator.mjs +0 -109
- package/lib/src/json-generator.mjs +0 -31
- package/lib/src/package-manager.mjs +0 -139
- package/lib/src/print.mjs +0 -32
- package/lib/src/utils.mjs +0 -18
- package/lib/src/version.mjs +0 -1
- package/templates/base/builder.config.js.hbs +0 -36
- package/templates/base/src/app/components/common/anchor/anchor.component.ts.hbs +0 -32
- package/templates/base/src/app/components/common/button/button.component.ts.hbs +0 -40
- package/templates/base/src/app/dom.test.js.hbs +0 -13
- package/templates/base/src/app/services/example/example.service.ts.hbs +0 -18
- package/templates/base/src/app/views/home/home.view.test.js.hbs +0 -26
- package/templates/base/src/core/services/store/store.test.js.hbs +0 -8
- package/templates/base/src/core/services/store/store.ts.hbs +0 -39
- package/templates/base/src/core/utils/anchor-manager/anchor-manager.utils.test.js.hbs +0 -0
- package/templates/base/src/core/utils/anchor-manager/anchor-manager.utils.ts.hbs +0 -113
- package/templates/base/src/core/utils/button-manager/button-manager.utils.test.js.hbs +0 -249
- package/templates/base/src/core/utils/button-manager/button-manager.utils.ts.hbs +0 -131
- package/templates/base/src/core/utils/helpers/attributes.helper.ts.hbs +0 -50
- package/templates/base/src/core/utils/index.ts.hbs +0 -2
- package/templates/base/src/libs/index.ts.hbs +0 -4
- package/templates/base/src/libs/popover/helpers/focus-trap.helper.ts.hbs +0 -197
- package/templates/base/src/libs/popover/helpers/popover-dom.helper.ts.hbs +0 -75
- package/templates/base/src/libs/popover/popover.ts.hbs +0 -138
- package/templates/base/src/libs/snackbar/snackbar.ts.hbs +0 -75
- package/templates/base/src/styles/base/_functions.scss.hbs +0 -9
- package/templates/base/src/styles/core/_anchors.scss.hbs +0 -10
- package/templates/base/src/styles/core/_index.scss.hbs +0 -6
- package/templates/base/src/styles/core/_popover.scss.hbs +0 -96
- package/templates/base/src/styles/core/_snackbar.scss.hbs +0 -50
- package/templates/base/tools/builder/core/esbuild.js.hbs +0 -25
- package/templates/base/tools/builder/core/minify-html.js.hbs +0 -60
- package/templates/base/tools/builder/core/sass.js.hbs +0 -22
- package/templates/base/tools/builder/core/tsc.js.hbs +0 -12
- package/templates/base/tools/generator/templates/service.template.js.hbs +0 -17
- package/templates/base/tools/utils/export-flags.js.hbs +0 -6
- package/templates/features/deployHelper/tools/builder/core/compress-files.js.hbs +0 -56
- package/templates/features/deployHelper/tools/deployment/Dockerfile.hbs +0 -30
- package/templates/features/i18n/src/app/views/home/locales/en.json.hbs +0 -9
- package/templates/features/i18n/src/app/views/not-found/locales/en.json +0 -8
- package/templates/features/i18n/src/app/views/not-found/locales/fr.json +0 -8
- package/templates/features/i18n/src/core/config.ts.hbs +0 -3
- package/templates/features/i18n/src/core/services/i18n/i18n.test.js.hbs +0 -125
- package/templates/features/i18n/tools/generator/templates/json.template.js.hbs +0 -8
- package/templates/features/i18n/tools/utils/languages.js.hbs +0 -7
- package/templates/features/stylinNutin/src/styles/_custom-classes.scss.hbs +0 -9
- package/templates/features/stylinNutin/src/styles/base/_mixins.scss.hbs +0 -372
- package/templates/features/stylinNutin/src/styles/components/_index.scss.hbs +0 -3
- package/templates/features/stylinNutin/src/styles/core/_utilities.scss.hbs +0 -254
- package/templates/features/stylinNutin/src/styles/views/_index.scss.hbs +0 -0
- package/templates/features/stylinNutin/tools/generator/templates/scss.template.js.hbs +0 -4
- package/templates/features/template/src/app/views/home/home.view.html.hbs +0 -56
- package/templates/features/template/src/app/views/not-found/not-found.view.html.hbs +0 -19
- package/templates/features/template/tools/builder/core/merge-templates.js.hbs +0 -49
- package/templates/features/template/tools/generator/templates/html.template.js.hbs +0 -2
- package/templates/features/testinNutin/testin-nutin/core/index.js.hbs +0 -5
- package/templates/features/testinNutin/testin-nutin/core/src/utils/print.js.hbs +0 -64
- package/templates/features/testinNutin/testin-nutin/core/src/utils/test-queue.js.hbs +0 -64
- package/templates/features/testinNutin/testin-nutin/core/tests/globals-each-all.test.js.hbs +0 -28
- package/templates/features/testinNutin/testin-nutin/mocks/mock-store.js.hbs +0 -38
- package/templates/features/testinNutin/testin-nutin/runner.js.hbs +0 -36
- package/templates/features/testinNutin/testin-nutin/watch-tests.js.hbs +0 -21
- package/templates/features/testinNutin/testin-nutin.config.js.hbs +0 -14
- /package/lib/src/{template-compiler.mjs → common/template-compiler.mjs} +0 -0
- /package/templates/{features/i18n → base}/src/core/base-classes/base-component/helpers/i18n.helper.ts.hbs +0 -0
- /package/templates/{features/i18n → base}/src/core/services/i18n/languages.ts.hbs +0 -0
- /package/templates/base/tools/builder/{variables → core/app}/binary-extensions.js.hbs +0 -0
- /package/templates/base/tools/builder/core/{paths.js.hbs → app/paths.js.hbs} +0 -0
- /package/templates/{features/testinNutin/testin-nutin/core/src/utils → base/tools/testin-nutin/core/queue}/queue.js.hbs +0 -0
- /package/templates/{features/testinNutin/testin-nutin/core/src/utils → base/tools/testin-nutin/core/queue}/test-discovery.js.hbs +0 -0
- /package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-http-client.js.hbs +0 -0
- /package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-i18n.js.hbs +0 -0
- /package/templates/{features/testinNutin → base/tools}/testin-nutin/mocks/mock-router.js.hbs +0 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { DomHelper } from '#root/dist/src/core/base-classes/base-component/helpers/dom.helper.js';
|
|
2
|
+
|
|
3
|
+
describe('DomHelper', () => {
|
|
4
|
+
let container;
|
|
5
|
+
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
container = document.createElement('div');
|
|
8
|
+
container.id = 'mount-target';
|
|
9
|
+
document.body.appendChild(container);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
container.remove();
|
|
14
|
+
container = null;
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('mountElement appends the element into a string selector target', () => {
|
|
18
|
+
const el = document.createElement('span');
|
|
19
|
+
DomHelper.mountElement(el, '#mount-target');
|
|
20
|
+
expect(container.contains(el)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('mountElement does nothing when the string selector target is not found', () => {
|
|
24
|
+
const el = document.createElement('span');
|
|
25
|
+
DomHelper.mountElement(el, '#does-not-exist');
|
|
26
|
+
expect(el.parentElement).toBe(null);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('mountElement replaces an HTMLElement target with the element', () => {
|
|
30
|
+
const placeholder = document.createElement('div');
|
|
31
|
+
placeholder.className = 'placeholder';
|
|
32
|
+
container.appendChild(placeholder);
|
|
33
|
+
|
|
34
|
+
const el = document.createElement('span');
|
|
35
|
+
el.className = 'replacement';
|
|
36
|
+
DomHelper.mountElement(el, placeholder);
|
|
37
|
+
|
|
38
|
+
expect(container.contains(placeholder)).toBe(false);
|
|
39
|
+
expect(container.contains(el)).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('createElement creates an element with the given tag name', () => {
|
|
43
|
+
const el = DomHelper.createElement('section');
|
|
44
|
+
expect(el.tagName).toBe('SECTION');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('createElement sets sanitized innerHTML from the template', () => {
|
|
48
|
+
const el = DomHelper.createElement('div', '<p>hi</p><script>alert(1)</script>');
|
|
49
|
+
expect(el.innerHTML).toBe('<p>hi</p>');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('createElement respects the trustLevel when sanitizing', () => {
|
|
53
|
+
const template = '<script>alert(1)</script>';
|
|
54
|
+
const el = DomHelper.createElement('div', template, 'trusted');
|
|
55
|
+
expect(el.innerHTML).toBe(template);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('cleanupOptionalContent removes an optional img with no src', () => {
|
|
59
|
+
container.innerHTML = '<img data-optional>';
|
|
60
|
+
DomHelper.cleanupOptionalContent();
|
|
61
|
+
expect(container.querySelector('img')).toBe(null);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('cleanupOptionalContent keeps an optional img that has a src', () => {
|
|
65
|
+
container.innerHTML = '<img data-optional src="pic.png">';
|
|
66
|
+
DomHelper.cleanupOptionalContent();
|
|
67
|
+
expect(container.querySelector('img')).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('cleanupOptionalContent removes an optional input with no value', () => {
|
|
71
|
+
container.innerHTML = '<input data-optional>';
|
|
72
|
+
DomHelper.cleanupOptionalContent();
|
|
73
|
+
expect(container.querySelector('input')).toBe(null);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('cleanupOptionalContent keeps an optional input that has a value', () => {
|
|
77
|
+
container.innerHTML = '<input data-optional value="hi">';
|
|
78
|
+
DomHelper.cleanupOptionalContent();
|
|
79
|
+
expect(container.querySelector('input')).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('cleanupOptionalContent removes an optional textarea with no value', () => {
|
|
83
|
+
container.innerHTML = '<textarea data-optional></textarea>';
|
|
84
|
+
DomHelper.cleanupOptionalContent();
|
|
85
|
+
expect(container.querySelector('textarea')).toBe(null);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('cleanupOptionalContent removes an optional source element with no src', () => {
|
|
89
|
+
container.innerHTML = '<video><source data-optional></video>';
|
|
90
|
+
DomHelper.cleanupOptionalContent();
|
|
91
|
+
expect(container.querySelector('source')).toBe(null);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('cleanupOptionalContent keeps an optional source element that has a src', () => {
|
|
95
|
+
container.innerHTML = '<video><source data-optional src="movie.mp4"></video>';
|
|
96
|
+
DomHelper.cleanupOptionalContent();
|
|
97
|
+
expect(container.querySelector('source')).toBeDefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('cleanupOptionalContent removes an optional element with empty textContent', () => {
|
|
101
|
+
container.innerHTML = '<span data-optional> </span>';
|
|
102
|
+
DomHelper.cleanupOptionalContent();
|
|
103
|
+
expect(container.querySelector('span')).toBe(null);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('cleanupOptionalContent removes an optional element whose textContent is the literal string "undefined"', () => {
|
|
107
|
+
container.innerHTML = '<span data-optional>undefined</span>';
|
|
108
|
+
DomHelper.cleanupOptionalContent();
|
|
109
|
+
expect(container.querySelector('span')).toBe(null);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('cleanupOptionalContent keeps an optional element with real textContent', () => {
|
|
113
|
+
container.innerHTML = '<span data-optional>hello</span>';
|
|
114
|
+
DomHelper.cleanupOptionalContent();
|
|
115
|
+
expect(container.querySelector('span')).toBeDefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('cleanupOptionalContent removes an element whose data-optional value is the literal string "undefined" or "null"', () => {
|
|
119
|
+
container.innerHTML = `
|
|
120
|
+
<span data-optional="undefined">has content</span>
|
|
121
|
+
<span data-optional="null">has content</span>
|
|
122
|
+
`;
|
|
123
|
+
DomHelper.cleanupOptionalContent();
|
|
124
|
+
expect(container.querySelectorAll('span').length).toBe(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('cleanupOptionalContent keeps an element with data-optional="" and non-empty content — a bare/empty data-optional is a plain marker deferred to isEmpty(el), not a forced-removal signal', () => {
|
|
128
|
+
container.innerHTML = '<span data-optional="">has content</span>';
|
|
129
|
+
DomHelper.cleanupOptionalContent();
|
|
130
|
+
expect(container.querySelectorAll('span').length).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('cleanupOptionalContent removes an element whose data-optional value is whitespace-only, even with real content', () => {
|
|
134
|
+
container.innerHTML = '<span data-optional=" ">has content</span>';
|
|
135
|
+
DomHelper.cleanupOptionalContent();
|
|
136
|
+
expect(container.querySelectorAll('span').length).toBe(0);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('cleanupOptionalContent always strips the data-optional attribute from surviving elements', () => {
|
|
140
|
+
container.innerHTML = '<span data-optional>hello</span>';
|
|
141
|
+
DomHelper.cleanupOptionalContent();
|
|
142
|
+
const span = container.querySelector('span');
|
|
143
|
+
expect(span.hasAttribute('data-optional')).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -45,6 +45,12 @@ export class DomHelper {
|
|
|
45
45
|
|
|
46
46
|
const isValueUndefined = (el: HTMLElement): boolean => {
|
|
47
47
|
const value = el.getAttribute("data-optional");
|
|
48
|
+
/*
|
|
49
|
+
A bare/empty data-optional (getAttribute returns "") is a plain marker and
|
|
50
|
+
defers entirely to isEmpty(el) below rather than forcing removal itself.
|
|
51
|
+
Only a present-but-blank value (whitespace) or the literal string produced
|
|
52
|
+
by interpolating an undefined/null expression counts as "value undefined".
|
|
53
|
+
*/
|
|
48
54
|
if (!value) return false;
|
|
49
55
|
return (
|
|
50
56
|
value.trim() === '' ||
|
|
@@ -69,7 +75,7 @@ export class DomHelper {
|
|
|
69
75
|
config.target.replaceWith(config.element);
|
|
70
76
|
}
|
|
71
77
|
} else {
|
|
72
|
-
|
|
78
|
+
return;
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
81
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { EventHelper } from '#root/dist/src/core/base-classes/base-component/helpers/event.helper.js';
|
|
2
|
+
|
|
3
|
+
describe('EventHelper', () => {
|
|
4
|
+
let container;
|
|
5
|
+
let eventListeners;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
container = document.createElement('div');
|
|
9
|
+
document.body.appendChild(container);
|
|
10
|
+
eventListeners = [];
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
container.remove();
|
|
15
|
+
container = null;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('binds a handler resolved from data-event and calls it with `this` set to the component', () => {
|
|
19
|
+
container.innerHTML = '<button data-event="click:handleClick"></button>';
|
|
20
|
+
const button = container.querySelector('button');
|
|
21
|
+
|
|
22
|
+
let calledWith = null;
|
|
23
|
+
const component = {
|
|
24
|
+
handleClick(...args) {
|
|
25
|
+
calledWith = { self: this, args };
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
EventHelper.bindEvents(component, container, eventListeners);
|
|
30
|
+
click(button);
|
|
31
|
+
|
|
32
|
+
expect(calledWith.self).toBe(component);
|
|
33
|
+
expect(calledWith.args.length).toBe(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('resolves comma-separated raw args via TokenHelper before invoking the handler', () => {
|
|
37
|
+
container.innerHTML = '<button data-event="click:handleClick:1,\'hi\'" id="btn"></button>';
|
|
38
|
+
const button = container.querySelector('button');
|
|
39
|
+
|
|
40
|
+
let received = null;
|
|
41
|
+
const component = {
|
|
42
|
+
handleClick(...args) { received = args; },
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
EventHelper.bindEvents(component, container, eventListeners);
|
|
46
|
+
click(button);
|
|
47
|
+
|
|
48
|
+
expect(received[0]).toBe(1);
|
|
49
|
+
expect(received[1]).toBe('hi');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('does nothing when the handler name is missing', () => {
|
|
53
|
+
container.innerHTML = '<button data-event="click:"></button>';
|
|
54
|
+
const component = {};
|
|
55
|
+
EventHelper.bindEvents(component, container, eventListeners);
|
|
56
|
+
expect(eventListeners.length).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does nothing when the referenced handler does not exist or is not a function', () => {
|
|
60
|
+
container.innerHTML = '<button data-event="click:missingHandler"></button>';
|
|
61
|
+
const component = { missingHandler: 'not-a-function' };
|
|
62
|
+
EventHelper.bindEvents(component, container, eventListeners);
|
|
63
|
+
expect(eventListeners.length).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('tracks each bound listener in the eventListeners array', () => {
|
|
67
|
+
container.innerHTML = '<button data-event="click:handleClick"></button>';
|
|
68
|
+
const component = { handleClick() {} };
|
|
69
|
+
|
|
70
|
+
EventHelper.bindEvents(component, container, eventListeners);
|
|
71
|
+
|
|
72
|
+
expect(eventListeners.length).toBe(1);
|
|
73
|
+
expect(eventListeners[0][1]).toBe('click');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('destroyEvents removes every tracked listener', () => {
|
|
77
|
+
container.innerHTML = '<button data-event="click:handleClick"></button>';
|
|
78
|
+
const button = container.querySelector('button');
|
|
79
|
+
let callCount = 0;
|
|
80
|
+
const component = { handleClick() { callCount++; } };
|
|
81
|
+
|
|
82
|
+
EventHelper.bindEvents(component, container, eventListeners);
|
|
83
|
+
EventHelper.destroyEvents(eventListeners);
|
|
84
|
+
|
|
85
|
+
click(button);
|
|
86
|
+
expect(callCount).toBe(0);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -8,8 +8,8 @@ export class EventHelper {
|
|
|
8
8
|
eventListeners: Array<[EventTarget, string, EventListener]>
|
|
9
9
|
): void {
|
|
10
10
|
element.querySelectorAll('[data-event]').forEach(el => {
|
|
11
|
-
const
|
|
12
|
-
const
|
|
11
|
+
const [eventName, handlerName, ...argParts] = el.getAttribute('data-event')!.split(':');
|
|
12
|
+
const argsString = argParts.length ? argParts.join(':') : '';
|
|
13
13
|
|
|
14
14
|
if (!handlerName || !eventName) return;
|
|
15
15
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { I18nHelper } from '#root/dist/src/core/base-classes/base-component/helpers/i18n.helper.js';
|
|
2
|
+
import { I18nService } from '#root/dist/src/core/services/index.js';
|
|
3
|
+
|
|
4
|
+
describe('I18nHelper', () => {
|
|
5
|
+
let container;
|
|
6
|
+
let originalTranslate;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
container = document.createElement('div');
|
|
10
|
+
document.body.appendChild(container);
|
|
11
|
+
originalTranslate = I18nService.translate;
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
I18nService.translate = originalTranslate;
|
|
16
|
+
container.remove();
|
|
17
|
+
container = null;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('sets the placeholder of an input element to the translated value', () => {
|
|
21
|
+
I18nService.translate = (key) => key === 'greeting' ? 'Hello' : key;
|
|
22
|
+
container.innerHTML = '<input data-i18n="greeting">';
|
|
23
|
+
|
|
24
|
+
I18nHelper.parseI18nAttributes(container);
|
|
25
|
+
|
|
26
|
+
const input = container.querySelector('input');
|
|
27
|
+
expect(input.placeholder).toBe('Hello');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('sets the textContent of a non-input element to the translated value', () => {
|
|
31
|
+
I18nService.translate = (key) => key === 'greeting' ? 'Hello' : key;
|
|
32
|
+
container.innerHTML = '<span data-i18n="greeting"></span>';
|
|
33
|
+
|
|
34
|
+
I18nHelper.parseI18nAttributes(container);
|
|
35
|
+
|
|
36
|
+
const span = container.querySelector('span');
|
|
37
|
+
expect(span.textContent).toBe('Hello');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('passes the element textContent as the fallback to translate', () => {
|
|
41
|
+
const calls = [];
|
|
42
|
+
I18nService.translate = (key, fallback) => { calls.push({ key, fallback }); return fallback; };
|
|
43
|
+
container.innerHTML = '<span data-i18n="missing.key">fallback text</span>';
|
|
44
|
+
|
|
45
|
+
I18nHelper.parseI18nAttributes(container);
|
|
46
|
+
|
|
47
|
+
expect(calls[0].key).toBe('missing.key');
|
|
48
|
+
expect(calls[0].fallback).toBe('fallback text');
|
|
49
|
+
expect(container.querySelector('span').textContent).toBe('fallback text');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('processes every element with a data-i18n attribute', () => {
|
|
53
|
+
I18nService.translate = (key) => `[${key}]`;
|
|
54
|
+
container.innerHTML = '<span data-i18n="a"></span><span data-i18n="b"></span>';
|
|
55
|
+
|
|
56
|
+
I18nHelper.parseI18nAttributes(container);
|
|
57
|
+
|
|
58
|
+
const spans = container.querySelectorAll('span');
|
|
59
|
+
expect(spans[0].textContent).toBe('[a]');
|
|
60
|
+
expect(spans[1].textContent).toBe('[b]');
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { PipeHelper } from '#root/dist/src/core/base-classes/base-component/helpers/pipe.helper.js';
|
|
2
|
+
import { AppPipeRegistry } from '#root/dist/src/core/services/index.js';
|
|
3
|
+
|
|
4
|
+
describe('PipeHelper', () => {
|
|
5
|
+
let container;
|
|
6
|
+
|
|
7
|
+
beforeAll(() => {
|
|
8
|
+
AppPipeRegistry.register('upper', (value) => String(value).toUpperCase());
|
|
9
|
+
AppPipeRegistry.register('suffix', (value, suffix) => `${value}${suffix}`);
|
|
10
|
+
AppPipeRegistry.register('join', (value, a, b) => `${value}-${a}-${b}`);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
container = document.createElement('div');
|
|
15
|
+
document.body.appendChild(container);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
container.remove();
|
|
20
|
+
container = null;
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('reads from textContent and writes the piped result back to textContent for a plain element', () => {
|
|
24
|
+
container.innerHTML = '<span data-pipe="upper">hi</span>';
|
|
25
|
+
PipeHelper.parsePipeAttributes(container);
|
|
26
|
+
expect(container.querySelector('span').textContent).toBe('HI');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('reads from .value and writes the piped result back to .value for an input element', () => {
|
|
30
|
+
container.innerHTML = '<input data-pipe="upper" value="hi">';
|
|
31
|
+
PipeHelper.parsePipeAttributes(container);
|
|
32
|
+
expect(container.querySelector('input').value).toBe('HI');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('reads from .value for a textarea element', () => {
|
|
36
|
+
container.innerHTML = '<textarea data-pipe="upper">hi</textarea>';
|
|
37
|
+
PipeHelper.parsePipeAttributes(container);
|
|
38
|
+
expect(container.querySelector('textarea').value).toBe('HI');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('prefers data-pipe-source over the element value when present', () => {
|
|
42
|
+
container.innerHTML = '<span data-pipe="upper" data-pipe-source="from source">ignored</span>';
|
|
43
|
+
PipeHelper.parsePipeAttributes(container);
|
|
44
|
+
expect(container.querySelector('span').textContent).toBe('FROM SOURCE');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('chains multiple pipes separated by |', () => {
|
|
48
|
+
container.innerHTML = '<span data-pipe="upper|suffix:!">hi</span>';
|
|
49
|
+
PipeHelper.parsePipeAttributes(container);
|
|
50
|
+
expect(container.querySelector('span').textContent).toBe('HI!');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('passes comma-separated arguments to the pipe function', () => {
|
|
54
|
+
container.innerHTML = '<span data-pipe="join:x,y">hi</span>';
|
|
55
|
+
PipeHelper.parsePipeAttributes(container);
|
|
56
|
+
expect(container.querySelector('span').textContent).toBe('hi-x-y');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('does nothing when the element has no data-pipe attribute', () => {
|
|
60
|
+
container.innerHTML = '<span>hi</span>';
|
|
61
|
+
PipeHelper.parsePipeAttributes(container);
|
|
62
|
+
expect(container.querySelector('span').textContent).toBe('hi');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('does nothing when the data-pipe attribute is empty', () => {
|
|
66
|
+
container.innerHTML = '<span data-pipe="">hi</span>';
|
|
67
|
+
PipeHelper.parsePipeAttributes(container);
|
|
68
|
+
expect(container.querySelector('span').textContent).toBe('hi');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('prefers data-pipe-source over .value for an input element', () => {
|
|
72
|
+
container.innerHTML = '<input data-pipe="upper" data-pipe-source="from source" value="ignored">';
|
|
73
|
+
PipeHelper.parsePipeAttributes(container);
|
|
74
|
+
expect(container.querySelector('input').value).toBe('FROM SOURCE');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('stops processing pipes when a pipe segment resolves to an empty name', () => {
|
|
78
|
+
container.innerHTML = '<span data-pipe=":arg">hi</span>';
|
|
79
|
+
PipeHelper.parsePipeAttributes(container);
|
|
80
|
+
expect(container.querySelector('span').textContent).toBe('hi');
|
|
81
|
+
});
|
|
82
|
+
});
|
package/templates/base/src/core/base-classes/base-component/helpers/security.helper.test.js.hbs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { SecurityHelper } from '#root/dist/src/core/base-classes/base-component/helpers/security.helper.js';
|
|
2
|
+
|
|
3
|
+
describe('SecurityHelper', () => {
|
|
4
|
+
it('sanitizeTemplate returns an empty string for null and undefined', () => {
|
|
5
|
+
expect(SecurityHelper.sanitizeTemplate(null)).toBe('');
|
|
6
|
+
expect(SecurityHelper.sanitizeTemplate(undefined)).toBe('');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('sanitizeTemplate stringifies non-string values before sanitizing', () => {
|
|
10
|
+
expect(SecurityHelper.sanitizeTemplate(42)).toBe('42');
|
|
11
|
+
expect(SecurityHelper.sanitizeTemplate(true)).toBe('true');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('sanitizeTemplate defaults to the "normal" trust level', () => {
|
|
15
|
+
const result = SecurityHelper.sanitizeTemplate('<div><script>alert(1)</script></div>');
|
|
16
|
+
expect(result).toBe('<div></div>');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('sanitizeTemplate "trusted" level leaves the template completely unchanged', () => {
|
|
20
|
+
const template = '<script>alert(1)</script><div onclick="x()">hi</div>';
|
|
21
|
+
expect(SecurityHelper.sanitizeTemplate(template, 'trusted')).toBe(template);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('sanitizeTemplate "normal" level strips <script> tags', () => {
|
|
25
|
+
const result = SecurityHelper.sanitizeTemplate('<p>hi</p><script>alert(1)</script>', 'normal');
|
|
26
|
+
expect(result).toBe('<p>hi</p>');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('sanitizeTemplate "normal" level strips inline on* event handlers', () => {
|
|
30
|
+
const result = SecurityHelper.sanitizeTemplate('<button onclick="doBad()">Click</button>', 'normal');
|
|
31
|
+
expect(result).toContain('<button');
|
|
32
|
+
expect(result.includes('onclick')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('sanitizeTemplate "normal" level keeps iframes (only "strict" removes them)', () => {
|
|
36
|
+
const result = SecurityHelper.sanitizeTemplate('<iframe src="https://example.com"></iframe>', 'normal');
|
|
37
|
+
expect(result).toContain('<iframe');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('sanitizeTemplate "strict" level also strips iframe, object and embed tags', () => {
|
|
41
|
+
const result = SecurityHelper.sanitizeTemplate(
|
|
42
|
+
'<iframe src="x"></iframe><object data="x"></object><embed src="x">',
|
|
43
|
+
'strict'
|
|
44
|
+
);
|
|
45
|
+
expect(result.includes('<iframe')).toBe(false);
|
|
46
|
+
expect(result.includes('<object')).toBe(false);
|
|
47
|
+
expect(result.includes('<embed')).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('sanitizeTemplate "strict" level strips javascript: hrefs and data: srcs', () => {
|
|
51
|
+
const result = SecurityHelper.sanitizeTemplate(
|
|
52
|
+
'<a href="javascript:alert(1)">link</a><img src="data:text/html,evil">',
|
|
53
|
+
'strict'
|
|
54
|
+
);
|
|
55
|
+
expect(result.includes('javascript:')).toBe(false);
|
|
56
|
+
expect(result.includes('src="data:')).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('sanitizeTemplate "strict" level still strips scripts and inline handlers like normal', () => {
|
|
60
|
+
const result = SecurityHelper.sanitizeTemplate(
|
|
61
|
+
'<script>bad()</script><div onmouseover="bad()">hi</div>',
|
|
62
|
+
'strict'
|
|
63
|
+
);
|
|
64
|
+
expect(result.includes('<script>')).toBe(false);
|
|
65
|
+
expect(result.includes('onmouseover')).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('sanitizeTemplate "normal" level strips unquoted event handler attributes', () => {
|
|
69
|
+
const result = SecurityHelper.sanitizeTemplate('<img src=x onerror=alert(1)>', 'normal');
|
|
70
|
+
expect(result.includes('onerror')).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('sanitizeTemplate "normal" level is not fooled by nested-tag script reassembly', () => {
|
|
74
|
+
const result = SecurityHelper.sanitizeTemplate(
|
|
75
|
+
'<scr<script></script>ipt>alert(document.domain)</scr<script></script>ipt>',
|
|
76
|
+
'normal'
|
|
77
|
+
);
|
|
78
|
+
// A naive substring check is misleading here: the sanitized output can still
|
|
79
|
+
// contain the literal text "<script>" as part of an inert malformed tag name
|
|
80
|
+
// (e.g. "SCR<SCRIPT"), which HTML parsers never re-interpret as a real <script>
|
|
81
|
+
// element. Re-parse the result and assert no live <script> element exists.
|
|
82
|
+
const reparsed = document.createElement('template');
|
|
83
|
+
reparsed.innerHTML = result;
|
|
84
|
+
expect(reparsed.content.querySelector('script')).toBe(null);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('sanitizeTemplate "normal" level is not fooled by nested-tag event handler reassembly', () => {
|
|
88
|
+
const result = SecurityHelper.sanitizeTemplate(
|
|
89
|
+
'<img src=x onerr<script>x</script>or=alert(1)>',
|
|
90
|
+
'normal'
|
|
91
|
+
);
|
|
92
|
+
expect(result.includes('onerror')).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('sanitizeTemplate "strict" level strips whitespace-obfuscated javascript: URLs', () => {
|
|
96
|
+
const result = SecurityHelper.sanitizeTemplate('<a href="java\tscript:alert(1)">x</a>', 'strict');
|
|
97
|
+
expect(result.includes('javascript:')).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('sanitizeTemplate "strict" level strips javascript: from action/formaction/poster/background too', () => {
|
|
101
|
+
const result = SecurityHelper.sanitizeTemplate('<form action="javascript:alert(1)">x</form>', 'strict');
|
|
102
|
+
expect(result.includes('javascript:')).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('sanitizeInputElement escapes the value of an input element', () => {
|
|
106
|
+
const input = document.createElement('input');
|
|
107
|
+
input.value = '<script>alert(1)</script>';
|
|
108
|
+
expect(SecurityHelper.sanitizeInputElement(input)).toBe('<script>alert(1)</script>');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('sanitizeInputElement escapes the value of a textarea element', () => {
|
|
112
|
+
const textarea = document.createElement('textarea');
|
|
113
|
+
textarea.value = '<b>bold</b>';
|
|
114
|
+
expect(SecurityHelper.sanitizeInputElement(textarea)).toBe('<b>bold</b>');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('sanitizeInputElement escapes innerText for contenteditable elements', () => {
|
|
118
|
+
const div = document.createElement('div');
|
|
119
|
+
div.setAttribute('contenteditable', 'true');
|
|
120
|
+
div.innerText = '<i>hi</i>';
|
|
121
|
+
expect(SecurityHelper.sanitizeInputElement(div)).toBe('<i>hi</i>');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('sanitizeInputElement returns an empty string for a plain, non-editable element', () => {
|
|
125
|
+
const div = document.createElement('div');
|
|
126
|
+
expect(SecurityHelper.sanitizeInputElement(div)).toBe('');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('escapeHtml escapes all special characters', () => {
|
|
130
|
+
expect(SecurityHelper.escapeHtml(`<div class="a" data='b'>&</div>`))
|
|
131
|
+
.toBe('<div class="a" data='b'>&</div>');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('escapeHtml returns an empty string for null and undefined', () => {
|
|
135
|
+
expect(SecurityHelper.escapeHtml(null)).toBe('');
|
|
136
|
+
expect(SecurityHelper.escapeHtml(undefined)).toBe('');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('escapeHtml stringifies non-string values before escaping', () => {
|
|
140
|
+
expect(SecurityHelper.escapeHtml(123)).toBe('123');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
export type TrustLevel = 'strict' | 'normal' | 'trusted';
|
|
2
2
|
|
|
3
3
|
export class SecurityHelper {
|
|
4
|
+
private static readonly STRICT_STRIPPED_TAGS = new Set(['IFRAME', 'OBJECT', 'EMBED']);
|
|
5
|
+
private static readonly URL_ATTRS = new Set([
|
|
6
|
+
'href',
|
|
7
|
+
'src',
|
|
8
|
+
'action',
|
|
9
|
+
'formaction',
|
|
10
|
+
'poster',
|
|
11
|
+
'background',
|
|
12
|
+
]);
|
|
13
|
+
private static readonly DANGEROUS_URL_SCHEMES = /^(javascript|data):/i;
|
|
14
|
+
|
|
4
15
|
public static sanitizeTemplate(value: unknown, trustLevel: TrustLevel = 'normal'): string {
|
|
5
16
|
if (value === null || value === undefined) return '';
|
|
6
17
|
let template = String(value);
|
|
@@ -22,35 +33,69 @@ export class SecurityHelper {
|
|
|
22
33
|
|
|
23
34
|
public static escapeHtml(value: unknown): string {
|
|
24
35
|
if (value === null || value === undefined) return '';
|
|
25
|
-
|
|
26
|
-
return
|
|
27
|
-
.replace(/&/g,
|
|
28
|
-
.replace(/</g,
|
|
29
|
-
.replace(/>/g,
|
|
30
|
-
.replace(/"/g,
|
|
31
|
-
.replace(/'/g,
|
|
36
|
+
|
|
37
|
+
return String(value)
|
|
38
|
+
.replace(/&/g, '&')
|
|
39
|
+
.replace(/</g, '<')
|
|
40
|
+
.replace(/>/g, '>')
|
|
41
|
+
.replace(/"/g, '"')
|
|
42
|
+
.replace(/'/g, ''');
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
private static sanitizeInputTemplate(template: string, trustLevel: TrustLevel): string {
|
|
35
|
-
// Trusted: no
|
|
46
|
+
// Trusted: no template sanitization
|
|
36
47
|
if (trustLevel === 'trusted') {
|
|
37
48
|
return template;
|
|
38
49
|
}
|
|
39
50
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
const holder = document.createElement('template');
|
|
52
|
+
holder.innerHTML = template;
|
|
53
|
+
this.sanitizeNode(holder.content, trustLevel);
|
|
54
|
+
|
|
55
|
+
return holder.innerHTML;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private static sanitizeNode(root: ParentNode, trustLevel: TrustLevel): void {
|
|
59
|
+
// Snapshot children before mutating — removing an element from a live
|
|
60
|
+
// collection while iterating it would skip its next sibling.
|
|
61
|
+
const children = Array.from(root.children);
|
|
62
|
+
|
|
63
|
+
for (const el of children) {
|
|
64
|
+
const tag = el.tagName;
|
|
65
|
+
|
|
66
|
+
if (tag === 'SCRIPT') {
|
|
67
|
+
el.remove();
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (trustLevel === 'strict' && this.STRICT_STRIPPED_TAGS.has(tag)) {
|
|
71
|
+
el.remove();
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
for (const attr of Array.from(el.attributes)) {
|
|
76
|
+
const name = attr.name.toLowerCase();
|
|
77
|
+
if (name.startsWith('on')) {
|
|
78
|
+
el.removeAttribute(attr.name);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (trustLevel === 'strict' && this.URL_ATTRS.has(name) && this.isDangerousUrl(attr.value)) {
|
|
82
|
+
el.removeAttribute(attr.name);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (tag === 'TEMPLATE') {
|
|
87
|
+
this.sanitizeNode((el as HTMLTemplateElement).content, trustLevel);
|
|
88
|
+
} else {
|
|
89
|
+
this.sanitizeNode(el, trustLevel);
|
|
90
|
+
}
|
|
53
91
|
}
|
|
54
|
-
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private static isDangerousUrl(value: string): boolean {
|
|
95
|
+
// Browsers strip ASCII tab/newline/CR from a URL (and trim leading C0
|
|
96
|
+
// control/space) before scheme-sniffing, so "java\tscript:" still runs
|
|
97
|
+
// as javascript: — normalize the same way before checking the scheme.
|
|
98
|
+
const normalized = value.replace(/[\t\n\r]/g, '').replace(/^[\x00-\x20]+/, '');
|
|
99
|
+
return this.DANGEROUS_URL_SCHEMES.test(normalized);
|
|
55
100
|
}
|
|
56
101
|
}
|