From 0ad790830a1121b6c645f4f9a678a60fc604cf94 Mon Sep 17 00:00:00 2001 From: Richard Herrera Date: Tue, 21 Mar 2017 13:39:27 -0700 Subject: [PATCH] Helmet 5.0.0: requestIdleCallback support (#248) * (feat) - new API to proved actual head tags as children to Helmet creating a more declarative API. Fully backwards compatible. * test: Update tests to use declarative API * Converting api -> title unit tests. * ESLint fix. * Duplicate prop * title attributes unit tests * Update tests to use declarative API * ESLint fixes. * ref -> rel * html Attributes unit tests * fix lang warning * test: Update onChangeClientState * test: Update script tags * test: Update noscript tags * Update baseTag + metaTag * test: Update style tags * partial link tag unit tests * more link tags * test: Update link tags * fix: Don't warn if no child props are provided. * (fix) title and titleAttribute unit tests * test: Fix html attributes tests * fix: Group array-type children into arrays. * fix: Bridge nested children into appropriate child props. * fix: Noscript test * (fix) server meta unit test - special character * (fix) convert html attributes to react keys when rewinding as a component * fix: Update describe name. * (fix) trailing comma * Bump to 5.0.0-beta * feat: Add {Helmet} export * feat: Add renderStatic alias * test: Update grammar * refactor: Organize new children to props code * 5.0.0-beta.2 * chore: Consolidate & organize Helmet constants. * feat: Support body attributes * fix: render body attributes on the server * chore: update README * feat: use requestIdleCallback to batch DOM updates * chore: move utilities into separate file * fix: correct scope for native requestIdleCallback * fix: add cancelIdleCallback polyfill * fix: clear pending callbacks to prevent multiple DOM writes * fix: export requestIdleCallback * fix: track attributes using attributeKeys * test: make tests asynchronous & update grammar * chore: improve warnings / errors * chore: update dev dependencies * fix: throw on invalid string as child * chore: update config * chore: update karma.config.js * test: fallback if mappedState is undefined * fix: avoid unnecessary DOM writes * feat: opt out of string encoding * chore: update documentation * chore: add note on backward compatibility * test: check for invalid self-closing tags --- .babelrc | 75 +- README.md | 383 +-- karma.config.js | 27 +- package.json | 89 +- src/Helmet.js | 502 +--- src/HelmetConstants.js | 5 + src/HelmetUtils.js | 530 ++++ src/test/HelmetDeclarativeTest.js | 2705 ----------------- src/test/HelmetTest.js | 2437 --------------- test/HelmetDeclarativeTest.js | 3207 ++++++++++++++++++++ test/HelmetTest.js | 2815 ++++++++++++++++++ test/test.js | 2 + yarn.lock | 4619 +++++++++++++++++++++++++++++ 13 files changed, 11455 insertions(+), 5941 deletions(-) create mode 100644 src/HelmetUtils.js delete mode 100644 src/test/HelmetDeclarativeTest.js delete mode 100644 src/test/HelmetTest.js create mode 100644 test/HelmetDeclarativeTest.js create mode 100644 test/HelmetTest.js create mode 100644 test/test.js create mode 100644 yarn.lock diff --git a/.babelrc b/.babelrc index 856fcacf..d28e25a9 100644 --- a/.babelrc +++ b/.babelrc @@ -1,28 +1,55 @@ { - "presets": [ - [ - "latest", - { - "es2015": { - "modules": false - } + "env": { + "commonjs": { + "presets": [ + ["env", { + "targets": { + "browsers": ["last 1 versions", "ie >= 10"] + }, + "modules": "commonjs", + "loose": true, + "useBuiltIns": true + }] + ], + "plugins": [ + "transform-export-extensions", + "transform-class-properties", + "transform-object-rest-spread", + "transform-remove-strict-mode" + ] + }, + "test": { + "plugins": [ + ["istanbul", { + "exclude": [ + "**/node_modules/**", + "**/packages/**", + "**/test/**", + "**/Test*" + ] + }], + "transform-export-extensions", + "transform-class-properties", + "transform-object-rest-spread", + "transform-remove-strict-mode" + ] } - ], - "react", - "stage-0" + }, + "presets": [ + ["env", { + "targets": { + "browsers": ["last 1 versions", "ie >= 10"] + }, + "modules": false, + "loose": true, + "useBuiltIns": true + }], + "react" ], - "env": { - "commonjs": { - "plugins": [ - "add-module-exports", - [ - "transform-es2015-modules-commonjs", - { - "loose": true, - "strict": false - } - ] - ] - } - } + "plugins": [ + "transform-export-extensions", + "transform-class-properties", + "transform-object-rest-spread", + "transform-remove-strict-mode" + ] } diff --git a/README.md b/README.md index a15b0a19..5a74f4ce 100644 --- a/README.md +++ b/README.md @@ -7,37 +7,22 @@ [![Dependency Status](https://img.shields.io/david/nfl/react-helmet.svg?style=flat-square)](https://david-dm.org/nfl/react-helmet) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md#pull-requests) -This reusable React component will manage all of your changes to the document head with support for document title, meta, link, style, script, noscript, and base tags. +This reusable React component will manage all of your changes to the document head. -Inspired by [react-document-title](https://github.com/gaearon/react-document-title) +Helmet _takes_ plain HTML tags and _outputs_ plain HTML tags. It's dead simple, and React beginner friendly. - - -**Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* - -- [Examples](#examples) -- [Features](#features) -- [Installation](#installation) -- [Server Usage](#server-usage) - - [As string output](#as-string-output) - - [As React components](#as-react-components) -- [Use Cases](#use-cases) -- [Contributing to this project](#contributing-to-this-project) -- [License](#license) -- [More Examples](#more-examples) - - - -## Examples +## Example ```javascript import React from "react"; import {Helmet} from "react-helmet"; -export function Application () { +class Application extends React.Component { return (
+ My Title + ...
@@ -45,72 +30,58 @@ export function Application () { }; ``` -```javascript -import React from "react"; -import {Helmet} from "react-helmet"; - -export function Application () { - return ( -
- console.log(newState)} - > - - - - My Title +Nested or latter components will override duplicate changes: - +```javascript + + + My Title + + + + + + Nested Title + + + + +``` - - +outputs: - - - - - - - - - - - ... -
- ); -}; +```html + + Nested Title + + ``` +See below for a full reference guide. + ## Features -- Supports `title`, `base`, `meta`, `link`, `script`, `noscript`, and `style` tags. +- Supports all valid head tags: `title`, `base`, `meta`, `link`, `script`, `noscript`, and `style` tags. - Supports attributes for `body`, `html` and `title` tags. -- Supports universal environments. +- Supports server-side rendering. - Nested components override duplicate head changes. -- Duplicate head changes preserved when specified in same component (support for tags like "apple-touch-icon"). +- Duplicate head changes are preserved when specified in the same component (support for tags like "apple-touch-icon"). - Callback for tracking DOM changes. +## Compatibility + +Helmet 5 is fully backward-compatible with previous Helmet releases, so you can upgrade at any time without fear of breaking changes. We encourage you to update your code to our more semantic API, but please feel free to do so at your own pace. + ## Installation + +Yarn: +```bash +yarn add react-helmet ``` + +npm: +```bash npm install --save react-helmet ``` -Dependencies: React >= 15.0.0 ## Server Usage To use on the server, call `Helmet.renderStatic()` after `ReactDOMServer.renderToString` or `ReactDOMServer.renderToStaticMarkup` to get the head data for use in your prerender. @@ -119,7 +90,7 @@ Because this component keeps track of mounted instances, **you have to make sure ```javascript ReactDOMServer.renderToString(); -let helmet = Helmet.renderStatic(); +const helmet = Helmet.renderStatic(); ``` This `helmet` instance contains the following properties: @@ -134,7 +105,7 @@ This `helmet` instance contains the following properties: - `title` - `titleAttributes` -Each property contains `toComponent()` and `toString()` methods. Use whichever is appropriate for your environment. For htmlAttributes, use the JSX spread operator on the object returned by `toComponent()`. E.g: +Each property contains `toComponent()` and `toString()` methods. Use whichever is appropriate for your environment. For attributes, use the JSX spread operator on the object returned by `toComponent()`. E.g: ### As string output ```javascript @@ -142,7 +113,7 @@ const html = ` - ${helmet.title.toString()} + ${helmet.title.toString()} ${helmet.meta.toString()} ${helmet.link.toString()} @@ -164,7 +135,7 @@ function HTML () { return ( - {helmet.title.toComponent()} + {helmet.title.toComponent()} {helmet.meta.toComponent()} {helmet.link.toComponent()} @@ -178,170 +149,97 @@ function HTML () { } ``` -## Use Cases -1. Nested or latter components will override duplicate changes. - ```javascript - - My Title - - - - Nested Title - - - ``` - Yields: - ```html - - Nested Title - - - ``` - -2. Use a titleTemplate to format title text in your page title - ```javascript - - My Title - - - Nested Title - - ``` - Yields: - ```html - - Nested Title | MyAwesomeWebsite.com - - ``` - -3. Duplicate `meta` and/or `link` tags in the same component are preserved - ```javascript - - - - - ``` - Yields: - ```html - - - - - ``` - -4. Duplicate tags can still be overwritten - ```javascript - - - - - - - - ``` - Yields: - ```html - - - - ``` - -5. Only one base tag is allowed - ```javascript - - - - - - - ``` - Yields: - ```html - - - - ``` - -6. defaultTitle can be used as a fallback when the template does not want to be used in the current Helmet - ```javascript - - ``` - Yields: - ```html - - My Site - - ``` - - But a child route with a title will use the titleTemplate, giving users a way to declare a titleTemplate for their app, but not have it apply to the root. - - ```javascript - - - - Nested Title - - ``` - Yields: - ```html - - My Site - Nested Title - - ``` - - And other child route components without a Helmet will inherit the defaultTitle. - -7. Usage with ` - - ``` - Yields: - ```html - - - - ``` - -8. Usage with ` - - ``` - Yields: - ```html - - - - ``` +### Reference Guide + +```javascript + + My Title + + + outputs: + + + Nested Title | MyAwesomeWebsite.com + + */} + titleTemplate="MySite.com - %s" + + {/* + (optional) used as a fallback when a template exists but a title is not defined + + + + outputs: + + + My Site + + */} + defaultTitle="My Default Title" + + {/* (optional) callback that tracks DOM changes */} + onChangeClientState={(newState) => console.log(newState)} +> + {/* html attributes */} + + + {/* body attributes */} + + + {/* title attributes and value */} + My Title + + {/* base element */} + + + {/* multiple meta elements */} + + + + {/* multiple link elements */} + + + + + {/* multiple script elements */} + + + {/* noscript elements */} + + + {/* inline style elements */} + + +``` ## Contributing to this project Please take a moment to review the [guidelines for contributing](CONTRIBUTING.md). @@ -352,6 +250,3 @@ Please take a moment to review the [guidelines for contributing](CONTRIBUTING.md ## License MIT - -## More Examples -[react-helmet-example](https://github.com/mattdennewitz/react-helmet-example) diff --git a/karma.config.js b/karma.config.js index fca88ac9..7ba5ad89 100644 --- a/karma.config.js +++ b/karma.config.js @@ -1,6 +1,10 @@ // Karma configuration module.exports = function (config) { + function normalizationBrowserName(browser) { + return browser.toLowerCase().split(/[ /-]/)[0]; + } + config.set({ // ... normal karma configuration basePath: "", @@ -10,34 +14,32 @@ module.exports = function (config) { client: { mocha: { + bail: true, reporter: "html" } }, // frameworks to use frameworks: [ - "phantomjs-shim", "chai-sinon", "mocha" ], files: [ - "lib/test/*.js" + "./test/test.js" ], preprocessors: { // add webpack as preprocessor - "lib/test/*.js": [ - "webpack", - "sourcemap" - ] + "./test/test.js": ["webpack", "sourcemap"] }, coverageReporter: { dir: "build/reports/coverage", + includeAllSources: true, reporters: [{ type: "html", - subdir: "html" + subdir: normalizationBrowserName }, { type: "text", subdir: ".", @@ -52,16 +54,13 @@ module.exports = function (config) { webpack: { devtool: "inline-source-map", module: { - preLoaders: [{ - test: /(\.js(x)?)$/, + rules: [{ + test: /\.js$/, // exclude this dirs from coverage - exclude: /(node_modules|bower_components)\//, - loader: "isparta" + exclude: [/node_modules/], + loader: "babel-loader" }] }, - resolve: { - extensions: ["", ".web.js", ".js"] - }, watch: true }, diff --git a/package.json b/package.json index 6247205a..8961c8b6 100644 --- a/package.json +++ b/package.json @@ -28,58 +28,59 @@ "react": ">=15.0.0" }, "dependencies": { - "deep-equal": "1.0.1", - "object-assign": "^4.0.1", + "deep-equal": "^1.0.1", + "object-assign": "^4.1.1", "react-side-effect": "^1.1.0" }, "devDependencies": { - "babel-cli": "6.16.0", - "babel-core": "6.16.0", - "babel-eslint": "7.0.0", - "babel-plugin-add-module-exports": "0.2.1", - "babel-plugin-transform-es2015-modules-commonjs": "6.16.0", - "babel-preset-latest": "6.16.0", - "babel-preset-react": "6.16.0", - "babel-preset-stage-0": "6.16.0", - "chai": "3.5.0", - "cross-env": "3.0.0", - "eslint": "3.11.1", - "eslint-config-nfl": "11.0.0", - "eslint-plugin-import": "1.16.0", - "eslint-plugin-jsx-a11y": "3.0.1", - "eslint-plugin-mocha": "4.7.0", - "eslint-plugin-react": "6.7.1", - "estraverse": "4.2.0", - "estraverse-fb": "1.3.1", - "isparta-loader": "2.0.0", - "karma": "1.3.0", - "karma-chai": "0.1.0", - "karma-chai-sinon": "0.1.5", - "karma-chrome-launcher": "2.0.0", - "karma-coverage": "1.1.1", - "karma-html-reporter": "0.2.7", - "karma-mocha": "1.2.0", - "karma-phantomjs-launcher": "1.0.2", - "karma-phantomjs-shim": "1.4.0", - "karma-sourcemap-loader": "0.3.7", - "karma-spec-reporter": "0.0.26", - "karma-tap-reporter": "0.0.6", - "karma-webpack": "1.8.0", - "mocha": "3.1.0", - "phantomjs-prebuilt": "2.1.12", - "react": "15.x", - "react-dom": "15.x", - "rimraf": "2.5.4", - "sinon": "1.17.6", - "sinon-chai": "2.8.0", - "webpack": "1.13.2" + "babel-cli": "^6.24.0", + "babel-core": "^6.24.0", + "babel-eslint": "^7.1.1", + "babel-loader": "^6.4.1", + "babel-plugin-add-module-exports": "^0.2.1", + "babel-plugin-istanbul": "^4.0.0", + "babel-plugin-transform-class-properties": "^6.23.0", + "babel-plugin-transform-export-extensions": "^6.22.0", + "babel-plugin-transform-object-rest-spread": "^6.23.0", + "babel-plugin-transform-remove-strict-mode": "^0.0.2", + "babel-preset-env": "^1.2.2", + "babel-preset-react": "^6.23.0", + "chai": "^3.5.0", + "cross-env": "^3.2.4", + "eslint": "^3.18.0", + "eslint-config-nfl": "^11.1.0", + "eslint-plugin-import": "^2.2.0", + "eslint-plugin-jsx-a11y": "^4.0.0", + "eslint-plugin-mocha": "^4.9.0", + "eslint-plugin-react": "^6.10.2", + "karma": "^1.5.0", + "karma-chai": "^0.1.0", + "karma-chai-sinon": "^0.1.5", + "karma-chrome-launcher": "^2.0.0", + "karma-coverage": "^1.1.1", + "karma-html-reporter": "^0.2.7", + "karma-mocha": "^1.3.0", + "karma-phantomjs-launcher": "^1.0.4", + "karma-phantomjs-shim": "^1.4.0", + "karma-sourcemap-loader": "^0.3.7", + "karma-spec-reporter": "^0.0.30", + "karma-tap-reporter": "^0.0.6", + "karma-webpack": "^2.0.3", + "mocha": "^3.2.0", + "phantomjs-prebuilt": "^2.1.14", + "react": "^15.x", + "react-dom": "^15.x", + "rimraf": "^2.6.1", + "sinon": "^2.1.0", + "sinon-chai": "^2.8.0", + "webpack": "^2.2.1" }, "scripts": { "clean": "rimraf lib build es", "lint": "eslint --ignore-path .gitignore -- .", - "test": "karma start karma.config.js", + "test": "cross-env BABEL_ENV=test karma start karma.config.js", "posttest": "cat ./build/reports/coverage/text.txt", - "pretest": "npm run clean && npm run lint && npm run compile", + "pretest": "npm run clean && npm run lint", "compile": "npm run compile:commonjs && npm run compile:es", "compile:commonjs": "cross-env BABEL_ENV=commonjs babel src --out-dir lib", "compile:es": "cross-env BABEL_ENV=es babel src --out-dir es --ignore test.js", diff --git a/src/Helmet.js b/src/Helmet.js index 56d04002..8dd32975 100644 --- a/src/Helmet.js +++ b/src/Helmet.js @@ -1,399 +1,21 @@ import React from "react"; import withSideEffect from "react-side-effect"; import deepEqual from "deep-equal"; -import objectAssign from "object-assign"; import { - ATTRIBUTE_NAMES, - HELMET_ATTRIBUTE, - HELMET_PROPS, - HTML_TAG_MAP, - REACT_TAG_MAP, - SELF_CLOSING_TAGS, - TAG_NAMES, - TAG_PROPERTIES -} from "./HelmetConstants.js"; - -const encodeSpecialCharacters = (str) => { - return String(str) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -}; - -const getInnermostProperty = (propsList, property) => { - for (let i = propsList.length - 1; i >= 0; i--) { - const props = propsList[i]; - - if (props[property]) { - return props[property]; - } - } - return null; -}; - -const getTitleFromPropsList = (propsList) => { - const innermostTitle = getInnermostProperty(propsList, TAG_NAMES.TITLE); - const innermostTemplate = getInnermostProperty(propsList, HELMET_PROPS.TITLE_TEMPLATE); - - if (innermostTemplate && innermostTitle) { - // use function arg to avoid need to escape $ characters - return innermostTemplate.replace(/%s/g, () => innermostTitle); - } - - const innermostDefaultTitle = getInnermostProperty(propsList, HELMET_PROPS.DEFAULT_TITLE); - - return innermostTitle || innermostDefaultTitle || ""; -}; - -const getOnChangeClientState = (propsList) => { - return getInnermostProperty(propsList, HELMET_PROPS.ON_CHANGE_CLIENT_STATE) ||(() => {}); -}; - -const getAttributesFromPropsList = (tagType, propsList) => { - return propsList - .filter(props => typeof props[tagType] !== "undefined") - .map(props => props[tagType]) - .reduce((tagAttrs, current) => { - return {...tagAttrs, ...current}; - }, {}); -}; - -const getBaseTagFromPropsList = (primaryAttributes, propsList) => { - return propsList - .filter(props => typeof props[TAG_NAMES.BASE] !== "undefined") - .map(props => props[TAG_NAMES.BASE]) - .reverse() - .reduce((innermostBaseTag, tag) => { - if (!innermostBaseTag.length) { - const keys = Object.keys(tag); - - for (let i = 0; i < keys.length; i++) { - const attributeKey = keys[i]; - const lowerCaseAttributeKey = attributeKey.toLowerCase(); - - if (primaryAttributes.indexOf(lowerCaseAttributeKey) !== -1 && - tag[lowerCaseAttributeKey]) { - return innermostBaseTag.concat(tag); - } - } - } - - return innermostBaseTag; - }, []); -}; - -const getTagsFromPropsList = (tagName, primaryAttributes, propsList) => { - // Calculate list of tags, giving priority innermost component (end of the propslist) - const approvedSeenTags = {}; - - return propsList - .filter((props) => { - if (Array.isArray(props[tagName])) { - return true; - } - if (typeof props[tagName] !== "undefined") { - warn(`Helmet: ${tagName} should be of type "Array". Instead found type "${typeof props[tagName]}"`); - } - return false; - }) - .map(props => props[tagName]) - .reverse() - .reduce((approvedTags, instanceTags) => { - const instanceSeenTags = {}; - - instanceTags.filter(tag => { - let primaryAttributeKey; - const keys = Object.keys(tag); - for (let i = 0; i < keys.length; i++) { - const attributeKey = keys[i]; - const lowerCaseAttributeKey = attributeKey.toLowerCase(); - - // Special rule with link tags, since rel and href are both primary tags, rel takes priority - if ( - primaryAttributes.indexOf(lowerCaseAttributeKey) !== -1 && - !( - primaryAttributeKey === TAG_PROPERTIES.REL && - tag[primaryAttributeKey].toLowerCase() === "canonical" - ) && - !( - lowerCaseAttributeKey === TAG_PROPERTIES.REL && - tag[lowerCaseAttributeKey].toLowerCase() === "stylesheet" - ) - ) { - primaryAttributeKey = lowerCaseAttributeKey; - } - // Special case for innerHTML which doesn't work lowercased - if ( - primaryAttributes.indexOf(attributeKey) !== -1 && - ( - attributeKey === TAG_PROPERTIES.INNER_HTML || - attributeKey === TAG_PROPERTIES.CSS_TEXT || - attributeKey === TAG_PROPERTIES.ITEM_PROP - ) - ) { - primaryAttributeKey = attributeKey; - } - } - - if (!primaryAttributeKey || !tag[primaryAttributeKey]) { - return false; - } - - const value = tag[primaryAttributeKey].toLowerCase(); - - if (!approvedSeenTags[primaryAttributeKey]) { - approvedSeenTags[primaryAttributeKey] = {}; - } - - if (!instanceSeenTags[primaryAttributeKey]) { - instanceSeenTags[primaryAttributeKey] = {}; - } - - if (!approvedSeenTags[primaryAttributeKey][value]) { - instanceSeenTags[primaryAttributeKey][value] = true; - return true; - } - - return false; - }) - .reverse() - .forEach(tag => approvedTags.push(tag)); - - // Update seen tags with tags from this instance - const keys = Object.keys(instanceSeenTags); - for (let i = 0; i < keys.length; i++) { - const attributeKey = keys[i]; - const tagUnion = objectAssign( - {}, - approvedSeenTags[attributeKey], - instanceSeenTags[attributeKey] - ); - - approvedSeenTags[attributeKey] = tagUnion; - } - - return approvedTags; - }, []) - .reverse(); -}; - -const updateTitle = (title, attributes) => { - document.title = title || document.title; - updateAttributes(TAG_NAMES.TITLE, attributes); -}; - -const updateAttributes = (tagName, attributes) => { - const elementTag = document.getElementsByTagName(tagName)[0]; - const helmetAttributeString = elementTag.getAttribute(HELMET_ATTRIBUTE); - const helmetAttributes = helmetAttributeString ? helmetAttributeString.split(",") : []; - const attributesToRemove = [].concat(helmetAttributes); - const attributeKeys = Object.keys(attributes); - - for (let i = 0; i < attributeKeys.length; i++) { - const attribute = attributeKeys[i]; - const value = attributes[attribute] || ""; - elementTag.setAttribute(attribute, value); - - if (helmetAttributes.indexOf(attribute) === -1) { - helmetAttributes.push(attribute); - } - - const indexToSave = attributesToRemove.indexOf(attribute); - if (indexToSave !== -1) { - attributesToRemove.splice(indexToSave, 1); - } - } - - for (let i = attributesToRemove.length - 1; i >= 0; i--) { - elementTag.removeAttribute(attributesToRemove[i]); - } - - if (helmetAttributes.length === attributesToRemove.length) { - elementTag.removeAttribute(HELMET_ATTRIBUTE); - } else { - elementTag.setAttribute(HELMET_ATTRIBUTE, helmetAttributes.join(",")); - } -}; - -const updateTags = (type, tags) => { - const headElement = document.head || document.querySelector(TAG_NAMES.HEAD); - const tagNodes = headElement.querySelectorAll(`${type}[${HELMET_ATTRIBUTE}]`); - const oldTags = Array.prototype.slice.call(tagNodes); - const newTags = []; - let indexToDelete; - - if (tags && tags.length) { - tags.forEach(tag => { - const newElement = document.createElement(type); - - for (const attribute in tag) { - if (tag.hasOwnProperty(attribute)) { - if (attribute === TAG_PROPERTIES.INNER_HTML) { - newElement.innerHTML = tag.innerHTML; - } else if (attribute === TAG_PROPERTIES.CSS_TEXT) { - if (newElement.styleSheet) { - newElement.styleSheet.cssText = tag.cssText; - } else { - newElement.appendChild(document.createTextNode(tag.cssText)); - } - } else { - const value = (typeof tag[attribute] === "undefined") ? "" : tag[attribute]; - newElement.setAttribute(attribute, value); - } - } - } - - newElement.setAttribute(HELMET_ATTRIBUTE, "true"); - - // Remove a duplicate tag from domTagstoRemove, so it isn't cleared. - if (oldTags.some((existingTag, index) => { - indexToDelete = index; - return newElement.isEqualNode(existingTag); - })) { - oldTags.splice(indexToDelete, 1); - } else { - newTags.push(newElement); - } - }); - } - - oldTags.forEach(tag => tag.parentNode.removeChild(tag)); - newTags.forEach(tag => headElement.appendChild(tag)); - - return { - oldTags, - newTags - }; -}; - -const generateElementAttributesAsString = (attributes) => Object.keys(attributes) - .reduce((str, key) => { - const attr = typeof attributes[key] !== "undefined" - ? `${key}="${attributes[key]}"` - : `${key}`; - return str ? `${str} ${attr}` : attr; - }, ""); - -const generateTitleAsString = (type, title, attributes) => { - const attributeString = generateElementAttributesAsString(attributes); - return attributeString - ? `<${type} ${HELMET_ATTRIBUTE}="true" ${attributeString}>${encodeSpecialCharacters(title)}` - : `<${type} ${HELMET_ATTRIBUTE}="true">${encodeSpecialCharacters(title)}`; -}; - -const generateTagsAsString = (type, tags) => tags.reduce((str, tag) => { - const attributeHtml = Object.keys(tag) - .filter(attribute => !(attribute === TAG_PROPERTIES.INNER_HTML || attribute === TAG_PROPERTIES.CSS_TEXT)) - .reduce((string, attribute) => { - const attr = typeof tag[attribute] === "undefined" - ? attribute - : `${attribute}="${encodeSpecialCharacters(tag[attribute])}"`; - return string ? `${string} ${attr}` : attr; - }, ""); - - const tagContent = tag.innerHTML || tag.cssText || ""; - - const isSelfClosing = SELF_CLOSING_TAGS.indexOf(type) === -1; - - return `${str}<${type} ${HELMET_ATTRIBUTE}="true" ${attributeHtml}${isSelfClosing ? `/>` : `>${tagContent}`}`; -}, ""); - -const convertElementAttributestoReactProps = (attributes, initProps = {}) => { - return Object.keys(attributes).reduce((obj, key) => { - obj[(REACT_TAG_MAP[key] || key)] = attributes[key]; - return obj; - }, initProps); -}; - -const convertReactPropstoHtmlAttributes = (props, initAttributes = {}) => { - return Object.keys(props).reduce((obj, key) => { - obj[(HTML_TAG_MAP[key] || key)] = props[key]; - return obj; - }, initAttributes); -}; - -const generateTitleAsReactComponent = (type, title, attributes) => { - // assigning into an array to define toString function on it - const initProps = { - key: title, - [HELMET_ATTRIBUTE]: true - }; - const props = convertElementAttributestoReactProps(attributes, initProps); - - return [React.createElement(TAG_NAMES.TITLE, props, title)]; -}; - -const generateTagsAsReactComponent = (type, tags) => tags.map((tag, i) => { - const mappedTag = { - key: i, - [HELMET_ATTRIBUTE]: true - }; - - Object.keys(tag).forEach((attribute) => { - const mappedAttribute = REACT_TAG_MAP[attribute] || attribute; - - if (mappedAttribute === TAG_PROPERTIES.INNER_HTML || mappedAttribute === TAG_PROPERTIES.CSS_TEXT) { - const content = tag.innerHTML || tag.cssText; - mappedTag.dangerouslySetInnerHTML = {__html: content}; - } else { - mappedTag[mappedAttribute] = tag[attribute]; - } - }); - - return React.createElement(type, mappedTag); -}); - -const getMethodsForTag = (type, tags) => { - switch (type) { - case TAG_NAMES.TITLE: - return { - toComponent: () => generateTitleAsReactComponent(type, tags.title, tags.titleAttributes), - toString: () => generateTitleAsString(type, tags.title, tags.titleAttributes) - }; - case ATTRIBUTE_NAMES.BODY: - case ATTRIBUTE_NAMES.HTML: - return { - toComponent: () => convertElementAttributestoReactProps(tags), - toString: () => generateElementAttributesAsString(tags) - }; - default: - return { - toComponent: () => generateTagsAsReactComponent(type, tags), - toString: () => generateTagsAsString(type, tags) - }; - } -}; - -const mapStateOnServer = ({ - baseTag, - bodyAttributes, - htmlAttributes, - linkTags, - metaTags, - noscriptTags, - scriptTags, - styleTags, - title, - titleAttributes -}) => ({ - base: getMethodsForTag(TAG_NAMES.BASE, baseTag), - bodyAttributes: getMethodsForTag(ATTRIBUTE_NAMES.BODY, bodyAttributes), - htmlAttributes: getMethodsForTag(ATTRIBUTE_NAMES.HTML, htmlAttributes), - link: getMethodsForTag(TAG_NAMES.LINK, linkTags), - meta: getMethodsForTag(TAG_NAMES.META, metaTags), - noscript: getMethodsForTag(TAG_NAMES.NOSCRIPT, noscriptTags), - script: getMethodsForTag(TAG_NAMES.SCRIPT, scriptTags), - style: getMethodsForTag(TAG_NAMES.STYLE, styleTags), - title: getMethodsForTag(TAG_NAMES.TITLE, {title, titleAttributes}) -}); + convertReactPropstoHtmlAttributes, + handleClientStateChange, + mapStateOnServer, + reducePropsToState, + warn +} from "./HelmetUtils.js"; +import {TAG_NAMES, VALID_TAG_NAMES} from "./HelmetConstants.js"; const Helmet = (Component) => class HelmetWrapper extends React.Component { /** * @param {Object} base: {"target": "_blank", "href": "http://mysite.com/"} * @param {Object} bodyAttributes: {"className": "root"} * @param {String} defaultTitle: "Default Title" + * @param {Boolean} encodeSpecialCharacters: true * @param {Object} htmlAttributes: {"lang": "en", "amp": undefined} * @param {Array} link: [{"rel": "canonical", "href": "http://mysite.com/example"}] * @param {Array} meta: [{"name": "description", "content": "Test description"}] @@ -413,6 +35,7 @@ const Helmet = (Component) => class HelmetWrapper extends React.Component { React.PropTypes.node ]), defaultTitle: React.PropTypes.string, + encodeSpecialCharacters: React.PropTypes.bool, htmlAttributes: React.PropTypes.object, link: React.PropTypes.arrayOf(React.PropTypes.object), meta: React.PropTypes.arrayOf(React.PropTypes.object), @@ -425,6 +48,10 @@ const Helmet = (Component) => class HelmetWrapper extends React.Component { titleTemplate: React.PropTypes.string }; + static defaultProps = { + encodeSpecialCharacters: true + }; + // Component.peek comes from react-side-effect: // For testing, you may use a static peek() method available on the returned component. // It lets you get the current state without resetting the mounted instance stack. @@ -438,6 +65,7 @@ const Helmet = (Component) => class HelmetWrapper extends React.Component { mappedState = mapStateOnServer({ baseTag: [], bodyAttributes: {}, + encodeSpecialCharacters: true, htmlAttributes: {}, linkTags: [], metaTags: [], @@ -478,7 +106,7 @@ const Helmet = (Component) => class HelmetWrapper extends React.Component { }; } - return nestedChildren; + throw new Error(`<${child.type} /> elements are self-closing and can not contain children. Refer to our API for more information.`); } flattenArrayTypeChildren({ @@ -548,12 +176,22 @@ const Helmet = (Component) => class HelmetWrapper extends React.Component { warnOnInvalidChildren(child, nestedChildren) { if ( - process.env.NODE_ENV !== "production" && - nestedChildren && - typeof nestedChildren !== "string" + process.env.NODE_ENV !== "production" ) { - console.warn(`Helmet expects a single string as a child of ${child.type}`); + if (!VALID_TAG_NAMES.some(name => child.type === name)) { + if (typeof child.type === "function") { + return warn(`You may be attempting to nest components within each other, which is not allowed. Refer to our API for more information.`); + } + + return warn(`Only elements types ${VALID_TAG_NAMES.join(", ")} are allowed. Helmet does not support rendering <${child.type}> elements. Refer to our API for more information.`); + } + + if (nestedChildren && typeof nestedChildren !== "string") { + throw new Error(`Helmet expects a string as a child of <${child.type}>. Did you forget to wrap your children in braces? ( <${child.type}>{\`\`} ) Refer to our API for more information.`); + } } + + return true; } mapChildrenToProps(children, newProps) { @@ -606,88 +244,6 @@ const Helmet = (Component) => class HelmetWrapper extends React.Component { } }; -const reducePropsToState = (propsList) => ({ - baseTag: getBaseTagFromPropsList([ - TAG_PROPERTIES.HREF - ], propsList), - bodyAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.BODY, propsList), - htmlAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.HTML, propsList), - linkTags: getTagsFromPropsList(TAG_NAMES.LINK, [ - TAG_PROPERTIES.REL, - TAG_PROPERTIES.HREF - ], propsList), - metaTags: getTagsFromPropsList(TAG_NAMES.META, [ - TAG_PROPERTIES.NAME, - TAG_PROPERTIES.CHARSET, - TAG_PROPERTIES.HTTPEQUIV, - TAG_PROPERTIES.PROPERTY, - TAG_PROPERTIES.ITEM_PROP - ], propsList), - noscriptTags: getTagsFromPropsList(TAG_NAMES.NOSCRIPT, [ - TAG_PROPERTIES.INNER_HTML - ], propsList), - onChangeClientState: getOnChangeClientState(propsList), - scriptTags: getTagsFromPropsList(TAG_NAMES.SCRIPT, [ - TAG_PROPERTIES.SRC, - TAG_PROPERTIES.INNER_HTML - ], propsList), - styleTags: getTagsFromPropsList(TAG_NAMES.STYLE, [ - TAG_PROPERTIES.CSS_TEXT - ], propsList), - title: getTitleFromPropsList(propsList), - titleAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.TITLE, propsList) -}); - -const handleClientStateChange = (newState) => { - const { - baseTag, - bodyAttributes, - htmlAttributes, - linkTags, - metaTags, - noscriptTags, - onChangeClientState, - scriptTags, - styleTags, - title, - titleAttributes - } = newState; - - updateAttributes(TAG_NAMES.BODY, bodyAttributes); - updateAttributes(TAG_NAMES.HTML, htmlAttributes); - - updateTitle(title, titleAttributes); - - const tagUpdates = { - baseTag: updateTags(TAG_NAMES.BASE, baseTag), - linkTags: updateTags(TAG_NAMES.LINK, linkTags), - metaTags: updateTags(TAG_NAMES.META, metaTags), - noscriptTags: updateTags(TAG_NAMES.NOSCRIPT, noscriptTags), - scriptTags: updateTags(TAG_NAMES.SCRIPT, scriptTags), - styleTags: updateTags(TAG_NAMES.STYLE, styleTags) - }; - - const addedTags = {}; - const removedTags = {}; - - Object.keys(tagUpdates).forEach(tagType => { - const {newTags, oldTags} = tagUpdates[tagType]; - - if (newTags.length) { - addedTags[tagType] = newTags; - } - if (oldTags.length) { - removedTags[tagType] = tagUpdates[tagType].oldTags; - } - }); - - onChangeClientState(newState, addedTags, removedTags); -}; - -const warn = (msg) => { - return console && typeof console.warn === "function" && console.warn(msg); -}; - const NullComponent = () => null; const HelmetSideEffects = withSideEffect( diff --git a/src/HelmetConstants.js b/src/HelmetConstants.js index d8a8838b..dfe384e0 100644 --- a/src/HelmetConstants.js +++ b/src/HelmetConstants.js @@ -17,6 +17,10 @@ export const TAG_NAMES = { TITLE: "title" }; +export const VALID_TAG_NAMES = Object + .keys(TAG_NAMES) + .map(name => TAG_NAMES[name]); + export const TAG_PROPERTIES = { CHARSET: "charset", CSS_TEXT: "cssText", @@ -43,6 +47,7 @@ export const REACT_TAG_MAP = { export const HELMET_PROPS = { DEFAULT_TITLE: "defaultTitle", + ENCODE_SPECIAL_CHARACTERS: "encodeSpecialCharacters", ON_CHANGE_CLIENT_STATE: "onChangeClientState", TITLE_TEMPLATE: "titleTemplate" }; diff --git a/src/HelmetUtils.js b/src/HelmetUtils.js new file mode 100644 index 00000000..802c1b53 --- /dev/null +++ b/src/HelmetUtils.js @@ -0,0 +1,530 @@ +import React from "react"; +import objectAssign from "object-assign"; +import { + ATTRIBUTE_NAMES, + HELMET_ATTRIBUTE, + HELMET_PROPS, + HTML_TAG_MAP, + REACT_TAG_MAP, + SELF_CLOSING_TAGS, + TAG_NAMES, + TAG_PROPERTIES +} from "./HelmetConstants.js"; + +const encodeSpecialCharacters = (str, encode = true) => { + if (encode === false) { + return String(str); + } + + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +}; + +const getTitleFromPropsList = (propsList) => { + const innermostTitle = getInnermostProperty(propsList, TAG_NAMES.TITLE); + const innermostTemplate = getInnermostProperty(propsList, HELMET_PROPS.TITLE_TEMPLATE); + + if (innermostTemplate && innermostTitle) { + // use function arg to avoid need to escape $ characters + return innermostTemplate.replace(/%s/g, () => innermostTitle); + } + + const innermostDefaultTitle = getInnermostProperty(propsList, HELMET_PROPS.DEFAULT_TITLE); + + return innermostTitle || innermostDefaultTitle || ""; +}; + +const getOnChangeClientState = (propsList) => { + return getInnermostProperty(propsList, HELMET_PROPS.ON_CHANGE_CLIENT_STATE) ||(() => {}); +}; + +const getAttributesFromPropsList = (tagType, propsList) => { + return propsList + .filter(props => typeof props[tagType] !== "undefined") + .map(props => props[tagType]) + .reduce((tagAttrs, current) => { + return {...tagAttrs, ...current}; + }, {}); +}; + +const getBaseTagFromPropsList = (primaryAttributes, propsList) => { + return propsList + .filter(props => typeof props[TAG_NAMES.BASE] !== "undefined") + .map(props => props[TAG_NAMES.BASE]) + .reverse() + .reduce((innermostBaseTag, tag) => { + if (!innermostBaseTag.length) { + const keys = Object.keys(tag); + + for (let i = 0; i < keys.length; i++) { + const attributeKey = keys[i]; + const lowerCaseAttributeKey = attributeKey.toLowerCase(); + + if (primaryAttributes.indexOf(lowerCaseAttributeKey) !== -1 && + tag[lowerCaseAttributeKey]) { + return innermostBaseTag.concat(tag); + } + } + } + + return innermostBaseTag; + }, []); +}; + +const getTagsFromPropsList = (tagName, primaryAttributes, propsList) => { + // Calculate list of tags, giving priority innermost component (end of the propslist) + const approvedSeenTags = {}; + + return propsList + .filter((props) => { + if (Array.isArray(props[tagName])) { + return true; + } + if (typeof props[tagName] !== "undefined") { + warn(`Helmet: ${tagName} should be of type "Array". Instead found type "${typeof props[tagName]}"`); + } + return false; + }) + .map(props => props[tagName]) + .reverse() + .reduce((approvedTags, instanceTags) => { + const instanceSeenTags = {}; + + instanceTags.filter(tag => { + let primaryAttributeKey; + const keys = Object.keys(tag); + for (let i = 0; i < keys.length; i++) { + const attributeKey = keys[i]; + const lowerCaseAttributeKey = attributeKey.toLowerCase(); + + // Special rule with link tags, since rel and href are both primary tags, rel takes priority + if ( + primaryAttributes.indexOf(lowerCaseAttributeKey) !== -1 && + !( + primaryAttributeKey === TAG_PROPERTIES.REL && + tag[primaryAttributeKey].toLowerCase() === "canonical" + ) && + !( + lowerCaseAttributeKey === TAG_PROPERTIES.REL && + tag[lowerCaseAttributeKey].toLowerCase() === "stylesheet" + ) + ) { + primaryAttributeKey = lowerCaseAttributeKey; + } + // Special case for innerHTML which doesn't work lowercased + if ( + primaryAttributes.indexOf(attributeKey) !== -1 && + ( + attributeKey === TAG_PROPERTIES.INNER_HTML || + attributeKey === TAG_PROPERTIES.CSS_TEXT || + attributeKey === TAG_PROPERTIES.ITEM_PROP + ) + ) { + primaryAttributeKey = attributeKey; + } + } + + if (!primaryAttributeKey || !tag[primaryAttributeKey]) { + return false; + } + + const value = tag[primaryAttributeKey].toLowerCase(); + + if (!approvedSeenTags[primaryAttributeKey]) { + approvedSeenTags[primaryAttributeKey] = {}; + } + + if (!instanceSeenTags[primaryAttributeKey]) { + instanceSeenTags[primaryAttributeKey] = {}; + } + + if (!approvedSeenTags[primaryAttributeKey][value]) { + instanceSeenTags[primaryAttributeKey][value] = true; + return true; + } + + return false; + }) + .reverse() + .forEach(tag => approvedTags.push(tag)); + + // Update seen tags with tags from this instance + const keys = Object.keys(instanceSeenTags); + for (let i = 0; i < keys.length; i++) { + const attributeKey = keys[i]; + const tagUnion = objectAssign( + {}, + approvedSeenTags[attributeKey], + instanceSeenTags[attributeKey] + ); + + approvedSeenTags[attributeKey] = tagUnion; + } + + return approvedTags; + }, []) + .reverse(); +}; + +const getInnermostProperty = (propsList, property) => { + for (let i = propsList.length - 1; i >= 0; i--) { + const props = propsList[i]; + + if (props.hasOwnProperty(property)) { + return props[property]; + } + } + + return null; +}; + +const reducePropsToState = (propsList) => ({ + baseTag: getBaseTagFromPropsList([ + TAG_PROPERTIES.HREF + ], propsList), + bodyAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.BODY, propsList), + encode: getInnermostProperty(propsList, HELMET_PROPS.ENCODE_SPECIAL_CHARACTERS), + htmlAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.HTML, propsList), + linkTags: getTagsFromPropsList(TAG_NAMES.LINK, [ + TAG_PROPERTIES.REL, + TAG_PROPERTIES.HREF + ], propsList), + metaTags: getTagsFromPropsList(TAG_NAMES.META, [ + TAG_PROPERTIES.NAME, + TAG_PROPERTIES.CHARSET, + TAG_PROPERTIES.HTTPEQUIV, + TAG_PROPERTIES.PROPERTY, + TAG_PROPERTIES.ITEM_PROP + ], propsList), + noscriptTags: getTagsFromPropsList(TAG_NAMES.NOSCRIPT, [ + TAG_PROPERTIES.INNER_HTML + ], propsList), + onChangeClientState: getOnChangeClientState(propsList), + scriptTags: getTagsFromPropsList(TAG_NAMES.SCRIPT, [ + TAG_PROPERTIES.SRC, + TAG_PROPERTIES.INNER_HTML + ], propsList), + styleTags: getTagsFromPropsList(TAG_NAMES.STYLE, [ + TAG_PROPERTIES.CSS_TEXT + ], propsList), + title: getTitleFromPropsList(propsList), + titleAttributes: getAttributesFromPropsList(ATTRIBUTE_NAMES.TITLE, propsList) +}); + +const requestIdleCallback = (() => { + if (typeof window !== "undefined" && typeof window.requestIdleCallback !== "undefined") { + return window.requestIdleCallback; + } + + return (cb) => { + const start = Date.now(); + return setTimeout(() => { + cb({ + didTimeout: false, + timeRemaining() { + return Math.max(0, 50 - (Date.now() - start)); + } + }); + }, 1); + }; +})(); + +const cancelIdleCallback = (() => { + if (typeof window !== "undefined" && typeof window.cancelIdleCallback !== "undefined") { + return window.cancelIdleCallback; + } + + return (id) => clearTimeout(id); +})(); + +const warn = (msg) => { + return console && typeof console.warn === "function" && console.warn(msg); +}; + +let _helmetIdleCallback = null; + +const handleClientStateChange = (newState) => { + const { + baseTag, + bodyAttributes, + htmlAttributes, + linkTags, + metaTags, + noscriptTags, + onChangeClientState, + scriptTags, + styleTags, + title, + titleAttributes + } = newState; + + if (_helmetIdleCallback) { + cancelIdleCallback(_helmetIdleCallback); + } + + _helmetIdleCallback = requestIdleCallback(() => { + updateAttributes(TAG_NAMES.BODY, bodyAttributes); + updateAttributes(TAG_NAMES.HTML, htmlAttributes); + + updateTitle(title, titleAttributes); + + const tagUpdates = { + baseTag: updateTags(TAG_NAMES.BASE, baseTag), + linkTags: updateTags(TAG_NAMES.LINK, linkTags), + metaTags: updateTags(TAG_NAMES.META, metaTags), + noscriptTags: updateTags(TAG_NAMES.NOSCRIPT, noscriptTags), + scriptTags: updateTags(TAG_NAMES.SCRIPT, scriptTags), + styleTags: updateTags(TAG_NAMES.STYLE, styleTags) + }; + + const addedTags = {}; + const removedTags = {}; + + Object.keys(tagUpdates).forEach(tagType => { + const {newTags, oldTags} = tagUpdates[tagType]; + + if (newTags.length) { + addedTags[tagType] = newTags; + } + if (oldTags.length) { + removedTags[tagType] = tagUpdates[tagType].oldTags; + } + }); + + _helmetIdleCallback = null; + onChangeClientState(newState, addedTags, removedTags); + }); +}; + +const updateTitle = (title, attributes) => { + if (document.title !== title) { + document.title = title; + } + + updateAttributes(TAG_NAMES.TITLE, attributes); +}; + +const updateAttributes = (tagName, attributes) => { + const elementTag = document.getElementsByTagName(tagName)[0]; + + if (!elementTag) { + return; + } + + const helmetAttributeString = elementTag.getAttribute(HELMET_ATTRIBUTE); + const helmetAttributes = helmetAttributeString ? helmetAttributeString.split(",") : []; + const attributesToRemove = [].concat(helmetAttributes); + const attributeKeys = Object.keys(attributes); + + for (let i = 0; i < attributeKeys.length; i++) { + const attribute = attributeKeys[i]; + const value = attributes[attribute] || ""; + + if (elementTag.getAttribute(attribute) !== value) { + elementTag.setAttribute(attribute, value); + } + + if (helmetAttributes.indexOf(attribute) === -1) { + helmetAttributes.push(attribute); + } + + const indexToSave = attributesToRemove.indexOf(attribute); + if (indexToSave !== -1) { + attributesToRemove.splice(indexToSave, 1); + } + } + + for (let i = attributesToRemove.length - 1; i >= 0; i--) { + elementTag.removeAttribute(attributesToRemove[i]); + } + + if (helmetAttributes.length === attributesToRemove.length) { + elementTag.removeAttribute(HELMET_ATTRIBUTE); + } else if (elementTag.getAttribute(HELMET_ATTRIBUTE) !== attributeKeys.join(",")) { + elementTag.setAttribute(HELMET_ATTRIBUTE, attributeKeys.join(",")); + } +}; + +const updateTags = (type, tags) => { + const headElement = document.head || document.querySelector(TAG_NAMES.HEAD); + const tagNodes = headElement.querySelectorAll(`${type}[${HELMET_ATTRIBUTE}]`); + const oldTags = Array.prototype.slice.call(tagNodes); + const newTags = []; + let indexToDelete; + + if (tags && tags.length) { + tags.forEach(tag => { + const newElement = document.createElement(type); + + for (const attribute in tag) { + if (tag.hasOwnProperty(attribute)) { + if (attribute === TAG_PROPERTIES.INNER_HTML) { + newElement.innerHTML = tag.innerHTML; + } else if (attribute === TAG_PROPERTIES.CSS_TEXT) { + if (newElement.styleSheet) { + newElement.styleSheet.cssText = tag.cssText; + } else { + newElement.appendChild(document.createTextNode(tag.cssText)); + } + } else { + const value = (typeof tag[attribute] === "undefined") ? "" : tag[attribute]; + newElement.setAttribute(attribute, value); + } + } + } + + newElement.setAttribute(HELMET_ATTRIBUTE, "true"); + + // Remove a duplicate tag from domTagstoRemove, so it isn't cleared. + if (oldTags.some((existingTag, index) => { + indexToDelete = index; + return newElement.isEqualNode(existingTag); + })) { + oldTags.splice(indexToDelete, 1); + } else { + newTags.push(newElement); + } + }); + } + + oldTags.forEach(tag => tag.parentNode.removeChild(tag)); + newTags.forEach(tag => headElement.appendChild(tag)); + + return { + oldTags, + newTags + }; +}; + +const generateElementAttributesAsString = (attributes) => Object.keys(attributes) + .reduce((str, key) => { + const attr = typeof attributes[key] !== "undefined" + ? `${key}="${attributes[key]}"` + : `${key}`; + return str ? `${str} ${attr}` : attr; + }, ""); + +const generateTitleAsString = (type, title, attributes, encode) => { + const attributeString = generateElementAttributesAsString(attributes); + return attributeString + ? `<${type} ${HELMET_ATTRIBUTE}="true" ${attributeString}>${encodeSpecialCharacters(title, encode)}` + : `<${type} ${HELMET_ATTRIBUTE}="true">${encodeSpecialCharacters(title, encode)}`; +}; + +const generateTagsAsString = (type, tags, encode) => tags.reduce((str, tag) => { + const attributeHtml = Object.keys(tag) + .filter(attribute => !(attribute === TAG_PROPERTIES.INNER_HTML || attribute === TAG_PROPERTIES.CSS_TEXT)) + .reduce((string, attribute) => { + const attr = typeof tag[attribute] === "undefined" + ? attribute + : `${attribute}="${encodeSpecialCharacters(tag[attribute], encode)}"`; + return string ? `${string} ${attr}` : attr; + }, ""); + + const tagContent = tag.innerHTML || tag.cssText || ""; + + const isSelfClosing = SELF_CLOSING_TAGS.indexOf(type) === -1; + + return `${str}<${type} ${HELMET_ATTRIBUTE}="true" ${attributeHtml}${isSelfClosing ? `/>` : `>${tagContent}`}`; +}, ""); + +const convertElementAttributestoReactProps = (attributes, initProps = {}) => { + return Object.keys(attributes).reduce((obj, key) => { + obj[(REACT_TAG_MAP[key] || key)] = attributes[key]; + return obj; + }, initProps); +}; + +const convertReactPropstoHtmlAttributes = (props, initAttributes = {}) => { + return Object.keys(props).reduce((obj, key) => { + obj[(HTML_TAG_MAP[key] || key)] = props[key]; + return obj; + }, initAttributes); +}; + +const generateTitleAsReactComponent = (type, title, attributes) => { + // assigning into an array to define toString function on it + const initProps = { + key: title, + [HELMET_ATTRIBUTE]: true + }; + const props = convertElementAttributestoReactProps(attributes, initProps); + + return [React.createElement(TAG_NAMES.TITLE, props, title)]; +}; + +const generateTagsAsReactComponent = (type, tags) => tags.map((tag, i) => { + const mappedTag = { + key: i, + [HELMET_ATTRIBUTE]: true + }; + + Object.keys(tag).forEach((attribute) => { + const mappedAttribute = REACT_TAG_MAP[attribute] || attribute; + + if (mappedAttribute === TAG_PROPERTIES.INNER_HTML || mappedAttribute === TAG_PROPERTIES.CSS_TEXT) { + const content = tag.innerHTML || tag.cssText; + mappedTag.dangerouslySetInnerHTML = {__html: content}; + } else { + mappedTag[mappedAttribute] = tag[attribute]; + } + }); + + return React.createElement(type, mappedTag); +}); + +const getMethodsForTag = (type, tags, encode) => { + switch (type) { + case TAG_NAMES.TITLE: + return { + toComponent: () => generateTitleAsReactComponent(type, tags.title, tags.titleAttributes, encode), + toString: () => generateTitleAsString(type, tags.title, tags.titleAttributes, encode) + }; + case ATTRIBUTE_NAMES.BODY: + case ATTRIBUTE_NAMES.HTML: + return { + toComponent: () => convertElementAttributestoReactProps(tags), + toString: () => generateElementAttributesAsString(tags) + }; + default: + return { + toComponent: () => generateTagsAsReactComponent(type, tags), + toString: () => generateTagsAsString(type, tags, encode) + }; + } +}; + +const mapStateOnServer = ({ + baseTag, + bodyAttributes, + encode, + htmlAttributes, + linkTags, + metaTags, + noscriptTags, + scriptTags, + styleTags, + title, + titleAttributes +}) => ({ + base: getMethodsForTag(TAG_NAMES.BASE, baseTag, encode), + bodyAttributes: getMethodsForTag(ATTRIBUTE_NAMES.BODY, bodyAttributes, encode), + htmlAttributes: getMethodsForTag(ATTRIBUTE_NAMES.HTML, htmlAttributes, encode), + link: getMethodsForTag(TAG_NAMES.LINK, linkTags, encode), + meta: getMethodsForTag(TAG_NAMES.META, metaTags, encode), + noscript: getMethodsForTag(TAG_NAMES.NOSCRIPT, noscriptTags, encode), + script: getMethodsForTag(TAG_NAMES.SCRIPT, scriptTags, encode), + style: getMethodsForTag(TAG_NAMES.STYLE, styleTags, encode), + title: getMethodsForTag(TAG_NAMES.TITLE, {title, titleAttributes}, encode) +}); + +export {convertReactPropstoHtmlAttributes}; +export {handleClientStateChange}; +export {mapStateOnServer}; +export {reducePropsToState}; +export {requestIdleCallback}; +export {warn}; diff --git a/src/test/HelmetDeclarativeTest.js b/src/test/HelmetDeclarativeTest.js deleted file mode 100644 index 325e171b..00000000 --- a/src/test/HelmetDeclarativeTest.js +++ /dev/null @@ -1,2705 +0,0 @@ -/* eslint max-nested-callbacks: [1, 6] */ -/* eslint-disable react/jsx-sort-props */ -/* eslint-disable jsx-a11y/html-has-lang */ - -import React from "react"; -import ReactDOM from "react-dom"; -import ReactServer from "react-dom/server"; -import {Helmet} from "../Helmet"; -import {HTML_TAG_MAP} from "../HelmetConstants"; - -const HELMET_ATTRIBUTE = "data-react-helmet"; - -describe("Helmet - Declarative API", () => { - let headElement; - - const container = document.createElement("div"); - - beforeEach(() => { - headElement = headElement || document.head || document.querySelector("head"); - }); - - afterEach(() => { - ReactDOM.unmountComponentAtNode(container); - }); - - describe("api", () => { - describe("title", () => { - it("will update page title", () => { - ReactDOM.render( - - Test Title - , - container - ); - - expect(document.title).to.equal("Test Title"); - }); - - it("will update page title with multiple children", () => { - ReactDOM.render( -
- - Test Title - - - Child One Title - - - Child Two Title - -
, - container - ); - - expect(document.title).to.equal("Child Two Title"); - }); - - it("will set title based on deepest nested component", () => { - ReactDOM.render( -
- - Main Title - - - Nested Title - -
, - container - ); - - expect(document.title).to.equal("Nested Title"); - }); - - it("will set title using deepest nested component with a defined title", () => { - ReactDOM.render( -
- - Main Title - - -
, - container - ); - - expect(document.title).to.equal("Main Title"); - }); - - it("will use defaultTitle if no title is defined", () => { - ReactDOM.render( - - - </Helmet>, - container - ); - - expect(document.title).to.equal("Fallback"); - }); - - it("will use a titleTemplate if defined", () => { - ReactDOM.render( - <Helmet - defaultTitle={"Fallback"} - titleTemplate={"This is a %s of the titleTemplate feature"} - > - <title>Test - , - container - ); - - expect(document.title).to.equal("This is a Test of the titleTemplate feature"); - }); - - it("will replace multiple title strings in titleTemplate", () => { - ReactDOM.render( - - Test - , - container - ); - - expect(document.title).to.equal("This is a Test of the titleTemplate feature. Another Test."); - }); - - it("will use a titleTemplate based on deepest nested component", () => { - ReactDOM.render( -
- - Test - - - Second Test - -
, - container - ); - - expect(document.title).to.equal("A Second Test using nested titleTemplate attributes"); - }); - - it("will merge deepest component title with nearest upstream titleTemplate", () => { - ReactDOM.render( -
- - Test - - - Second Test - -
, - container - ); - - expect(document.title).to.equal("This is a Second Test of the titleTemplate feature"); - }); - - it("will render dollar characters in a title correctly when titleTemplate present", () => { - const dollarTitle = "te$t te$$t te$$$t te$$$$t"; - - ReactDOM.render( - - {dollarTitle} - , - container - ); - - expect(document.title).to.equal("This is a te$t te$$t te$$$t te$$$$t"); - }); - - it("will not encode all characters with HTML character entity equivalents", () => { - const chineseTitle = "膣膗 鍆錌雔"; - - ReactDOM.render( - - {chineseTitle} - , - container - ); - - expect(document.title).to.equal(chineseTitle); - }); - - it("page tite with prop itemprop", () => { - ReactDOM.render( - - Test Title with itemProp - , - container - ); - - const titleTag = document.getElementsByTagName("title")[0]; - expect(document.title).to.equal("Test Title with itemProp"); - expect(titleTag.getAttribute("itemprop")).to.equal("name"); - }); - }); - - describe("title attributes", () => { - it("updates title attributes", () => { - ReactDOM.render( - - - </Helmet>, - container - ); - - const titleTag = document.getElementsByTagName("title")[0]; - - expect(titleTag.getAttribute("itemprop")).to.equal("name"); - expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("itemprop"); - }); - - it("set attributes based on the deepest nested component", () => { - ReactDOM.render( - <div> - <Helmet> - <title lang="en" hidden /> - </Helmet> - <Helmet> - <title lang="ja" /> - </Helmet> - </div>, - container - ); - - const titleTag = document.getElementsByTagName("title")[0]; - - expect(titleTag.getAttribute("lang")).to.equal("ja"); - expect(titleTag.getAttribute("hidden")).to.equal("true"); - expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,hidden"); - }); - - it("handle valueless attributes", () => { - ReactDOM.render( - <Helmet> - <title hidden /> - </Helmet>, - container - ); - - const titleTag = document.getElementsByTagName("title")[0]; - - expect(titleTag.getAttribute("hidden")).to.equal("true"); - expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("hidden"); - }); - - it("clears title attributes that are handled within helmet", () => { - ReactDOM.render( - <Helmet> - <title lang="en" hidden /> - </Helmet>, - container - ); - - ReactDOM.render( - <Helmet />, - container - ); - - const titleTag = document.getElementsByTagName("title")[0]; - - expect(titleTag.getAttribute("lang")).to.be.null; - expect(titleTag.getAttribute("hidden")).to.be.null; - expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); - }); - }); - - describe("html attributes", () => { - it("updates multiple html attributes", () => { - ReactDOM.render( - <Helmet> - <html className="myClassName" lang="en" /> - </Helmet>, - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("class")).to.equal("myClassName"); - expect(htmlTag.getAttribute("lang")).to.equal("en"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("class,lang"); - }); - - it("set attributes based on the deepest nested component", () => { - ReactDOM.render( - <div> - <Helmet> - <html lang="en" /> - </Helmet> - <Helmet> - <html lang="ja" /> - </Helmet> - </div>, - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("lang")).to.equal("ja"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang"); - }); - - it("handle valueless attributes", () => { - ReactDOM.render( - <Helmet> - <html amp /> - </Helmet>, - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("amp")).to.equal("true"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("amp"); - }); - - it("clears html attributes that are handled within helmet", () => { - ReactDOM.render( - <Helmet> - <html lang="en" amp /> - </Helmet>, - container - ); - - ReactDOM.render( - <Helmet />, - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("lang")).to.be.null; - expect(htmlTag.getAttribute("amp")).to.be.null; - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); - }); - - it("updates with multiple additions and removals - overwrite and new", () => { - ReactDOM.render( - <Helmet> - <html lang="en" amp /> - </Helmet>, - container - ); - - ReactDOM.render( - <Helmet> - <html lang="ja" id="html-tag" title="html tag" /> - </Helmet>, - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("amp")).to.equal(null); - expect(htmlTag.getAttribute("lang")).to.equal("ja"); - expect(htmlTag.getAttribute("id")).to.equal("html-tag"); - expect(htmlTag.getAttribute("title")).to.equal("html tag"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,amp,id,title"); - }); - - it("updates with multiple additions and removals - all new", () => { - ReactDOM.render( - <Helmet> - <html lang="en" amp /> - </Helmet>, - container - ); - - ReactDOM.render( - <Helmet> - <html id="html-tag" title="html tag" /> - </Helmet>, - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("amp")).to.equal(null); - expect(htmlTag.getAttribute("lang")).to.equal(null); - expect(htmlTag.getAttribute("id")).to.equal("html-tag"); - expect(htmlTag.getAttribute("title")).to.equal("html tag"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,amp,id,title"); - }); - - context("initialized outside of helmet", () => { - before(() => { - const htmlTag = document.getElementsByTagName("html")[0]; - htmlTag.setAttribute("test", "test"); - }); - - it("will not be cleared", () => { - ReactDOM.render( - <Helmet />, - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("test")).to.equal("test"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); - }); - - it("will be overwritten if specified in helmet", () => { - ReactDOM.render( - <Helmet> - <html test="helmet-attr" /> - </Helmet>, - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("test")).to.equal("helmet-attr"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("test"); - }); - - it("can be cleared once it is managed in helmet", () => { - ReactDOM.render( - <Helmet> - <html test="helmet-attr" /> - </Helmet>, - container - ); - - ReactDOM.render( - <Helmet />, - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("test")).to.equal(null); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); - }); - }); - }); - - describe("body attributes", () => { - context("valid attributes", () => { - const attributeList = { - "accessKey": "c", - "className": "test", - "contentEditable": "true", - "contextMenu": "mymenu", - "data-animal-type": "lion", - "dir": "rtl", - "draggable": "true", - "dropzone": "copy", - "hidden": "true", - "id": "test", - "lang": "fr", - "spellcheck": "true", - "style": "color:green", - "tabIndex": "-1", - "title": "test", - "translate": "no" - }; - - Object.keys(attributeList).forEach(attribute => { - it(attribute, () => { - const attrValue = attributeList[attribute]; - - const attr = { - [attribute]: attrValue - }; - - // console.log(attr, attribute, attrValue); - - ReactDOM.render( - <Helmet> - <body {...attr} /> - </Helmet>, - container - ); - - const bodyTag = document.body; - - const reactCompatAttr = HTML_TAG_MAP[attribute] || attribute; - expect(bodyTag.getAttribute(reactCompatAttr)).to.equal(attrValue); - expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(reactCompatAttr); - }); - }); - }); - - it("updates multiple body attributes", () => { - ReactDOM.render( - <Helmet> - <body className="myClassName" tabIndex={-1} /> - </Helmet>, - container - ); - - const bodyTag = document.body; - - expect(bodyTag.getAttribute("class")).to.equal("myClassName"); - expect(bodyTag.getAttribute("tabindex")).to.equal("-1"); - expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("class,tabindex"); - }); - - it("set attributes based on the deepest nested component", () => { - ReactDOM.render( - <div> - <Helmet> - <body lang="en" /> - </Helmet> - <Helmet> - <body lang="ja" /> - </Helmet> - </div>, - container - ); - - const bodyTag = document.body; - - expect(bodyTag.getAttribute("lang")).to.equal("ja"); - expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang"); - }); - - it("handle valueless attributes", () => { - ReactDOM.render( - <Helmet> - <body hidden /> - </Helmet>, - container - ); - - const bodyTag = document.body; - - expect(bodyTag.getAttribute("hidden")).to.equal("true"); - expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("hidden"); - }); - - it("clears body attributes that are handled within helmet", () => { - ReactDOM.render( - <Helmet> - <body lang="en" hidden /> - </Helmet>, - container - ); - - ReactDOM.render( - <Helmet />, - container - ); - - const bodyTag = document.body; - - expect(bodyTag.getAttribute("lang")).to.be.null; - expect(bodyTag.getAttribute("hidden")).to.be.null; - expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); - }); - - it("updates with multiple additions and removals - overwrite and new", () => { - ReactDOM.render( - <Helmet> - <body lang="en" hidden /> - </Helmet>, - container - ); - - ReactDOM.render( - <Helmet> - <body lang="ja" id="body-tag" title="body tag" /> - </Helmet>, - container - ); - - const bodyTag = document.body; - - expect(bodyTag.getAttribute("hidden")).to.equal(null); - expect(bodyTag.getAttribute("lang")).to.equal("ja"); - expect(bodyTag.getAttribute("id")).to.equal("body-tag"); - expect(bodyTag.getAttribute("title")).to.equal("body tag"); - expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,hidden,id,title"); - }); - - it("updates with multiple additions and removals - all new", () => { - ReactDOM.render( - <Helmet> - <body lang="en" hidden /> - </Helmet>, - container - ); - - ReactDOM.render( - <Helmet> - <body id="body-tag" title="body tag" /> - </Helmet>, - container - ); - - const bodyTag = document.body; - - expect(bodyTag.getAttribute("hidden")).to.equal(null); - expect(bodyTag.getAttribute("lang")).to.equal(null); - expect(bodyTag.getAttribute("id")).to.equal("body-tag"); - expect(bodyTag.getAttribute("title")).to.equal("body tag"); - expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,hidden,id,title"); - }); - - context("initialized outside of helmet", () => { - before(() => { - const bodyTag = document.body; - bodyTag.setAttribute("test", "test"); - }); - - it("will not be cleared", () => { - ReactDOM.render( - <Helmet />, - container - ); - - const bodyTag = document.body; - - expect(bodyTag.getAttribute("test")).to.equal("test"); - expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); - }); - - it("will be overwritten if specified in helmet", () => { - ReactDOM.render( - <Helmet> - <body test="helmet-attr" /> - </Helmet>, - container - ); - - const bodyTag = document.body; - - expect(bodyTag.getAttribute("test")).to.equal("helmet-attr"); - expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("test"); - }); - - it("can be cleared once it is managed in helmet", () => { - ReactDOM.render( - <Helmet> - <body test="helmet-attr" /> - </Helmet>, - container - ); - - ReactDOM.render( - <Helmet />, - container - ); - - const bodyTag = document.body; - - expect(bodyTag.getAttribute("test")).to.equal(null); - expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); - }); - }); - }); - - describe("onChangeClientState", () => { - it("when handling client state change, calls the function with new state, addedTags and removedTags ", () => { - const spy = sinon.spy(); - ReactDOM.render( - <div> - <Helmet onChangeClientState={spy}> - <base href="http://mysite.com/" /> - <link href="http://localhost/helmet" rel="canonical" /> - <meta charSet="utf-8" /> - <script src="http://localhost/test.js" type="text/javascript" /> - <title>Main Title - - , - container - ); - - expect(spy.called).to.equal(true); - const newState = spy.getCall(0).args[0]; - const addedTags = spy.getCall(0).args[1]; - const removedTags = spy.getCall(0).args[2]; - - expect(newState).to.contain({title: "Main Title"}); - expect(newState.baseTag).to.contain({href: "http://mysite.com/"}); - expect(newState.metaTags).to.contain({"charset": "utf-8"}); - expect(newState.linkTags).to.contain({"href": "http://localhost/helmet", "rel": "canonical"}); - expect(newState.scriptTags).to.contain({"src": "http://localhost/test.js", "type": "text/javascript"}); - - expect(addedTags).to.have.property("baseTag"); - expect(addedTags.baseTag).to.have.deep.property("[0]"); - expect(addedTags.baseTag[0].outerHTML).to.equal(``); - - expect(addedTags).to.have.property("metaTags"); - expect(addedTags.metaTags).to.have.deep.property("[0]"); - expect(addedTags.metaTags[0].outerHTML).to.equal(``); - - expect(addedTags).to.have.property("linkTags"); - expect(addedTags.linkTags).to.have.deep.property("[0]"); - expect(addedTags.linkTags[0].outerHTML).to.equal(``); - - expect(addedTags).to.have.property("scriptTags"); - expect(addedTags.scriptTags).to.have.deep.property("[0]"); - expect(addedTags.scriptTags[0].outerHTML).to.equal(``); - - expect(removedTags).to.be.empty; - }); - - it("calls the deepest defined callback with the deepest state", () => { - const spy = sinon.spy(); - ReactDOM.render( -
- - Main Title - - - Deeper Title - -
, - container - ); - - expect(spy.callCount).to.equal(2); - expect(spy.getCall(0).args[0]).to.contain({title: "Main Title"}); - expect(spy.getCall(1).args[0]).to.contain({title: "Deeper Title"}); - }); - }); - - describe("base tag", () => { - it("can update base tag", () => { - ReactDOM.render( - - - , - container - ); - - const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - - const filteredTags = [].slice.call(existingTags).filter((tag) => { - return tag.getAttribute("href") === "http://mysite.com/"; - }); - - expect(filteredTags.length).to.equal(1); - }); - - it("will clear the base tag if one is not specified", () => { - ReactDOM.render( - , - container - ); - - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("tags without 'href' will not be accepted", () => { - ReactDOM.render( - - - , - container - ); - - const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("will set base tag based on deepest nested component", () => { - ReactDOM.render( -
- - - - - - -
, - container - ); - - const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); - const firstTag = Array.prototype.slice.call(existingTags)[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("href")).to.equal("http://mysite.com/public"); - expect(firstTag.outerHTML).to.equal(``); - }); - - it("won't render tag when primary attribute is null", () => { - ReactDOM.render( - - - , - container - ); - - const tagNodes = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - expect(existingTags).to.be.empty; - }); - }); - - describe("meta tags", () => { - it("can update meta tags", () => { - ReactDOM.render( - - - - - - - , - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - - expect(existingTags).to.not.equal(undefined); - - const filteredTags = [].slice.call(existingTags).filter((tag) => { - return tag.getAttribute("charset") === "utf-8" || - (tag.getAttribute("name") === "description" && tag.getAttribute("content") === "Test description") || - (tag.getAttribute("http-equiv") === "content-type" && tag.getAttribute("content") === "text/html") || - (tag.getAttribute("itemprop") === "name" && tag.getAttribute("content") === "Test name itemprop"); - }); - - expect(filteredTags.length).to.be.at.least(4); - }); - - it("will clear all meta tags if none are specified", () => { - ReactDOM.render( - - - , - container - ); - - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("tags without 'name', 'http-equiv', 'property', 'charset', or 'itemprop' will not be accepted", () => { - ReactDOM.render( - - - , - container - ); - - const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("will set meta tags based on deepest nested component", () => { - ReactDOM.render( -
- - - - - - - - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - const thirdTag = existingTags[2]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(3); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("charset")).to.equal("utf-8"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("name")).to.equal("description"); - expect(secondTag.getAttribute("content")).to.equal("Inner description"); - expect(secondTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[2]") - .that.is.an.instanceof(Element); - expect(thirdTag).to.have.property("getAttribute"); - expect(thirdTag.getAttribute("name")).to.equal("keywords"); - expect(thirdTag.getAttribute("content")).to.equal("test,meta,tags"); - expect(thirdTag.outerHTML).to.equal(``); - }); - - it("will allow duplicate meta tags if specified in the same component", () => { - ReactDOM.render( - - - - , - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.equal(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("name")).to.equal("description"); - expect(firstTag.getAttribute("content")).to.equal("Test description"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("name")).to.equal("description"); - expect(secondTag.getAttribute("content")).to.equal("Duplicate description"); - expect(secondTag.outerHTML).to.equal(``); - }); - - it("will override duplicate meta tags with single meta tag in a nested component", () => { - ReactDOM.render( -
- - - - - - - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("name")).to.equal("description"); - expect(firstTag.getAttribute("content")).to.equal("Inner description"); - expect(firstTag.outerHTML).to.equal(``); - }); - - it("will override single meta tag with duplicate meta tags in a nested component", () => { - ReactDOM.render( -
- - - - - - - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.equal(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("name")).to.equal("description"); - expect(firstTag.getAttribute("content")).to.equal("Inner description"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("name")).to.equal("description"); - expect(secondTag.getAttribute("content")).to.equal("Inner duplicate description"); - expect(secondTag.outerHTML).to.equal(``); - }); - - it("won't render tag when primary attribute is null", () => { - ReactDOM.render( - - - , - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - expect(existingTags).to.be.empty; - }); - }); - - describe("link tags", () => { - it("can update link tags", () => { - ReactDOM.render( - - - - , - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - - expect(existingTags).to.not.equal(undefined); - - const filteredTags = [].slice.call(existingTags).filter((tag) => { - return (tag.getAttribute("href") === "http://localhost/style.css" && tag.getAttribute("rel") === "stylesheet" && tag.getAttribute("type") === "text/css") || - (tag.getAttribute("href") === "http://localhost/helmet" && tag.getAttribute("rel") === "canonical"); - }); - - expect(filteredTags.length).to.be.at.least(2); - }); - - it("will clear all link tags if none are specified", () => { - ReactDOM.render( - - - , - container - ); - - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("tags without 'href' or 'rel' will not be accepted, even if they are valid for other tags", () => { - ReactDOM.render( - - - , - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("tags 'rel' and 'href' will properly use 'rel' as the primary identification for this tag, regardless of ordering", () => { - ReactDOM.render( -
- - - - - - - - - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("rel")).to.equal("canonical"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/newest"); - expect(firstTag.outerHTML).to.equal(``); - }); - - it("tags with rel='stylesheet' will use the href as the primary identification of the tag, regardless of ordering", () => { - ReactDOM.render( -
- - - - - - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.equal(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/style.css"); - expect(firstTag.getAttribute("rel")).to.equal("stylesheet"); - expect(firstTag.getAttribute("type")).to.equal("text/css"); - expect(firstTag.getAttribute("media")).to.equal("all"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("rel")).to.equal("stylesheet"); - expect(secondTag.getAttribute("href")).to.equal("http://localhost/inner.css"); - expect(secondTag.getAttribute("type")).to.equal("text/css"); - expect(secondTag.getAttribute("media")).to.equal("all"); - expect(secondTag.outerHTML).to.equal(``); - }); - - it("will set link tags based on deepest nested component", () => { - ReactDOM.render( -
- - - - - - - - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - const thirdTag = existingTags[2]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.at.least(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/style.css"); - expect(firstTag.getAttribute("rel")).to.equal("stylesheet"); - expect(firstTag.getAttribute("type")).to.equal("text/css"); - expect(firstTag.getAttribute("media")).to.equal("all"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); - expect(secondTag.getAttribute("rel")).to.equal("canonical"); - expect(secondTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[2]") - .that.is.an.instanceof(Element); - expect(thirdTag).to.have.property("getAttribute"); - expect(thirdTag.getAttribute("href")).to.equal("http://localhost/inner.css"); - expect(thirdTag.getAttribute("rel")).to.equal("stylesheet"); - expect(thirdTag.getAttribute("type")).to.equal("text/css"); - expect(thirdTag.getAttribute("media")).to.equal("all"); - expect(thirdTag.outerHTML).to.equal(``); - }); - - it("will allow duplicate link tags if specified in the same component", () => { - ReactDOM.render( - - - - , - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.at.least(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("rel")).to.equal("canonical"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("rel")).to.equal("canonical"); - expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); - expect(secondTag.outerHTML).to.equal(``); - }); - - it("will override duplicate link tags with a single link tag in a nested component", () => { - ReactDOM.render( -
- - - - - - - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("rel")).to.equal("canonical"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); - expect(firstTag.outerHTML).to.equal(``); - }); - - it("will override single link tag with duplicate link tags in a nested component", () => { - ReactDOM.render( -
- - - - - - - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("rel")).to.equal("canonical"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("rel")).to.equal("canonical"); - expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); - expect(secondTag.outerHTML).to.equal(``); - }); - - it("won't render tag when primary attribute is null", () => { - ReactDOM.render( - - - - , - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.be.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("rel")).to.equal("canonical"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); - expect(firstTag.outerHTML).to.equal(``); - }); - }); - - describe("script tags", () => { - it("can update script tags", () => { - const scriptInnerHTML = ` - { - "@context": "http://schema.org", - "@type": "NewsArticle", - "url": "http://localhost/helmet" - } - `; - ReactDOM.render( - - - , - container - ); - - const existingTags = headElement.getElementsByTagName("script"); - - expect(existingTags).to.not.equal(undefined); - - const filteredTags = [].slice.call(existingTags).filter((tag) => { - return (tag.getAttribute("src") === "http://localhost/test.js" && tag.getAttribute("type") === "text/javascript") || - (tag.getAttribute("src") === "http://localhost/test2.js" && tag.getAttribute("type") === "text/javascript") || - (tag.getAttribute("type") === "application/ld+json" && tag.innerHTML === scriptInnerHTML); - }); - - expect(filteredTags.length).to.be.at.least(3); - }); - - it("will clear all scripts tags if none are specified", () => { - ReactDOM.render( - - `); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("src")).to.equal("http://localhost/test2.js"); - expect(secondTag.getAttribute("type")).to.equal("text/javascript"); - expect(secondTag.outerHTML).to.equal(``); - }); - - - it("sets undefined attribute values to empty strings", () => { - ReactDOM.render( - - `); - }); - - it("won't render tag when primary attribute (src) is null", () => { - ReactDOM.render( - - `, - `` - ].join(""); - - const stringifiedNoscriptTags = [ - ``, - `` - ].join(""); - - const stringifiedStyleTags = [ - ``, - `` - ].join(""); - - before(() => { - Helmet.canUseDOM = false; - }); - - it("will html encode title", () => { - ReactDOM.render( - - {`Dangerous <script> include`} - , - container - ); - - const head = Helmet.rewind(); - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toString"); - - expect(head.title.toString()).to.equal(stringifiedTitle); - }); - - it("will render title as React component", () => { - ReactDOM.render( - - {`Dangerous <script> include`} - , - container - ); - - const head = Helmet.rewind(); - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toComponent"); - - const titleComponent = head.title.toComponent(); - - expect(titleComponent) - .to.be.an("array") - .that.has.length.of(1); - - titleComponent.forEach(title => { - expect(title) - .to.be.an("object") - .that.contains.property("type", "title"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {titleComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedTitle - }
`); - }); - - it("will render title with itemprop name as React component", () => { - ReactDOM.render( - - Title with Itemprop - , - container - ); - - const head = Helmet.rewind(); - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toComponent"); - - const titleComponent = head.title.toComponent(); - - expect(titleComponent) - .to.be.an("array") - .that.has.length.of(1); - - titleComponent.forEach(title => { - expect(title) - .to.be.an("object") - .that.contains.property("type", "title"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {titleComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedTitleWithItemprop - }
`); - }); - - it("will render base tag as React component", () => { - ReactDOM.render( - - - , - container - ); - - const head = Helmet.rewind(); - - expect(head.base).to.exist; - expect(head.base).to.respondTo("toComponent"); - - const baseComponent = head.base.toComponent(); - - expect(baseComponent) - .to.be.an("array") - .that.has.length.of(1); - - baseComponent.forEach(base => { - expect(base) - .to.be.an("object") - .that.contains.property("type", "base"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {baseComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedBaseTag - }
`); - }); - - it("will render meta tags as React components", () => { - ReactDOM.render( - - - < `"} /> - - - - , - container - ); - - const head = Helmet.rewind(); - - expect(head.meta).to.exist; - expect(head.meta).to.respondTo("toComponent"); - - const metaComponent = head.meta.toComponent(); - - expect(metaComponent) - .to.be.an("array") - .that.has.length.of(5); - - metaComponent.forEach(meta => { - expect(meta) - .to.be.an("object") - .that.contains.property("type", "meta"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {metaComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedMetaTags - }
`); - }); - - it("will render link tags as React components", () => { - ReactDOM.render( - - - - , - container - ); - - const head = Helmet.rewind(); - - expect(head.link).to.exist; - expect(head.link).to.respondTo("toComponent"); - - const linkComponent = head.link.toComponent(); - - expect(linkComponent) - .to.be.an("array") - .that.has.length.of(2); - - linkComponent.forEach(link => { - expect(link) - .to.be.an("object") - .that.contains.property("type", "link"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {linkComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedLinkTags - }
`); - }); - - it("will render script tags as React components", () => { - ReactDOM.render( - - `); - }); - - context("renderStatic", () => { - it("will html encode title", () => { - ReactDOM.render( - - {`Dangerous <script> include`} - , - container - ); - - const head = Helmet.renderStatic(); - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toString"); - - expect(head.title.toString()).to.equal(stringifiedTitle); - }); - - it("will render title as React component", () => { - ReactDOM.render( - - {`Dangerous <script> include`} - , - container - ); - - const head = Helmet.renderStatic(); - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toComponent"); - - const titleComponent = head.title.toComponent(); - - expect(titleComponent) - .to.be.an("array") - .that.has.length.of(1); - - titleComponent.forEach(title => { - expect(title) - .to.be.an("object") - .that.contains.property("type", "title"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {titleComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedTitle - }
`); - }); - }); - - after(() => { - Helmet.canUseDOM = true; - }); - }); - - describe("misc", () => { - it("throws in rewind() when a DOM is present", () => { - ReactDOM.render( - - Fancy title - , - container - ); - - expect(Helmet.rewind).to.throw( - "You may only call rewind() on the server. Call peek() to read the current state." - ); - }); - - it("lets you read current state in peek() whether or not a DOM is present", () => { - ReactDOM.render( - - Fancy title - , - container - ); - - expect(Helmet.peek().title).to.be.equal("Fancy title"); - Helmet.canUseDOM = false; - expect(Helmet.peek().title).to.be.equal("Fancy title"); - Helmet.canUseDOM = true; - }); - - it("will html encode string", () => { - ReactDOM.render( - - - , - container - ); - - const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(existingTag).to.have.property("getAttribute"); - expect(existingTag.getAttribute("name")).to.equal("description"); - expect(existingTag.getAttribute("content")).to.equal("This is \"quoted\" text and & and '."); - expect(existingTag.outerHTML).to.equal(``); - }); - - it("will not change the DOM if it is recevies identical props", () => { - const spy = sinon.spy(); - ReactDOM.render( - - - Test Title - , - container - ); - - // Re-rendering will pass new props to an already mounted Helmet - ReactDOM.render( - - - Test Title - , - container - ); - - expect(spy.callCount).to.equal(1); - }); - - it("will only add new tags and will perserve tags when rendering additional Helmet instances", () => { - const spy = sinon.spy(); - let addedTags; - let removedTags; - ReactDOM.render( - - - - , - container - ); - - expect(spy.called).to.equal(true); - addedTags = spy.getCall(0).args[1]; - removedTags = spy.getCall(0).args[2]; - - expect(addedTags).to.have.property("metaTags"); - expect(addedTags.metaTags).to.have.deep.property("[0]"); - expect(addedTags.metaTags[0].outerHTML).to.equal(``); - expect(addedTags).to.have.property("linkTags"); - expect(addedTags.linkTags).to.have.deep.property("[0]"); - expect(addedTags.linkTags[0].outerHTML).to.equal(``); - expect(removedTags).to.be.empty; - - // Re-rendering will pass new props to an already mounted Helmet - ReactDOM.render( - - - - - , - container - ); - - expect(spy.callCount).to.equal(2); - addedTags = spy.getCall(1).args[1]; - removedTags = spy.getCall(1).args[2]; - - expect(addedTags).to.have.property("metaTags"); - expect(addedTags.metaTags).to.have.deep.property("[0]"); - expect(addedTags.metaTags[0].outerHTML).to.equal(``); - expect(addedTags).to.have.property("linkTags"); - expect(addedTags.linkTags).to.have.deep.property("[0]"); - expect(addedTags.linkTags[0].outerHTML).to.equal(``); - expect(removedTags).to.have.property("metaTags"); - expect(removedTags.metaTags).to.have.deep.property("[0]"); - expect(removedTags.metaTags[0].outerHTML).to.equal(``); - expect(removedTags).to.not.have.property("linkTags"); - }); - - it("can not nest Helmets", () => { - ReactDOM.render( - - Test Title - - Title you'll never see - - , - container - ); - - expect(document.title).to.equal("Test Title"); - }); - - it("will recognize valid tags regardless of attribute ordering", () => { - ReactDOM.render( - - - , - container - ); - - const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(existingTag).to.have.property("getAttribute"); - expect(existingTag.getAttribute("name")).to.equal("description"); - expect(existingTag.getAttribute("content")).to.equal("Test Description"); - expect(existingTag.outerHTML).to.equal(``); - }); - }); -}); diff --git a/src/test/HelmetTest.js b/src/test/HelmetTest.js deleted file mode 100644 index 4cd8f788..00000000 --- a/src/test/HelmetTest.js +++ /dev/null @@ -1,2437 +0,0 @@ -/* eslint max-nested-callbacks: [1, 5] */ -/* eslint-disable import/no-named-as-default */ - -import React from "react"; -import ReactDOM from "react-dom"; -import ReactServer from "react-dom/server"; -import Helmet from "../Helmet"; - -const HELMET_ATTRIBUTE = "data-react-helmet"; - -describe("Helmet", () => { - let headElement; - - const container = document.createElement("div"); - - beforeEach(() => { - headElement = headElement || document.head || document.querySelector("head"); - }); - - afterEach(() => { - ReactDOM.unmountComponentAtNode(container); - }); - - describe("api", () => { - describe("title", () => { - it("can update page title", () => { - ReactDOM.render( - , - container - ); - - expect(document.title).to.equal("Test Title"); - }); - - it("can update page title with multiple children", () => { - ReactDOM.render( -
- - - -
, - container - ); - - expect(document.title).to.equal("Child Two Title"); - }); - - it("will set title based on deepest nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - expect(document.title).to.equal("Nested Title"); - }); - - it("will set title using deepest nested component with a defined title", () => { - ReactDOM.render( -
- - -
, - container - ); - - expect(document.title).to.equal("Main Title"); - }); - - it("will use defaultTitle if no title is defined", () => { - ReactDOM.render( - , - container - ); - - expect(document.title).to.equal("Fallback"); - }); - - it("will use a titleTemplate if defined", () => { - ReactDOM.render( - , - container - ); - - expect(document.title).to.equal("This is a Test of the titleTemplate feature"); - }); - - it("will replace multiple title strings in titleTemplate", () => { - ReactDOM.render( - , - container - ); - - expect(document.title).to.equal("This is a Test of the titleTemplate feature. Another Test."); - }); - - it("will use a titleTemplate based on deepest nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - expect(document.title).to.equal("A Second Test using nested titleTemplate attributes"); - }); - - it("will merge deepest component title with nearest upstream titleTemplate", () => { - ReactDOM.render( -
- - -
, - container - ); - - expect(document.title).to.equal("This is a Second Test of the titleTemplate feature"); - }); - - it("will render dollar characters in a title correctly when titleTemplate present", () => { - const dollarTitle = "te$t te$$t te$$$t te$$$$t"; - - ReactDOM.render( - , - container - ); - - expect(document.title).to.equal("This is a te$t te$$t te$$$t te$$$$t"); - }); - - it("will not encode all characters with HTML character entity equivalents", () => { - const chineseTitle = "膣膗 鍆錌雔"; - - ReactDOM.render( -
- -
, - container - ); - - expect(document.title).to.equal(chineseTitle); - }); - - it("page tite with prop itemprop", () => { - ReactDOM.render( - , - container - ); - - const titleTag = document.getElementsByTagName("title")[0]; - expect(document.title).to.equal("Test Title with itemProp"); - expect(titleTag.getAttribute("itemprop")).to.equal("name"); - }); - }); - - describe("title attributes", () => { - it("update title attributes", () => { - ReactDOM.render( - , - container - ); - - const titleTag = document.getElementsByTagName("title")[0]; - - expect(titleTag.getAttribute("itemprop")).to.equal("name"); - expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("itemprop"); - }); - - it("set attributes based on the deepest nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - const titleTag = document.getElementsByTagName("title")[0]; - - expect(titleTag.getAttribute("lang")).to.equal("ja"); - expect(titleTag.getAttribute("hidden")).to.equal(""); - expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,hidden"); - }); - - it("handle valueless attributes", () => { - ReactDOM.render( - , - container - ); - - const titleTag = document.getElementsByTagName("title")[0]; - - expect(titleTag.getAttribute("hidden")).to.equal(""); - expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("hidden"); - }); - - it("clears title attributes that are handled within helmet", () => { - ReactDOM.render( - , - container - ); - - ReactDOM.render( - , - container - ); - - const titleTag = document.getElementsByTagName("title")[0]; - - expect(titleTag.getAttribute("lang")).to.be.null; - expect(titleTag.getAttribute("hidden")).to.be.null; - expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); - }); - }); - - describe("html attributes", () => { - it("update html attributes", () => { - ReactDOM.render( - , - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("class")).to.equal("myClassName"); - expect(htmlTag.getAttribute("lang")).to.equal("en"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("class,lang"); - }); - - it("set attributes based on the deepest nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("lang")).to.equal("ja"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang"); - }); - - it("handle valueless attributes", () => { - ReactDOM.render( - , - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("amp")).to.equal(""); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("amp"); - }); - - it("clears html attributes that are handled within helmet", () => { - ReactDOM.render( - , - container - ); - - ReactDOM.render( - , - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("lang")).to.be.null; - expect(htmlTag.getAttribute("amp")).to.be.null; - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); - }); - - it("updates with multiple additions and removals - overwrite and new", () => { - ReactDOM.render( - , - container - ); - - ReactDOM.render( - , - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("amp")).to.equal(null); - expect(htmlTag.getAttribute("lang")).to.equal("ja"); - expect(htmlTag.getAttribute("id")).to.equal("html-tag"); - expect(htmlTag.getAttribute("title")).to.equal("html tag"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,amp,id,title"); - }); - - it("updates with multiple additions and removals - all new", () => { - ReactDOM.render( - , - container - ); - - ReactDOM.render( - , - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("amp")).to.equal(null); - expect(htmlTag.getAttribute("lang")).to.equal(null); - expect(htmlTag.getAttribute("id")).to.equal("html-tag"); - expect(htmlTag.getAttribute("title")).to.equal("html tag"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,amp,id,title"); - }); - - context("initialized outside of helmet", () => { - before(() => { - const htmlTag = document.getElementsByTagName("html")[0]; - htmlTag.setAttribute("test", "test"); - }); - - it("will not be cleared", () => { - ReactDOM.render( - , - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("test")).to.equal("test"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); - }); - - it("will be overwritten if specified in helmet", () => { - ReactDOM.render( - , - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("test")).to.equal("helmet-attr"); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("test"); - }); - - it("can be cleared once it is managed in helmet", () => { - ReactDOM.render( - , - container - ); - - ReactDOM.render( - , - container - ); - - const htmlTag = document.getElementsByTagName("html")[0]; - - expect(htmlTag.getAttribute("test")).to.equal(null); - expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); - }); - }); - }); - - describe("onChangeClientState", () => { - it("when handling client state change, calls the function with new state, addedTags and removedTags ", () => { - const spy = sinon.spy(); - ReactDOM.render( -
- -
, - container - ); - - expect(spy.called).to.equal(true); - const newState = spy.getCall(0).args[0]; - const addedTags = spy.getCall(0).args[1]; - const removedTags = spy.getCall(0).args[2]; - - expect(newState).to.contain({title: "Main Title"}); - expect(newState.baseTag).to.contain({href: "http://mysite.com/"}); - expect(newState.metaTags).to.contain({"charset": "utf-8"}); - expect(newState.linkTags).to.contain({"href": "http://localhost/helmet", "rel": "canonical"}); - expect(newState.scriptTags).to.contain({"src": "http://localhost/test.js", "type": "text/javascript"}); - - expect(addedTags).to.have.property("baseTag"); - expect(addedTags.baseTag).to.have.deep.property("[0]"); - expect(addedTags.baseTag[0].outerHTML).to.equal(``); - - expect(addedTags).to.have.property("metaTags"); - expect(addedTags.metaTags).to.have.deep.property("[0]"); - expect(addedTags.metaTags[0].outerHTML).to.equal(``); - - expect(addedTags).to.have.property("linkTags"); - expect(addedTags.linkTags).to.have.deep.property("[0]"); - expect(addedTags.linkTags[0].outerHTML).to.equal(``); - - expect(addedTags).to.have.property("scriptTags"); - expect(addedTags.scriptTags).to.have.deep.property("[0]"); - expect(addedTags.scriptTags[0].outerHTML).to.equal(``); - - expect(removedTags).to.be.empty; - }); - - it("calls the deepest defined callback with the deepest state", () => { - const spy = sinon.spy(); - ReactDOM.render( -
- - -
, - container - ); - - expect(spy.callCount).to.equal(2); - expect(spy.getCall(0).args[0]).to.contain({title: "Main Title"}); - expect(spy.getCall(1).args[0]).to.contain({title: "Deeper Title"}); - }); - }); - - describe("base tag", () => { - it("can update base tag", () => { - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - - const filteredTags = [].slice.call(existingTags).filter((tag) => { - return tag.getAttribute("href") === "http://mysite.com/"; - }); - - expect(filteredTags.length).to.equal(1); - }); - - it("will clear the base tag if one is not specified", () => { - ReactDOM.render( - , - container - ); - - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("tags without 'href' will not be accepted", () => { - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("will set base tag based on deepest nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); - const firstTag = Array.prototype.slice.call(existingTags)[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("href")).to.equal("http://mysite.com/public"); - expect(firstTag.outerHTML).to.equal(``); - }); - - it("won't render tag when primary attribute is null", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - expect(existingTags).to.be.empty; - }); - }); - - describe("meta tags", () => { - it("can update meta tags", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - - expect(existingTags).to.not.equal(undefined); - - const filteredTags = [].slice.call(existingTags).filter((tag) => { - return tag.getAttribute("charset") === "utf-8" || - (tag.getAttribute("name") === "description" && tag.getAttribute("content") === "Test description") || - (tag.getAttribute("http-equiv") === "content-type" && tag.getAttribute("content") === "text/html") || - (tag.getAttribute("itemprop") === "name" && tag.getAttribute("content") === "Test name itemprop"); - }); - - expect(filteredTags.length).to.be.at.least(4); - }); - - it("will clear all meta tags if none are specified", () => { - ReactDOM.render( - , - container - ); - - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("tags without 'name', 'http-equiv', 'property', 'charset', or 'itemprop' will not be accepted", () => { - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("will set meta tags based on deepest nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - const thirdTag = existingTags[2]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(3); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("charset")).to.equal("utf-8"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("name")).to.equal("description"); - expect(secondTag.getAttribute("content")).to.equal("Inner description"); - expect(secondTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[2]") - .that.is.an.instanceof(Element); - expect(thirdTag).to.have.property("getAttribute"); - expect(thirdTag.getAttribute("name")).to.equal("keywords"); - expect(thirdTag.getAttribute("content")).to.equal("test,meta,tags"); - expect(thirdTag.outerHTML).to.equal(``); - }); - - it("will allow duplicate meta tags if specified in the same component", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.equal(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("name")).to.equal("description"); - expect(firstTag.getAttribute("content")).to.equal("Test description"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("name")).to.equal("description"); - expect(secondTag.getAttribute("content")).to.equal("Duplicate description"); - expect(secondTag.outerHTML).to.equal(``); - }); - - it("will override duplicate meta tags with single meta tag in a nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("name")).to.equal("description"); - expect(firstTag.getAttribute("content")).to.equal("Inner description"); - expect(firstTag.outerHTML).to.equal(``); - }); - - it("will override single meta tag with duplicate meta tags in a nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.equal(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("name")).to.equal("description"); - expect(firstTag.getAttribute("content")).to.equal("Inner description"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("name")).to.equal("description"); - expect(secondTag.getAttribute("content")).to.equal("Inner duplicate description"); - expect(secondTag.outerHTML).to.equal(``); - }); - - it("won't render tag when primary attribute is null", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - expect(existingTags).to.be.empty; - }); - - it("fails gracefully when meta is wrong shape", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - expect(existingTags).to.be.empty; - }); - }); - - describe("link tags", () => { - it("can update link tags", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - - expect(existingTags).to.not.equal(undefined); - - const filteredTags = [].slice.call(existingTags).filter((tag) => { - return (tag.getAttribute("href") === "http://localhost/style.css" && tag.getAttribute("rel") === "stylesheet" && tag.getAttribute("type") === "text/css") || - (tag.getAttribute("href") === "http://localhost/helmet" && tag.getAttribute("rel") === "canonical"); - }); - - expect(filteredTags.length).to.be.at.least(2); - }); - - it("will clear all link tags if none are specified", () => { - ReactDOM.render( - , - container - ); - - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("tags without 'href' or 'rel' will not be accepted, even if they are valid for other tags", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("tags 'rel' and 'href' will properly use 'rel' as the primary identification for this tag, regardless of ordering", () => { - ReactDOM.render( -
- - - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("rel")).to.equal("canonical"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/newest"); - expect(firstTag.outerHTML).to.equal(``); - }); - - it("tags with rel='stylesheet' will use the href as the primary identification of the tag, regardless of ordering", () => { - ReactDOM.render( -
- - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.equal(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/style.css"); - expect(firstTag.getAttribute("rel")).to.equal("stylesheet"); - expect(firstTag.getAttribute("type")).to.equal("text/css"); - expect(firstTag.getAttribute("media")).to.equal("all"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("rel")).to.equal("stylesheet"); - expect(secondTag.getAttribute("href")).to.equal("http://localhost/inner.css"); - expect(secondTag.getAttribute("type")).to.equal("text/css"); - expect(secondTag.getAttribute("media")).to.equal("all"); - expect(secondTag.outerHTML).to.equal(``); - }); - - it("will set link tags based on deepest nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - const thirdTag = existingTags[2]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.at.least(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/style.css"); - expect(firstTag.getAttribute("rel")).to.equal("stylesheet"); - expect(firstTag.getAttribute("type")).to.equal("text/css"); - expect(firstTag.getAttribute("media")).to.equal("all"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); - expect(secondTag.getAttribute("rel")).to.equal("canonical"); - expect(secondTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[2]") - .that.is.an.instanceof(Element); - expect(thirdTag).to.have.property("getAttribute"); - expect(thirdTag.getAttribute("href")).to.equal("http://localhost/inner.css"); - expect(thirdTag.getAttribute("rel")).to.equal("stylesheet"); - expect(thirdTag.getAttribute("type")).to.equal("text/css"); - expect(thirdTag.getAttribute("media")).to.equal("all"); - expect(thirdTag.outerHTML).to.equal(``); - }); - - it("will allow duplicate link tags if specified in the same component", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.at.least(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("rel")).to.equal("canonical"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("rel")).to.equal("canonical"); - expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); - expect(secondTag.outerHTML).to.equal(``); - }); - - it("will override duplicate link tags with a single link tag in a nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("rel")).to.equal("canonical"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); - expect(firstTag.outerHTML).to.equal(``); - }); - - it("will override single link tag with duplicate link tags in a nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("rel")).to.equal("canonical"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("rel")).to.equal("canonical"); - expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); - expect(secondTag.outerHTML).to.equal(``); - }); - - it("won't render tag when primary attribute is null", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.be.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("rel")).to.equal("canonical"); - expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); - expect(firstTag.outerHTML).to.equal(``); - }); - }); - - describe("script tags", () => { - it("can update script tags", () => { - const scriptInnerHTML = ` - { - "@context": "http://schema.org", - "@type": "NewsArticle", - "url": "http://localhost/helmet" - } - `; - ReactDOM.render( - , - container - ); - - const existingTags = headElement.getElementsByTagName("script"); - - expect(existingTags).to.not.equal(undefined); - - const filteredTags = [].slice.call(existingTags).filter((tag) => { - return (tag.getAttribute("src") === "http://localhost/test.js" && tag.getAttribute("type") === "text/javascript") || - (tag.getAttribute("src") === "http://localhost/test2.js" && tag.getAttribute("type") === "text/javascript") || - (tag.getAttribute("type") === "application/ld+json" && tag.innerHTML === scriptInnerHTML); - }); - - expect(filteredTags.length).to.be.at.least(3); - }); - - it("will clear all scripts tags if none are specified", () => { - ReactDOM.render( - , - container - ); - - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("tags without 'src' will not be accepted", () => { - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("will set script tags based on deepest nested component", () => { - ReactDOM.render( -
- - -
, - container - ); - - const tagNodes = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - const firstTag = existingTags[0]; - const secondTag = existingTags[1]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.at.least(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("src")).to.equal("http://localhost/test.js"); - expect(firstTag.getAttribute("type")).to.equal("text/javascript"); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag).to.have.property("getAttribute"); - expect(secondTag.getAttribute("src")).to.equal("http://localhost/test2.js"); - expect(secondTag.getAttribute("type")).to.equal("text/javascript"); - expect(secondTag.outerHTML).to.equal(``); - }); - - - it("sets undefined attribute values to empty strings", () => { - ReactDOM.render( - , - container - ); - - const existingTag = headElement.querySelector(`script[${HELMET_ATTRIBUTE}]`); - - expect(existingTag).to.not.equal(undefined); - expect(existingTag.outerHTML) - .to.be.a("string") - .that.equals(``); - }); - - it("won't render tag when primary attribute (src) is null", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - expect(existingTags).to.be.empty; - }); - - it("won't render tag when primary attribute (innerHTML) is null", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - expect(existingTags).to.be.empty; - }); - }); - - describe("noscript tags", () => { - it("can update noscript tags", () => { - const noscriptInnerHTML = ``; - ReactDOM.render( - , - container - ); - - const existingTags = headElement.getElementsByTagName("noscript"); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(1); - expect(existingTags[0].innerHTML === noscriptInnerHTML && existingTags[0].id === "bar"); - }); - - it("will clear all noscripts tags if none are specified", () => { - ReactDOM.render(, container); - - ReactDOM.render(, container); - - const existingTags = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("tags without 'innerHTML' will not be accepted", () => { - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`noscript[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("won't render tag when primary attribute is null", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`noscript[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - expect(existingTags).to.be.empty; - }); - }); - - describe("style tags", () => { - it("can update style tags", () => { - const cssText1 = ` - body { - background-color: green; - } - `; - const cssText2 = ` - p { - font-size: 12px; - } - `; - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`style[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - - const [ - firstTag, - secondTag - ] = existingTags; - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.be.equal(2); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(firstTag).to.have.property("getAttribute"); - expect(firstTag.getAttribute("type")).to.equal("text/css"); - expect(firstTag.innerHTML).to.equal(cssText1); - expect(firstTag.outerHTML).to.equal(``); - - expect(existingTags) - .to.have.deep.property("[1]") - .that.is.an.instanceof(Element); - expect(secondTag.innerHTML).to.equal(cssText2); - expect(secondTag.outerHTML).to.equal(``); - }); - - it("will clear all style tags if none are specified", () => { - const cssText = ` - body { - background-color: green; - } - `; - ReactDOM.render( - , - container - ); - - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`style[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("tags without 'cssText' will not be accepted", () => { - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`style[${HELMET_ATTRIBUTE}]`); - - expect(existingTags).to.not.equal(undefined); - expect(existingTags.length).to.equal(0); - }); - - it("won't render tag when primary attribute is null", () => { - ReactDOM.render( - , - container - ); - - const tagNodes = headElement.querySelectorAll(`style[${HELMET_ATTRIBUTE}]`); - const existingTags = Array.prototype.slice.call(tagNodes); - expect(existingTags).to.be.empty; - }); - }); - }); - - describe("server", () => { - const stringifiedHtmlAttributes = `lang="ga" class="myClassName"`; - const stringifiedTitle = `Dangerous <script> include`; - const stringifiedTitleWithItemprop = `Title with Itemprop`; - const stringifiedBaseTag = ``; - - const stringifiedMetaTags = [ - ``, - ``, - ``, - ``, - `` - ].join(""); - - const stringifiedLinkTags = [ - ``, - `` - ].join(""); - - const stringifiedScriptTags = [ - ``, - `` - ].join(""); - - const stringifiedNoscriptTags = [ - ``, - `` - ].join(""); - - const stringifiedStyleTags = [ - ``, - `` - ].join(""); - - before(() => { - Helmet.canUseDOM = false; - }); - - it("will html encode title", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toString"); - - expect(head.title.toString()).to.equal(stringifiedTitle); - }); - - it("will render title as React component", () => { - ReactDOM.render( - include"} - />, - container - ); - - const head = Helmet.rewind(); - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toComponent"); - - const titleComponent = head.title.toComponent(); - - expect(titleComponent) - .to.be.an("array") - .that.has.length.of(1); - - titleComponent.forEach(title => { - expect(title) - .to.be.an("object") - .that.contains.property("type", "title"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {titleComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedTitle - }
`); - }); - - it("will render title with itemprop name as React component", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toComponent"); - - const titleComponent = head.title.toComponent(); - - expect(titleComponent) - .to.be.an("array") - .that.has.length.of(1); - - titleComponent.forEach(title => { - expect(title) - .to.be.an("object") - .that.contains.property("type", "title"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {titleComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedTitleWithItemprop - }
`); - }); - - it("will render base tag as React component", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.base).to.exist; - expect(head.base).to.respondTo("toComponent"); - - const baseComponent = head.base.toComponent(); - - expect(baseComponent) - .to.be.an("array") - .that.has.length.of(1); - - baseComponent.forEach(base => { - expect(base) - .to.be.an("object") - .that.contains.property("type", "base"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {baseComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedBaseTag - }
`); - }); - - it("will render meta tags as React components", () => { - ReactDOM.render( - < `"}, - {"http-equiv": "content-type", "content": "text/html"}, - {"property": "og:type", "content": "article"}, - {"itemprop": "name", "content": "Test name itemprop"} - ]} - />, - container - ); - - const head = Helmet.rewind(); - - expect(head.meta).to.exist; - expect(head.meta).to.respondTo("toComponent"); - - const metaComponent = head.meta.toComponent(); - - expect(metaComponent) - .to.be.an("array") - .that.has.length.of(5); - - metaComponent.forEach(meta => { - expect(meta) - .to.be.an("object") - .that.contains.property("type", "meta"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {metaComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedMetaTags - }
`); - }); - - it("will render link tags as React components", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.link).to.exist; - expect(head.link).to.respondTo("toComponent"); - - const linkComponent = head.link.toComponent(); - - expect(linkComponent) - .to.be.an("array") - .that.has.length.of(2); - - linkComponent.forEach(link => { - expect(link) - .to.be.an("object") - .that.contains.property("type", "link"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {linkComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedLinkTags - }
`); - }); - - it("will render script tags as React components", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.script).to.exist; - expect(head.script).to.respondTo("toComponent"); - - const scriptComponent = head.script.toComponent(); - - expect(scriptComponent) - .to.be.an("array") - .that.has.length.of(2); - - scriptComponent.forEach(script => { - expect(script) - .to.be.an("object") - .that.contains.property("type", "script"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {scriptComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedScriptTags - }
`); - }); - - it("will render noscript tags as React components", () => { - ReactDOM.render( - '}, - {id: "bar", innerHTML: ''} - ]} - />, - container - ); - - const head = Helmet.rewind(); - - expect(head.noscript).to.exist; - expect(head.noscript).to.respondTo("toComponent"); - - const noscriptComponent = head.noscript.toComponent(); - - expect(noscriptComponent) - .to.be.an("array") - .that.has.length.of(2); - - noscriptComponent.forEach(noscript => { - expect(noscript) - .to.be.an("object") - .that.contains.property("type", "noscript"); - }); - - const markup = ReactServer.renderToStaticMarkup( -
- {noscriptComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedNoscriptTags - }
`); - }); - - it("will render style tags as React components", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.style).to.exist; - expect(head.style).to.respondTo("toComponent"); - - const styleComponent = head.style.toComponent(); - - expect(styleComponent) - .to.be.an("array") - .that.has.length.of(2); - - const markup = ReactServer.renderToStaticMarkup( -
- {styleComponent} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
${ - stringifiedStyleTags - }
`); - }); - - it("will render title tag as string", () => { - ReactDOM.render( - include"} - />, - container - ); - - const head = Helmet.rewind(); - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toString"); - - expect(head.title.toString()) - .to.be.a("string") - .that.equals(stringifiedTitle); - }); - - it("will render title with itemprop name as string", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toString"); - - const titleString = head.title.toString(); - expect(titleString) - .to.be.a("string") - .that.equals(stringifiedTitleWithItemprop); - }); - - it("will render base tags as string", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.base).to.exist; - expect(head.base).to.respondTo("toString"); - - expect(head.base.toString()) - .to.be.a("string") - .that.equals(stringifiedBaseTag); - }); - - it("will render meta tags as string", () => { - ReactDOM.render( - < `"}, - {"http-equiv": "content-type", "content": "text/html"}, - {"property": "og:type", "content": "article"}, - {"itemprop": "name", "content": "Test name itemprop"} - ]} - />, - container - ); - - const head = Helmet.rewind(); - - expect(head.meta).to.exist; - expect(head.meta).to.respondTo("toString"); - - expect(head.meta.toString()) - .to.be.a("string") - .that.equals(stringifiedMetaTags); - }); - - it("will render link tags as string", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.link).to.exist; - expect(head.link).to.respondTo("toString"); - - expect(head.link.toString()) - .to.be.a("string") - .that.equals(stringifiedLinkTags); - }); - - it("will render script tags as string", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.script).to.exist; - expect(head.script).to.respondTo("toString"); - - expect(head.script.toString()) - .to.be.a("string") - .that.equals(stringifiedScriptTags); - }); - - it("will render style tags as string", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.style).to.exist; - expect(head.style).to.respondTo("toString"); - - expect(head.style.toString()) - .to.be.a("string") - .that.equals(stringifiedStyleTags); - }); - - it("will render html attributes as component", () => { - ReactDOM.render( - , - container - ); - - const {htmlAttributes} = Helmet.rewind(); - const attrs = htmlAttributes.toComponent(); - - expect(attrs).to.exist; - - const markup = ReactServer.renderToStaticMarkup( - - ); - - expect(markup) - .to.be.a("string") - .that.equals(``); - }); - - it("will render html attributes as string", () => { - ReactDOM.render( - , - container - ); - - const head = Helmet.rewind(); - - expect(head.htmlAttributes).to.exist; - expect(head.htmlAttributes).to.respondTo("toString"); - - expect(head.htmlAttributes.toString()) - .to.be.a("string") - .that.equals(stringifiedHtmlAttributes); - }); - - it("will not encode all characters with HTML character entity equivalents", () => { - const chineseTitle = "膣膗 鍆錌雔"; - const stringifiedChineseTitle = `${chineseTitle}`; - - ReactDOM.render( -
- -
, - container - ); - - const head = Helmet.rewind(); - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toString"); - - expect(head.title.toString()) - .to.be.a("string") - .that.equals(stringifiedChineseTitle); - }); - - it("rewind() provides a fallback object for empty Helmet state", () => { - ReactDOM.render( -
, - container - ); - - const head = Helmet.rewind(); - - expect(head.htmlAttributes).to.exist; - expect(head.htmlAttributes).to.respondTo("toString"); - expect(head.htmlAttributes.toString()).to.equal(""); - expect(head.htmlAttributes).to.respondTo("toComponent"); - expect(head.htmlAttributes.toComponent()).to.be.an("object") - .that.is.empty; - - expect(head.title).to.exist; - expect(head.title).to.respondTo("toString"); - expect(head.title.toString()).to.equal(``); - expect(head.title).to.respondTo("toComponent"); - - const markup = ReactServer.renderToStaticMarkup( -
- {head.title.toComponent()} -
- ); - - expect(markup) - .to.be.a("string") - .that.equals(`
`); - - expect(head.base).to.exist; - expect(head.base).to.respondTo("toString"); - expect(head.base.toString()).to.equal(""); - expect(head.base).to.respondTo("toComponent"); - expect(head.base.toComponent()).to.be.an("array") - .that.is.empty; - - expect(head.meta).to.exist; - expect(head.meta).to.respondTo("toString"); - expect(head.meta.toString()).to.equal(""); - expect(head.meta).to.respondTo("toComponent"); - expect(head.meta.toComponent()).to.be.an("array") - .that.is.empty; - - expect(head.link).to.exist; - expect(head.link).to.respondTo("toString"); - expect(head.link.toString()).to.equal(""); - expect(head.link).to.respondTo("toComponent"); - expect(head.link.toComponent()).to.be.an("array") - .that.is.empty; - - expect(head.script).to.exist; - expect(head.script).to.respondTo("toString"); - expect(head.script.toString()).to.equal(""); - expect(head.script).to.respondTo("toComponent"); - expect(head.script.toComponent()).to.be.an("array") - .that.is.empty; - - expect(head.noscript).to.exist; - expect(head.noscript).to.respondTo("toString"); - expect(head.noscript.toString()).to.equal(""); - expect(head.noscript).to.respondTo("toComponent"); - expect(head.noscript.toComponent()).to.be.an("array") - .that.is.empty; - - expect(head.style).to.exist; - expect(head.style).to.respondTo("toString"); - expect(head.style.toString()).to.equal(""); - expect(head.style).to.respondTo("toComponent"); - expect(head.style.toComponent()).to.be.an("array") - .that.is.empty; - }); - - it("does not render undefined attribute values", () => { - ReactDOM.render( - , - container - ); - - const {script} = Helmet.rewind(); - const stringifiedScriptTag = script.toString(); - - expect(stringifiedScriptTag) - .to.be.a("string") - .that.equals(``); - }); - after(() => { - Helmet.canUseDOM = true; - }); - }); - - describe("misc", () => { - it("throws in rewind() when a DOM is present", () => { - ReactDOM.render( - , - container - ); - - expect(Helmet.rewind).to.throw( - "You may only call rewind() on the server. Call peek() to read the current state." - ); - }); - - it("lets you read current state in peek() whether or not a DOM is present", () => { - ReactDOM.render( - , - container - ); - - expect(Helmet.peek().title).to.be.equal("Fancy title"); - Helmet.canUseDOM = false; - expect(Helmet.peek().title).to.be.equal("Fancy title"); - Helmet.canUseDOM = true; - }); - - it("will html encode string", () => { - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(existingTag).to.have.property("getAttribute"); - expect(existingTag.getAttribute("name")).to.equal("description"); - expect(existingTag.getAttribute("content")).to.equal("This is \"quoted\" text and & and '."); - expect(existingTag.outerHTML).to.equal(``); - }); - - it("will not change the DOM if it is recevies identical props", () => { - const spy = sinon.spy(); - ReactDOM.render( - , - container - ); - - // Re-rendering will pass new props to an already mounted Helmet - ReactDOM.render( - , - container - ); - - expect(spy.callCount).to.equal(1); - }); - - it("will only add new tags and will perserve tags when rendering additional Helmet instances", () => { - const spy = sinon.spy(); - let addedTags; - let removedTags; - ReactDOM.render( - , - container - ); - - expect(spy.called).to.equal(true); - addedTags = spy.getCall(0).args[1]; - removedTags = spy.getCall(0).args[2]; - - expect(addedTags).to.have.property("metaTags"); - expect(addedTags.metaTags).to.have.deep.property("[0]"); - expect(addedTags.metaTags[0].outerHTML).to.equal(``); - expect(addedTags).to.have.property("linkTags"); - expect(addedTags.linkTags).to.have.deep.property("[0]"); - expect(addedTags.linkTags[0].outerHTML).to.equal(``); - expect(removedTags).to.be.empty; - - // Re-rendering will pass new props to an already mounted Helmet - ReactDOM.render( - , - container - ); - - expect(spy.callCount).to.equal(2); - addedTags = spy.getCall(1).args[1]; - removedTags = spy.getCall(1).args[2]; - - expect(addedTags).to.have.property("metaTags"); - expect(addedTags.metaTags).to.have.deep.property("[0]"); - expect(addedTags.metaTags[0].outerHTML).to.equal(``); - expect(addedTags).to.have.property("linkTags"); - expect(addedTags.linkTags).to.have.deep.property("[0]"); - expect(addedTags.linkTags[0].outerHTML).to.equal(``); - expect(removedTags).to.have.property("metaTags"); - expect(removedTags.metaTags).to.have.deep.property("[0]"); - expect(removedTags.metaTags[0].outerHTML).to.equal(``); - expect(removedTags).to.not.have.property("linkTags"); - }); - - it("can not nest Helmets", () => { - ReactDOM.render( - - - , - container - ); - - expect(document.title).to.equal("Test Title"); - }); - - it("will recognize valid tags regardless of attribute ordering", () => { - ReactDOM.render( - , - container - ); - - const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); - const existingTag = existingTags[0]; - - expect(existingTags).to.not.equal(undefined); - - expect(existingTags.length).to.be.equal(1); - - expect(existingTags) - .to.have.deep.property("[0]") - .that.is.an.instanceof(Element); - expect(existingTag).to.have.property("getAttribute"); - expect(existingTag.getAttribute("name")).to.equal("description"); - expect(existingTag.getAttribute("content")).to.equal("Test Description"); - expect(existingTag.outerHTML).to.equal(``); - }); - }); -}); diff --git a/test/HelmetDeclarativeTest.js b/test/HelmetDeclarativeTest.js new file mode 100644 index 00000000..04087ea5 --- /dev/null +++ b/test/HelmetDeclarativeTest.js @@ -0,0 +1,3207 @@ +/* eslint max-nested-callbacks: [1, 7] */ +/* eslint-disable react/jsx-sort-props */ +/* eslint-disable jsx-a11y/html-has-lang */ + +import React from "react"; +import ReactDOM from "react-dom"; +import ReactServer from "react-dom/server"; +import {Helmet} from "../src/Helmet"; +import {HTML_TAG_MAP} from "../src/HelmetConstants"; +import {requestIdleCallback} from "../src/HelmetUtils.js"; + +const HELMET_ATTRIBUTE = "data-react-helmet"; + +describe("Helmet - Declarative API", () => { + let headElement; + + const container = document.createElement("div"); + + beforeEach(() => { + headElement = headElement || document.head || document.querySelector("head"); + + // resets DOM after each run + headElement.innerHTML = ""; + }); + + afterEach(() => { + ReactDOM.unmountComponentAtNode(container); + }); + + describe("api", () => { + describe("title", () => { + it("updates page title", (done) => { + ReactDOM.render( + + Test Title + , + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("Test Title"); + + done(); + }); + }); + + it("updates page title with multiple children", (done) => { + ReactDOM.render( +
+ + Test Title + + + Child One Title + + + Child Two Title + +
, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("Child Two Title"); + + done(); + }); + }); + + it("sets title based on deepest nested component", (done) => { + ReactDOM.render( +
+ + Main Title + + + Nested Title + +
, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("Nested Title"); + + done(); + }); + }); + + it("sets title using deepest nested component with a defined title", (done) => { + ReactDOM.render( +
+ + Main Title + + +
, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("Main Title"); + + done(); + }); + }); + + it("uses defaultTitle if no title is defined", (done) => { + ReactDOM.render( + + + </Helmet>, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("Fallback"); + + done(); + }); + }); + + it("uses a titleTemplate if defined", (done) => { + ReactDOM.render( + <Helmet + defaultTitle={"Fallback"} + titleTemplate={"This is a %s of the titleTemplate feature"} + > + <title>Test + , + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("This is a Test of the titleTemplate feature"); + + done(); + }); + }); + + it("replaces multiple title strings in titleTemplate", (done) => { + ReactDOM.render( + + Test + , + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("This is a Test of the titleTemplate feature. Another Test."); + + done(); + }); + }); + + it("uses a titleTemplate based on deepest nested component", (done) => { + ReactDOM.render( +
+ + Test + + + Second Test + +
, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("A Second Test using nested titleTemplate attributes"); + + done(); + }); + }); + + it("merges deepest component title with nearest upstream titleTemplate", (done) => { + ReactDOM.render( +
+ + Test + + + Second Test + +
, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("This is a Second Test of the titleTemplate feature"); + + done(); + }); + }); + + it("renders dollar characters in a title correctly when titleTemplate present", (done) => { + const dollarTitle = "te$t te$$t te$$$t te$$$$t"; + + ReactDOM.render( + + {dollarTitle} + , + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("This is a te$t te$$t te$$$t te$$$$t"); + + done(); + }); + }); + + it("does not encode all characters with HTML character entity equivalents", (done) => { + const chineseTitle = "膣膗 鍆錌雔"; + + ReactDOM.render( + + {chineseTitle} + , + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal(chineseTitle); + + done(); + }); + }); + + it("page title with prop itemProp", (done) => { + ReactDOM.render( + + Test Title with itemProp + , + container + ); + + requestIdleCallback(() => { + const titleTag = document.getElementsByTagName("title")[0]; + expect(document.title).to.equal("Test Title with itemProp"); + expect(titleTag.getAttribute("itemprop")).to.equal("name"); + + done(); + }); + }); + }); + + describe("title attributes", () => { + beforeEach(() => { + headElement.innerHTML = `Test Title`; + }); + + it("updates title attributes", (done) => { + ReactDOM.render( + + + </Helmet>, + container + ); + + requestIdleCallback(() => { + const titleTag = document.getElementsByTagName("title")[0]; + + expect(titleTag.getAttribute("itemprop")).to.equal("name"); + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("itemprop"); + + done(); + }); + }); + + it("sets attributes based on the deepest nested component", (done) => { + ReactDOM.render( + <div> + <Helmet> + <title lang="en" hidden /> + </Helmet> + <Helmet> + <title lang="ja" /> + </Helmet> + </div>, + container + ); + + requestIdleCallback(() => { + const titleTag = document.getElementsByTagName("title")[0]; + + expect(titleTag.getAttribute("lang")).to.equal("ja"); + expect(titleTag.getAttribute("hidden")).to.equal("true"); + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,hidden"); + + done(); + }); + }); + + it("handles valueless attributes", (done) => { + ReactDOM.render( + <Helmet> + <title hidden /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + const titleTag = document.getElementsByTagName("title")[0]; + + expect(titleTag.getAttribute("hidden")).to.equal("true"); + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("hidden"); + + done(); + }); + }); + + it("clears title attributes that are handled within helmet", (done) => { + ReactDOM.render( + <Helmet> + <title lang="en" hidden /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + <Helmet />, + container + ); + + requestIdleCallback(() => { + const titleTag = document.getElementsByTagName("title")[0]; + + expect(titleTag.getAttribute("lang")).to.be.null; + expect(titleTag.getAttribute("hidden")).to.be.null; + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); + + done(); + }); + }); + }); + }); + + describe("html attributes", () => { + it("updates html attributes", (done) => { + ReactDOM.render( + <Helmet> + <html className="myClassName" lang="en" /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + + expect(htmlTag.getAttribute("class")).to.equal("myClassName"); + expect(htmlTag.getAttribute("lang")).to.equal("en"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("class,lang"); + + done(); + }); + }); + + it("sets attributes based on the deepest nested component", (done) => { + ReactDOM.render( + <div> + <Helmet> + <html lang="en" /> + </Helmet> + <Helmet> + <html lang="ja" /> + </Helmet> + </div>, + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + + expect(htmlTag.getAttribute("lang")).to.equal("ja"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang"); + + done(); + }); + }); + + it("handles valueless attributes", (done) => { + ReactDOM.render( + <Helmet> + <html amp /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + + expect(htmlTag.getAttribute("amp")).to.equal("true"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("amp"); + + done(); + }); + }); + + it("clears html attributes that are handled within helmet", (done) => { + ReactDOM.render( + <Helmet> + <html lang="en" amp /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + <Helmet />, + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + + expect(htmlTag.getAttribute("lang")).to.be.null; + expect(htmlTag.getAttribute("amp")).to.be.null; + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); + + done(); + }); + }); + }); + + it("updates with multiple additions and removals - overwrite and new", (done) => { + ReactDOM.render( + <Helmet> + <html lang="en" amp /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + <Helmet> + <html lang="ja" id="html-tag" title="html tag" /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + + expect(htmlTag.getAttribute("amp")).to.equal(null); + expect(htmlTag.getAttribute("lang")).to.equal("ja"); + expect(htmlTag.getAttribute("id")).to.equal("html-tag"); + expect(htmlTag.getAttribute("title")).to.equal("html tag"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,id,title"); + + done(); + }); + }); + }); + + it("updates with multiple additions and removals - all new", (done) => { + ReactDOM.render( + <Helmet> + <html lang="en" amp /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + <Helmet> + <html id="html-tag" title="html tag" /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + + expect(htmlTag.getAttribute("amp")).to.equal(null); + expect(htmlTag.getAttribute("lang")).to.equal(null); + expect(htmlTag.getAttribute("id")).to.equal("html-tag"); + expect(htmlTag.getAttribute("title")).to.equal("html tag"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("id,title"); + + done(); + }); + }); + }); + + context("initialized outside of helmet", () => { + before(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + htmlTag.setAttribute("test", "test"); + }); + + it("are not cleared", (done) => { + ReactDOM.render( + <Helmet />, + container + ); + + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + + expect(htmlTag.getAttribute("test")).to.equal("test"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); + + done(); + }); + }); + + it("overwritten if specified in helmet", (done) => { + ReactDOM.render( + <Helmet> + <html test="helmet-attr" /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + + expect(htmlTag.getAttribute("test")).to.equal("helmet-attr"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("test"); + + done(); + }); + }); + + it("cleared once it is managed in helmet", (done) => { + ReactDOM.render( + <Helmet> + <html test="helmet-attr" /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + <Helmet />, + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + + expect(htmlTag.getAttribute("test")).to.equal(null); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); + + done(); + }); + }); + }); + }); + }); + + describe("body attributes", () => { + context("valid attributes", () => { + const attributeList = { + "accessKey": "c", + "className": "test", + "contentEditable": "true", + "contextMenu": "mymenu", + "data-animal-type": "lion", + "dir": "rtl", + "draggable": "true", + "dropzone": "copy", + "hidden": "true", + "id": "test", + "lang": "fr", + "spellcheck": "true", + "style": "color:green", + "tabIndex": "-1", + "title": "test", + "translate": "no" + }; + + Object.keys(attributeList).forEach(attribute => { + it(attribute, (done) => { + const attrValue = attributeList[attribute]; + + const attr = { + [attribute]: attrValue + }; + + ReactDOM.render( + <Helmet> + <body {...attr} /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + const bodyTag = document.body; + + const reactCompatAttr = HTML_TAG_MAP[attribute] || attribute; + expect(bodyTag.getAttribute(reactCompatAttr)).to.equal(attrValue); + expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(reactCompatAttr); + + done(); + }); + }); + }); + }); + + it("updates multiple body attributes", (done) => { + ReactDOM.render( + <Helmet> + <body className="myClassName" tabIndex={-1} /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + const bodyTag = document.body; + + expect(bodyTag.getAttribute("class")).to.equal("myClassName"); + expect(bodyTag.getAttribute("tabindex")).to.equal("-1"); + expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("class,tabindex"); + + done(); + }); + }); + + it("sets attributes based on the deepest nested component", (done) => { + ReactDOM.render( + <div> + <Helmet> + <body lang="en" /> + </Helmet> + <Helmet> + <body lang="ja" /> + </Helmet> + </div>, + container + ); + + requestIdleCallback(() => { + const bodyTag = document.body; + + expect(bodyTag.getAttribute("lang")).to.equal("ja"); + expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang"); + + done(); + }); + }); + + it("handles valueless attributes", (done) => { + ReactDOM.render( + <Helmet> + <body hidden /> + </Helmet>, + container + ); + + + requestIdleCallback(() => { + const bodyTag = document.body; + + expect(bodyTag.getAttribute("hidden")).to.equal("true"); + expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("hidden"); + + done(); + }); + }); + + it("clears body attributes that are handled within helmet", (done) => { + ReactDOM.render( + <Helmet> + <body lang="en" hidden /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + <Helmet />, + container + ); + + requestIdleCallback(() => { + const bodyTag = document.body; + + expect(bodyTag.getAttribute("lang")).to.be.null; + expect(bodyTag.getAttribute("hidden")).to.be.null; + expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); + + done(); + }); + }); + }); + + it("updates with multiple additions and removals - overwrite and new", (done) => { + ReactDOM.render( + <Helmet> + <body lang="en" hidden /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + <Helmet> + <body lang="ja" id="body-tag" title="body tag" /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + const bodyTag = document.body; + + expect(bodyTag.getAttribute("hidden")).to.equal(null); + expect(bodyTag.getAttribute("lang")).to.equal("ja"); + expect(bodyTag.getAttribute("id")).to.equal("body-tag"); + expect(bodyTag.getAttribute("title")).to.equal("body tag"); + expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,id,title"); + + done(); + }); + }); + }); + + it("updates with multiple additions and removals - all new", (done) => { + ReactDOM.render( + <Helmet> + <body lang="en" hidden /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + <Helmet> + <body id="body-tag" title="body tag" /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + const bodyTag = document.body; + + expect(bodyTag.getAttribute("hidden")).to.equal(null); + expect(bodyTag.getAttribute("lang")).to.equal(null); + expect(bodyTag.getAttribute("id")).to.equal("body-tag"); + expect(bodyTag.getAttribute("title")).to.equal("body tag"); + expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("id,title"); + + done(); + }); + }); + }); + + context("initialized outside of helmet", () => { + before(() => { + const bodyTag = document.body; + bodyTag.setAttribute("test", "test"); + }); + + it("attributes are not cleared", (done) => { + ReactDOM.render( + <Helmet />, + container + ); + + requestIdleCallback(() => { + const bodyTag = document.body; + + expect(bodyTag.getAttribute("test")).to.equal("test"); + expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); + + done(); + }); + }); + + it("attributes are overwritten if specified in helmet", (done) => { + ReactDOM.render( + <Helmet> + <body test="helmet-attr" /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + const bodyTag = document.body; + + expect(bodyTag.getAttribute("test")).to.equal("helmet-attr"); + expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("test"); + + done(); + }); + }); + + it("attributes are cleared once managed in helmet", (done) => { + ReactDOM.render( + <Helmet> + <body test="helmet-attr" /> + </Helmet>, + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + <Helmet />, + container + ); + + requestIdleCallback(() => { + const bodyTag = document.body; + + expect(bodyTag.getAttribute("test")).to.equal(null); + expect(bodyTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); + + done(); + }); + }); + }); + }); + }); + + describe("onChangeClientState", () => { + it("when handling client state change, calls the function with new state, addedTags and removedTags ", (done) => { + const spy = sinon.spy(); + ReactDOM.render( + <div> + <Helmet onChangeClientState={spy}> + <base href="http://mysite.com/" /> + <link href="http://localhost/helmet" rel="canonical" /> + <meta charSet="utf-8" /> + <script src="http://localhost/test.js" type="text/javascript" /> + <title>Main Title + +
, + container + ); + + requestIdleCallback(() => { + expect(spy.called).to.equal(true); + const newState = spy.getCall(0).args[0]; + const addedTags = spy.getCall(0).args[1]; + const removedTags = spy.getCall(0).args[2]; + + expect(newState).to.contain({title: "Main Title"}); + expect(newState.baseTag).to.contain({href: "http://mysite.com/"}); + expect(newState.metaTags).to.contain({"charset": "utf-8"}); + expect(newState.linkTags).to.contain({"href": "http://localhost/helmet", "rel": "canonical"}); + expect(newState.scriptTags).to.contain({"src": "http://localhost/test.js", "type": "text/javascript"}); + + expect(addedTags).to.have.property("baseTag"); + expect(addedTags.baseTag).to.have.deep.property("[0]"); + expect(addedTags.baseTag[0].outerHTML).to.equal(``); + + expect(addedTags).to.have.property("metaTags"); + expect(addedTags.metaTags).to.have.deep.property("[0]"); + expect(addedTags.metaTags[0].outerHTML).to.equal(``); + + expect(addedTags).to.have.property("linkTags"); + expect(addedTags.linkTags).to.have.deep.property("[0]"); + expect(addedTags.linkTags[0].outerHTML).to.equal(``); + + expect(addedTags).to.have.property("scriptTags"); + expect(addedTags.scriptTags).to.have.deep.property("[0]"); + expect(addedTags.scriptTags[0].outerHTML).to.equal(``); + + expect(removedTags).to.be.empty; + + done(); + }); + }); + + it("calls the deepest defined callback with the deepest state", (done) => { + const spy = sinon.spy(); + ReactDOM.render( +
+ + Main Title + + + Deeper Title + +
, + container + ); + + requestIdleCallback(() => { + expect(spy.callCount).to.equal(1); + expect(spy.getCall(0).args[0]).to.contain({title: "Deeper Title"}); + + done(); + }); + }); + }); + + describe("base tag", () => { + it("updates base tag", (done) => { + ReactDOM.render( + + + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice.call(existingTags).filter((tag) => { + return tag.getAttribute("href") === "http://mysite.com/"; + }); + + expect(filteredTags.length).to.equal(1); + + done(); + }); + }); + + it("clears the base tag if one is not specified", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'href' are not accepted", (done) => { + ReactDOM.render( + + + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("sets base tag based on deepest nested component", (done) => { + ReactDOM.render( +
+ + + + + + +
, + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + const firstTag = Array.prototype.slice.call(existingTags)[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("href")).to.equal("http://mysite.com/public"); + expect(firstTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", (done) => { + ReactDOM.render( + + + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + }); + + describe("meta tags", () => { + it("updates meta tags", (done) => { + ReactDOM.render( + + + + + + + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice.call(existingTags).filter((tag) => { + return tag.getAttribute("charset") === "utf-8" || + (tag.getAttribute("name") === "description" && tag.getAttribute("content") === "Test description") || + (tag.getAttribute("http-equiv") === "content-type" && tag.getAttribute("content") === "text/html") || + (tag.getAttribute("itemprop") === "name" && tag.getAttribute("content") === "Test name itemprop"); + }); + + expect(filteredTags.length).to.be.at.least(4); + + done(); + }); + }); + + it("clears all meta tags if none are specified", (done) => { + ReactDOM.render( + + + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'name', 'http-equiv', 'property', 'charset', or 'itemprop' are not accepted", (done) => { + ReactDOM.render( + + + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("sets meta tags based on deepest nested component", (done) => { + ReactDOM.render( +
+ + + + + + + + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + const thirdTag = existingTags[2]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(3); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("charset")).to.equal("utf-8"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("name")).to.equal("description"); + expect(secondTag.getAttribute("content")).to.equal("Inner description"); + expect(secondTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[2]") + .that.is.an.instanceof(Element); + expect(thirdTag).to.have.property("getAttribute"); + expect(thirdTag.getAttribute("name")).to.equal("keywords"); + expect(thirdTag.getAttribute("content")).to.equal("test,meta,tags"); + expect(thirdTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("allows duplicate meta tags if specified in the same component", (done) => { + ReactDOM.render( + + + + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("name")).to.equal("description"); + expect(firstTag.getAttribute("content")).to.equal("Test description"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("name")).to.equal("description"); + expect(secondTag.getAttribute("content")).to.equal("Duplicate description"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("overrides duplicate meta tags with single meta tag in a nested component", (done) => { + ReactDOM.render( +
+ + + + + + + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("name")).to.equal("description"); + expect(firstTag.getAttribute("content")).to.equal("Inner description"); + expect(firstTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("overrides single meta tag with duplicate meta tags in a nested component", (done) => { + ReactDOM.render( +
+ + + + + + + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("name")).to.equal("description"); + expect(firstTag.getAttribute("content")).to.equal("Inner description"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("name")).to.equal("description"); + expect(secondTag.getAttribute("content")).to.equal("Inner duplicate description"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", (done) => { + ReactDOM.render( + + + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + }); + + describe("link tags", () => { + it("updates link tags", (done) => { + ReactDOM.render( + + + + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice.call(existingTags).filter((tag) => { + return (tag.getAttribute("href") === "http://localhost/style.css" && tag.getAttribute("rel") === "stylesheet" && tag.getAttribute("type") === "text/css") || + (tag.getAttribute("href") === "http://localhost/helmet" && tag.getAttribute("rel") === "canonical"); + }); + + expect(filteredTags.length).to.be.at.least(2); + + done(); + }); + }); + + it("clears all link tags if none are specified", (done) => { + ReactDOM.render( + + + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'href' or 'rel' are not accepted, even if they are valid for other tags", (done) => { + ReactDOM.render( + + + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("tags 'rel' and 'href' properly use 'rel' as the primary identification for this tag, regardless of ordering", (done) => { + ReactDOM.render( +
+ + + + + + + + + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/newest"); + expect(firstTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("tags with rel='stylesheet' uses the href as the primary identification of the tag, regardless of ordering", (done) => { + ReactDOM.render( +
+ + + + + + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/style.css"); + expect(firstTag.getAttribute("rel")).to.equal("stylesheet"); + expect(firstTag.getAttribute("type")).to.equal("text/css"); + expect(firstTag.getAttribute("media")).to.equal("all"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("rel")).to.equal("stylesheet"); + expect(secondTag.getAttribute("href")).to.equal("http://localhost/inner.css"); + expect(secondTag.getAttribute("type")).to.equal("text/css"); + expect(secondTag.getAttribute("media")).to.equal("all"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("sets link tags based on deepest nested component", (done) => { + ReactDOM.render( +
+ + + + + + + + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + const thirdTag = existingTags[2]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.at.least(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/style.css"); + expect(firstTag.getAttribute("rel")).to.equal("stylesheet"); + expect(firstTag.getAttribute("type")).to.equal("text/css"); + expect(firstTag.getAttribute("media")).to.equal("all"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); + expect(secondTag.getAttribute("rel")).to.equal("canonical"); + expect(secondTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[2]") + .that.is.an.instanceof(Element); + expect(thirdTag).to.have.property("getAttribute"); + expect(thirdTag.getAttribute("href")).to.equal("http://localhost/inner.css"); + expect(thirdTag.getAttribute("rel")).to.equal("stylesheet"); + expect(thirdTag.getAttribute("type")).to.equal("text/css"); + expect(thirdTag.getAttribute("media")).to.equal("all"); + expect(thirdTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("allows duplicate link tags if specified in the same component", (done) => { + ReactDOM.render( + + + + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.at.least(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("rel")).to.equal("canonical"); + expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("overrides duplicate link tags with a single link tag in a nested component", (done) => { + ReactDOM.render( +
+ + + + + + + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); + expect(firstTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("overrides single link tag with duplicate link tags in a nested component", (done) => { + ReactDOM.render( +
+ + + + + + + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("rel")).to.equal("canonical"); + expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", (done) => { + ReactDOM.render( + + + + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); + expect(firstTag.outerHTML).to.equal(``); + + done(); + }); + }); + }); + + describe("script tags", () => { + it("updates script tags", (done) => { + const scriptInnerHTML = ` + { + "@context": "http://schema.org", + "@type": "NewsArticle", + "url": "http://localhost/helmet" + } + `; + ReactDOM.render( + + + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.getElementsByTagName("script"); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice.call(existingTags).filter((tag) => { + return (tag.getAttribute("src") === "http://localhost/test.js" && tag.getAttribute("type") === "text/javascript") || + (tag.getAttribute("src") === "http://localhost/test2.js" && tag.getAttribute("type") === "text/javascript") || + (tag.getAttribute("type") === "application/ld+json" && tag.innerHTML === scriptInnerHTML); + }); + + expect(filteredTags.length).to.be.at.least(3); + + done(); + }); + }); + + it("clears all scripts tags if none are specified", (done) => { + ReactDOM.render( + + `); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("src")).to.equal("http://localhost/test2.js"); + expect(secondTag.getAttribute("type")).to.equal("text/javascript"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("sets undefined attribute values to empty strings", (done) => { + ReactDOM.render( + + `); + + done(); + }); + }); + + it("does not render tag when primary attribute (src) is null", (done) => { + ReactDOM.render( + + `, + `` + ].join(""); + + const stringifiedNoscriptTags = [ + ``, + `` + ].join(""); + + const stringifiedStyleTags = [ + ``, + `` + ].join(""); + + before(() => { + Helmet.canUseDOM = false; + }); + + it("provides initial values if no state is found", () => { + let head = Helmet.rewind(); + head = Helmet.rewind(); + + expect(head.meta).to.exist; + expect(head.meta).to.respondTo("toString"); + + expect(head.meta.toString()).to.equal(""); + }); + + it("encodes special characters in title", () => { + ReactDOM.render( + + {`Dangerous <script> include`} + , + container + ); + + const head = Helmet.rewind(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + expect(head.title.toString()).to.equal(stringifiedTitle); + }); + + it("opts out of string encoding", () => { + ReactDOM.render( + + {"This is text and & and '."} + , + container + ); + + const head = Helmet.rewind(); + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + expect(head.title.toString()).to.equal(unEncodedStringifiedTitle); + }); + + it("renders title as React component", () => { + ReactDOM.render( + + {`Dangerous <script> include`} + , + container + ); + + const head = Helmet.rewind(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toComponent"); + + const titleComponent = head.title.toComponent(); + + expect(titleComponent) + .to.be.an("array") + .that.has.length.of(1); + + titleComponent.forEach(title => { + expect(title) + .to.be.an("object") + .that.contains.property("type", "title"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {titleComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedTitle + }
`); + }); + + it("renders title with itemprop name as React component", () => { + ReactDOM.render( + + Title with Itemprop + , + container + ); + + const head = Helmet.rewind(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toComponent"); + + const titleComponent = head.title.toComponent(); + + expect(titleComponent) + .to.be.an("array") + .that.has.length.of(1); + + titleComponent.forEach(title => { + expect(title) + .to.be.an("object") + .that.contains.property("type", "title"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {titleComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedTitleWithItemprop + }
`); + }); + + it("renders base tag as React component", () => { + ReactDOM.render( + + + , + container + ); + + const head = Helmet.rewind(); + + expect(head.base).to.exist; + expect(head.base).to.respondTo("toComponent"); + + const baseComponent = head.base.toComponent(); + + expect(baseComponent) + .to.be.an("array") + .that.has.length.of(1); + + baseComponent.forEach(base => { + expect(base) + .to.be.an("object") + .that.contains.property("type", "base"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {baseComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedBaseTag + }
`); + }); + + it("renders meta tags as React components", () => { + ReactDOM.render( + + + < `"} /> + + + + , + container + ); + + const head = Helmet.rewind(); + + expect(head.meta).to.exist; + expect(head.meta).to.respondTo("toComponent"); + + const metaComponent = head.meta.toComponent(); + + expect(metaComponent) + .to.be.an("array") + .that.has.length.of(5); + + metaComponent.forEach(meta => { + expect(meta) + .to.be.an("object") + .that.contains.property("type", "meta"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {metaComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedMetaTags + }
`); + }); + + it("renders link tags as React components", () => { + ReactDOM.render( + + + + , + container + ); + + const head = Helmet.rewind(); + + expect(head.link).to.exist; + expect(head.link).to.respondTo("toComponent"); + + const linkComponent = head.link.toComponent(); + + expect(linkComponent) + .to.be.an("array") + .that.has.length.of(2); + + linkComponent.forEach(link => { + expect(link) + .to.be.an("object") + .that.contains.property("type", "link"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {linkComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedLinkTags + }
`); + }); + + it("renders script tags as React components", () => { + ReactDOM.render( + + `); + }); + + context("renderStatic", () => { + it("does html encode title", () => { + ReactDOM.render( + + {`Dangerous <script> include`} + , + container + ); + + const head = Helmet.renderStatic(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + expect(head.title.toString()).to.equal(stringifiedTitle); + }); + + it("renders title as React component", () => { + ReactDOM.render( + + {`Dangerous <script> include`} + , + container + ); + + const head = Helmet.renderStatic(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toComponent"); + + const titleComponent = head.title.toComponent(); + + expect(titleComponent) + .to.be.an("array") + .that.has.length.of(1); + + titleComponent.forEach(title => { + expect(title) + .to.be.an("object") + .that.contains.property("type", "title"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {titleComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedTitle + }
`); + }); + }); + + after(() => { + Helmet.canUseDOM = true; + }); + }); + + describe("misc", () => { + it("throws in rewind() when a DOM is present", () => { + ReactDOM.render( + + Fancy title + , + container + ); + + expect(Helmet.rewind).to.throw( + "You may only call rewind() on the server. Call peek() to read the current state." + ); + }); + + it("lets you read current state in peek() whether or not a DOM is present", (done) => { + ReactDOM.render( + + Fancy title + , + container + ); + + requestIdleCallback(() => { + expect(Helmet.peek().title).to.be.equal("Fancy title"); + Helmet.canUseDOM = false; + expect(Helmet.peek().title).to.be.equal("Fancy title"); + Helmet.canUseDOM = true; + + done(); + }); + }); + + it("encodes special characters", (done) => { + ReactDOM.render( + + + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(existingTag).to.have.property("getAttribute"); + expect(existingTag.getAttribute("name")).to.equal("description"); + expect(existingTag.getAttribute("content")).to.equal("This is \"quoted\" text and & and '."); + expect(existingTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("does not change the DOM if it recevies identical props", (done) => { + const spy = sinon.spy(); + ReactDOM.render( + + + Test Title + , + container + ); + + requestIdleCallback(() => { + // Re-rendering will pass new props to an already mounted Helmet + ReactDOM.render( + + + Test Title + , + container + ); + + requestIdleCallback(() => { + expect(spy.callCount).to.equal(1); + + done(); + }); + }); + }); + + it("does not write the DOM if the client and server are identical", (done) => { + headElement.innerHTML = ` +
, + container + ) + ); + + expect(renderInvalid).to.throw(Error, "Helmet expects a string as a child of ) Refer to our API for more information."); + }); + + it("recognizes valid tags regardless of attribute ordering", (done) => { + ReactDOM.render( + + + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(existingTag).to.have.property("getAttribute"); + expect(existingTag.getAttribute("name")).to.equal("description"); + expect(existingTag.getAttribute("content")).to.equal("Test Description"); + expect(existingTag.outerHTML).to.equal(``); + + done(); + }); + }); + }); +}); diff --git a/test/HelmetTest.js b/test/HelmetTest.js new file mode 100644 index 00000000..e086c187 --- /dev/null +++ b/test/HelmetTest.js @@ -0,0 +1,2815 @@ +/* eslint max-nested-callbacks: [1, 7] */ +/* eslint-disable import/no-named-as-default */ + +import React from "react"; +import ReactDOM from "react-dom"; +import ReactServer from "react-dom/server"; +import {Helmet} from "../src/Helmet"; +import {requestIdleCallback} from "../src/HelmetUtils.js"; + +const HELMET_ATTRIBUTE = "data-react-helmet"; + +describe("Helmet", () => { + let headElement; + + const container = document.createElement("div"); + + beforeEach(() => { + headElement = headElement || document.head || document.querySelector("head"); + + // resets DOM after each run + headElement.innerHTML = ""; + }); + + afterEach(() => { + ReactDOM.unmountComponentAtNode(container); + }); + + describe("api", () => { + describe("title", () => { + it("updates page title", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("Test Title"); + + done(); + }); + }); + + it("updates page title with multiple children", (done) => { + ReactDOM.render( +
+ + + +
, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("Child Two Title"); + + done(); + }); + }); + + it("sets title based on deepest nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("Nested Title"); + + done(); + }); + }); + + it("sets title using deepest nested component with a defined title", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("Main Title"); + + done(); + }); + }); + + it("uses defaultTitle if no title is defined", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("Fallback"); + + done(); + }); + }); + + it("uses a titleTemplate if defined", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("This is a Test of the titleTemplate feature"); + + done(); + }); + }); + + it("replaces multiple title strings in titleTemplate", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("This is a Test of the titleTemplate feature. Another Test."); + + done(); + }); + }); + + it("uses a titleTemplate based on deepest nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("A Second Test using nested titleTemplate attributes"); + + done(); + }); + }); + + it("merges deepest component title with nearest upstream titleTemplate", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("This is a Second Test of the titleTemplate feature"); + + done(); + }); + }); + + it("renders dollar characters in a title correctly when titleTemplate present", (done) => { + const dollarTitle = "te$t te$$t te$$$t te$$$$t"; + + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal("This is a te$t te$$t te$$$t te$$$$t"); + + done(); + }); + }); + + it("does not encode all characters with HTML character entity equivalents", (done) => { + const chineseTitle = "膣膗 鍆錌雔"; + + ReactDOM.render( +
+ +
, + container + ); + + requestIdleCallback(() => { + expect(document.title).to.equal(chineseTitle); + + done(); + }); + }); + + it("page title with prop itemprop", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const titleTag = document.getElementsByTagName("title")[0]; + expect(document.title).to.equal("Test Title with itemProp"); + expect(titleTag.getAttribute("itemprop")).to.equal("name"); + + done(); + }); + }); + }); + + describe("title attributes", () => { + beforeEach(() => { + headElement.innerHTML = `Test Title`; + }); + + it("update title attributes", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const titleTag = document.getElementsByTagName("title")[0]; + expect(titleTag.getAttribute("itemprop")).to.equal("name"); + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("itemprop"); + + done(); + }); + }); + + it("sets attributes based on the deepest nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + const titleTag = document.getElementsByTagName("title")[0]; + expect(titleTag.getAttribute("lang")).to.equal("ja"); + expect(titleTag.getAttribute("hidden")).to.equal(""); + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,hidden"); + + done(); + }); + }); + + it("handles valueless attributes", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const titleTag = document.getElementsByTagName("title")[0]; + expect(titleTag.getAttribute("hidden")).to.equal(""); + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("hidden"); + + done(); + }); + }); + + it("clears title attributes that are handled within helmet", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const titleTag = document.getElementsByTagName("title")[0]; + expect(titleTag.getAttribute("lang")).to.be.null; + expect(titleTag.getAttribute("hidden")).to.be.null; + expect(titleTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); + + done(); + }); + }); + }); + }); + + describe("html attributes", () => { + it("updates html attributes", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("class")).to.equal("myClassName"); + expect(htmlTag.getAttribute("lang")).to.equal("en"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("class,lang"); + + done(); + }); + }); + + it("sets attributes based on the deepest nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("lang")).to.equal("ja"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang"); + + done(); + }); + }); + + it("handles valueless attributes", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("amp")).to.equal(""); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("amp"); + + done(); + }); + }); + + it("clears html attributes that are handled within helmet", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("lang")).to.be.null; + expect(htmlTag.getAttribute("amp")).to.be.null; + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); + + done(); + }); + }); + }); + + it("updates with multiple additions and removals - overwrite and new", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("amp")).to.equal(null); + expect(htmlTag.getAttribute("lang")).to.equal("ja"); + expect(htmlTag.getAttribute("id")).to.equal("html-tag"); + expect(htmlTag.getAttribute("title")).to.equal("html tag"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("lang,id,title"); + + done(); + }); + }); + }); + + it("updates with multiple additions and removals - all new", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("amp")).to.equal(null); + expect(htmlTag.getAttribute("lang")).to.equal(null); + expect(htmlTag.getAttribute("id")).to.equal("html-tag"); + expect(htmlTag.getAttribute("title")).to.equal("html tag"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("id,title"); + + done(); + }); + }); + }); + + context("initialized outside of helmet", () => { + before(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + htmlTag.setAttribute("test", "test"); + }); + + it("attributes are not cleared", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("test")).to.equal("test"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); + + done(); + }); + }); + + it("attributes are overwritten if specified in helmet", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("test")).to.equal("helmet-attr"); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal("test"); + + done(); + }); + }); + + it("attributes are cleared once managed in helmet", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const htmlTag = document.getElementsByTagName("html")[0]; + expect(htmlTag.getAttribute("test")).to.equal(null); + expect(htmlTag.getAttribute(HELMET_ATTRIBUTE)).to.equal(null); + + done(); + }); + }); + }); + }); + }); + + describe("onChangeClientState", () => { + it("when handling client state change, calls the function with new state, addedTags and removedTags ", (done) => { + const spy = sinon.spy(); + ReactDOM.render( +
+ +
, + container + ); + + requestIdleCallback(() => { + expect(spy.called).to.equal(true); + const newState = spy.getCall(0).args[0]; + const addedTags = spy.getCall(0).args[1]; + const removedTags = spy.getCall(0).args[2]; + + expect(newState).to.contain({title: "Main Title"}); + expect(newState.baseTag).to.contain({href: "http://mysite.com/"}); + expect(newState.metaTags).to.contain({"charset": "utf-8"}); + expect(newState.linkTags).to.contain({"href": "http://localhost/helmet", "rel": "canonical"}); + expect(newState.scriptTags).to.contain({"src": "http://localhost/test.js", "type": "text/javascript"}); + + expect(addedTags).to.have.property("baseTag"); + expect(addedTags.baseTag).to.have.deep.property("[0]"); + expect(addedTags.baseTag[0].outerHTML).to.equal(``); + + expect(addedTags).to.have.property("metaTags"); + expect(addedTags.metaTags).to.have.deep.property("[0]"); + expect(addedTags.metaTags[0].outerHTML).to.equal(``); + + expect(addedTags).to.have.property("linkTags"); + expect(addedTags.linkTags).to.have.deep.property("[0]"); + expect(addedTags.linkTags[0].outerHTML).to.equal(``); + + expect(addedTags).to.have.property("scriptTags"); + expect(addedTags.scriptTags).to.have.deep.property("[0]"); + expect(addedTags.scriptTags[0].outerHTML).to.equal(``); + + expect(removedTags).to.be.empty; + + done(); + }); + }); + + it("calls the deepest defined callback with the deepest state", (done) => { + const spy = sinon.spy(); + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + expect(spy.callCount).to.equal(1); + expect(spy.getCall(0).args[0]).to.contain({title: "Deeper Title"}); + + done(); + }); + }); + }); + + describe("base tag", () => { + it("updates base tag", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice.call(existingTags).filter((tag) => { + return tag.getAttribute("href") === "http://mysite.com/"; + }); + + expect(filteredTags.length).to.equal(1); + + done(); + }); + }); + + it("clears the base tag if one is not specified", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'href' are not accepted", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("sets base tag based on deepest nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + const firstTag = Array.prototype.slice.call(existingTags)[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("href")).to.equal("http://mysite.com/public"); + expect(firstTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`base[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + }); + + describe("meta tags", () => { + it("updates meta tags", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice.call(existingTags).filter((tag) => { + return tag.getAttribute("charset") === "utf-8" || + (tag.getAttribute("name") === "description" && tag.getAttribute("content") === "Test description") || + (tag.getAttribute("http-equiv") === "content-type" && tag.getAttribute("content") === "text/html") || + (tag.getAttribute("itemprop") === "name" && tag.getAttribute("content") === "Test name itemprop"); + }); + + expect(filteredTags.length).to.be.at.least(4); + + done(); + }); + }); + + it("clears all meta tags if none are specified", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'name', 'http-equiv', 'property', 'charset', or 'itemprop' are not accepted", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("sets meta tags based on deepest nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + const thirdTag = existingTags[2]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(3); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("charset")).to.equal("utf-8"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("name")).to.equal("description"); + expect(secondTag.getAttribute("content")).to.equal("Inner description"); + expect(secondTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[2]") + .that.is.an.instanceof(Element); + expect(thirdTag).to.have.property("getAttribute"); + expect(thirdTag.getAttribute("name")).to.equal("keywords"); + expect(thirdTag.getAttribute("content")).to.equal("test,meta,tags"); + expect(thirdTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("allows duplicate meta tags if specified in the same component", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("name")).to.equal("description"); + expect(firstTag.getAttribute("content")).to.equal("Test description"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("name")).to.equal("description"); + expect(secondTag.getAttribute("content")).to.equal("Duplicate description"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("overrides duplicate meta tags with single meta tag in a nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("name")).to.equal("description"); + expect(firstTag.getAttribute("content")).to.equal("Inner description"); + expect(firstTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("overrides single meta tag with duplicate meta tags in a nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("name")).to.equal("description"); + expect(firstTag.getAttribute("content")).to.equal("Inner description"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("name")).to.equal("description"); + expect(secondTag.getAttribute("content")).to.equal("Inner duplicate description"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + + it("fails gracefully when meta is wrong shape", (done) => { + const error = sinon.stub(console, "error"); + const warn = sinon.stub(console, "warn"); + + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + expect(error.called).to.be.true; + expect(warn.called).to.be.true; + + const [warning] = warn.getCall(0).args; + expect(warning).to.equal(`Helmet: meta should be of type "Array". Instead found type "object"`); + + error.restore(); + warn.restore(); + + done(); + }); + }); + }); + + describe("link tags", () => { + it("updates link tags", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice.call(existingTags).filter((tag) => { + return (tag.getAttribute("href") === "http://localhost/style.css" && tag.getAttribute("rel") === "stylesheet" && tag.getAttribute("type") === "text/css") || + (tag.getAttribute("href") === "http://localhost/helmet" && tag.getAttribute("rel") === "canonical"); + }); + + expect(filteredTags.length).to.be.at.least(2); + + done(); + }); + }); + + it("clears all link tags if none are specified", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'href' or 'rel' are not accepted, even if they are valid for other tags", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("tags 'rel' and 'href' properly use 'rel' as the primary identification for this tag, regardless of ordering", (done) => { + ReactDOM.render( +
+ + + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/newest"); + expect(firstTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("tags with rel='stylesheet' uses the href as the primary identification of the tag, regardless of ordering", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.equal(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/style.css"); + expect(firstTag.getAttribute("rel")).to.equal("stylesheet"); + expect(firstTag.getAttribute("type")).to.equal("text/css"); + expect(firstTag.getAttribute("media")).to.equal("all"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("rel")).to.equal("stylesheet"); + expect(secondTag.getAttribute("href")).to.equal("http://localhost/inner.css"); + expect(secondTag.getAttribute("type")).to.equal("text/css"); + expect(secondTag.getAttribute("media")).to.equal("all"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("sets link tags based on deepest nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + const thirdTag = existingTags[2]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.at.least(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/style.css"); + expect(firstTag.getAttribute("rel")).to.equal("stylesheet"); + expect(firstTag.getAttribute("type")).to.equal("text/css"); + expect(firstTag.getAttribute("media")).to.equal("all"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); + expect(secondTag.getAttribute("rel")).to.equal("canonical"); + expect(secondTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[2]") + .that.is.an.instanceof(Element); + expect(thirdTag).to.have.property("getAttribute"); + expect(thirdTag.getAttribute("href")).to.equal("http://localhost/inner.css"); + expect(thirdTag.getAttribute("rel")).to.equal("stylesheet"); + expect(thirdTag.getAttribute("type")).to.equal("text/css"); + expect(thirdTag.getAttribute("media")).to.equal("all"); + expect(thirdTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("allows duplicate link tags if specified in the same component", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.at.least(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("rel")).to.equal("canonical"); + expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("overrides duplicate link tags with a single link tag in a nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); + expect(firstTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("overrides single link tag with duplicate link tags in a nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("rel")).to.equal("canonical"); + expect(secondTag.getAttribute("href")).to.equal("http://localhost/helmet/innercomponent"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`link[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("rel")).to.equal("canonical"); + expect(firstTag.getAttribute("href")).to.equal("http://localhost/helmet/component"); + expect(firstTag.outerHTML).to.equal(``); + + done(); + }); + }); + }); + + describe("script tags", () => { + it("updates script tags", (done) => { + const scriptInnerHTML = ` + { + "@context": "http://schema.org", + "@type": "NewsArticle", + "url": "http://localhost/helmet" + } + `; + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.getElementsByTagName("script"); + + expect(existingTags).to.not.equal(undefined); + + const filteredTags = [].slice.call(existingTags).filter((tag) => { + return (tag.getAttribute("src") === "http://localhost/test.js" && tag.getAttribute("type") === "text/javascript") || + (tag.getAttribute("src") === "http://localhost/test2.js" && tag.getAttribute("type") === "text/javascript") || + (tag.getAttribute("type") === "application/ld+json" && tag.innerHTML === scriptInnerHTML); + }); + + expect(filteredTags.length).to.be.at.least(3); + + done(); + }); + }); + + it("clears all scripts tags if none are specified", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'src' are not accepted", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("sets script tags based on deepest nested component", (done) => { + ReactDOM.render( +
+ + +
, + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + const firstTag = existingTags[0]; + const secondTag = existingTags[1]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.at.least(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("src")).to.equal("http://localhost/test.js"); + expect(firstTag.getAttribute("type")).to.equal("text/javascript"); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag).to.have.property("getAttribute"); + expect(secondTag.getAttribute("src")).to.equal("http://localhost/test2.js"); + expect(secondTag.getAttribute("type")).to.equal("text/javascript"); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("sets undefined attribute values to empty strings", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTag = headElement.querySelector(`script[${HELMET_ATTRIBUTE}]`); + + expect(existingTag).to.not.equal(undefined); + expect(existingTag.outerHTML) + .to.be.a("string") + .that.equals(``); + + done(); + }); + }); + + it("does not render tag when primary attribute (src) is null", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + + it("does not render tag when primary attribute (innerHTML) is null", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + }); + + describe("noscript tags", () => { + it("updates noscript tags", (done) => { + const noscriptInnerHTML = ``; + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.getElementsByTagName("noscript"); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(1); + expect(existingTags[0].innerHTML === noscriptInnerHTML && existingTags[0].id === "bar"); + + done(); + }); + }); + + it("clears all noscripts tags if none are specified", (done) => { + ReactDOM.render(, container); + + requestIdleCallback(() => { + ReactDOM.render(, container); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`script[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'innerHTML' are not accepted", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`noscript[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`noscript[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + }); + + describe("style tags", () => { + it("updates style tags", (done) => { + const cssText1 = ` + body { + background-color: green; + } + `; + const cssText2 = ` + p { + font-size: 12px; + } + `; + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`style[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + + const [ + firstTag, + secondTag + ] = existingTags; + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.be.equal(2); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(firstTag).to.have.property("getAttribute"); + expect(firstTag.getAttribute("type")).to.equal("text/css"); + expect(firstTag.innerHTML).to.equal(cssText1); + expect(firstTag.outerHTML).to.equal(``); + + expect(existingTags) + .to.have.deep.property("[1]") + .that.is.an.instanceof(Element); + expect(secondTag.innerHTML).to.equal(cssText2); + expect(secondTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("clears all style tags if none are specified", (done) => { + const cssText = ` + body { + background-color: green; + } + `; + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`style[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + }); + + it("tags without 'cssText' are not accepted", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`style[${HELMET_ATTRIBUTE}]`); + + expect(existingTags).to.not.equal(undefined); + expect(existingTags.length).to.equal(0); + + done(); + }); + }); + + it("does not render tag when primary attribute is null", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const tagNodes = headElement.querySelectorAll(`style[${HELMET_ATTRIBUTE}]`); + const existingTags = Array.prototype.slice.call(tagNodes); + expect(existingTags).to.be.empty; + + done(); + }); + }); + }); + }); + + describe("server", () => { + const stringifiedHtmlAttributes = `lang="ga" class="myClassName"`; + const stringifiedTitle = `Dangerous <script> include`; + const unEncodedStringifiedTitle = `This is text and & and '.`; + const stringifiedTitleWithItemprop = `Title with Itemprop`; + const stringifiedBaseTag = ``; + + const stringifiedMetaTags = [ + ``, + ``, + ``, + ``, + `` + ].join(""); + + const stringifiedLinkTags = [ + ``, + `` + ].join(""); + + const stringifiedScriptTags = [ + ``, + `` + ].join(""); + + const stringifiedNoscriptTags = [ + ``, + `` + ].join(""); + + const stringifiedStyleTags = [ + ``, + `` + ].join(""); + + before(() => { + Helmet.canUseDOM = false; + }); + + it("provides initial values if no state is found", () => { + let head = Helmet.rewind(); + head = Helmet.rewind(); + + expect(head.meta).to.exist; + expect(head.meta).to.respondTo("toString"); + + expect(head.meta.toString()).to.equal(""); + }); + + it("encodes special characters in title", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + expect(head.title.toString()).to.equal(stringifiedTitle); + }); + + it("opts out of string encoding", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + expect(head.title.toString()).to.equal(unEncodedStringifiedTitle); + }); + + it("renders title as React component", () => { + ReactDOM.render( + include"} + />, + container + ); + + const head = Helmet.rewind(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toComponent"); + + const titleComponent = head.title.toComponent(); + + expect(titleComponent) + .to.be.an("array") + .that.has.length.of(1); + + titleComponent.forEach(title => { + expect(title) + .to.be.an("object") + .that.contains.property("type", "title"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {titleComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedTitle + }
`); + }); + + it("renders title with itemprop name as React component", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toComponent"); + + const titleComponent = head.title.toComponent(); + + expect(titleComponent) + .to.be.an("array") + .that.has.length.of(1); + + titleComponent.forEach(title => { + expect(title) + .to.be.an("object") + .that.contains.property("type", "title"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {titleComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedTitleWithItemprop + }
`); + }); + + it("renders base tag as React component", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.base).to.exist; + expect(head.base).to.respondTo("toComponent"); + + const baseComponent = head.base.toComponent(); + + expect(baseComponent) + .to.be.an("array") + .that.has.length.of(1); + + baseComponent.forEach(base => { + expect(base) + .to.be.an("object") + .that.contains.property("type", "base"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {baseComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedBaseTag + }
`); + }); + + it("renders meta tags as React components", () => { + ReactDOM.render( + < `"}, + {"http-equiv": "content-type", "content": "text/html"}, + {"property": "og:type", "content": "article"}, + {"itemprop": "name", "content": "Test name itemprop"} + ]} + />, + container + ); + + const head = Helmet.rewind(); + + expect(head.meta).to.exist; + expect(head.meta).to.respondTo("toComponent"); + + const metaComponent = head.meta.toComponent(); + + expect(metaComponent) + .to.be.an("array") + .that.has.length.of(5); + + metaComponent.forEach(meta => { + expect(meta) + .to.be.an("object") + .that.contains.property("type", "meta"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {metaComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedMetaTags + }
`); + }); + + it("renders link tags as React components", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.link).to.exist; + expect(head.link).to.respondTo("toComponent"); + + const linkComponent = head.link.toComponent(); + + expect(linkComponent) + .to.be.an("array") + .that.has.length.of(2); + + linkComponent.forEach(link => { + expect(link) + .to.be.an("object") + .that.contains.property("type", "link"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {linkComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedLinkTags + }
`); + }); + + it("renders script tags as React components", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.script).to.exist; + expect(head.script).to.respondTo("toComponent"); + + const scriptComponent = head.script.toComponent(); + + expect(scriptComponent) + .to.be.an("array") + .that.has.length.of(2); + + scriptComponent.forEach(script => { + expect(script) + .to.be.an("object") + .that.contains.property("type", "script"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {scriptComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedScriptTags + }
`); + }); + + it("renders noscript tags as React components", () => { + ReactDOM.render( + '}, + {id: "bar", innerHTML: ''} + ]} + />, + container + ); + + const head = Helmet.rewind(); + + expect(head.noscript).to.exist; + expect(head.noscript).to.respondTo("toComponent"); + + const noscriptComponent = head.noscript.toComponent(); + + expect(noscriptComponent) + .to.be.an("array") + .that.has.length.of(2); + + noscriptComponent.forEach(noscript => { + expect(noscript) + .to.be.an("object") + .that.contains.property("type", "noscript"); + }); + + const markup = ReactServer.renderToStaticMarkup( +
+ {noscriptComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedNoscriptTags + }
`); + }); + + it("renders style tags as React components", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.style).to.exist; + expect(head.style).to.respondTo("toComponent"); + + const styleComponent = head.style.toComponent(); + + expect(styleComponent) + .to.be.an("array") + .that.has.length.of(2); + + const markup = ReactServer.renderToStaticMarkup( +
+ {styleComponent} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
${ + stringifiedStyleTags + }
`); + }); + + it("renders title tag as string", () => { + ReactDOM.render( + include"} + />, + container + ); + + const head = Helmet.rewind(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + expect(head.title.toString()) + .to.be.a("string") + .that.equals(stringifiedTitle); + }); + + it("renders title with itemprop name as string", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + const titleString = head.title.toString(); + expect(titleString) + .to.be.a("string") + .that.equals(stringifiedTitleWithItemprop); + }); + + it("renders base tags as string", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.base).to.exist; + expect(head.base).to.respondTo("toString"); + + expect(head.base.toString()) + .to.be.a("string") + .that.equals(stringifiedBaseTag); + }); + + it("renders meta tags as string", () => { + ReactDOM.render( + < `"}, + {"http-equiv": "content-type", "content": "text/html"}, + {"property": "og:type", "content": "article"}, + {"itemprop": "name", "content": "Test name itemprop"} + ]} + />, + container + ); + + const head = Helmet.rewind(); + + expect(head.meta).to.exist; + expect(head.meta).to.respondTo("toString"); + + expect(head.meta.toString()) + .to.be.a("string") + .that.equals(stringifiedMetaTags); + }); + + it("renders link tags as string", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.link).to.exist; + expect(head.link).to.respondTo("toString"); + + expect(head.link.toString()) + .to.be.a("string") + .that.equals(stringifiedLinkTags); + }); + + it("renders script tags as string", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.script).to.exist; + expect(head.script).to.respondTo("toString"); + + expect(head.script.toString()) + .to.be.a("string") + .that.equals(stringifiedScriptTags); + }); + + it("renders style tags as string", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.style).to.exist; + expect(head.style).to.respondTo("toString"); + + expect(head.style.toString()) + .to.be.a("string") + .that.equals(stringifiedStyleTags); + }); + + it("renders html attributes as component", () => { + ReactDOM.render( + , + container + ); + + const {htmlAttributes} = Helmet.rewind(); + const attrs = htmlAttributes.toComponent(); + + expect(attrs).to.exist; + + const markup = ReactServer.renderToStaticMarkup( + + ); + + expect(markup) + .to.be.a("string") + .that.equals(``); + }); + + it("renders html attributes as string", () => { + ReactDOM.render( + , + container + ); + + const head = Helmet.rewind(); + + expect(head.htmlAttributes).to.exist; + expect(head.htmlAttributes).to.respondTo("toString"); + + expect(head.htmlAttributes.toString()) + .to.be.a("string") + .that.equals(stringifiedHtmlAttributes); + }); + + it("does not encode all characters with HTML character entity equivalents", () => { + const chineseTitle = "膣膗 鍆錌雔"; + const stringifiedChineseTitle = `${chineseTitle}`; + + ReactDOM.render( +
+ +
, + container + ); + + const head = Helmet.rewind(); + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + + expect(head.title.toString()) + .to.be.a("string") + .that.equals(stringifiedChineseTitle); + }); + + it("rewind() provides a fallback object for empty Helmet state", () => { + ReactDOM.render( +
, + container + ); + + const head = Helmet.rewind(); + + expect(head.htmlAttributes).to.exist; + expect(head.htmlAttributes).to.respondTo("toString"); + expect(head.htmlAttributes.toString()).to.equal(""); + expect(head.htmlAttributes).to.respondTo("toComponent"); + expect(head.htmlAttributes.toComponent()).to.be.an("object") + .that.is.empty; + + expect(head.title).to.exist; + expect(head.title).to.respondTo("toString"); + expect(head.title.toString()).to.equal(``); + expect(head.title).to.respondTo("toComponent"); + + const markup = ReactServer.renderToStaticMarkup( +
+ {head.title.toComponent()} +
+ ); + + expect(markup) + .to.be.a("string") + .that.equals(`
`); + + expect(head.base).to.exist; + expect(head.base).to.respondTo("toString"); + expect(head.base.toString()).to.equal(""); + expect(head.base).to.respondTo("toComponent"); + expect(head.base.toComponent()).to.be.an("array") + .that.is.empty; + + expect(head.meta).to.exist; + expect(head.meta).to.respondTo("toString"); + expect(head.meta.toString()).to.equal(""); + expect(head.meta).to.respondTo("toComponent"); + expect(head.meta.toComponent()).to.be.an("array") + .that.is.empty; + + expect(head.link).to.exist; + expect(head.link).to.respondTo("toString"); + expect(head.link.toString()).to.equal(""); + expect(head.link).to.respondTo("toComponent"); + expect(head.link.toComponent()).to.be.an("array") + .that.is.empty; + + expect(head.script).to.exist; + expect(head.script).to.respondTo("toString"); + expect(head.script.toString()).to.equal(""); + expect(head.script).to.respondTo("toComponent"); + expect(head.script.toComponent()).to.be.an("array") + .that.is.empty; + + expect(head.noscript).to.exist; + expect(head.noscript).to.respondTo("toString"); + expect(head.noscript.toString()).to.equal(""); + expect(head.noscript).to.respondTo("toComponent"); + expect(head.noscript.toComponent()).to.be.an("array") + .that.is.empty; + + expect(head.style).to.exist; + expect(head.style).to.respondTo("toString"); + expect(head.style.toString()).to.equal(""); + expect(head.style).to.respondTo("toComponent"); + expect(head.style.toComponent()).to.be.an("array") + .that.is.empty; + }); + + it("does not render undefined attribute values", () => { + ReactDOM.render( + , + container + ); + + const {script} = Helmet.rewind(); + const stringifiedScriptTag = script.toString(); + + expect(stringifiedScriptTag) + .to.be.a("string") + .that.equals(``); + }); + after(() => { + Helmet.canUseDOM = true; + }); + }); + + describe("misc", () => { + it("throws in rewind() when a DOM is present", () => { + ReactDOM.render( + , + container + ); + + expect(Helmet.rewind).to.throw( + "You may only call rewind() on the server. Call peek() to read the current state." + ); + }); + + it("lets you read current state in peek() whether or not a DOM is present", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + expect(Helmet.peek().title).to.be.equal("Fancy title"); + Helmet.canUseDOM = false; + expect(Helmet.peek().title).to.be.equal("Fancy title"); + Helmet.canUseDOM = true; + + done(); + }); + }); + + it("encodes special characters", (done) => { + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + const existingTags = headElement.querySelectorAll(`meta[${HELMET_ATTRIBUTE}]`); + const existingTag = existingTags[0]; + + expect(existingTags).to.not.equal(undefined); + + expect(existingTags.length).to.be.equal(1); + + expect(existingTags) + .to.have.deep.property("[0]") + .that.is.an.instanceof(Element); + expect(existingTag).to.have.property("getAttribute"); + expect(existingTag.getAttribute("name")).to.equal("description"); + expect(existingTag.getAttribute("content")).to.equal("This is \"quoted\" text and & and '."); + expect(existingTag.outerHTML).to.equal(``); + + done(); + }); + }); + + it("does not change the DOM if it recevies identical props", (done) => { + const spy = sinon.spy(); + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + // Re-rendering will pass new props to an already mounted Helmet + ReactDOM.render( + , + container + ); + + requestIdleCallback(() => { + expect(spy.callCount).to.equal(1); + + done(); + }); + }); + }); + + it("does not write the DOM if the client and server are identical", (done) => { + headElement.innerHTML = `