diff --git a/CHANGELOG.md b/CHANGELOG.md index 8367d07c8a62..bb29698e4da4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - [core] feat: Export `makeMain` (#2665) - [core] fix: Call `bindClient` when creating new `Hub` to make integrations work automatically (#2665) - [gatsby] feat: Add @sentry/gatsby package (#2652) +- [core] ref: Rename `whitelistUrls/blacklistUrls` to `allowUrls/denyUrls` ## 5.17.0 diff --git a/MIGRATION.md b/MIGRATION.md index 06a40b167305..d7cdb20b70c0 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -253,7 +253,7 @@ Sentry.addBreadcrumb({ ### Ignoring Urls -> 'ignoreUrls' was renamed to 'blacklistUrls'. 'ignoreErrors', which has a similar name was not renamed. [Docs](https://docs.sentry.io/error-reporting/configuration/?platform=browser#blacklist-urls) and [Decluttering Sentry](https://docs.sentry.io/platforms/javascript/#decluttering-sentry) +> 'ignoreUrls' was renamed to 'denyUrls'. 'ignoreErrors', which has a similar name was not renamed. [Docs](https://docs.sentry.io/error-reporting/configuration/?platform=browser#deny-urls) and [Decluttering Sentry](https://docs.sentry.io/platforms/javascript/#decluttering-sentry) _Old_: @@ -270,7 +270,7 @@ _New_: ```js Sentry.init({ - blacklistUrls: [ + denyUrls: [ 'https://www.baddomain.com', /graph\.facebook\.com/i, ], diff --git a/packages/browser/examples/app.js b/packages/browser/examples/app.js index a6035444c43a..0ab6d7960915 100644 --- a/packages/browser/examples/app.js +++ b/packages/browser/examples/app.js @@ -37,9 +37,9 @@ Sentry.init({ // An array of strings or regexps that'll be used to ignore specific errors based on their type/message ignoreErrors: [/PickleRick_\d\d/, 'RangeError'], // An array of strings or regexps that'll be used to ignore specific errors based on their origin url - blacklistUrls: ['external-lib.js'], + denyUrls: ['external-lib.js'], // An array of strings or regexps that'll be used to allow specific errors based on their origin url - whitelistUrls: ['http://localhost:5000', 'https://browser.sentry-cdn'], + allowUrls: ['http://localhost:5000', 'https://browser.sentry-cdn'], // Debug mode with valuable initialization/lifecycle informations. debug: true, // Whether SDK should be enabled or not. @@ -93,7 +93,7 @@ Sentry.init({ // Testing code, irrelevant vvvvv document.addEventListener('DOMContentLoaded', () => { - document.querySelector('#blacklist-url').addEventListener('click', () => { + document.querySelector('#deny-url').addEventListener('click', () => { const script = document.createElement('script'); script.crossOrigin = 'anonymous'; script.src = @@ -101,7 +101,7 @@ document.addEventListener('DOMContentLoaded', () => { document.body.appendChild(script); }); - document.querySelector('#whitelist-url').addEventListener('click', () => { + document.querySelector('#allow-url').addEventListener('click', () => { const script = document.createElement('script'); script.crossOrigin = 'anonymous'; script.src = diff --git a/packages/browser/examples/index.html b/packages/browser/examples/index.html index a6f3915b2d84..20d9529b5d2f 100644 --- a/packages/browser/examples/index.html +++ b/packages/browser/examples/index.html @@ -20,8 +20,8 @@ - - + + diff --git a/packages/browser/src/backend.ts b/packages/browser/src/backend.ts index a7c5847fa1a8..84b16953a5f2 100644 --- a/packages/browser/src/backend.ts +++ b/packages/browser/src/backend.ts @@ -11,18 +11,24 @@ import { FetchTransport, XHRTransport } from './transports'; */ export interface BrowserOptions extends Options { /** - * A pattern for error URLs which should not be sent to Sentry. - * To whitelist certain errors instead, use {@link Options.whitelistUrls}. + * A pattern for error URLs which should exclusively be sent to Sentry. + * This is the opposite of {@link Options.denyUrls}. * By default, all errors will be sent. */ - blacklistUrls?: Array; + allowUrls?: Array; /** - * A pattern for error URLs which should exclusively be sent to Sentry. - * This is the opposite of {@link Options.blacklistUrls}. + * A pattern for error URLs which should not be sent to Sentry. + * To allow certain errors instead, use {@link Options.allowUrls}. * By default, all errors will be sent. */ + denyUrls?: Array; + + /** @deprecated use {@link Options.allowUrls} instead. */ whitelistUrls?: Array; + + /** @deprecated use {@link Options.denyUrls} instead. */ + blacklistUrls?: Array; } /** diff --git a/packages/browser/test/integration/common/init.js b/packages/browser/test/integration/common/init.js index e5f67a3ca812..64d5f9ee431a 100644 --- a/packages/browser/test/integration/common/init.js +++ b/packages/browser/test/integration/common/init.js @@ -34,7 +34,7 @@ function initSDK() { integrations: [new Sentry.Integrations.Dedupe()], attachStacktrace: true, ignoreErrors: ["ignoreErrorTest"], - blacklistUrls: ["foo.js"], + denyUrls: ["foo.js"], beforeSend: function(event, eventHint) { events.push(event); eventHints.push(eventHint); diff --git a/packages/browser/test/integration/suites/config.js b/packages/browser/test/integration/suites/config.js index 3ba82a06c3fc..f3c903b64a57 100644 --- a/packages/browser/test/integration/suites/config.js +++ b/packages/browser/test/integration/suites/config.js @@ -21,10 +21,10 @@ describe("config", function() { * > bar.js file called a function in baz.js * > baz.js threw an error * - * foo.js is blacklisted in the `init` call (init.js), thus we filter it + * foo.js is denied in the `init` call (init.js), thus we filter it * */ - var urlWithBlacklistedUrl = new Error("filter"); - urlWithBlacklistedUrl.stack = + var urlWithDeniedUrl = new Error("filter"); + urlWithDeniedUrl.stack = "Error: bar\n" + " at http://localhost:5000/foo.js:7:19\n" + " at bar(http://localhost:5000/bar.js:2:3)\n" + @@ -35,17 +35,17 @@ describe("config", function() { * > bar-pass.js file called a function in baz-pass.js * > baz-pass.js threw an error * - * foo-pass.js is *not* blacklisted in the `init` call (init.js), thus we don't filter it + * foo-pass.js is *not* denied in the `init` call (init.js), thus we don't filter it * */ - var urlWithoutBlacklistedUrl = new Error("pass"); - urlWithoutBlacklistedUrl.stack = + var urlWithoutDeniedUrl = new Error("pass"); + urlWithoutDeniedUrl.stack = "Error: bar\n" + " at http://localhost:5000/foo-pass.js:7:19\n" + " at bar(http://localhost:5000/bar-pass.js:2:3)\n" + " at baz(http://localhost:5000/baz-pass.js:2:9)\n"; - Sentry.captureException(urlWithBlacklistedUrl); - Sentry.captureException(urlWithoutBlacklistedUrl); + Sentry.captureException(urlWithDeniedUrl); + Sentry.captureException(urlWithoutDeniedUrl); }).then(function(summary) { assert.lengthOf(summary.events, 1); assert.equal(summary.events[0].exception.values[0].type, "Error"); diff --git a/packages/core/src/integrations/inboundfilters.ts b/packages/core/src/integrations/inboundfilters.ts index 99ece511eac5..5d868ebc50d6 100644 --- a/packages/core/src/integrations/inboundfilters.ts +++ b/packages/core/src/integrations/inboundfilters.ts @@ -8,10 +8,15 @@ const DEFAULT_IGNORE_ERRORS = [/^Script error\.?$/, /^Javascript error: Script e /** JSDoc */ interface InboundFiltersOptions { - blacklistUrls?: Array; - ignoreErrors?: Array; - ignoreInternal?: boolean; - whitelistUrls?: Array; + allowUrls: Array; + denyUrls: Array; + ignoreErrors: Array; + ignoreInternal: boolean; + + /** @deprecated use {@link InboundFiltersOptions.allowUrls} instead. */ + whitelistUrls: Array; + /** @deprecated use {@link InboundFiltersOptions.denyUrls} instead. */ + blacklistUrls: Array; } /** Inbound filters configurable by the user */ @@ -25,7 +30,7 @@ export class InboundFilters implements Integration { */ public static id: string = 'InboundFilters'; - public constructor(private readonly _options: InboundFiltersOptions = {}) {} + public constructor(private readonly _options: Partial = {}) {} /** * @inheritDoc @@ -50,7 +55,7 @@ export class InboundFilters implements Integration { } /** JSDoc */ - private _shouldDropEvent(event: Event, options: InboundFiltersOptions): boolean { + private _shouldDropEvent(event: Event, options: Partial): boolean { if (this._isSentryError(event, options)) { logger.warn(`Event dropped due to being internal Sentry Error.\nEvent: ${getEventDescription(event)}`); return true; @@ -61,17 +66,17 @@ export class InboundFilters implements Integration { ); return true; } - if (this._isBlacklistedUrl(event, options)) { + if (this._isDeniedUrl(event, options)) { logger.warn( - `Event dropped due to being matched by \`blacklistUrls\` option.\nEvent: ${getEventDescription( + `Event dropped due to being matched by \`denyUrls\` option.\nEvent: ${getEventDescription( event, )}.\nUrl: ${this._getEventFilterUrl(event)}`, ); return true; } - if (!this._isWhitelistedUrl(event, options)) { + if (!this._isAllowedUrl(event, options)) { logger.warn( - `Event dropped due to not being matched by \`whitelistUrls\` option.\nEvent: ${getEventDescription( + `Event dropped due to not being matched by \`allowUrls\` option.\nEvent: ${getEventDescription( event, )}.\nUrl: ${this._getEventFilterUrl(event)}`, ); @@ -81,7 +86,7 @@ export class InboundFilters implements Integration { } /** JSDoc */ - private _isSentryError(event: Event, options: InboundFiltersOptions = {}): boolean { + private _isSentryError(event: Event, options: Partial): boolean { if (!options.ignoreInternal) { return false; } @@ -101,7 +106,7 @@ export class InboundFilters implements Integration { } /** JSDoc */ - private _isIgnoredError(event: Event, options: InboundFiltersOptions = {}): boolean { + private _isIgnoredError(event: Event, options: Partial): boolean { if (!options.ignoreErrors || !options.ignoreErrors.length) { return false; } @@ -113,36 +118,47 @@ export class InboundFilters implements Integration { } /** JSDoc */ - private _isBlacklistedUrl(event: Event, options: InboundFiltersOptions = {}): boolean { + private _isDeniedUrl(event: Event, options: Partial): boolean { // TODO: Use Glob instead? - if (!options.blacklistUrls || !options.blacklistUrls.length) { + if (!options.denyUrls || !options.denyUrls.length) { return false; } const url = this._getEventFilterUrl(event); - return !url ? false : options.blacklistUrls.some(pattern => isMatchingPattern(url, pattern)); + return !url ? false : options.denyUrls.some(pattern => isMatchingPattern(url, pattern)); } /** JSDoc */ - private _isWhitelistedUrl(event: Event, options: InboundFiltersOptions = {}): boolean { + private _isAllowedUrl(event: Event, options: Partial): boolean { // TODO: Use Glob instead? - if (!options.whitelistUrls || !options.whitelistUrls.length) { + if (!options.allowUrls || !options.allowUrls.length) { return true; } const url = this._getEventFilterUrl(event); - return !url ? true : options.whitelistUrls.some(pattern => isMatchingPattern(url, pattern)); + return !url ? true : options.allowUrls.some(pattern => isMatchingPattern(url, pattern)); } /** JSDoc */ - private _mergeOptions(clientOptions: InboundFiltersOptions = {}): InboundFiltersOptions { + private _mergeOptions(clientOptions: Partial = {}): Partial { + // tslint:disable:deprecation return { - blacklistUrls: [...(this._options.blacklistUrls || []), ...(clientOptions.blacklistUrls || [])], + allowUrls: [ + ...(this._options.whitelistUrls || []), + ...(this._options.allowUrls || []), + ...(clientOptions.whitelistUrls || []), + ...(clientOptions.allowUrls || []), + ], + denyUrls: [ + ...(this._options.blacklistUrls || []), + ...(this._options.denyUrls || []), + ...(clientOptions.blacklistUrls || []), + ...(clientOptions.denyUrls || []), + ], ignoreErrors: [ ...(this._options.ignoreErrors || []), ...(clientOptions.ignoreErrors || []), ...DEFAULT_IGNORE_ERRORS, ], ignoreInternal: typeof this._options.ignoreInternal !== 'undefined' ? this._options.ignoreInternal : true, - whitelistUrls: [...(this._options.whitelistUrls || []), ...(clientOptions.whitelistUrls || [])], }; } diff --git a/packages/core/test/lib/integrations/inboundfilters.test.ts b/packages/core/test/lib/integrations/inboundfilters.test.ts index 14b06bc138c6..75b6ad848fd8 100644 --- a/packages/core/test/lib/integrations/inboundfilters.test.ts +++ b/packages/core/test/lib/integrations/inboundfilters.test.ts @@ -18,38 +18,38 @@ describe('InboundFilters', () => { expect(inboundFilters._shouldDropEvent({}, inboundFilters._mergeOptions())).toBe(true); }); - it('should drop when url is blacklisted', () => { - inboundFilters._isBlacklistedUrl = () => true; + it('should drop when url is denied', () => { + inboundFilters._isDeniedUrl = () => true; expect(inboundFilters._shouldDropEvent({}, inboundFilters._mergeOptions())).toBe(true); }); - it('should drop when url is not whitelisted', () => { - inboundFilters._isWhitelistedUrl = () => false; + it('should drop when url is not allowed', () => { + inboundFilters._isAllowedUrl = () => false; expect(inboundFilters._shouldDropEvent({}, inboundFilters._mergeOptions())).toBe(true); }); - it('should drop when url is not blacklisted, but also not whitelisted', () => { - inboundFilters._isBlacklistedUrl = () => false; - inboundFilters._isWhitelistedUrl = () => false; + it('should drop when url is not denied, but also not allowed', () => { + inboundFilters._isDeniedUrl = () => false; + inboundFilters._isAllowedUrl = () => false; expect(inboundFilters._shouldDropEvent({}, inboundFilters._mergeOptions())).toBe(true); }); - it('should drop when url is blacklisted and whitelisted at the same time', () => { - inboundFilters._isBlacklistedUrl = () => true; - inboundFilters._isWhitelistedUrl = () => true; + it('should drop when url is denied and allowed at the same time', () => { + inboundFilters._isDeniedUrl = () => true; + inboundFilters._isAllowedUrl = () => true; expect(inboundFilters._shouldDropEvent({}, inboundFilters._mergeOptions())).toBe(true); }); - it('should not drop when url is not blacklisted, but whitelisted', () => { - inboundFilters._isBlacklistedUrl = () => false; - inboundFilters._isWhitelistedUrl = () => true; + it('should not drop when url is not denied, but allowed', () => { + inboundFilters._isDeniedUrl = () => false; + inboundFilters._isAllowedUrl = () => true; expect(inboundFilters._shouldDropEvent({}, inboundFilters._mergeOptions())).toBe(false); }); it('should not drop when any of checks dont match', () => { inboundFilters._isIgnoredError = () => false; - inboundFilters._isBlacklistedUrl = () => false; - inboundFilters._isWhitelistedUrl = () => true; + inboundFilters._isDeniedUrl = () => false; + inboundFilters._isAllowedUrl = () => true; expect(inboundFilters._shouldDropEvent({}, inboundFilters._mergeOptions())).toBe(false); }); }); @@ -251,7 +251,7 @@ describe('InboundFilters', () => { }); }); - describe('blacklistUrls/whitelistUrls', () => { + describe('denyUrls/allowUrls', () => { const messageEvent = { message: 'wat', stacktrace: { @@ -284,20 +284,20 @@ describe('InboundFilters', () => { it('should filter captured message based on its stack trace using string filter', () => { expect( - inboundFilters._isBlacklistedUrl( + inboundFilters._isDeniedUrl( messageEvent, inboundFilters._mergeOptions({ - blacklistUrls: ['https://awesome-analytics.io'], - whitelistUrls: ['https://awesome-analytics.io'], + allowUrls: ['https://awesome-analytics.io'], + denyUrls: ['https://awesome-analytics.io'], }), ), ).toBe(true); expect( - inboundFilters._isWhitelistedUrl( + inboundFilters._isAllowedUrl( messageEvent, inboundFilters._mergeOptions({ - blacklistUrls: ['https://awesome-analytics.io'], - whitelistUrls: ['https://awesome-analytics.io'], + allowUrls: ['https://awesome-analytics.io'], + denyUrls: ['https://awesome-analytics.io'], }), ), ).toBe(true); @@ -305,18 +305,18 @@ describe('InboundFilters', () => { it('should filter captured message based on its stack trace using regexp filter', () => { expect( - inboundFilters._isBlacklistedUrl( + inboundFilters._isDeniedUrl( messageEvent, inboundFilters._mergeOptions({ - blacklistUrls: [/awesome-analytics\.io/], + denyUrls: [/awesome-analytics\.io/], }), ), ).toBe(true); expect( - inboundFilters._isWhitelistedUrl( + inboundFilters._isAllowedUrl( messageEvent, inboundFilters._mergeOptions({ - blacklistUrls: [/awesome-analytics\.io/], + denyUrls: [/awesome-analytics\.io/], }), ), ).toBe(true); @@ -324,24 +324,24 @@ describe('InboundFilters', () => { it('should not filter captured messages with no stacktraces', () => { expect( - inboundFilters._isBlacklistedUrl( + inboundFilters._isDeniedUrl( { message: 'any', }, inboundFilters._mergeOptions({ - blacklistUrls: ['https://awesome-analytics.io'], - whitelistUrls: ['https://awesome-analytics.io'], + allowUrls: ['https://awesome-analytics.io'], + denyUrls: ['https://awesome-analytics.io'], }), ), ).toBe(false); expect( - inboundFilters._isWhitelistedUrl( + inboundFilters._isAllowedUrl( { message: 'any', }, inboundFilters._mergeOptions({ - blacklistUrls: ['https://awesome-analytics.io'], - whitelistUrls: ['https://awesome-analytics.io'], + allowUrls: ['https://awesome-analytics.io'], + denyUrls: ['https://awesome-analytics.io'], }), ), ).toBe(true); @@ -349,20 +349,20 @@ describe('InboundFilters', () => { it('should filter captured exception based on its stack trace using string filter', () => { expect( - inboundFilters._isBlacklistedUrl( + inboundFilters._isDeniedUrl( exceptionEvent, inboundFilters._mergeOptions({ - blacklistUrls: ['https://awesome-analytics.io'], - whitelistUrls: ['https://awesome-analytics.io'], + allowUrls: ['https://awesome-analytics.io'], + denyUrls: ['https://awesome-analytics.io'], }), ), ).toBe(true); expect( - inboundFilters._isWhitelistedUrl( + inboundFilters._isAllowedUrl( exceptionEvent, inboundFilters._mergeOptions({ - blacklistUrls: ['https://awesome-analytics.io'], - whitelistUrls: ['https://awesome-analytics.io'], + allowUrls: ['https://awesome-analytics.io'], + denyUrls: ['https://awesome-analytics.io'], }), ), ).toBe(true); @@ -370,20 +370,20 @@ describe('InboundFilters', () => { it('should filter captured exceptions based on its stack trace using regexp filter', () => { expect( - inboundFilters._isBlacklistedUrl( + inboundFilters._isDeniedUrl( exceptionEvent, inboundFilters._mergeOptions({ - blacklistUrls: [/awesome-analytics\.io/], - whitelistUrls: [/awesome-analytics\.io/], + allowUrls: [/awesome-analytics\.io/], + denyUrls: [/awesome-analytics\.io/], }), ), ).toBe(true); expect( - inboundFilters._isWhitelistedUrl( + inboundFilters._isAllowedUrl( exceptionEvent, inboundFilters._mergeOptions({ - blacklistUrls: [/awesome-analytics\.io/], - whitelistUrls: [/awesome-analytics\.io/], + allowUrls: [/awesome-analytics\.io/], + denyUrls: [/awesome-analytics\.io/], }), ), ).toBe(true); @@ -391,20 +391,20 @@ describe('InboundFilters', () => { it('should not filter events that doesnt pass the test', () => { expect( - inboundFilters._isBlacklistedUrl( + inboundFilters._isDeniedUrl( exceptionEvent, inboundFilters._mergeOptions({ - blacklistUrls: ['some-other-domain.com'], - whitelistUrls: ['some-other-domain.com'], + allowUrls: ['some-other-domain.com'], + denyUrls: ['some-other-domain.com'], }), ), ).toBe(false); expect( - inboundFilters._isWhitelistedUrl( + inboundFilters._isAllowedUrl( exceptionEvent, inboundFilters._mergeOptions({ - blacklistUrls: ['some-other-domain.com'], - whitelistUrls: ['some-other-domain.com'], + allowUrls: ['some-other-domain.com'], + denyUrls: ['some-other-domain.com'], }), ), ).toBe(false); @@ -412,46 +412,46 @@ describe('InboundFilters', () => { it('should be able to use multiple filters', () => { expect( - inboundFilters._isBlacklistedUrl( + inboundFilters._isDeniedUrl( exceptionEvent, inboundFilters._mergeOptions({ - blacklistUrls: ['some-other-domain.com', /awesome-analytics\.io/], - whitelistUrls: ['some-other-domain.com', /awesome-analytics\.io/], + allowUrls: ['some-other-domain.com', /awesome-analytics\.io/], + denyUrls: ['some-other-domain.com', /awesome-analytics\.io/], }), ), ).toBe(true); expect( - inboundFilters._isWhitelistedUrl( + inboundFilters._isAllowedUrl( exceptionEvent, inboundFilters._mergeOptions({ - blacklistUrls: ['some-other-domain.com', /awesome-analytics\.io/], - whitelistUrls: ['some-other-domain.com', /awesome-analytics\.io/], + allowUrls: ['some-other-domain.com', /awesome-analytics\.io/], + denyUrls: ['some-other-domain.com', /awesome-analytics\.io/], }), ), ).toBe(true); }); - it('should not fail with malformed event event and default to false for isBlacklistedUrl and true for isWhitelistedUrl', () => { + it('should not fail with malformed event event and default to false for isdeniedUrl and true for isallowedUrl', () => { const malformedEvent = { stacktrace: { frames: undefined, }, }; expect( - inboundFilters._isBlacklistedUrl( + inboundFilters._isDeniedUrl( malformedEvent, inboundFilters._mergeOptions({ - blacklistUrls: ['https://awesome-analytics.io'], - whitelistUrls: ['https://awesome-analytics.io'], + allowUrls: ['https://awesome-analytics.io'], + denyUrls: ['https://awesome-analytics.io'], }), ), ).toBe(false); expect( - inboundFilters._isWhitelistedUrl( + inboundFilters._isAllowedUrl( malformedEvent, inboundFilters._mergeOptions({ - blacklistUrls: ['https://awesome-analytics.io'], - whitelistUrls: ['https://awesome-analytics.io'], + allowUrls: ['https://awesome-analytics.io'], + denyUrls: ['https://awesome-analytics.io'], }), ), ).toBe(true); diff --git a/packages/types/src/options.ts b/packages/types/src/options.ts index b5a68dff005e..c8e75744b896 100644 --- a/packages/types/src/options.ts +++ b/packages/types/src/options.ts @@ -131,7 +131,7 @@ export interface Options { * @returns The breadcrumb that will be added | null. */ beforeBreadcrumb?(breadcrumb: Breadcrumb, hint?: BreadcrumbHint): Breadcrumb | null; - + /** * Controls how many milliseconds to wait before shutting down. The default is * SDK-specific but typically around 2 seconds. Setting this too low can cause @@ -139,7 +139,7 @@ export interface Options { * high can cause the application to block for users with network connectivity * problems. */ - shutdownTimeout?: number; + shutdownTimeout?: number; _experiments?: { [key: string]: any; diff --git a/packages/utils/src/misc.ts b/packages/utils/src/misc.ts index 76720fe7575a..92c1f1c2f00f 100644 --- a/packages/utils/src/misc.ts +++ b/packages/utils/src/misc.ts @@ -335,9 +335,9 @@ function _htmlElementAsString(el: unknown): string { out.push(`.${classes[i]}`); } } - const attrWhitelist = ['type', 'name', 'title', 'alt']; - for (i = 0; i < attrWhitelist.length; i++) { - key = attrWhitelist[i]; + const allowedAttrs = ['type', 'name', 'title', 'alt']; + for (i = 0; i < allowedAttrs.length; i++) { + key = allowedAttrs[i]; attr = elem.getAttribute(key); if (attr) { out.push(`[${key}="${attr}"]`);