diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f5e161f89..e878e8eb02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,42 @@ on: [push, 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@v1 + - uses: actions/setup-node@v2 + with: + node-version: 16 + cache: npm + - run: npm install + env: + SKIP_PREPARE: true + - run: npm run build + env: + PUBLISH: true + - uses: actions/cache@v3 + with: + # cache key based on OS as the full path for each OS may be different + # and windows is not able to reuse the cache from ubuntu + key: output-${{ github.run_id }}-${{ matrix.os }} + path: | + index.* + compiler.* + ssr.* + action/ + animate/ + easing/ + internal/ + motion/ + store/ + transition/ + types/ Tests: + needs: Setup runs-on: ${{ matrix.os }} timeout-minutes: 15 strategy: @@ -11,21 +46,38 @@ jobs: node-version: [8, 10, 12, 14, 16] 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 + - uses: actions/cache@v3 + with: + key: output-${{ github.run_id }}-${{ matrix.os }} + path: | + index.* + compiler.* + ssr.* + action/ + animate/ + easing/ + internal/ + motion/ + store/ + transition/ + types/ - 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' @@ -36,8 +88,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/.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 cbf22201f6..3099d542d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Svelte changelog +## Unreleased + +* Support `|important` modifier to style directive ([#7489](https://github.com/sveltejs/svelte/pull/7489)) +* Warn when using `` without `rel="noreferrer"` ([#6188](https://github.com/sveltejs/svelte/issues/6188)) +* Throw helpful compiler error for attempting to update `const` variable ([#4895](https://github.com/sveltejs/svelte/issues/4895)) +* Refix hydration with `{@html}` and components in `` ([#7941](https://github.com/sveltejs/svelte/pull/7941)) + ## 3.51.0 * Add a11y warnings: diff --git a/package.json b/package.json index bf6b9e7754..c9a67c9311 100644 --- a/package.json +++ b/package.json @@ -86,15 +86,15 @@ }, "types": "types/runtime/index.d.ts", "scripts": { - "test": "mocha --exit", - "test:unit": "mocha --require sucrase/register --recursive src/**/__test__.ts --exit", - "quicktest": "mocha", + "test": "npm run test:unit && npm run test:integration", + "test:integration": "mocha --exit", + "test:unit": "mocha --config .mocharc.unit.js --exit", + "quicktest": "mocha --exit", "build": "rollup -c && npm run tsd", - "prepare": "npm run build", + "prepare": "node scripts/skip_in_ci.js npm run build", "dev": "rollup -cw", - "pretest": "npm run build", "posttest": "agadoo internal/index.mjs", - "prepublishOnly": "node check_publish_env.js && npm run lint && npm test", + "prepublishOnly": "node check_publish_env.js && npm run lint && npm run build && npm test", "tsd": "node ./generate-type-definitions.js", "lint": "eslint \"{src,test}/**/*.{ts,js}\"" }, diff --git a/scripts/skip_in_ci.js b/scripts/skip_in_ci.js new file mode 100644 index 0000000000..d845d000ab --- /dev/null +++ b/scripts/skip_in_ci.js @@ -0,0 +1,7 @@ +if (process.env.SKIP_PREPARE) { + console.log('Skipped "prepare" script'); +} else { + const { execSync } = require("child_process"); + const command = process.argv.slice(2).join(" "); + execSync(command, { stdio: "inherit" }); +} diff --git a/site/content/docs/03-template-syntax.md b/site/content/docs/03-template-syntax.md index 24b6111fae..9afe399469 100644 --- a/site/content/docs/03-template-syntax.md +++ b/site/content/docs/03-template-syntax.md @@ -158,7 +158,7 @@ You can use HTML comments inside components. --- -Comments beginning with `svelte-ignore` disable warnings for the next block of markup. Usually these are accessibility warnings; make sure that you're disabling them for a good reason. +Comments beginning with `svelte-ignore` disable warnings for the next block of markup. Usually, these are accessibility warnings; make sure that you're disabling them for a good reason. ```sv @@ -593,7 +593,7 @@ The simplest bindings reflect the value of a property, such as `input.value`. --- -If the name matches the value, you can use a shorthand. +If the name matches the value, you can use shorthand. ```sv @@ -750,7 +750,7 @@ Videos additionally have readonly `videoWidth` and `videoHeight` bindings. --- -Block-level elements have 4 readonly bindings, measured using a technique similar to [this one](http://www.backalleycoder.com/2013/03/18/cross-browser-event-based-element-resize-detection/): +Block-level elements have 4 read-only bindings, measured using a technique similar to [this one](http://www.backalleycoder.com/2013/03/18/cross-browser-event-based-element-resize-detection/): * `clientWidth` * `clientHeight` @@ -874,6 +874,9 @@ The `style:` directive provides a shorthand for setting multiple styles on an el
...
+ + +
...
``` --- @@ -981,7 +984,7 @@ transition = (node: HTMLElement, params: any) => { A transition is triggered by an element entering or leaving the DOM as a result of a state change. -When a block is transitioning out, all elements inside the block, including those that do not have their own transitions, are kept in the DOM until every transition in the block has completed. +When a block is transitioning out, all elements inside the block, including those that do not have their own transitions, are kept in the DOM until every transition in the block has been completed. The `transition:` directive indicates a *bidirectional* transition, which means it can be smoothly reversed while the transition is in progress. @@ -1241,7 +1244,7 @@ As with actions and transitions, animations can have parameters. --- -Animations can use custom functions that provide the `node`, an `animation` object and any `parameters` as arguments. The `animation` parameter is an object containing `from` and `to` properties each containing a [DOMRect](https://developer.mozilla.org/en-US/docs/Web/API/DOMRect#Properties) describing the geometry of the element in its `start` and `end` positions. The `from` property is the DOMRect of the element in its starting position, the `to` property is the DOMRect of the element in its final position after the list has been reordered and the DOM updated. +Animations can use custom functions that provide the `node`, an `animation` object and any `parameters` as arguments. The `animation` parameter is an object containing `from` and `to` properties each containing a [DOMRect](https://developer.mozilla.org/en-US/docs/Web/API/DOMRect#Properties) describing the geometry of the element in its `start` and `end` positions. The `from` property is the DOMRect of the element in its starting position, and the `to` property is the DOMRect of the element in its final position after the list has been reordered and the DOM updated. If the returned object has a `css` method, Svelte will create a CSS animation that plays on the element. @@ -1390,7 +1393,7 @@ For SVG namespace, the example above desugars into using `` instead: --- -Svelte's CSS Variables support allows for easily themable components: +Svelte's CSS Variables support allows for easily themeable components: ```sv @@ -1654,7 +1657,7 @@ If `this` is falsy, no component is rendered. The `` element lets you render an element of a dynamically specified type. This is useful for example when displaying rich text content from a CMS. Any properties and event listeners present will be applied to the element. -The only supported binding is `bind:this`, since the element type specific bindings that Svelte does at build time (e.g. `bind:value` for input elements) does not work with a dynamic tag type. +The only supported binding is `bind:this`, since the element type specific bindings that Svelte does at build time (e.g. `bind:value` for input elements) do not work with a dynamic tag type. If `this` has a nullish value, the element and its children will not be rendered. diff --git a/site/content/docs/06-accessibility-warnings.md b/site/content/docs/06-accessibility-warnings.md index c82ef21fcb..8be8eb888c 100644 --- a/site/content/docs/06-accessibility-warnings.md +++ b/site/content/docs/06-accessibility-warnings.md @@ -268,7 +268,7 @@ Some HTML elements have default ARIA roles. Giving these elements an ARIA role t Tab key navigation should be limited to elements on the page that can be interacted with. ```sv - +
``` diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts index 2f8874de7a..c0d703892b 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,42 @@ export default class Component { scope = map.get(node); } + let deep = false; + let names: string[] | undefined; + + if (node.type === 'AssignmentExpression') { + 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 = [name]; + } + + if (names) { + 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 +835,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); @@ -918,7 +958,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 +1005,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 +1129,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 +1500,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 +1515,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 +1547,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 63c0e691f9..62103d26d6 100644 --- a/src/compiler/compile/compiler_errors.ts +++ b/src/compiler/compile/compiler_errors.ts @@ -282,6 +282,10 @@ export default { 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}` + }), invalid_component_svelte_directive: (name) => ({ code: 'invalid-component-svelte-directive', message: `svelte:${name} directives cannot be used on components` @@ -301,5 +305,5 @@ export default { directive_conflict: (directive1, directive2) => ({ code: 'directive-conflict', message: `Cannot use ${directive1} and ${directive2} on the same element` - }) + }) }; diff --git a/src/compiler/compile/compiler_warnings.ts b/src/compiler/compile/compiler_warnings.ts index f194090870..3a11e04bd1 100644 --- a/src/compiler/compile/compiler_warnings.ts +++ b/src/compiler/compile/compiler_warnings.ts @@ -185,7 +185,7 @@ export default { }), a11y_no_noninteractive_tabindex: { code: 'a11y-no-noninteractive-tabindex', - message: 'A11y: not interactive element cannot have positive tabIndex value' + message: 'A11y: noninteractive element cannot have positive tabIndex value' }, redundant_event_modifier_for_touch: { code: 'redundant-event-modifier', diff --git a/src/compiler/compile/css/Selector.ts b/src/compiler/compile/css/Selector.ts index 17302c4abd..28cf5ba6af 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; @@ -157,7 +160,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); } } @@ -281,12 +284,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; @@ -371,7 +376,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 +397,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 +411,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..9a3cbe9d13 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) => @@ -147,10 +150,10 @@ 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, ':'); diff --git a/src/compiler/compile/index.ts b/src/compiler/compile/index.ts index afe9c56cf4..a5edc3a6f7 100644 --- a/src/compiler/compile/index.ts +++ b/src/compiler/compile/index.ts @@ -35,6 +35,9 @@ const valid_options = [ 'cssHash' ]; +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; @@ -48,11 +51,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', diff --git a/src/compiler/compile/nodes/Binding.ts b/src/compiler/compile/nodes/Binding.ts index 594490a5fb..f826df4828 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 } from '../../utils/patterns'; import { Node as ESTreeNode } from 'estree'; import { TemplateNode } from '../../interfaces'; import Element from './Element'; @@ -88,7 +88,7 @@ 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) || (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/Element.ts b/src/compiler/compile/nodes/Element.ts index 09435f9b06..6f61e43c7b 100644 --- a/src/compiler/compile/nodes/Element.ts +++ b/src/compiler/compile/nodes/Element.ts @@ -11,7 +11,7 @@ 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 { regex_dimensions, regex_starts_with_newline, regex_non_whitespace_character } from '../../utils/patterns'; import fuzzymatch from '../../utils/fuzzymatch'; import list from '../../utils/list'; import Let from './Let'; @@ -204,6 +204,10 @@ function is_valid_aria_attribute_value(schema: ARIAPropertyDefinition, value: st } } +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; @@ -255,7 +259,7 @@ export default class Element extends Node { // 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, ''); } } @@ -416,7 +420,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)); } @@ -482,7 +486,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)); } @@ -627,6 +631,26 @@ 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'); + + if (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'); + const rel_values = rel ? rel.get_static_value().split(' ') : []; + const expected_values = ['noreferrer']; + + expected_values.forEach(expected_value => { + if (!rel || rel && rel_values.indexOf(expected_value) < 0) { + component.warn(this, { + code: `security-anchor-rel-${expected_value}`, + message: `Security: Anchor with "target=_blank" should have rel attribute containing the value "${expected_value}"` + }); + } + }); + } + } if (href_attribute) { const href_value = href_attribute.get_static_value(); @@ -747,7 +771,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; }); @@ -879,7 +903,7 @@ export default class Element extends Node { if (this.name !== 'video') { return component.error(binding, compiler_errors.invalid_binding_element_with('