diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7de5a35e13..05b48f2ae0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ ### Before submitting the PR, please make sure you do the following - [ ] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs -- [ ] Prefix your PR title with `[feat]`, `[fix]`, `[chore]`, or `[docs]`. +- [ ] Prefix your PR title with `feat:`, `fix:`, `chore:`, or `docs:`. - [ ] This message body should clearly illustrate what problems it solves. - [ ] Ideally, include a test that fails without this PR but passes with it. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbfde04f67..1e0ce53864 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,29 +1,86 @@ name: CI -on: [push, pull_request] +on: + push: + branches: [ master ] + pull_request: +permissions: + contents: read # to fetch code (actions/checkout) jobs: + Setup: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macOS-latest] + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + cache: npm + - run: npm install + env: + SKIP_PREPARE: true + - run: npm run build + env: + PUBLISH: true + - name: Upload build assets + id: upload-artifact + uses: actions/upload-artifact@v3 + with: + name: build-assets + path: | + index.* + compiler.* + ssr.* + action/ + animate/ + easing/ + internal/ + motion/ + store/ + transition/ + types/ Tests: + needs: Setup runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: matrix: - node-version: [8, 10, 12, 14, 16] + node-version: [8, 10, 12, 14, 16, 18] os: [ubuntu-latest, windows-latest, macOS-latest] steps: - - uses: actions/checkout@v1 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: npm + - name: Download build assets + uses: actions/download-artifact@v3 + id: download-artifact + with: + name: build-assets + - name: Get Node version ${{ runner.os }} + run: echo "NODE_VERSION=`node --version`" >> $GITHUB_ENV + if: runner.os != 'Windows' + - name: Get Node version ${{ runner.os }} + run: | + chcp 65001 + echo ("NODE_VERSION=$(node --version)") >> $env:GITHUB_ENV + if: runner.os == 'Windows' + - run: npm install --save-dev puppeteer@13 + if: ${{ runner.os == 'Linux' && (!startsWith(env.NODE_VERSION, 'v8.') && !startsWith(env.NODE_VERSION, 'v10.')) }} - run: npm install - - run: npm test + env: + SKIP_PREPARE: true + - run: npm run test:integration env: CI: true Lint: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v1 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: cache: npm - run: 'npm i && npm run lint' @@ -34,8 +91,11 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macOS-latest] steps: - - uses: actions/checkout@v1 - - uses: actions/setup-node@v2 + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 with: cache: npm - - run: 'npm i && npm run test:unit' + - run: npm install + env: + SKIP_PREPARE: true + - run: npm run test:unit diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ea6e0f5a60..763fb72577 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -7,6 +7,8 @@ on: paths: - site/content/** +permissions: {} + jobs: release: name: Deploy docs @@ -25,4 +27,4 @@ jobs: repo: 'svelte' branch: 'master' docs_path: 'site/content' - token: ${{ steps.github-app.outputs.token }} \ No newline at end of file + token: ${{ steps.github-app.outputs.token }} diff --git a/.gitignore b/.gitignore index 478a6f5ceb..e70d995994 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ node_modules _actual*.* _output /types +.eslintcache diff --git a/.mocharc.js b/.mocharc.js index e55f26099e..449f81ecd6 100644 --- a/.mocharc.js +++ b/.mocharc.js @@ -1,7 +1,7 @@ +const is_unit_test = process.env.UNIT_TEST; + module.exports = { - file: [ - 'test/test.ts' - ], + file: is_unit_test ? [] : ['test/test.ts'], require: [ 'sucrase/register' ] diff --git a/.mocharc.unit.js b/.mocharc.unit.js new file mode 100644 index 0000000000..387d70e7e0 --- /dev/null +++ b/.mocharc.unit.js @@ -0,0 +1,15 @@ +module.exports = { + spec: [ + 'src/**/__test__.ts', + ], + require: [ + 'sucrase/register' + ], + recursive: true, +}; + +// add coverage options when running 'npx c8 mocha' +if (process.env.NODE_V8_COVERAGE) { + module.exports.fullTrace = true; + module.exports.require.push('source-map-support/register'); +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 442c941571..ea9545aa65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,143 @@ ## Unreleased -* Fix hydration issue with using `{@html}` and components in `svelte:head` ([#4533](https://github.com/sveltejs/svelte/issues/4533), [#6463](https://github.com/sveltejs/svelte/issues/6463), [#7444](https://github.com/sveltejs/svelte/issues/7444)) -* Warn instead of throwing error if `` is void tag ([#7566](https://github.com/sveltejs/svelte/issues/7566)) -* Treat `inert` as boolean attribute ([#7785](https://github.com/sveltejs/svelte/pull/7785)) -* Supporting scoped style for `` ([#7443](https://github.com/sveltejs/svelte/issues/7443)) -* Supports SVG elements with ``([#7613](https://github.com/sveltejs/svelte/issues/7613)) -* Warn user when binding on a `{...rest}` object in `{#each}` block ([#6860](https://github.com/sveltejs/svelte/issues/6860)) +* Handle `width`/`height` attributes when spreading ([#6752](https://github.com/sveltejs/svelte/issues/6752)) +* Add support for resize observer bindings (`
`) ([#8022](https://github.com/sveltejs/svelte/pull/8022)) + +## 3.58.0 + +* Add `bind:innerText` for `contenteditable` elements ([#3311](https://github.com/sveltejs/svelte/issues/3311)) +* Add support for CSS `@container` queries ([#6969](https://github.com/sveltejs/svelte/issues/6969)) +* Respect `preserveComments` in DOM output ([#7182](https://github.com/sveltejs/svelte/pull/7182)) +* Allow use of `document` for `target` in typings ([#7554](https://github.com/sveltejs/svelte/pull/7554)) +* Add `a11y-interactive-supports-focus` warning ([#8392](https://github.com/sveltejs/svelte/pull/8392)) +* Fix equality check when updating dynamic text ([#5931](https://github.com/sveltejs/svelte/issues/5931)) +* Relax `a11y-no-noninteractive-element-to-interactive-role` warning ([#8402](https://github.com/sveltejs/svelte/pull/8402)) +* Properly handle microdata attributes ([#8413](https://github.com/sveltejs/svelte/issues/8413)) +* Prevent name collision when using computed destructuring variables ([#8417](https://github.com/sveltejs/svelte/issues/8417)) +* Fix escaping ` @@ -14,4 +13,4 @@ In cases like these, where the names match, we can also use a shorthand form: ``` -This applies to all bindings, not just textareas. \ No newline at end of file +This applies to all bindings, not just textareas. diff --git a/site/content/tutorial/06-bindings/08-contenteditable-bindings/text.md b/site/content/tutorial/06-bindings/08-contenteditable-bindings/text.md index a73b07c0ba..4d4b52f3b8 100644 --- a/site/content/tutorial/06-bindings/08-contenteditable-bindings/text.md +++ b/site/content/tutorial/06-bindings/08-contenteditable-bindings/text.md @@ -2,11 +2,16 @@ title: Contenteditable bindings --- -Elements with a `contenteditable="true"` attribute support `textContent` and `innerHTML` bindings: +Elements with the `contenteditable` attribute support the following bindings: +- [`innerHTML`](https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML) +- [`innerText`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/innerText) +- [`textContent`](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent) + +There are slight differences between each of these, read more about them [here](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent#Differences_from_innerText). ```html
-``` \ No newline at end of file +``` diff --git a/site/content/tutorial/09-motion/02-spring/app-b/App.svelte b/site/content/tutorial/09-motion/02-spring/app-b/App.svelte index d8a0287c2c..3ded9350a9 100644 --- a/site/content/tutorial/09-motion/02-spring/app-b/App.svelte +++ b/site/content/tutorial/09-motion/02-spring/app-b/App.svelte @@ -33,6 +33,7 @@ svg { width: 100%; height: 100%; + margin: -8px; } circle { fill: #ff3e00; diff --git a/site/content/tutorial/12-actions/02-adding-parameters-to-actions/app-a/longpress.js b/site/content/tutorial/12-actions/02-adding-parameters-to-actions/app-a/longpress.js index 00bb9d05c8..07235f21dc 100644 --- a/site/content/tutorial/12-actions/02-adding-parameters-to-actions/app-a/longpress.js +++ b/site/content/tutorial/12-actions/02-adding-parameters-to-actions/app-a/longpress.js @@ -18,6 +18,7 @@ export function longpress(node, duration) { return { destroy() { + clearTimeout(timer); node.removeEventListener('mousedown', handleMousedown); node.removeEventListener('mouseup', handleMouseup); } diff --git a/site/content/tutorial/12-actions/02-adding-parameters-to-actions/app-b/longpress.js b/site/content/tutorial/12-actions/02-adding-parameters-to-actions/app-b/longpress.js index 9d807cb13a..4ce0dfe05b 100644 --- a/site/content/tutorial/12-actions/02-adding-parameters-to-actions/app-b/longpress.js +++ b/site/content/tutorial/12-actions/02-adding-parameters-to-actions/app-b/longpress.js @@ -21,6 +21,7 @@ export function longpress(node, duration) { duration = newDuration; }, destroy() { + clearTimeout(timer); node.removeEventListener('mousedown', handleMousedown); node.removeEventListener('mouseup', handleMouseup); } diff --git a/site/content/tutorial/16-special-elements/01-svelte-self/app-a/Folder.svelte b/site/content/tutorial/16-special-elements/01-svelte-self/app-a/Folder.svelte index e93ffdc548..814e383924 100644 --- a/site/content/tutorial/16-special-elements/01-svelte-self/app-a/Folder.svelte +++ b/site/content/tutorial/16-special-elements/01-svelte-self/app-a/Folder.svelte @@ -10,7 +10,7 @@ } -{name} + {#if expanded}
    @@ -27,12 +27,14 @@ {/if} \ No newline at end of file + diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/app-a/flash.js b/site/content/tutorial/16-special-elements/09-svelte-options/app-a/flash.js similarity index 100% rename from site/content/tutorial/16-special-elements/08-svelte-options/app-a/flash.js rename to site/content/tutorial/16-special-elements/09-svelte-options/app-a/flash.js diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/app-b/App.svelte b/site/content/tutorial/16-special-elements/09-svelte-options/app-b/App.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/08-svelte-options/app-b/App.svelte rename to site/content/tutorial/16-special-elements/09-svelte-options/app-b/App.svelte diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/app-b/Todo.svelte b/site/content/tutorial/16-special-elements/09-svelte-options/app-b/Todo.svelte similarity index 70% rename from site/content/tutorial/16-special-elements/08-svelte-options/app-b/Todo.svelte rename to site/content/tutorial/16-special-elements/09-svelte-options/app-b/Todo.svelte index fa81d35989..848f167d02 100644 --- a/site/content/tutorial/16-special-elements/08-svelte-options/app-b/Todo.svelte +++ b/site/content/tutorial/16-special-elements/09-svelte-options/app-b/Todo.svelte @@ -6,22 +6,24 @@ export let todo; - let div; + let button; afterUpdate(() => { - flash(div); + flash(button); }); -
    +
    + \ No newline at end of file + diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/app-b/flash.js b/site/content/tutorial/16-special-elements/09-svelte-options/app-b/flash.js similarity index 100% rename from site/content/tutorial/16-special-elements/08-svelte-options/app-b/flash.js rename to site/content/tutorial/16-special-elements/09-svelte-options/app-b/flash.js diff --git a/site/content/tutorial/16-special-elements/08-svelte-options/text.md b/site/content/tutorial/16-special-elements/09-svelte-options/text.md similarity index 100% rename from site/content/tutorial/16-special-elements/08-svelte-options/text.md rename to site/content/tutorial/16-special-elements/09-svelte-options/text.md diff --git a/site/content/tutorial/16-special-elements/09-svelte-fragment/app-a/App.svelte b/site/content/tutorial/16-special-elements/10-svelte-fragment/app-a/App.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/09-svelte-fragment/app-a/App.svelte rename to site/content/tutorial/16-special-elements/10-svelte-fragment/app-a/App.svelte diff --git a/site/content/tutorial/16-special-elements/09-svelte-fragment/app-a/Box.svelte b/site/content/tutorial/16-special-elements/10-svelte-fragment/app-a/Box.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/09-svelte-fragment/app-a/Box.svelte rename to site/content/tutorial/16-special-elements/10-svelte-fragment/app-a/Box.svelte diff --git a/site/content/tutorial/16-special-elements/09-svelte-fragment/app-b/App.svelte b/site/content/tutorial/16-special-elements/10-svelte-fragment/app-b/App.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/09-svelte-fragment/app-b/App.svelte rename to site/content/tutorial/16-special-elements/10-svelte-fragment/app-b/App.svelte diff --git a/site/content/tutorial/16-special-elements/09-svelte-fragment/app-b/Box.svelte b/site/content/tutorial/16-special-elements/10-svelte-fragment/app-b/Box.svelte similarity index 100% rename from site/content/tutorial/16-special-elements/09-svelte-fragment/app-b/Box.svelte rename to site/content/tutorial/16-special-elements/10-svelte-fragment/app-b/Box.svelte diff --git a/site/content/tutorial/16-special-elements/09-svelte-fragment/text.md b/site/content/tutorial/16-special-elements/10-svelte-fragment/text.md similarity index 100% rename from site/content/tutorial/16-special-elements/09-svelte-fragment/text.md rename to site/content/tutorial/16-special-elements/10-svelte-fragment/text.md diff --git a/site/content/tutorial/18-debugging/meta.json b/site/content/tutorial/18-debugging/meta.json deleted file mode 100644 index 5eeed281d3..0000000000 --- a/site/content/tutorial/18-debugging/meta.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "title": "Debugging" -} \ No newline at end of file diff --git a/site/content/tutorial/18-debugging/01-debug/app-a/App.svelte b/site/content/tutorial/18-special-tags/01-debug/app-a/App.svelte similarity index 100% rename from site/content/tutorial/18-debugging/01-debug/app-a/App.svelte rename to site/content/tutorial/18-special-tags/01-debug/app-a/App.svelte diff --git a/site/content/tutorial/18-debugging/01-debug/app-b/App.svelte b/site/content/tutorial/18-special-tags/01-debug/app-b/App.svelte similarity index 100% rename from site/content/tutorial/18-debugging/01-debug/app-b/App.svelte rename to site/content/tutorial/18-special-tags/01-debug/app-b/App.svelte diff --git a/site/content/tutorial/18-debugging/01-debug/text.md b/site/content/tutorial/18-special-tags/01-debug/text.md similarity index 100% rename from site/content/tutorial/18-debugging/01-debug/text.md rename to site/content/tutorial/18-special-tags/01-debug/text.md diff --git a/site/content/tutorial/01-introduction/06-html-tags/app-a/App.svelte b/site/content/tutorial/18-special-tags/02-html-tags/app-a/App.svelte similarity index 100% rename from site/content/tutorial/01-introduction/06-html-tags/app-a/App.svelte rename to site/content/tutorial/18-special-tags/02-html-tags/app-a/App.svelte diff --git a/site/content/tutorial/01-introduction/06-html-tags/app-b/App.svelte b/site/content/tutorial/18-special-tags/02-html-tags/app-b/App.svelte similarity index 100% rename from site/content/tutorial/01-introduction/06-html-tags/app-b/App.svelte rename to site/content/tutorial/18-special-tags/02-html-tags/app-b/App.svelte diff --git a/site/content/tutorial/01-introduction/06-html-tags/text.md b/site/content/tutorial/18-special-tags/02-html-tags/text.md similarity index 57% rename from site/content/tutorial/01-introduction/06-html-tags/text.md rename to site/content/tutorial/18-special-tags/02-html-tags/text.md index ff547247e2..0da4708020 100644 --- a/site/content/tutorial/01-introduction/06-html-tags/text.md +++ b/site/content/tutorial/18-special-tags/02-html-tags/text.md @@ -12,4 +12,4 @@ In Svelte, you do this with the special `{@html ...}` tag:

    {@html string}

    ``` -> Svelte doesn't perform any sanitization of the expression inside `{@html ...}` before it gets inserted into the DOM. In other words, if you use this feature it's critical that you manually escape HTML that comes from sources you don't trust, otherwise you risk exposing your users to XSS attacks. +> **Warning!** Svelte doesn't perform any sanitization of the expression inside `{@html ...}` before it gets inserted into the DOM. In other words, if you use this feature it's **critical** that you manually escape HTML that comes from sources you don't trust, otherwise you risk exposing your users to XSS attacks. diff --git a/site/content/tutorial/18-special-tags/meta.json b/site/content/tutorial/18-special-tags/meta.json new file mode 100644 index 0000000000..06e1732030 --- /dev/null +++ b/site/content/tutorial/18-special-tags/meta.json @@ -0,0 +1,3 @@ +{ + "title": "Special tags" +} \ No newline at end of file diff --git a/site/content/tutorial/19-next-steps/01-congratulations/text.md b/site/content/tutorial/19-next-steps/01-congratulations/text.md index 5b8256463e..1b4b13d4ab 100644 --- a/site/content/tutorial/19-next-steps/01-congratulations/text.md +++ b/site/content/tutorial/19-next-steps/01-congratulations/text.md @@ -4,7 +4,7 @@ title: Congratulations! You've now finished the Svelte tutorial and are ready to start building apps. You can refer back to individual chapters at any time (click the title above to reveal a dropdown) or continue your learning via the [API reference](/docs), [Examples](/examples) and [Blog](/blog). If you're a Twitter user, you can get updates via [@sveltejs](https://twitter.com/sveltejs). -To get set up in your local development environment, check out [the quickstart guide](/blog/the-easiest-way-to-get-started). +To get set up in your local development environment, check out [the quickstart guide](/docs#getting-started). If you're looking for a more expansive framework that includes routing, server-side rendering and everything else, take a look at [SvelteKit](https://kit.svelte.dev). diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts index 2f8874de7a..e87cf6218a 100644 --- a/src/compiler/compile/Component.ts +++ b/src/compiler/compile/Component.ts @@ -47,6 +47,10 @@ interface ComponentOptions { preserveWhitespace?: boolean; } +const regex_leading_directory_separator = /^[/\\]/; +const regex_starts_with_term_export = /^Export/; +const regex_contains_term_function = /Function/; + export default class Component { stats: Stats; warnings: Warning[]; @@ -136,7 +140,7 @@ export default class Component { (typeof process !== 'undefined' ? compile_options.filename .replace(process.cwd(), '') - .replace(/^[/\\]/, '') + .replace(regex_leading_directory_separator, '') : compile_options.filename); this.locate = getLocator(this.source, { offsetLine: 1 }); @@ -638,7 +642,7 @@ export default class Component { body.splice(i, 1); } - if (/^Export/.test(node.type)) { + if (regex_starts_with_term_export.test(node.type)) { const replacement = this.extract_exports(node, true); if (replacement) { body[i] = replacement; @@ -788,6 +792,53 @@ export default class Component { scope = map.get(node); } + let deep = false; + let names: string[] = []; + + if (node.type === 'AssignmentExpression') { + if (node.left.type === 'ArrayPattern') { + walk(node.left, { + enter(node: Node, parent: Node) { + if (node.type === 'Identifier' && + parent.type !== 'MemberExpression' && + (parent.type !== 'AssignmentPattern' || parent.right !== node)) { + names.push(node.name); + } + } + }); + } else { + deep = node.left.type === 'MemberExpression'; + names = deep + ? [get_object(node.left).name] + : extract_names(node.left); + } + } else if (node.type === 'UpdateExpression') { + deep = node.argument.type === 'MemberExpression'; + const { name } = get_object(node.argument); + names.push(name); + } + if (names.length > 0) { + names.forEach(name => { + let current_scope = scope; + let declaration; + + while (current_scope) { + if (current_scope.declarations.has(name)) { + declaration = current_scope.declarations.get(name); + break; + } + current_scope = current_scope.parent; + } + + if (declaration && declaration.kind === 'const' && !deep) { + component.error(node as any, { + code: 'assignment-to-const', + message: 'You are assigning to a const' + }); + } + }); + } + if (node.type === 'ImportDeclaration') { component.extract_imports(node); // TODO: to use actual remove @@ -795,7 +846,7 @@ export default class Component { return this.skip(); } - if (/^Export/.test(node.type)) { + if (regex_starts_with_term_export.test(node.type)) { const replacement = component.extract_exports(node); if (replacement) { this.replace(replacement); @@ -899,7 +950,7 @@ export default class Component { }); } - warn_on_undefined_store_value_references(node: Node, parent: Node, prop: string, scope: Scope) { + warn_on_undefined_store_value_references(node: Node, parent: Node, prop: string | number | symbol, scope: Scope) { if ( node.type === 'LabeledStatement' && node.label.name === '$' && @@ -918,7 +969,7 @@ export default class Component { } if (name[1] !== '$' && scope.has(name.slice(1)) && scope.find_owner(name.slice(1)) !== this.instance_scope) { - if (!((/Function/.test(parent.type) && prop === 'params') || (parent.type === 'VariableDeclarator' && prop === 'id'))) { + if (!((regex_contains_term_function.test(parent.type) && prop === 'params') || (parent.type === 'VariableDeclarator' && prop === 'id'))) { return this.error(node as any, compiler_errors.contextual_store); } } @@ -965,7 +1016,7 @@ export default class Component { walk(this.ast.instance.content, { enter(node: Node) { - if (/Function/.test(node.type)) { + if (regex_contains_term_function.test(node.type)) { return this.skip(); } @@ -1089,7 +1140,7 @@ export default class Component { this.replace(b` ${node.declarations.length ? node : null} - ${ props.length > 0 && b`let { ${ props } } = $$props;`} + ${ props.length > 0 && b`let { ${props} } = $$props;`} ${inserts} ` as any); return this.skip(); @@ -1460,6 +1511,8 @@ export default class Component { } } +const regex_valid_tag_name = /^[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]+$/; + function process_component_options(component: Component, nodes) { const component_options: ComponentOptions = { immutable: component.compile_options.immutable || false, @@ -1473,7 +1526,7 @@ function process_component_options(component: Component, nodes) { const node = nodes.find(node => node.name === 'svelte:options'); - function get_value(attribute, {code, message}) { + function get_value(attribute, { code, message }) { const { value } = attribute; const chunk = value[0]; @@ -1505,7 +1558,7 @@ function process_component_options(component: Component, nodes) { return component.error(attribute, compiler_errors.invalid_tag_attribute); } - if (tag && !/^[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]+$/.test(tag)) { + if (tag && !regex_valid_tag_name.test(tag)) { return component.error(attribute, compiler_errors.invalid_tag_property); } diff --git a/src/compiler/compile/compiler_errors.ts b/src/compiler/compile/compiler_errors.ts index c1a7d8bc5c..bad3911673 100644 --- a/src/compiler/compile/compiler_errors.ts +++ b/src/compiler/compile/compiler_errors.ts @@ -44,7 +44,7 @@ export default { code: 'invalid-binding', message: 'Cannot bind to a variable declared with {@const ...}' }, - invalid_binding_writibale: { + invalid_binding_writable: { code: 'invalid-binding', message: 'Cannot bind to a variable which is not writable' }, @@ -66,7 +66,7 @@ export default { }, missing_contenteditable_attribute: { code: 'missing-contenteditable-attribute', - message: '\'contenteditable\' attribute is required for textContent and innerHTML two-way bindings' + message: '\'contenteditable\' attribute is required for textContent, innerHTML and innerText two-way bindings' }, dynamic_contenteditable_attribute: { code: 'dynamic-contenteditable-attribute', @@ -234,6 +234,10 @@ export default { code: 'css-invalid-global-selector', message: ':global(...) must contain a single selector' }, + css_invalid_global_selector_position: { + code: 'css-invalid-global-selector-position', + message: ':global(...) not at the start of a selector sequence should not contain type or universal selectors' + }, css_invalid_selector: (selector: string) => ({ code: 'css-invalid-selector', message: `Invalid selector "${selector}"` @@ -281,5 +285,9 @@ export default { invalid_component_style_directive: { code: 'invalid-component-style-directive', message: 'Style directives cannot be used on components' - } + }, + invalid_style_directive_modifier: (valid: string) => ({ + code: 'invalid-style-directive-modifier', + message: `Valid modifiers for style directives are: ${valid}` + }) }; diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index b3e2e36797..a851bc24c2 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -119,10 +119,25 @@ export default { code: 'a11y-no-interactive-element-to-noninteractive-role', message: `A11y: <${element}> cannot have role '${role}'` }), + a11y_no_noninteractive_element_to_interactive_role: (role: string | boolean, element: string) => ({ + code: 'a11y-no-noninteractive-element-to-interactive-role', + message: `A11y: Non-interactive element <${element}> cannot have interactive role '${role}'` + }), a11y_role_has_required_aria_props: (role: string, props: string[]) => ({ code: 'a11y-role-has-required-aria-props', message: `A11y: Elements with the ARIA role "${role}" must have the following attributes defined: ${props.map(name => `"${name}"`).join(', ')}` }), + a11y_role_supports_aria_props: (attribute: string, role: string, is_implicit: boolean, name: string) => { + let message = `The attribute '${attribute}' is not supported by the role '${role}'.`; + if (is_implicit) { + message += ` This role is implicit on the element <${name}>.`; + } + + return { + code: 'a11y-role-supports-aria-props', + message: `A11y: ${message}` + }; + }, a11y_accesskey: { code: 'a11y-accesskey', message: 'A11y: Avoid using accesskey' @@ -151,6 +166,10 @@ export default { code: 'a11y-img-redundant-alt', message: 'A11y: Screenreaders already announce elements as an image.' }, + a11y_interactive_supports_focus: (role: string) => ({ + code: 'a11y-interactive-supports-focus', + message: `A11y: Elements with the '${role}' interactive role must have a tabindex value.` + }), a11y_label_has_associated_control: { code: 'a11y-label-has-associated-control', message: 'A11y: A form label must be associated with a control.' @@ -175,10 +194,22 @@ export default { code: 'a11y-mouse-events-have-key-events', message: `A11y: on:${event} must be accompanied by on:${accompanied_by}` }), + a11y_click_events_have_key_events: { + code: 'a11y-click-events-have-key-events', + message: 'A11y: visible, non-interactive elements with an on:click event must be accompanied by an on:keydown, on:keyup, or on:keypress event.' + }, a11y_missing_content: (name: string) => ({ code: 'a11y-missing-content', message: `A11y: <${name}> element should have child content` }), + a11y_no_noninteractive_tabindex: { + code: 'a11y-no-noninteractive-tabindex', + message: 'A11y: noninteractive element cannot have nonnegative tabIndex value' + }, + a11y_aria_activedescendant_has_tabindex: { + code: 'a11y-aria-activedescendant-has-tabindex', + message: 'A11y: Elements with attribute aria-activedescendant should have tabindex value' + }, redundant_event_modifier_for_touch: { code: 'redundant-event-modifier', message: 'Touch event handlers that don\'t use the \'event\' object are passive by default' @@ -189,6 +220,10 @@ export default { }, invalid_rest_eachblock_binding: (rest_element_name: string) => ({ code: 'invalid-rest-eachblock-binding', - message: `...${rest_element_name} operator will create a new object and binding propogation with original object will not work` - }) + message: `...${rest_element_name} operator will create a new object and binding propagation with original object will not work` + }), + avoid_mouse_events_on_document: { + code: 'avoid-mouse-events-on-document', + message: 'Mouse enter/leave events on the document are not supported in all browsers and should be avoided' + } }; diff --git a/src/compiler/compile/css/Selector.ts b/src/compiler/compile/css/Selector.ts index 17302c4abd..c2ad450416 100644 --- a/src/compiler/compile/css/Selector.ts +++ b/src/compiler/compile/css/Selector.ts @@ -9,6 +9,7 @@ import EachBlock from '../nodes/EachBlock'; import IfBlock from '../nodes/IfBlock'; import AwaitBlock from '../nodes/AwaitBlock'; import compiler_errors from '../compiler_errors'; +import { regex_starts_with_whitespace, regex_ends_with_whitespace } from '../../utils/patterns'; enum BlockAppliesToNode { NotPossible, @@ -25,6 +26,8 @@ const whitelist_attribute_selector = new Map([ ['dialog', new Set(['open'])] ]); +const regex_is_single_css_selector = /[^\\],(?!([^([]+[^\\]|[^([\\])[)\]])/; + export default class Selector { node: CssNode; stylesheet: Stylesheet; @@ -73,7 +76,7 @@ export default class Selector { this.blocks.forEach((block, i) => { if (i > 0) { if (block.start - c > 1) { - code.overwrite(c, block.start, block.combinator.name || ' '); + code.update(c, block.start, block.combinator.name || ' '); } } @@ -109,7 +112,7 @@ export default class Selector { } if (selector.type === 'TypeSelector' && selector.name === '*') { - code.overwrite(selector.start, selector.end, attr); + code.update(selector.start, selector.end, attr); } else { code.appendLeft(selector.end, attr); } @@ -145,6 +148,7 @@ export default class Selector { } this.validate_global_with_multiple_selectors(component); + this.validate_global_compound_selector(component); this.validate_invalid_combinator_without_selector(component); } @@ -157,7 +161,7 @@ export default class Selector { for (const block of this.blocks) { for (const selector of block.selectors) { if (selector.type === 'PseudoClassSelector' && selector.name === 'global') { - if (/[^\\],(?!([^([]+[^\\]|[^([\\])[)\]])/.test(selector.children[0].value)) { + if (regex_is_single_css_selector.test(selector.children[0].value)) { component.error(selector, compiler_errors.css_invalid_global_selector); } } @@ -176,6 +180,23 @@ export default class Selector { } } + validate_global_compound_selector(component: Component) { + for (const block of this.blocks) { + for (let index = 0; index < block.selectors.length; index++) { + const selector = block.selectors[index]; + if (selector.type === 'PseudoClassSelector' && + selector.name === 'global' && + index !== 0 && + selector.children && + selector.children.length > 0 && + !/[.:#\s]/.test(selector.children[0].value[0]) + ) { + component.error(selector, compiler_errors.css_invalid_global_selector_position); + } + } + } + } + get_amount_class_specificity_increased() { let count = 0; for (const block of this.blocks) { @@ -209,7 +230,7 @@ function apply_selector(blocks: Block[], node: Element, to_encapsulate: Array<{ } if (block.combinator) { - if (block.combinator.type === 'WhiteSpace') { + if (block.combinator.type === 'Combinator' && block.combinator.name === ' ') { for (const ancestor_block of blocks) { if (ancestor_block.global) { continue; @@ -281,12 +302,14 @@ function apply_selector(blocks: Block[], node: Element, to_encapsulate: Array<{ return true; } +const regex_backslash_and_following_character = /\\(.)/g; + function block_might_apply_to_node(block: Block, node: Element): BlockAppliesToNode { let i = block.selectors.length; while (i--) { const selector = block.selectors[i]; - const name = typeof selector.name === 'string' && selector.name.replace(/\\(.)/g, '$1'); + const name = typeof selector.name === 'string' && selector.name.replace(regex_backslash_and_following_character, '$1'); if (selector.type === 'PseudoClassSelector' && (name === 'host' || name === 'root')) { return BlockAppliesToNode.NotPossible; @@ -345,7 +368,7 @@ function attribute_matches(node: CssNode, name: string, expected_value: string, const attr = node.attributes.find((attr: CssNode) => attr.name === name); if (!attr) return false; if (attr.is_true) return operator === null; - if (!expected_value) return true; + if (expected_value == null) return true; if (attr.chunks.length === 1) { const value = attr.chunks[0]; @@ -371,7 +394,7 @@ function attribute_matches(node: CssNode, name: string, expected_value: string, const start_with_space = []; const remaining = []; current_possible_values.forEach((current_possible_value: string) => { - if (/^\s/.test(current_possible_value)) { + if (regex_starts_with_whitespace.test(current_possible_value)) { start_with_space.push(current_possible_value); } else { remaining.push(current_possible_value); @@ -392,7 +415,7 @@ function attribute_matches(node: CssNode, name: string, expected_value: string, prev_values = combined; start_with_space.forEach((value: string) => { - if (/\s$/.test(value)) { + if (regex_ends_with_whitespace.test(value)) { possible_values.add(value); } else { prev_values.push(value); @@ -406,7 +429,7 @@ function attribute_matches(node: CssNode, name: string, expected_value: string, } current_possible_values.forEach((current_possible_value: string) => { - if (/\s$/.test(current_possible_value)) { + if (regex_ends_with_whitespace.test(current_possible_value)) { possible_values.add(current_possible_value); } else { prev_values.push(current_possible_value); diff --git a/src/compiler/compile/css/Stylesheet.ts b/src/compiler/compile/css/Stylesheet.ts index 1a9ea7feeb..7cb1af3635 100644 --- a/src/compiler/compile/css/Stylesheet.ts +++ b/src/compiler/compile/css/Stylesheet.ts @@ -9,9 +9,12 @@ import hash from '../utils/hash'; import compiler_warnings from '../compiler_warnings'; import { extract_ignores_above_position } from '../../utils/extract_svelte_ignore'; import { push_array } from '../../utils/push_array'; +import { regex_only_whitespaces, regex_whitespace } from '../../utils/patterns'; + +const regex_css_browser_prefix = /^-((webkit)|(moz)|(o)|(ms))-/; function remove_css_prefix(name: string): string { - return name.replace(/^-((webkit)|(moz)|(o)|(ms))-/, ''); + return name.replace(regex_css_browser_prefix, ''); } const is_keyframes_node = (node: CssNode) => @@ -32,7 +35,7 @@ function minify_declarations( declarations.forEach((declaration, i) => { const separator = i > 0 ? ';' : ''; if ((declaration.node.start - c) > separator.length) { - code.overwrite(c, declaration.node.start, separator); + code.update(c, declaration.node.start, separator); } declaration.minify(code); c = declaration.node.end; @@ -72,7 +75,7 @@ class Rule { if (selector.used) { const separator = started ? ',' : ''; if ((selector.node.start - c) > separator.length) { - code.overwrite(c, selector.node.start, separator); + code.update(c, selector.node.start, separator); } selector.minify(code); @@ -130,7 +133,7 @@ class Declaration { if (block.type === 'Identifier') { const name = block.name; if (keyframes.has(name)) { - code.overwrite(block.start, block.end, keyframes.get(name)); + code.update(block.start, block.end, keyframes.get(name)); } } }); @@ -147,13 +150,13 @@ class Declaration { // Don't minify whitespace in custom properties, since some browsers (Chromium < 99) // treat --foo: ; and --foo:; differently - if (first.type === 'Raw' && /^\s+$/.test(first.value)) return; + if (first.type === 'Raw' && regex_only_whitespaces.test(first.value)) return; let start = first.start; - while (/\s/.test(code.original[start])) start += 1; + while (regex_whitespace.test(code.original[start])) start += 1; if (start - c > 1) { - code.overwrite(c, start, ':'); + code.update(c, start, ':'); } } } @@ -170,7 +173,7 @@ class Atrule { } apply(node: Element) { - if (this.node.name === 'media' || this.node.name === 'supports' || this.node.name === 'layer') { + if (this.node.name === 'container' || this.node.name === 'media' || this.node.name === 'supports' || this.node.name === 'layer') { this.children.forEach(child => { child.apply(node); }); @@ -201,7 +204,7 @@ class Atrule { code.remove(c, this.node.block.start); } else if (this.node.name === 'supports') { let c = this.node.start + 9; - if (this.node.prelude.start - c > 1) code.overwrite(c, this.node.prelude.start, ' '); + if (this.node.prelude.start - c > 1) code.update(c, this.node.prelude.start, ' '); this.node.prelude.children.forEach((query: CssNode) => { // TODO minify queries c = query.end; @@ -210,7 +213,7 @@ class Atrule { } else { let c = this.node.start + this.node.name.length + 1; if (this.node.prelude) { - if (this.node.prelude.start - c > 1) code.overwrite(c, this.node.prelude.start, ' '); + if (this.node.prelude.start - c > 1) code.update(c, this.node.prelude.start, ' '); c = this.node.prelude.end; } if (this.node.block && this.node.block.start - c > 0) { @@ -252,7 +255,7 @@ class Atrule { }); }); } else { - code.overwrite(start, end, keyframes.get(name)); + code.update(start, end, keyframes.get(name)); } } }); diff --git a/src/compiler/compile/index.ts b/src/compiler/compile/index.ts index afe9c56cf4..7a18231cef 100644 --- a/src/compiler/compile/index.ts +++ b/src/compiler/compile/index.ts @@ -35,8 +35,19 @@ const valid_options = [ 'cssHash' ]; +const valid_css_values = [ + true, + false, + 'injected', + 'external', + 'none' +]; + +const regex_valid_identifier = /^[a-zA-Z_$][a-zA-Z_$0-9]*$/; +const regex_starts_with_lowercase_character = /^[a-z]/; + function validate_options(options: CompileOptions, warnings: Warning[]) { - const { name, filename, loopGuardTimeout, dev, namespace } = options; + const { name, filename, loopGuardTimeout, dev, namespace, css } = options; Object.keys(options).forEach(key => { if (!valid_options.includes(key)) { @@ -48,11 +59,11 @@ function validate_options(options: CompileOptions, warnings: Warning[]) { } }); - if (name && !/^[a-zA-Z_$][a-zA-Z_$0-9]*$/.test(name)) { + if (name && !regex_valid_identifier.test(name)) { throw new Error(`options.name must be a valid identifier (got '${name}')`); } - if (name && /^[a-z]/.test(name)) { + if (name && regex_starts_with_lowercase_character.test(name)) { const message = 'options.name should be capitalised'; warnings.push({ code: 'options-lowercase-name', @@ -72,6 +83,23 @@ function validate_options(options: CompileOptions, warnings: Warning[]) { }); } + if (valid_css_values.indexOf(css) === -1) { + throw new Error(`options.css must be true, false, 'injected', 'external', or 'none' (got '${css}')`); + } + + if (css === true || css === false) { + options.css = css === true ? 'injected' : 'external'; + // possibly show this warning once we decided how Svelte 4 looks like + // const message = `options.css as a boolean is deprecated. Use '${options.css}' instead of ${css}.`; + // warnings.push({ + // code: 'options-css-boolean-deprecated', + // message, + // filename, + // toString: () => message + // }); + // } + } + if (namespace && valid_namespaces.indexOf(namespace) === -1) { const match = fuzzymatch(namespace, valid_namespaces); if (match) { @@ -83,7 +111,7 @@ function validate_options(options: CompileOptions, warnings: Warning[]) { } export default function compile(source: string, options: CompileOptions = {}) { - options = Object.assign({ generate: 'dom', dev: false, enableSourcemap: true }, options); + options = Object.assign({ generate: 'dom', dev: false, enableSourcemap: true, css: 'injected' }, options); const stats = new Stats(); const warnings = []; diff --git a/src/compiler/compile/nodes/Binding.ts b/src/compiler/compile/nodes/Binding.ts index 594490a5fb..303506222f 100644 --- a/src/compiler/compile/nodes/Binding.ts +++ b/src/compiler/compile/nodes/Binding.ts @@ -3,7 +3,7 @@ import get_object from '../utils/get_object'; import Expression from './shared/Expression'; import Component from '../Component'; import TemplateScope from './shared/TemplateScope'; -import {dimensions} from '../../utils/patterns'; +import { regex_dimensions, regex_box_size } from '../../utils/patterns'; import { Node as ESTreeNode } from 'estree'; import { TemplateNode } from '../../interfaces'; import Element from './Element'; @@ -22,7 +22,10 @@ const read_only_media_attributes = new Set([ 'seeking', 'ended', 'videoHeight', - 'videoWidth' + 'videoWidth', + 'naturalWidth', + 'naturalHeight', + 'readyState' ]); export default class Binding extends Node { @@ -80,7 +83,7 @@ export default class Binding extends Node { variable[this.expression.node.type === 'MemberExpression' ? 'mutated' : 'reassigned'] = true; if (info.expression.type === 'Identifier' && !variable.writable) { - component.error(this.expression.node as any, compiler_errors.invalid_binding_writibale); + component.error(this.expression.node as any, compiler_errors.invalid_binding_writable); return; } } @@ -88,7 +91,8 @@ export default class Binding extends Node { const type = parent.get_static_attribute_value('type'); this.is_readonly = - dimensions.test(this.name) || + regex_dimensions.test(this.name) || + regex_box_size.test(this.name) || (isElement(parent) && ((parent.is_media_node() && read_only_media_attributes.has(this.name)) || (parent.name === 'input' && type === 'file')) /* TODO others? */); diff --git a/src/compiler/compile/nodes/CatchBlock.ts b/src/compiler/compile/nodes/CatchBlock.ts index ba6a4b77a6..d92b4eda56 100644 --- a/src/compiler/compile/nodes/CatchBlock.ts +++ b/src/compiler/compile/nodes/CatchBlock.ts @@ -17,6 +17,7 @@ export default class CatchBlock extends AbstractBlock { this.scope = scope.child(); if (parent.catch_node) { parent.catch_contexts.forEach(context => { + if (context.type !== 'DestructuredVariable') return; this.scope.add(context.key.name, parent.expression.dependencies, this); }); } diff --git a/src/compiler/compile/nodes/ConstTag.ts b/src/compiler/compile/nodes/ConstTag.ts index 44a50aa005..edcb949c15 100644 --- a/src/compiler/compile/nodes/ConstTag.ts +++ b/src/compiler/compile/nodes/ConstTag.ts @@ -65,6 +65,7 @@ export default class ConstTag extends Node { }); this.expression = new Expression(this.component, this, this.scope, this.node.expression.right); this.contexts.forEach(context => { + if (context.type !== 'DestructuredVariable') return; const owner = this.scope.get_owner(context.key.name); if (owner && owner.type === 'ConstTag' && owner.parent === this.parent) { this.component.error(this.node, compiler_errors.invalid_const_declaration(context.key.name)); diff --git a/src/compiler/compile/nodes/Document.ts b/src/compiler/compile/nodes/Document.ts new file mode 100644 index 0000000000..653ccb627b --- /dev/null +++ b/src/compiler/compile/nodes/Document.ts @@ -0,0 +1,41 @@ +import Node from './shared/Node'; +import EventHandler from './EventHandler'; +import Action from './Action'; +import Component from '../Component'; +import TemplateScope from './shared/TemplateScope'; +import { Element } from '../../interfaces'; +import compiler_warnings from '../compiler_warnings'; + +export default class Document extends Node { + type: 'Document'; + handlers: EventHandler[] = []; + actions: Action[] = []; + + constructor(component: Component, parent: Node, scope: TemplateScope, info: Element) { + super(component, parent, scope, info); + + info.attributes.forEach((node) => { + if (node.type === 'EventHandler') { + this.handlers.push(new EventHandler(component, this, scope, node)); + } else if (node.type === 'Action') { + this.actions.push(new Action(component, this, scope, node)); + } else { + // TODO there shouldn't be anything else here... + } + }); + + this.validate(); + } + + private validate() { + const handlers_map = new Set(); + + this.handlers.forEach(handler => ( + handlers_map.add(handler.name) + )); + + if (handlers_map.has('mouseenter') || handlers_map.has('mouseleave')) { + this.component.warn(this, compiler_warnings.avoid_mouse_events_on_document); + } + } +} diff --git a/src/compiler/compile/nodes/EachBlock.ts b/src/compiler/compile/nodes/EachBlock.ts index 4a5ea19e37..4659a53d73 100644 --- a/src/compiler/compile/nodes/EachBlock.ts +++ b/src/compiler/compile/nodes/EachBlock.ts @@ -45,6 +45,7 @@ export default class EachBlock extends AbstractBlock { unpack_destructuring({ contexts: this.contexts, node: info.context, scope, component, context_rest_properties: this.context_rest_properties }); this.contexts.forEach(context => { + if (context.type !== 'DestructuredVariable') return; this.scope.add(context.key.name, this.expression.dependencies, this); }); diff --git a/src/compiler/compile/nodes/Element.ts b/src/compiler/compile/nodes/Element.ts index df0e649122..2410904d63 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -11,7 +11,8 @@ import StyleDirective from './StyleDirective'; import Text from './Text'; import { namespaces } from '../../utils/namespaces'; import map_children from './shared/map_children'; -import { dimensions, start_newline } from '../../utils/patterns'; +import { is_name_contenteditable, get_contenteditable_attr } from '../utils/contenteditable'; +import { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character, regex_box_size } from '../../utils/patterns'; import fuzzymatch from '../../utils/fuzzymatch'; import list from '../../utils/list'; import Let from './Let'; @@ -23,15 +24,14 @@ import { string_literal } from '../utils/stringify'; import { Literal } from 'estree'; import compiler_warnings from '../compiler_warnings'; import compiler_errors from '../compiler_errors'; -import { ARIARoleDefintionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query'; -import { is_interactive_element, is_non_interactive_roles, is_presentation_role } from '../utils/a11y'; +import { ARIARoleDefinitionKey, roles, aria, ARIAPropertyDefinition, ARIAProperty } from 'aria-query'; +import { is_interactive_element, is_non_interactive_element, is_non_interactive_roles, is_presentation_role, is_interactive_roles, is_hidden_from_screen_reader, is_semantic_role_element, is_abstract_role, is_static_element, has_disabled_attribute } from '../utils/a11y'; const aria_attributes = 'activedescendant atomic autocomplete busy checked colcount colindex colspan controls current describedby description details disabled dropeffect errormessage expanded flowto grabbed haspopup hidden invalid keyshortcuts label labelledby level live modal multiline multiselectable orientation owns placeholder posinset pressed readonly relevant required roledescription rowcount rowindex rowspan selected setsize sort valuemax valuemin valuenow valuetext'.split(' '); const aria_attribute_set = new Set(aria_attributes); const aria_roles = roles.keys(); const aria_role_set = new Set(aria_roles); -const aria_role_abstract_set = new Set(roles.keys().filter(role => roles.get(role).abstract)); const a11y_required_attributes = { a: ['href'], @@ -75,6 +75,33 @@ const a11y_labelable = new Set([ 'textarea' ]); +const a11y_interactive_handlers = new Set([ + // Keyboard events + 'keypress', + 'keydown', + 'keyup', + + // Click events + 'click', + 'contextmenu', + 'dblclick', + 'drag', + 'dragend', + 'dragenter', + 'dragexit', + 'dragleave', + 'dragover', + 'dragstart', + 'drop', + 'mousedown', + 'mouseenter', + 'mouseleave', + 'mousemove', + 'mouseout', + 'mouseover', + 'mouseup' +]); + const a11y_nested_implicit_semantics = new Map([ ['header', 'banner'], ['footer', 'contentinfo'] @@ -82,11 +109,15 @@ const a11y_nested_implicit_semantics = new Map([ const a11y_implicit_semantics = new Map([ ['a', 'link'], + ['area', 'link'], + ['article', 'article'], ['aside', 'complementary'], ['body', 'document'], + ['button', 'button'], ['datalist', 'listbox'], ['dd', 'definition'], ['dfn', 'term'], + ['dialog', 'dialog'], ['details', 'group'], ['dt', 'term'], ['fieldset', 'group'], @@ -98,10 +129,14 @@ const a11y_implicit_semantics = new Map([ ['h5', 'heading'], ['h6', 'heading'], ['hr', 'separator'], + ['img', 'img'], ['li', 'listitem'], + ['link', 'link'], ['menu', 'list'], + ['meter', 'progressbar'], ['nav', 'navigation'], ['ol', 'list'], + ['option', 'option'], ['optgroup', 'group'], ['output', 'status'], ['progress', 'progressbar'], @@ -115,11 +150,96 @@ const a11y_implicit_semantics = new Map([ ['ul', 'list'] ]); +const menuitem_type_to_implicit_role = new Map([ + ['command', 'menuitem'], + ['checkbox', 'menuitemcheckbox'], + ['radio', 'menuitemradio'] +]); + +const input_type_to_implicit_role = new Map([ + ['button', 'button'], + ['image', 'button'], + ['reset', 'button'], + ['submit', 'button'], + ['checkbox', 'checkbox'], + ['radio', 'radio'], + ['range', 'slider'], + ['number', 'spinbutton'], + ['email', 'textbox'], + ['search', 'searchbox'], + ['tel', 'textbox'], + ['text', 'textbox'], + ['url', 'textbox'] +]); + +/** + * Exceptions to the rule which follows common A11y conventions + * TODO make this configurable by the user + */ +const a11y_non_interactive_element_to_interactive_role_exceptions = { + ul: [ + 'listbox', + 'menu', + 'menubar', + 'radiogroup', + 'tablist', + 'tree', + 'treegrid' + ], + ol: [ + 'listbox', + 'menu', + 'menubar', + 'radiogroup', + 'tablist', + 'tree', + 'treegrid' + ], + li: ['menuitem', 'option', 'row', 'tab', 'treeitem'], + table: ['grid'], + td: ['gridcell'], + fieldset: ['radiogroup', 'presentation'] +}; + +const combobox_if_list = new Set(['email', 'search', 'tel', 'text', 'url']); + +function input_implicit_role(attribute_map: Map) { + const type_attribute = attribute_map.get('type'); + if (!type_attribute || !type_attribute.is_static) return; + const type = type_attribute.get_static_value() as string; + + const list_attribute_exists = attribute_map.has('list'); + + if (list_attribute_exists && combobox_if_list.has(type)) { + return 'combobox'; + } + + return input_type_to_implicit_role.get(type); +} + +function menuitem_implicit_role(attribute_map: Map) { + const type_attribute = attribute_map.get('type'); + if (!type_attribute || !type_attribute.is_static) return; + const type = type_attribute.get_static_value() as string; + return menuitem_type_to_implicit_role.get(type); +} + +function get_implicit_role(name: string, attribute_map: Map) : (string | undefined) { + if (name === 'menuitem') { + return menuitem_implicit_role(attribute_map); + } else if (name === 'input') { + return input_implicit_role(attribute_map); + } else { + return a11y_implicit_semantics.get(name); + } +} + const invisible_elements = new Set(['meta', 'html', 'script', 'style']); const valid_modifiers = new Set([ 'preventDefault', 'stopPropagation', + 'stopImmediatePropagation', 'capture', 'once', 'passive', @@ -194,15 +314,19 @@ function is_valid_aria_attribute_value(schema: ARIAPropertyDefinition, value: st .indexOf(typeof value === 'string' ? value.toLowerCase() : value) > -1; case 'idlist': // if list of ids, split each return typeof value === 'string' - && value.split(' ').every((id) => typeof id === 'string'); + && value.split(regex_any_repeated_whitespaces).every((id) => typeof id === 'string'); case 'tokenlist': // if list of tokens, split each return typeof value === 'string' - && value.split(' ').every((token) => (schema.values || []).indexOf(token.toLowerCase()) > -1); + && value.split(regex_any_repeated_whitespaces).every((token) => (schema.values || []).indexOf(token.toLowerCase()) > -1); default: return false; } } +const regex_any_repeated_whitespaces = /[\s]+/g; +const regex_heading_tags = /^h[1-6]$/; +const regex_illegal_attribute_character = /(^[0-9-.])|[\^$@%&#?!|()[\]{}^*+~;]/; + export default class Element extends Node { type: 'Element'; name: string; @@ -221,6 +345,7 @@ export default class Element extends Node { namespace: string; needs_manual_style_scoping: boolean; tag_expr: Expression; + contains_a11y_label: boolean; get is_dynamic_element() { return this.name === 'svelte:element'; @@ -235,6 +360,7 @@ export default class Element extends Node { this.tag_expr = new Expression(component, this, scope, info.tag); } else { this.tag_expr = new Expression(component, this, scope, string_literal(info.tag) as Literal); + this.name = info.tag; } } else { this.tag_expr = new Expression(component, this, scope, string_literal(this.name) as Literal); @@ -246,14 +372,14 @@ export default class Element extends Node { if (this.name === 'pre' || this.name === 'textarea') { const first = info.children[0]; if (first && first.type === 'Text') { - // The leading newline character needs to be stripped because of a qirk, + // The leading newline character needs to be stripped because of a quirk, // it is ignored by browsers if the tag and its contents are set through // innerHTML (NOT if set through the innerHTML of the tag or dynamically). // Therefore strip it here but add it back in the appropriate // places if there's another newline afterwards. // see https://html.spec.whatwg.org/multipage/syntax.html#element-restrictions // see https://html.spec.whatwg.org/multipage/grouping-content.html#the-pre-element - first.data = first.data.replace(start_newline, ''); + first.data = first.data.replace(regex_starts_with_newline, ''); } } @@ -398,7 +524,7 @@ export default class Element extends Node { // Errors - if (/(^[0-9-.])|[\^$@%&#?!|()[\]{}^*+~;]/.test(name)) { + if (regex_illegal_attribute_character.test(name)) { return component.error(attribute, compiler_errors.illegal_attribute(name)); } @@ -434,12 +560,17 @@ export default class Element extends Node { } validate_attributes_a11y() { - const { component, attributes } = this; + const { component, attributes, handlers } = this; const attribute_map = new Map(); + const handlers_map = new Map(); + attributes.forEach(attribute => ( attribute_map.set(attribute.name, attribute) )); + handlers.forEach(handler => ( + handlers_map.set(handler.name, handler) + )); attributes.forEach(attribute => { if (attribute.is_spread) return; @@ -459,7 +590,7 @@ export default class Element extends Node { component.warn(attribute, compiler_warnings.a11y_unknown_aria_attribute(type, match)); } - if (name === 'aria-hidden' && /^h[1-6]$/.test(this.name)) { + if (name === 'aria-hidden' && regex_heading_tags.test(this.name)) { component.warn(attribute, compiler_warnings.a11y_hidden(this.name)); } @@ -474,6 +605,11 @@ export default class Element extends Node { component.warn(attribute, compiler_warnings.a11y_incorrect_attribute_type(schema, name)); } } + + // aria-activedescendant-has-tabindex + if (name === 'aria-activedescendant' && !this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !attribute_map.has('tabindex')) { + component.warn(attribute, compiler_warnings.a11y_aria_activedescendant_has_tabindex); + } } // aria-role @@ -483,45 +619,71 @@ export default class Element extends Node { component.warn(attribute, compiler_warnings.a11y_misplaced_role(this.name)); } - const value = attribute.get_static_value() as ARIARoleDefintionKey; - - if (value && aria_role_abstract_set.has(value)) { - component.warn(attribute, compiler_warnings.a11y_no_abstract_role(value)); - } else if (value && !aria_role_set.has(value)) { - const match = fuzzymatch(value, aria_roles); - component.warn(attribute, compiler_warnings.a11y_unknown_role(value, match)); - } + const value = attribute.get_static_value(); - // no-redundant-roles - const has_redundant_role = value === a11y_implicit_semantics.get(this.name); + if (typeof value === 'string') { + value.split(regex_any_repeated_whitespaces).forEach((current_role: ARIARoleDefinitionKey) => { + if (current_role && is_abstract_role(current_role)) { + component.warn(attribute, compiler_warnings.a11y_no_abstract_role(current_role)); + } else if (current_role && !aria_role_set.has(current_role)) { + const match = fuzzymatch(current_role, aria_roles); + component.warn(attribute, compiler_warnings.a11y_unknown_role(current_role, match)); + } - if (this.name === value || has_redundant_role) { - component.warn(attribute, compiler_warnings.a11y_no_redundant_roles(value)); - } + // no-redundant-roles + const has_redundant_role = current_role === get_implicit_role(this.name, attribute_map); - // Footers and headers are special cases, and should not have redundant roles unless they are the children of sections or articles. - const is_parent_section_or_article = is_parent(this.parent, ['section', 'article']); - if (!is_parent_section_or_article) { - const has_nested_redundant_role = value === a11y_nested_implicit_semantics.get(this.name); - if (has_nested_redundant_role) { - component.warn(attribute, compiler_warnings.a11y_no_redundant_roles(value)); - } - } + if (this.name === current_role || has_redundant_role) { + component.warn(attribute, compiler_warnings.a11y_no_redundant_roles(current_role)); + } - // role-has-required-aria-props - const role = roles.get(value); - if (role) { - const required_role_props = Object.keys(role.requiredProps); - const has_missing_props = required_role_props.some(prop => !attributes.find(a => a.name === prop)); + // Footers and headers are special cases, and should not have redundant roles unless they are the children of sections or articles. + const is_parent_section_or_article = is_parent(this.parent, ['section', 'article']); + if (!is_parent_section_or_article) { + const has_nested_redundant_role = current_role === a11y_nested_implicit_semantics.get(this.name); + if (has_nested_redundant_role) { + component.warn(attribute, compiler_warnings.a11y_no_redundant_roles(current_role)); + } + } - if (has_missing_props) { - component.warn(attribute, compiler_warnings.a11y_role_has_required_aria_props(value as string, required_role_props)); - } - } + // role-has-required-aria-props + if (!this.is_dynamic_element && !is_semantic_role_element(current_role, this.name, attribute_map)) { + const role = roles.get(current_role); + if (role) { + const required_role_props = Object.keys(role.requiredProps); + const has_missing_props = required_role_props.some(prop => !attributes.find(a => a.name === prop)); + + if (has_missing_props) { + component.warn(attribute, compiler_warnings.a11y_role_has_required_aria_props(current_role, required_role_props)); + } + } + } - // no-interactive-element-to-noninteractive-role - if (is_interactive_element(this.name, attribute_map) && (is_non_interactive_roles(value) || is_presentation_role(value))) { - component.warn(this, compiler_warnings.a11y_no_interactive_element_to_noninteractive_role(value, this.name)); + // interactive-supports-focus + if ( + !has_disabled_attribute(attribute_map) && + !is_hidden_from_screen_reader(this.name, attribute_map) && + !is_presentation_role(current_role) && + is_interactive_roles(current_role) && + is_static_element(this.name, attribute_map) && + !attribute_map.get('tabindex') + ) { + const has_interactive_handlers = handlers.some((handler) => a11y_interactive_handlers.has(handler.name)); + if (has_interactive_handlers) { + component.warn(this, compiler_warnings.a11y_interactive_supports_focus(current_role)); + } + } + + // no-interactive-element-to-noninteractive-role + if (is_interactive_element(this.name, attribute_map) && (is_non_interactive_roles(current_role) || is_presentation_role(current_role))) { + component.warn(this, compiler_warnings.a11y_no_interactive_element_to_noninteractive_role(current_role, this.name)); + } + + // no-noninteractive-element-to-interactive-role + if (is_non_interactive_element(this.name, attribute_map) && is_interactive_roles(current_role) && !a11y_non_interactive_element_to_interactive_role_exceptions[this.name]?.includes(current_role)) { + component.warn(this, compiler_warnings.a11y_no_noninteractive_element_to_interactive_role(current_role, this.name)); + } + }); } } @@ -536,7 +698,7 @@ export default class Element extends Node { } // scope - if (name === 'scope' && this.name !== 'th') { + if (name === 'scope' && !this.is_dynamic_element && this.name !== 'th') { component.warn(attribute, compiler_warnings.a11y_misplaced_scope); } @@ -549,8 +711,58 @@ export default class Element extends Node { } } }); - } + // click-events-have-key-events + if (handlers_map.has('click')) { + const role = attribute_map.get('role'); + const is_non_presentation_role = role?.is_static && !is_presentation_role(role.get_static_value() as ARIARoleDefinitionKey); + + if ( + !this.is_dynamic_element && + !is_hidden_from_screen_reader(this.name, attribute_map) && + (!role || is_non_presentation_role) && + !is_interactive_element(this.name, attribute_map) && + !this.attributes.find(attr => attr.is_spread) + ) { + const has_key_event = + handlers_map.has('keydown') || + handlers_map.has('keyup') || + handlers_map.has('keypress'); + + if (!has_key_event) { + component.warn( + this, + compiler_warnings.a11y_click_events_have_key_events + ); + } + } + } + + // no-noninteractive-tabindex + if (!this.is_dynamic_element && !is_interactive_element(this.name, attribute_map) && !is_interactive_roles(attribute_map.get('role')?.get_static_value() as ARIARoleDefinitionKey)) { + const tab_index = attribute_map.get('tabindex'); + if (tab_index && (!tab_index.is_static || Number(tab_index.get_static_value()) >= 0)) { + component.warn(this, compiler_warnings.a11y_no_noninteractive_tabindex); + } + } + + // role-supports-aria-props + const role = attribute_map.get('role'); + const role_value = (role ? role.get_static_value() : get_implicit_role(this.name, attribute_map)) as ARIARoleDefinitionKey; + if (typeof role_value === 'string' && roles.has(role_value)) { + const { props } = roles.get(role_value); + const invalid_aria_props = new Set(aria.keys().filter(attribute => !(attribute in props))); + const is_implicit = role_value && role === undefined; + + attributes + .filter(prop => prop.type !== 'Spread') + .forEach(prop => { + if (invalid_aria_props.has(prop.name as ARIAProperty)) { + component.warn(prop, compiler_warnings.a11y_role_supports_aria_props(prop.name, role_value, is_implicit, this.name)); + } + }); + } + } validate_special_cases() { const { component, attributes, handlers } = this; @@ -570,6 +782,36 @@ export default class Element extends Node { const href_attribute = attribute_map.get('href') || attribute_map.get('xlink:href'); const id_attribute = attribute_map.get('id'); const name_attribute = attribute_map.get('name'); + const target_attribute = attribute_map.get('target'); + const aria_label_attribute = attribute_map.get('aria-label'); + + // links with target="_blank" should have noopener or noreferrer: https://developer.chrome.com/docs/lighthouse/best-practices/external-anchors-use-rel-noopener/ + // modern browsers add noopener by default, so we only need to check legacy browsers + // legacy browsers don't support noopener so we only check for noreferrer there + if (component.compile_options.legacy && target_attribute && target_attribute.get_static_value() === '_blank' && href_attribute) { + const href_static_value = href_attribute.get_static_value() ? href_attribute.get_static_value().toLowerCase() : null; + + if (href_static_value === null || href_static_value.match(/^(https?:)?\/\//i)) { + const rel = attribute_map.get('rel'); + if (rel == null || rel.is_static) { + const rel_values = rel ? rel.get_static_value().split(regex_any_repeated_whitespaces) : []; + if (!rel || !rel_values.includes('noreferrer')) { + component.warn(this, { + code: 'security-anchor-rel-noreferrer', + message: + 'Security: Anchor with "target=_blank" should have rel attribute containing the value "noreferrer"' + }); + } + } + } + } + + if (aria_label_attribute) { + const aria_value = aria_label_attribute.get_static_value(); + if (aria_value != '') { + this.contains_a11y_label = true; + } + } if (href_attribute) { const href_value = href_attribute.get_static_value(); @@ -647,7 +889,10 @@ export default class Element extends Node { } if (this.name === 'video') { - if (attribute_map.has('muted')) { + const aria_hidden_attribute = attribute_map.get('aria-hidden'); + const aria_hidden_exist = aria_hidden_attribute && aria_hidden_attribute.get_static_value(); + + if (attribute_map.has('muted') || aria_hidden_exist === 'true') { return; } @@ -690,7 +935,7 @@ export default class Element extends Node { if (this.name === 'figure') { const children = this.children.filter(node => { if (node.type === 'Comment') return false; - if (node.type === 'Text') return /\S/.test(node.data); + if (node.type === 'Text') return regex_non_whitespace_character.test(node.data); return true; }); @@ -810,7 +1055,8 @@ export default class Element extends Node { name === 'muted' || name === 'playbackRate' || name === 'seeking' || - name === 'ended' + name === 'ended' || + name === 'readyState' ) { if (this.name !== 'audio' && this.name !== 'video') { return component.error(binding, compiler_errors.invalid_binding_element_with('audio> or