@nutin/cli 2.1.0 → 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 +322 -0
- package/README.md +14 -6
- 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 +12 -11
- package/templates/base/.gitignore.hbs +1 -0
- package/templates/base/AGENTS.md.hbs +45 -43
- package/templates/base/GETTING_STARTED.md.hbs +32 -23
- package/templates/base/README.md.hbs +7 -4
- package/templates/base/nutin.config.js.hbs +1 -1
- package/templates/base/src/app/components/.gitkeep +0 -0
- 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 +9 -3
- package/templates/base/src/app/services/.gitkeep +0 -0
- 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 +307 -21
- 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 +105 -4
- package/templates/base/src/core/base-classes/base-component/helpers/dom.helper.ts.hbs +136 -62
- 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 +101 -51
- 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 +217 -4
- package/templates/base/src/core/services/router/helpers/navigation-manager.helper.ts.hbs +210 -65
- 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 +321 -0
- package/templates/base/src/core/services/router/router.ts.hbs +122 -54
- package/templates/base/src/index.html.hbs +1 -0
- package/templates/base/src/styles/_styles.scss.hbs +1 -113
- package/templates/base/src/styles/main.scss.hbs +1 -1
- package/templates/base/tools/builder/builder.config.js.hbs +6 -0
- package/templates/base/tools/builder/builder.js.hbs +26 -8
- 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/add-tags.js.hbs +1 -4
- package/templates/base/tools/builder/core/html-index/add-tags.test.js.hbs +3 -3
- 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 +25 -8
- 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 +61 -15
- 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 +48 -18
- 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 +26 -8
- 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 +26 -9
- package/templates/base/tools/builder/core/styles/sass.js.hbs +7 -5
- package/templates/base/tools/builder/core/styles/tailwind.js.hbs +19 -59
- package/templates/base/tools/dev/dev-serve.js.hbs +71 -7
- package/templates/base/tools/dev/serve.js.hbs +152 -22
- package/templates/base/tools/dev/watcher.js.hbs +75 -28
- package/templates/base/tools/generator/generator.js.hbs +23 -18
- package/templates/base/tools/generator/handle-file.js.hbs +4 -24
- package/templates/base/tools/generator/handle-file.test.js.hbs +4 -23
- 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 +97 -0
- 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 +3 -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 +5 -2
- package/templates/features/docker/tools/docker/nginx.conf.template +19 -2
- 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 +29 -0
- 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/src/app/components/index.ts.hbs +0 -1
- package/templates/base/src/app/services/index.ts.hbs +0 -1
- package/templates/base/src/app/views/index.ts.hbs +0 -3
- /package/templates/base/tools/testin-nutin/core/{tests → globals}/clock.test.js.hbs +0 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { Component, View, html, raw, trustedRaw, SafeHtml } from '#root/dist/src/core/index.js';
|
|
2
|
+
|
|
3
|
+
class TestComponent extends Component {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
super(options);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
_remove() {
|
|
9
|
+
this.removed = true;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class TestView extends View {
|
|
14
|
+
constructor(template) {
|
|
15
|
+
super({ template, viewName: 'html-test' });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('html', () => {
|
|
20
|
+
let app;
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
app = document.getElementById('app');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
app.innerHTML = '';
|
|
28
|
+
app = null;
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('html returns a SafeHtml whose toString is the markup', () => {
|
|
32
|
+
const result = html`<p>hi</p>`;
|
|
33
|
+
expect(result instanceof SafeHtml).toBe(true);
|
|
34
|
+
expect(String(result)).toBe('<p>hi</p>');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('html escapes interpolated strings', () => {
|
|
38
|
+
expect(String(html`<p>${'<b class="x">a & \'b\'</b>'}</p>`))
|
|
39
|
+
.toBe('<p><b class="x">a & 'b'</b></p>');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('html escapes quotes so a value cannot break out of an attribute', () => {
|
|
43
|
+
const value = '" autofocus onfocus="alert(1)';
|
|
44
|
+
expect(String(html`<input value="${value}">`))
|
|
45
|
+
.toBe('<input value="" autofocus onfocus="alert(1)">');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('html stringifies numbers and true, and renders null, undefined and false as empty', () => {
|
|
49
|
+
expect(String(html`${1}|${true}|${null}|${undefined}|${false}|${0}`)).toBe('1|true||||0');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('html keeps null and undefined as literal strings in a data-optional value only', () => {
|
|
53
|
+
expect(String(html`<p data-optional="${undefined}">a</p>`)).toBe('<p data-optional="undefined">a</p>');
|
|
54
|
+
expect(String(html`<p data-optional='${null}'>a</p>`)).toBe(`<p data-optional='null'>a</p>`);
|
|
55
|
+
expect(String(html`<p data-optional=${undefined}>a</p>`)).toBe('<p data-optional="undefined">a</p>');
|
|
56
|
+
expect(String(html`<p data-optional="${'x'}" title="${undefined}">${undefined}</p>`)).toBe('<p data-optional="x" title=""></p>');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('data-optional="${value}" removes the element when value is undefined, even with other content', () => {
|
|
60
|
+
const component = new TestComponent({
|
|
61
|
+
config: {},
|
|
62
|
+
templateFn: (cfg) => html`<div><h3 data-optional="${cfg.title}">• ${cfg.title}</h3><p data-optional="${'kept'}">• kept</p></div>`,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
component.render();
|
|
66
|
+
|
|
67
|
+
expect(component.getElement().querySelector('h3')).toBe(null);
|
|
68
|
+
expect(component.getElement().querySelector('p').textContent).toBe('• kept');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('html inserts nested html results without escaping them again', () => {
|
|
72
|
+
const inner = html`<b>${'<i>'}</b>`;
|
|
73
|
+
expect(String(html`<p>${inner}</p>`)).toBe('<p><b><i></b></p>');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('html joins arrays item by item, escaping strings and keeping SafeHtml', () => {
|
|
77
|
+
const items = ['<a>', 'b'].map((item) => html`<li>${item}</li>`);
|
|
78
|
+
expect(String(html`<ul>${items}${['<c>']}</ul>`)).toBe('<ul><li><a></li><li>b</li><c></ul>');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('raw inserts markup unescaped and renders null and undefined as empty', () => {
|
|
82
|
+
expect(String(html`<div>${raw('<em>x</em>')}</div>`)).toBe('<div><em>x</em></div>');
|
|
83
|
+
expect(String(raw(null))).toBe('');
|
|
84
|
+
expect(String(raw(undefined))).toBe('');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('raw keeps a plain attribute fragment unchanged', () => {
|
|
88
|
+
expect(String(html`<a ${raw('aria-current="page"')}>x</a>`)).toBe('<a aria-current="page">x</a>');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('raw strips binding attributes so injected markup cannot call component methods', () => {
|
|
92
|
+
const component = new TestComponent({
|
|
93
|
+
config: { body: '<button class="evil" data-event="click:_remove">x</button>' },
|
|
94
|
+
templateFn: (cfg) => html`<div>${raw(cfg.body)}</div>`,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
component.render();
|
|
98
|
+
const button = component.getElement().querySelector('button.evil');
|
|
99
|
+
button.click();
|
|
100
|
+
|
|
101
|
+
expect(button.hasAttribute('data-event')).toBe(false);
|
|
102
|
+
expect(component.removed).toBe(undefined);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('raw content is parsed in its parent context and stripped there (svg)', () => {
|
|
106
|
+
const component = new TestComponent({
|
|
107
|
+
templateFn: () => html`<svg>${raw('<style><button class="hidden" data-event="click:_remove">x</button></style>')}</svg>`,
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
component.render();
|
|
111
|
+
component.getElement().querySelectorAll('[class="hidden"], button').forEach((el) => el.dispatchEvent(new Event('click')));
|
|
112
|
+
|
|
113
|
+
expect(component.getElement().querySelector('[data-event]')).toBe(null);
|
|
114
|
+
expect(component.removed).toBe(undefined);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('raw content is parsed in its parent context (table)', () => {
|
|
118
|
+
const component = new TestComponent({
|
|
119
|
+
templateFn: () => html`<table>${raw('<tr><td>cell</td></tr>')}</table>`,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
component.render();
|
|
123
|
+
|
|
124
|
+
expect(component.getElement().querySelector('table td').textContent).toBe('cell');
|
|
125
|
+
expect(component.getElement().querySelector('template')).toBe(null);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('raw content inside nested html and arrays is resolved', () => {
|
|
129
|
+
const items = ['<b>1</b>', '<b>2</b>'].map((item) => html`<li>${raw(item)}</li>`);
|
|
130
|
+
const component = new TestComponent({ templateFn: () => html`<ul>${items}</ul>` });
|
|
131
|
+
|
|
132
|
+
component.render();
|
|
133
|
+
|
|
134
|
+
expect(component.getElement().innerHTML).toBe('<ul><li><b>1</b></li><li><b>2</b></li></ul>');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('raw content is sanitized at the component trust level', () => {
|
|
138
|
+
const component = new TestComponent({
|
|
139
|
+
templateFn: () => html`<div>${raw('<img src="x.png" onerror="x()"><script>x()</script>')}</div>`,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
component.render();
|
|
143
|
+
|
|
144
|
+
expect(component.getElement().innerHTML).toBe('<div><img src="x.png"></div>');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('String() of html with raw inlines the raw markup without bindings', () => {
|
|
148
|
+
expect(String(html`<p>${raw('<b data-event="click:_x">b</b>')}</p>`)).toBe('<p><b>b</b></p>');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('raw at the top level and inside an HTML template is resolved', () => {
|
|
152
|
+
const component = new TestComponent({
|
|
153
|
+
templateFn: () => html`${raw('<b data-event="click:_remove">top</b>')}<template>${raw('<i>in</i>')}</template>`,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
component.render();
|
|
157
|
+
const el = component.getElement();
|
|
158
|
+
|
|
159
|
+
expect(el.querySelector('b').outerHTML).toBe('<b>top</b>');
|
|
160
|
+
expect(el.querySelector('template').content.querySelector('i').textContent).toBe('in');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('raw inside an svg template is parsed as svg', () => {
|
|
164
|
+
const component = new TestComponent({
|
|
165
|
+
templateFn: () => html`<svg><template>${raw('<text>t</text>')}</template></svg>`,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
component.render();
|
|
169
|
+
|
|
170
|
+
expect(component.getElement().querySelector('svg text').namespaceURI).toBe('http://www.w3.org/2000/svg');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('a placeholder without a matching raw source is removed', () => {
|
|
174
|
+
const component = new TestComponent({
|
|
175
|
+
templateFn: () => html`${trustedRaw('<template data-nutin-raw="missing"></template>')}<p>x</p>`,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
component.render();
|
|
179
|
+
|
|
180
|
+
expect(component.getElement().innerHTML).toBe('<p>x</p>');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('trustedRaw keeps binding attributes', () => {
|
|
184
|
+
const component = new TestComponent({
|
|
185
|
+
templateFn: () => html`<div>${trustedRaw('<button class="own" data-event="click:_remove">x</button>')}</div>`,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
component.render();
|
|
189
|
+
component.getElement().querySelector('button.own').click();
|
|
190
|
+
|
|
191
|
+
expect(component.removed).toBe(true);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('trustedRaw renders null and undefined as empty', () => {
|
|
195
|
+
expect(String(trustedRaw(null))).toBe('');
|
|
196
|
+
expect(String(trustedRaw(undefined))).toBe('');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('html quotes a value in an unquoted attribute so it stays one attribute', () => {
|
|
200
|
+
expect(String(html`<div title=${'a data-event=click:_remove'}>t</div>`))
|
|
201
|
+
.toBe('<div title="a data-event=click:_remove">t</div>');
|
|
202
|
+
expect(String(html`<div title=${''} id="x">t</div>`)).toBe('<div title="" id="x">t</div>');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('html encodes terminators inside an unquoted value that is already started', () => {
|
|
206
|
+
expect(String(html`<div class=a${' b=c'}>t</div>`)).toBe('<div class=a b=c>t</div>');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('html quotes nested html results in unquoted attributes too', () => {
|
|
210
|
+
expect(String(html`<div title=${html`a ${'b'}`}>t</div>`)).toBe('<div title="a b">t</div>');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('html leaves "=" in text and quoted attributes alone', () => {
|
|
214
|
+
expect(String(html`<p>Total = ${5}</p><a title="${'x y'}">l</a>`)).toBe('<p>Total = 5</p><a title="x y">l</a>');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('raw in attribute position keeps plain attributes and drops handlers, bindings and script URLs', () => {
|
|
218
|
+
const attrs = 'checked aria-label="ok" onclick="x()" data-event="click:_remove" href="javascript:x()" srcdoc="x"';
|
|
219
|
+
expect(String(html`<input ${raw(attrs)}>`)).toBe('<input checked="" aria-label="ok">');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('html tracks single-quoted, spaced and empty unquoted attributes', () => {
|
|
223
|
+
expect(String(html`<a title='${"x'y"}' id=${'i d'}>t</a>`)).toBe('<a title=\'x'y\' id="i d">t</a>');
|
|
224
|
+
expect(String(html`<a title= ${'v w'}>t</a>`)).toBe('<a title= "v w">t</a>');
|
|
225
|
+
expect(String(html`<a title=>${'<t>'}</a>`)).toBe('<a title=><t></a>');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('html returns to text and tag states after an unquoted value', () => {
|
|
229
|
+
expect(String(html`<a class=x>${'a b'}</a>`)).toBe('<a class=x>a b</a>');
|
|
230
|
+
expect(String(html`<a class=x title=${'v w'}>t</a>`)).toBe('<a class=x title="v w">t</a>');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('html keeps plain attributes interpolated in tag position', () => {
|
|
234
|
+
expect(String(html`<input ${true ? 'disabled' : ''}>`)).toBe('<input disabled="">');
|
|
235
|
+
expect(String(html`<input ${'aria-label="ok"'}>`)).toBe('<input aria-label="ok">');
|
|
236
|
+
expect(String(html`<input ${''}>`)).toBe('<input >');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('html drops handlers, bindings and script URLs interpolated as a plain string in tag position', () => {
|
|
240
|
+
expect(String(html`<div ${'data-event=click:_remove'}>x</div>`)).toBe('<div >x</div>');
|
|
241
|
+
expect(String(html`<a ${'href=javascript:x() onclick=x()'}>x</a>`)).toBe('<a >x</a>');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('a binding attribute injected in tag position cannot call a component method', () => {
|
|
245
|
+
const component = new TestComponent({
|
|
246
|
+
config: { attrs: 'data-event=click:_remove' },
|
|
247
|
+
templateFn: (cfg) => html`<button ${cfg.attrs}>x</button>`,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
component.render();
|
|
251
|
+
component.getElement().querySelector('button').click();
|
|
252
|
+
|
|
253
|
+
expect(component.removed).toBe(undefined);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('raw in an attribute value position is escaped as text', () => {
|
|
257
|
+
expect(String(html`<a title="${raw('<b>"q"</b>')}">t</a>`)).toBe('<a title="<b>"q"</b>">t</a>');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('raw in attribute position with an unterminated quote yields no attributes', () => {
|
|
261
|
+
expect(String(html`<b ${raw('a="x')}>t</b>`)).toBe('<b >t</b>');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('raw in attribute position drops attributes with invalid names', () => {
|
|
265
|
+
expect(String(html`<b ${raw('a"b="1" ok="2"')}>t</b>`)).toBe('<b ok="2">t</b>');
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('html replaces a javascript: URL from data in a URL attribute and warns', () => {
|
|
269
|
+
const warnSpy = spyOn(console, 'warn').andCallFake(() => {});
|
|
270
|
+
const bad = 'javascript:alert(1)';
|
|
271
|
+
const results = [
|
|
272
|
+
String(html`<a href="${bad}">x</a>`),
|
|
273
|
+
String(html`<a href='${bad}'>x</a>`),
|
|
274
|
+
String(html`<a href=${bad}>x</a>`),
|
|
275
|
+
String(html`<a href=" ${bad}">x</a>`),
|
|
276
|
+
String(html`<a HREF="${'java\tscript:x'}">x</a>`),
|
|
277
|
+
String(html`<a href="${'JavaScript:x'}">x</a>`),
|
|
278
|
+
String(html`<button formaction="${bad}">x</button>`),
|
|
279
|
+
String(html`<svg><a xlink:href="${bad}">x</a></svg>`),
|
|
280
|
+
String(html`<a href="${html`${bad}`}">x</a>`),
|
|
281
|
+
String(html`<a href="${raw(bad)}">x</a>`),
|
|
282
|
+
String(html`<a href="${'java'}${'script:x'}">x</a>`),
|
|
283
|
+
];
|
|
284
|
+
warnSpy.restore();
|
|
285
|
+
|
|
286
|
+
expect(results).toEqual([
|
|
287
|
+
'<a href="about:invalid#nutin-blocked">x</a>',
|
|
288
|
+
"<a href='about:invalid#nutin-blocked'>x</a>",
|
|
289
|
+
'<a href="about:invalid#nutin-blocked">x</a>',
|
|
290
|
+
'<a href=" about:invalid#nutin-blocked">x</a>',
|
|
291
|
+
'<a HREF="about:invalid#nutin-blocked">x</a>',
|
|
292
|
+
'<a href="about:invalid#nutin-blocked">x</a>',
|
|
293
|
+
'<button formaction="about:invalid#nutin-blocked">x</button>',
|
|
294
|
+
'<svg><a xlink:href="about:invalid#nutin-blocked">x</a></svg>',
|
|
295
|
+
'<a href="about:invalid#nutin-blocked">x</a>',
|
|
296
|
+
'<a href="about:invalid#nutin-blocked">x</a>',
|
|
297
|
+
'<a href="javaabout:invalid#nutin-blocked">x</a>',
|
|
298
|
+
]);
|
|
299
|
+
expect(warnSpy.callCount).toBe(11);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('a javascript: URL from data is blocked at the trusted level too', () => {
|
|
303
|
+
const component = new TestComponent({
|
|
304
|
+
config: { url: 'javascript:alert(1)' },
|
|
305
|
+
templateFn: (cfg) => html`<a href="${cfg.url}">x</a>`,
|
|
306
|
+
trustLevel: 'trusted',
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
silenceConsole('warn', () => component.render());
|
|
310
|
+
|
|
311
|
+
expect(component.getElement().querySelector('a').getAttribute('href')).toBe('about:invalid#nutin-blocked');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('html keeps URLs whose scheme is fixed by the template, other schemes and non-URL attributes', () => {
|
|
315
|
+
const warnSpy = spyOn(console, 'warn').andCallFake(() => {});
|
|
316
|
+
const results = [
|
|
317
|
+
String(html`<a href="/p/${'javascript:x'}">x</a>`),
|
|
318
|
+
String(html`<a href="#${'javascript:x'}">x</a>`),
|
|
319
|
+
String(html`<a href="${'https://example.com/a'}">x</a>`),
|
|
320
|
+
String(html`<a title="${'javascript:x'}" data-x=${'javascript:x'}>x</a>`),
|
|
321
|
+
String(html`<a href="javascript:go(${1})">x</a>`),
|
|
322
|
+
String(html`<a title="t"href="${'/ok'}">x</a>`),
|
|
323
|
+
];
|
|
324
|
+
warnSpy.restore();
|
|
325
|
+
|
|
326
|
+
expect(results).toEqual([
|
|
327
|
+
'<a href="/p/javascript:x">x</a>',
|
|
328
|
+
'<a href="#javascript:x">x</a>',
|
|
329
|
+
'<a href="https://example.com/a">x</a>',
|
|
330
|
+
'<a title="javascript:x" data-x="javascript:x">x</a>',
|
|
331
|
+
'<a href="javascript:go(1)">x</a>',
|
|
332
|
+
'<a title="t"href="/ok">x</a>',
|
|
333
|
+
]);
|
|
334
|
+
expect(warnSpy.callCount).toBe(0);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('raw in a textarea or title is inserted as text, with entities decoded', () => {
|
|
338
|
+
app.innerHTML = String(html`<textarea>${raw('<b>Tom & Jerry</b>')}</textarea>`);
|
|
339
|
+
expect(app.querySelector('textarea').value).toBe('<b>Tom & Jerry</b>');
|
|
340
|
+
|
|
341
|
+
const fragment = String(html`<TITLE>${raw('<i>t</i>')}</TITLE>`);
|
|
342
|
+
expect(fragment).toBe('<TITLE><i>t</i></TITLE>');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('raw in a textarea cannot close it, in String() and at render', () => {
|
|
346
|
+
const payload = '</textarea><img src="x" onerror="x()">';
|
|
347
|
+
expect(String(html`<textarea>${raw(payload)}</textarea>`))
|
|
348
|
+
.toBe('<textarea></textarea><img src="x" onerror="x()"></textarea>');
|
|
349
|
+
|
|
350
|
+
const component = new TestComponent({ templateFn: () => html`<div><textarea>${raw(payload)}</textarea></div>` });
|
|
351
|
+
component.render();
|
|
352
|
+
|
|
353
|
+
expect(component.getElement().querySelector('img')).toBe(null);
|
|
354
|
+
expect(component.getElement().querySelector('textarea').value).toBe(payload);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('nested html and trustedRaw in a textarea are kept as they are, raws inside nested html become text', () => {
|
|
358
|
+
expect(String(html`<title>${html`${'Tom & Jerry'} - Site`}</title>`)).toBe('<title>Tom & Jerry - Site</title>');
|
|
359
|
+
expect(String(html`<textarea>${trustedRaw('a & b')}</textarea>`)).toBe('<textarea>a & b</textarea>');
|
|
360
|
+
expect(String(html`<textarea>${[html`<b>${raw('<i>$&</i>')}</b>`, 'x', null]}</textarea>`))
|
|
361
|
+
.toBe('<textarea><b><i>$&</i></b>x</textarea>');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('html tracks the end of a textarea: tag position on it and raw after it work as usual', () => {
|
|
365
|
+
const result = html`<textarea ${raw('rows="3" onclick="x()"')}>a</textarea >${raw('<b>b</b>')}`;
|
|
366
|
+
|
|
367
|
+
expect(/^<textarea rows="3">a<\/textarea ><template data-nutin-raw="r\d+"><\/template>$/.test(result.value)).toBe(true);
|
|
368
|
+
expect(result.raws.size).toBe(1);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('a raw after a textarea is resolved as nodes at render', () => {
|
|
372
|
+
const component = new TestComponent({
|
|
373
|
+
templateFn: () => html`<div><textarea>a < b</textarea>${raw('<b>bold</b>')}</div>`,
|
|
374
|
+
});
|
|
375
|
+
component.render();
|
|
376
|
+
|
|
377
|
+
expect(component.getElement().innerHTML).toBe('<div><textarea>a < b</textarea><b>bold</b></div>');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('String() of a raw containing $ patterns keeps them literally', () => {
|
|
381
|
+
expect(String(html`<p>${raw('cost: $& $1')}</p>`)).toBe('<p>cost: $& $1</p>');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('a component templateFn using html renders injected markup as text', () => {
|
|
385
|
+
const payload = '<button class="evil" data-event="click:_remove">x</button>';
|
|
386
|
+
const component = new TestComponent({
|
|
387
|
+
config: { name: payload },
|
|
388
|
+
templateFn: (cfg) => html`<span>${cfg.name}</span>`,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
component.render();
|
|
392
|
+
|
|
393
|
+
expect(component.getElement().querySelector('button')).toBe(null);
|
|
394
|
+
expect(component.getElement().querySelector('span').textContent).toBe(payload);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it('a view accepts an html template', () => {
|
|
398
|
+
const view = new TestView(html`<h1>${'<Title>'}</h1>`);
|
|
399
|
+
|
|
400
|
+
view.render();
|
|
401
|
+
|
|
402
|
+
expect(view.getElement().querySelector('h1').textContent).toBe('<Title>');
|
|
403
|
+
});
|
|
404
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import * as SecurityHelper from './security.helper.js';
|
|
2
|
+
|
|
3
|
+
// Markup from html``: `value` is safe markup; `raws` maps a placeholder token to the
|
|
4
|
+
// unescaped source of a raw() that sits in element position, resolved as DOM nodes at render.
|
|
5
|
+
export class SafeHtml {
|
|
6
|
+
constructor(
|
|
7
|
+
public readonly value: string,
|
|
8
|
+
public readonly raws: ReadonlyMap<string, string> = new Map()
|
|
9
|
+
) {}
|
|
10
|
+
|
|
11
|
+
public toString(): string {
|
|
12
|
+
// Fallback for String(): inline each raw with its bindings stripped.
|
|
13
|
+
// The render path (sanitizeToFragment) resolves placeholders as nodes instead.
|
|
14
|
+
let out = this.value;
|
|
15
|
+
this.raws.forEach((source, token) => {
|
|
16
|
+
// A replacer function: a string would expand `$&`, `$'`, ... found in the source.
|
|
17
|
+
out = out.replace(SafeHtml.placeholder(token), () => SecurityHelper.stripBindings(source));
|
|
18
|
+
});
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
public static placeholder(token: string): string {
|
|
23
|
+
return `<template data-nutin-raw="${token}"></template>`;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// raw()'s return value: carries the unescaped source so html() can place it as a node.
|
|
28
|
+
class RawHtml extends SafeHtml {
|
|
29
|
+
constructor(public readonly source: string) {
|
|
30
|
+
super('');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Context = 'text' | 'tag' | 'attr-doublequoted' | 'attr-singlequoted' | 'attr-unquoted-start' | 'attr-unquoted' | 'rcdata';
|
|
35
|
+
|
|
36
|
+
interface ScanState {
|
|
37
|
+
ctx: Context;
|
|
38
|
+
tagName: string; // element of the current tag, lowercased; '/'-prefixed for a closing tag
|
|
39
|
+
inTagName: boolean;
|
|
40
|
+
attrName: string; // last attribute name in the current tag, lowercased
|
|
41
|
+
newAttrName: boolean; // whitespace seen: the next name character starts a new attribute name
|
|
42
|
+
attrStatic: string; // the current attribute value's static text
|
|
43
|
+
attrValue: string; // the current attribute value so far, static text and interpolations
|
|
44
|
+
rcdataTail: string; // last characters seen in rcdata, to find its end tag
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Elements whose content is text: markup in them is never parsed as elements.
|
|
48
|
+
const RCDATA_TAGS = new Set(['textarea', 'title']);
|
|
49
|
+
|
|
50
|
+
const BLOCKED_URL = 'about:invalid#nutin-blocked';
|
|
51
|
+
|
|
52
|
+
let rawCounter = 0;
|
|
53
|
+
|
|
54
|
+
// Tagged template: every ${} is HTML-escaped unless it is a SafeHtml (nested html``/raw()).
|
|
55
|
+
// Arrays are interpolated item by item, so `${items.map((i) => html`<li>${i}</li>`)}` needs no join.
|
|
56
|
+
// The parse context is tracked over the STATIC parts only, so interpolated data can't change it.
|
|
57
|
+
// `data-optional="${x}"` removes its element when x is null/undefined: DomHelper.cleanupOptionalContent
|
|
58
|
+
// looks for the literal strings, so they are kept there instead of rendering as ''.
|
|
59
|
+
const OPTIONAL_VALUE = /\sdata-optional=["']?$/;
|
|
60
|
+
|
|
61
|
+
export function html(strings: TemplateStringsArray, ...values: unknown[]): SafeHtml {
|
|
62
|
+
const raws = new Map<string, string>();
|
|
63
|
+
const state: ScanState = {
|
|
64
|
+
ctx: 'text', tagName: '', inTagName: false, attrName: '', newAttrName: false, attrStatic: '', attrValue: '', rcdataTail: '',
|
|
65
|
+
};
|
|
66
|
+
let out = strings[0] as string;
|
|
67
|
+
scan(state, out);
|
|
68
|
+
for (let i = 0; i < values.length; i++) {
|
|
69
|
+
const value = values[i];
|
|
70
|
+
const ctx = state.ctx;
|
|
71
|
+
const inAttr = ctx.startsWith('attr');
|
|
72
|
+
const fillsOptional = (value === null || value === undefined) && inAttr && OPTIONAL_VALUE.test(out);
|
|
73
|
+
let piece = fillsOptional ? String(value) : interpolate(value, ctx, raws);
|
|
74
|
+
if (inAttr) piece = checkUrl(state, piece);
|
|
75
|
+
if (ctx === 'attr-unquoted-start') {
|
|
76
|
+
// Unquoted attribute (`title=${x}`): quote the whole value so data can't add attributes.
|
|
77
|
+
out += `"${piece.replace(/"/g, '"')}"`;
|
|
78
|
+
state.ctx = 'tag';
|
|
79
|
+
state.newAttrName = true;
|
|
80
|
+
} else if (ctx === 'attr-unquoted') {
|
|
81
|
+
// Inside an unquoted value already started (`class=a${x}`): encode anything that would end it.
|
|
82
|
+
out += piece.replace(/[\s"'`=<>]/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
83
|
+
} else {
|
|
84
|
+
out += piece;
|
|
85
|
+
}
|
|
86
|
+
state.rcdataTail = '';
|
|
87
|
+
const next = strings[i + 1] as string;
|
|
88
|
+
out += next;
|
|
89
|
+
scan(state, next);
|
|
90
|
+
}
|
|
91
|
+
return new SafeHtml(out, raws);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Escaping can't make a URL safe: `javascript:alert(1)` has nothing to escape. A value from data that
|
|
95
|
+
// turns a URL attribute into a javascript: URL is replaced, at every trust level (the render
|
|
96
|
+
// sanitizer doesn't run at 'trusted'). A javascript: URL written in the template itself is kept.
|
|
97
|
+
function checkUrl(state: ScanState, piece: string): string {
|
|
98
|
+
const value = state.attrValue + piece;
|
|
99
|
+
if (SecurityHelper.isScriptUrl(state.attrName, value) && !SecurityHelper.isScriptUrl(state.attrName, state.attrStatic)) {
|
|
100
|
+
console.warn(`Blocked a javascript: URL in the "${state.attrName}" attribute - validate URLs from data.`);
|
|
101
|
+
piece = BLOCKED_URL;
|
|
102
|
+
}
|
|
103
|
+
state.attrValue += piece;
|
|
104
|
+
return piece;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Inserts markup unescaped, minus Nutin's binding attributes (data-event, data-component, ...),
|
|
108
|
+
// so it can't call the component's methods or mount children. Sanitized at the component's trustLevel.
|
|
109
|
+
export function raw(value: unknown): SafeHtml {
|
|
110
|
+
return new RawHtml(toMarkup(value));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Like raw(), but keeps binding attributes: only for markup you wrote or compiled yourself
|
|
114
|
+
// (e.g. Markdown pages whose links use data-event).
|
|
115
|
+
export function trustedRaw(value: unknown): SafeHtml {
|
|
116
|
+
return new SafeHtml(toMarkup(value));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toMarkup(value: unknown): string {
|
|
120
|
+
return value === null || value === undefined ? '' : String(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function interpolate(value: unknown, ctx: Context, raws: Map<string, string>): string {
|
|
124
|
+
if (ctx === 'rcdata') return interpolateText(value);
|
|
125
|
+
if (value instanceof RawHtml) return interpolateRaw(value.source, ctx, raws);
|
|
126
|
+
if (value instanceof SafeHtml) {
|
|
127
|
+
value.raws.forEach((source, token) => raws.set(token, source));
|
|
128
|
+
return value.value;
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(value)) return value.map((item) => interpolate(item, ctx, raws)).join('');
|
|
131
|
+
if (value === null || value === undefined || value === false) return '';
|
|
132
|
+
// In tag position (`<input ${x}>`) escaping alone leaves `name=value` intact, so data could
|
|
133
|
+
// add attributes: it is filtered like raw() there (no handlers, bindings or script URLs).
|
|
134
|
+
if (ctx === 'tag') return SecurityHelper.sanitizeAttributes(String(value));
|
|
135
|
+
return SecurityHelper.escapeHtml(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Inside <textarea>/<title> everything is text, so no placeholder could be resolved there. raw() is
|
|
139
|
+
// inserted as innerHTML would put it (entities decode, tags show as text) with `<` escaped, so it
|
|
140
|
+
// can't end the element. Nested html`` and trustedRaw() are kept as they are, as anywhere else.
|
|
141
|
+
function interpolateText(value: unknown): string {
|
|
142
|
+
if (value instanceof RawHtml) return rawAsText(value.source);
|
|
143
|
+
if (value instanceof SafeHtml) {
|
|
144
|
+
let out = value.value;
|
|
145
|
+
value.raws.forEach((source, token) => {
|
|
146
|
+
out = out.replace(SafeHtml.placeholder(token), () => rawAsText(source));
|
|
147
|
+
});
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(value)) return value.map(interpolateText).join('');
|
|
151
|
+
if (value === null || value === undefined || value === false) return '';
|
|
152
|
+
return SecurityHelper.escapeHtml(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function rawAsText(source: string): string {
|
|
156
|
+
return source.replace(/</g, '<');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function interpolateRaw(source: string, ctx: Context, raws: Map<string, string>): string {
|
|
160
|
+
// In element position raw() is resolved as nodes at render; elsewhere it is inlined safely now.
|
|
161
|
+
if (ctx === 'text') {
|
|
162
|
+
const token = `r${++rawCounter}`;
|
|
163
|
+
raws.set(token, source);
|
|
164
|
+
return SafeHtml.placeholder(token);
|
|
165
|
+
}
|
|
166
|
+
if (ctx === 'tag') return SecurityHelper.sanitizeAttributes(source);
|
|
167
|
+
return SecurityHelper.escapeHtml(source);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Follows the HTML tokenizer closely enough to know where each ${} lands: text, inside a tag,
|
|
171
|
+
// inside an attribute value (and which attribute), or inside a <textarea>/<title>.
|
|
172
|
+
function scan(state: ScanState, chunk: string): void {
|
|
173
|
+
for (const ch of chunk) {
|
|
174
|
+
switch (state.ctx) {
|
|
175
|
+
case 'text':
|
|
176
|
+
if (ch === '<') {
|
|
177
|
+
state.ctx = 'tag';
|
|
178
|
+
state.tagName = '';
|
|
179
|
+
state.inTagName = true;
|
|
180
|
+
state.attrName = '';
|
|
181
|
+
}
|
|
182
|
+
break;
|
|
183
|
+
case 'tag':
|
|
184
|
+
if (ch === '>') {
|
|
185
|
+
state.ctx = RCDATA_TAGS.has(state.tagName) ? 'rcdata' : 'text';
|
|
186
|
+
state.rcdataTail = '';
|
|
187
|
+
} else if (ch === '=') {
|
|
188
|
+
state.ctx = 'attr-unquoted-start';
|
|
189
|
+
state.attrStatic = '';
|
|
190
|
+
state.attrValue = '';
|
|
191
|
+
} else if (/\s/.test(ch) || (ch === '/' && state.tagName !== '')) {
|
|
192
|
+
state.inTagName = false;
|
|
193
|
+
state.newAttrName = true;
|
|
194
|
+
} else if (state.inTagName) {
|
|
195
|
+
state.tagName += ch.toLowerCase();
|
|
196
|
+
} else {
|
|
197
|
+
state.attrName = state.newAttrName ? ch.toLowerCase() : state.attrName + ch.toLowerCase();
|
|
198
|
+
state.newAttrName = false;
|
|
199
|
+
}
|
|
200
|
+
break;
|
|
201
|
+
case 'attr-unquoted-start':
|
|
202
|
+
if (ch === '"') state.ctx = 'attr-doublequoted';
|
|
203
|
+
else if (ch === "'") state.ctx = 'attr-singlequoted';
|
|
204
|
+
else if (ch === '>') state.ctx = 'text';
|
|
205
|
+
else if (!/\s/.test(ch)) {
|
|
206
|
+
state.ctx = 'attr-unquoted';
|
|
207
|
+
addAttrChar(state, ch);
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
210
|
+
case 'attr-unquoted':
|
|
211
|
+
if (ch === '>') state.ctx = 'text';
|
|
212
|
+
else if (/\s/.test(ch)) {
|
|
213
|
+
state.ctx = 'tag';
|
|
214
|
+
state.newAttrName = true;
|
|
215
|
+
} else addAttrChar(state, ch);
|
|
216
|
+
break;
|
|
217
|
+
case 'attr-doublequoted':
|
|
218
|
+
case 'attr-singlequoted':
|
|
219
|
+
if (ch === (state.ctx === 'attr-doublequoted' ? '"' : "'")) {
|
|
220
|
+
state.ctx = 'tag';
|
|
221
|
+
state.newAttrName = true;
|
|
222
|
+
} else addAttrChar(state, ch);
|
|
223
|
+
break;
|
|
224
|
+
case 'rcdata': {
|
|
225
|
+
// Ends at `</textarea` (or `</title`) followed by whitespace, `/` or `>`.
|
|
226
|
+
const end = `</${state.tagName}`;
|
|
227
|
+
state.rcdataTail = (state.rcdataTail + ch).slice(-(end.length + 1));
|
|
228
|
+
if (state.rcdataTail.slice(0, -1).toLowerCase() === end && /[\s/>]/.test(ch)) {
|
|
229
|
+
state.ctx = ch === '>' ? 'text' : 'tag';
|
|
230
|
+
state.tagName = `/${state.tagName}`;
|
|
231
|
+
state.inTagName = false;
|
|
232
|
+
}
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function addAttrChar(state: ScanState, ch: string): void {
|
|
240
|
+
state.attrStatic += ch;
|
|
241
|
+
state.attrValue += ch;
|
|
242
|
+
}
|