diff --git a/packages/url-utils/.eslintrc.js b/packages/url-utils/.eslintrc.js new file mode 100644 index 000000000..6a5eab530 --- /dev/null +++ b/packages/url-utils/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/node', + ] +}; diff --git a/packages/url-utils/LICENSE b/packages/url-utils/LICENSE new file mode 100644 index 000000000..1051206d3 --- /dev/null +++ b/packages/url-utils/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Ghost Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/url-utils/README.md b/packages/url-utils/README.md new file mode 100644 index 000000000..91f4278f1 --- /dev/null +++ b/packages/url-utils/README.md @@ -0,0 +1,39 @@ +# Url Utils + +## Install + +`npm install @tryghost/url-utils --save` + +or + +`yarn add @tryghost/url-utils` + + +## Usage + + +## Develop + +This is a mono repository, managed with [lerna](https://lernajs.io/). + +Follow the instructions for the top-level repo. +1. `git clone` this repo & `cd` into it as usual +2. Run `yarn` to install top-level dependencies. + + +## Run + +- `yarn dev` + + +## Test + +- `yarn lint` run just eslint +- `yarn test` run lint and tests + + + + +# Copyright & License + +Copyright (c) 2019 Ghost Foundation - Released under the [MIT license](LICENSE). diff --git a/packages/url-utils/index.js b/packages/url-utils/index.js new file mode 100644 index 000000000..e69de29bb diff --git a/packages/url-utils/lib/deduplicate-slashes.js b/packages/url-utils/lib/deduplicate-slashes.js new file mode 100644 index 000000000..beda744d3 --- /dev/null +++ b/packages/url-utils/lib/deduplicate-slashes.js @@ -0,0 +1,5 @@ +function deduplicateDoubleSlashes(url) { + return url.replace(/\/\//g, '/'); +} + +module.exports = deduplicateDoubleSlashes; diff --git a/packages/url-utils/lib/index.js b/packages/url-utils/lib/index.js new file mode 100644 index 000000000..bf09cb743 --- /dev/null +++ b/packages/url-utils/lib/index.js @@ -0,0 +1,474 @@ +// Contains all path information to be used throughout the codebase. +const _ = require('lodash'); +const url = require('url'); +const cheerio = require('cheerio'); +const replacePermalink = require('./replace-permalink'); +const deduplicateDoubleSlashes = require('./deduplicate-slashes'); +const isSSL = require('./is-ssl'); + +/** + * Initialization method to pass in URL configurations + * @param {Object} options + * @param {String} options.url Ghost instance blog URL + * @param {String} options.adminUrl Ghost instance admin URL + * @param {Object} options.apiVersions configuration object which has defined `all` property which is an array of keys for other available properties + * @param {Object} options.slugs object with 2 properties reserved and protected containing arrays of special case slugs + * @param {Number} options.redirectCacheMaxAge + * @param {String} options.baseApiPath static prefix for serving API. Should not te passed in, unless the API is being run under custom URL + * @param {String} options.staticImageUrlPrefix static prefix for serving images. Should not be passed in, unless customizing ghost instance image storage + */ +module.exports = function urlUtils(options = {}) { + // NOTE: assumes all provided values, like URLs, are valid + const config = { + url: options.url, + adminUrl: options.adminUrl, + apiVersions: options.apiVersions, + slugs: options.slugs, + redirectCacheMaxAge: options.redirectCacheMaxAge, + baseApiPath: options.baseApiPath || '/ghost/api', + staticImageUrlPrefix: options.staticImageUrlPrefix || 'content/images' + }; + + /** + * Returns API path combining base path and path for specific version asked or deprecated by default + * @param {Object} options {version} for which to get the path(stable, actice, deprecated), + * {type} admin|content: defaults to {version: deprecated, type: content} + * @return {string} API Path for version + */ + function getApiPath(options) { + const versionPath = getVersionPath(options); + return `${config.baseApiPath}${versionPath}`; + } + + /** + * Returns path containing only the path for the specific version asked or deprecated by default + * @param {Object} options {version} for which to get the path(stable, active, deprecated), + * {type} admin|content: defaults to {version: deprecated, type: content} + * @return {string} API version path + */ + function getVersionPath(options) { + let requestedVersion = options.version || 'v0.1'; + let requestedVersionType = options.type || 'content'; + let versionData = config.apiVersions[requestedVersion]; + if (typeof versionData === 'string') { + versionData = config.apiVersions[versionData]; + } + let versionPath = versionData[requestedVersionType]; + return `/${versionPath}/`; + } + + /** + * Returns the base URL of the blog as set in the config. + * + * Secure: + * If the request is secure, we want to force returning the blog url as https. + * Imagine Ghost runs with http, but nginx allows SSL connections. + * + * @param {boolean} secure + * @return {string} URL returns the url as defined in config, but always with a trailing `/` + */ + function getBlogUrl(secure) { + var blogUrl; + + if (secure) { + blogUrl = config.url.replace('http://', 'https://'); + } else { + blogUrl = config.url; + } + + if (!blogUrl.match(/\/$/)) { + blogUrl += '/'; + } + + return blogUrl; + } + + /** + * Returns a subdirectory URL, if defined so in the config. + * @return {string} URL a subdirectory if configured. + */ + function getSubdir() { + // Parse local path location + var localPath = url.parse(config.url).path, + subdir; + + // Remove trailing slash + if (localPath !== '/') { + localPath = localPath.replace(/\/$/, ''); + } + + subdir = localPath === '/' ? '' : localPath; + return subdir; + } + + function deduplicateSubDir(url) { + var subDir = getSubdir(), + subDirRegex; + + if (!subDir) { + return url; + } + + subDir = subDir.replace(/^\/|\/+$/, ''); + // we can have subdirs that match TLDs so we need to restrict matches to + // duplicates that start with a / or the beginning of the url + subDirRegex = new RegExp('(^|/)' + subDir + '/' + subDir + '/'); + + return url.replace(subDirRegex, '$1' + subDir + '/'); + } + + function getProtectedSlugs() { + var subDir = getSubdir(); + + if (!_.isEmpty(subDir)) { + return config.slugs.protected.concat([subDir.split('/').pop()]); + } else { + return config.slugs.protected; + } + } + + /** urlJoin + * Returns a URL/path for internal use in Ghost. + * @param {string} arguments takes arguments and concats those to a valid path/URL. + * @return {string} URL concatinated URL/path of arguments. + */ + function urlJoin() { + var args = Array.prototype.slice.call(arguments), + prefixDoubleSlash = false, + url; + + // Remove empty item at the beginning + if (args[0] === '') { + args.shift(); + } + + // Handle schemeless protocols + if (args[0].indexOf('//') === 0) { + prefixDoubleSlash = true; + } + + // join the elements using a slash + url = args.join('/'); + + // Fix multiple slashes + url = url.replace(/(^|[^:])\/\/+/g, '$1/'); + + // Put the double slash back at the beginning if this was a schemeless protocol + if (prefixDoubleSlash) { + url = url.replace(/^\//, '//'); + } + + url = deduplicateSubDir(url); + return url; + } + + /** + * admin:url is optional + */ + function getAdminUrl() { + var adminUrl = config.adminUrl, + subDir = getSubdir(); + + if (!adminUrl) { + return; + } + + if (!adminUrl.match(/\/$/)) { + adminUrl += '/'; + } + + adminUrl = urlJoin(adminUrl, subDir, '/'); + adminUrl = deduplicateSubDir(adminUrl); + return adminUrl; + } + + // ## createUrl + // Simple url creation from a given path + // Ensures that our urls contain the subdirectory if there is one + // And are correctly formatted as either relative or absolute + // Usage: + // createUrl('/', true) -> http://my-ghost-blog.com/ + // E.g. /blog/ subdir + // createUrl('/welcome-to-ghost/') -> /blog/welcome-to-ghost/ + // Parameters: + // - urlPath - string which must start and end with a slash + // - absolute (optional, default:false) - boolean whether or not the url should be absolute + // - secure (optional, default:false) - boolean whether or not to force SSL + // Returns: + // - a URL which always ends with a slash + function createUrl(urlPath, absolute, secure, trailingSlash) { + urlPath = urlPath || '/'; + absolute = absolute || false; + var base; + + // create base of url, always ends without a slash + if (absolute) { + base = getBlogUrl(secure); + } else { + base = getSubdir(); + } + + if (trailingSlash) { + if (!urlPath.match(/\/$/)) { + urlPath += '/'; + } + } + + return urlJoin(base, urlPath); + } + + // ## urlFor + // Synchronous url creation for a given context + // Can generate a url for a named path and given path. + // Determines what sort of context it has been given, and delegates to the correct generation method, + // Finally passing to createUrl, to ensure any subdirectory is honoured, and the url is absolute if needed + // Usage: + // urlFor('home', true) -> http://my-ghost-blog.com/ + // E.g. /blog/ subdir + // urlFor({relativeUrl: '/my-static-page/'}) -> /blog/my-static-page/ + // Parameters: + // - context - a string, or json object describing the context for which you need a url + // - data (optional) - a json object containing data needed to generate a url + // - absolute (optional, default:false) - boolean whether or not the url should be absolute + // This is probably not the right place for this, but it's the best place for now + // @TODO: rewrite, very hard to read, create private functions! + function urlFor(context, data, absolute) { + var urlPath = '/', + secure, imagePathRe, + knownObjects = ['image', 'nav'], baseUrl, + hostname, + + // this will become really big + knownPaths = { + home: '/', + sitemap_xsl: '/sitemap.xsl' + }; + + // Make data properly optional + if (_.isBoolean(data)) { + absolute = data; + data = null; + } + + // Can pass 'secure' flag in either context or data arg + secure = (context && context.secure) || (data && data.secure); + + if (_.isObject(context) && context.relativeUrl) { + urlPath = context.relativeUrl; + } else if (_.isString(context) && _.indexOf(knownObjects, context) !== -1) { + if (context === 'image' && data.image) { + urlPath = data.image; + imagePathRe = new RegExp('^' + getSubdir() + '/' + config.staticImageUrlPrefix); + absolute = imagePathRe.test(data.image) ? absolute : false; + + if (absolute) { + // Remove the sub-directory from the URL because ghostConfig will add it back. + urlPath = urlPath.replace(new RegExp('^' + getSubdir()), ''); + baseUrl = getBlogUrl(secure).replace(/\/$/, ''); + urlPath = baseUrl + urlPath; + } + + return urlPath; + } else if (context === 'nav' && data.nav) { + urlPath = data.nav.url; + secure = data.nav.secure || secure; + baseUrl = getBlogUrl(secure); + hostname = baseUrl.split('//')[1]; + + // If the hostname is present in the url + if (urlPath.indexOf(hostname) > -1 + // do no not apply, if there is a subdomain, or a mailto link + && !urlPath.split(hostname)[0].match(/\.|mailto:/) + // do not apply, if there is a port after the hostname + && urlPath.split(hostname)[1].substring(0, 1) !== ':') { + // make link relative to account for possible mismatch in http/https etc, force absolute + urlPath = urlPath.split(hostname)[1]; + urlPath = urlJoin('/', urlPath); + absolute = true; + } + } + } else if (context === 'home' && absolute) { + urlPath = getBlogUrl(secure); + + // CASE: there are cases where urlFor('home') needs to be returned without trailing + // slash e. g. the `{{@site.url}}` helper. See https://github.com/TryGhost/Ghost/issues/8569 + if (data && data.trailingSlash === false) { + urlPath = urlPath.replace(/\/$/, ''); + } + } else if (context === 'admin') { + urlPath = getAdminUrl() || getBlogUrl(); + + if (absolute) { + urlPath += 'ghost/'; + } else { + urlPath = '/ghost/'; + } + } else if (context === 'api') { + urlPath = getAdminUrl() || getBlogUrl(); + let apiPath = getApiPath({version: 'v0.1', type: 'content'}); + // CASE: with or without protocol? If your blog url (or admin url) is configured to http, it's still possible that e.g. nginx allows both https+http. + // So it depends how you serve your blog. The main focus here is to avoid cors problems. + // @TODO: rename cors + if (data && data.cors) { + if (!urlPath.match(/^https:/)) { + urlPath = urlPath.replace(/^.*?:\/\//g, '//'); + } + } + + if (data && data.version) { + apiPath = getApiPath({version: data.version, type: data.versionType}); + } + + if (absolute) { + urlPath = urlPath.replace(/\/$/, '') + apiPath; + } else { + urlPath = apiPath; + } + } else if (_.isString(context) && _.indexOf(_.keys(knownPaths), context) !== -1) { + // trying to create a url for a named path + urlPath = knownPaths[context]; + } + + // This url already has a protocol so is likely an external url to be returned + // or it is an alternative scheme, protocol-less, or an anchor-only path + if (urlPath && (urlPath.indexOf('://') !== -1 || urlPath.match(/^(\/\/|#|[a-zA-Z0-9-]+:)/))) { + return urlPath; + } + + return createUrl(urlPath, absolute, secure); + } + + function redirect301(res, redirectUrl) { + res.set({'Cache-Control': 'public, max-age=' + config.redirectCacheMaxAge}); + return res.redirect(301, redirectUrl); + } + + function redirectToAdmin(status, res, adminPath) { + var redirectUrl = urlJoin(urlFor('admin'), adminPath, '/'); + + if (status === 301) { + return redirect301(res, redirectUrl); + } + return res.redirect(redirectUrl); + } + + /** + * Make absolute URLs + * @param {string} html + * @param {string} siteUrl (blog URL) + * @param {string} itemUrl (URL of current context) + * @returns {object} htmlContent + * @description Takes html, blog url and item url and converts relative url into + * absolute urls. Returns an object. The html string can be accessed by calling `html()` on + * the variable that takes the result of this function + */ + function makeAbsoluteUrls(html, siteUrl, itemUrl, options = {assetsOnly: false}) { + html = html || ''; + const htmlContent = cheerio.load(html, {decodeEntities: false}); + const staticImageUrlPrefixRegex = new RegExp(config.staticImageUrlPrefix); + + // convert relative resource urls to absolute + ['href', 'src'].forEach(function forEach(attributeName) { + htmlContent('[' + attributeName + ']').each(function each(ix, el) { + el = htmlContent(el); + + let attributeValue = el.attr(attributeName); + + // if URL is absolute move on to the next element + try { + const parsed = url.parse(attributeValue); + + if (parsed.protocol) { + return; + } + + // Do not convert protocol relative URLs + if (attributeValue.lastIndexOf('//', 0) === 0) { + return; + } + } catch (e) { + return; + } + + // CASE: don't convert internal links + if (attributeValue[0] === '#') { + return; + } + + if (options.assetsOnly && !attributeValue.match(staticImageUrlPrefixRegex)) { + return; + } + + // compose an absolute URL + // if the relative URL begins with a '/' use the blog URL (including sub-directory) + // as the base URL, otherwise use the post's URL. + const baseUrl = attributeValue[0] === '/' ? siteUrl : itemUrl; + attributeValue = urlJoin(baseUrl, attributeValue); + el.attr(attributeName, attributeValue); + }); + }); + + return htmlContent; + } + + function absoluteToRelative(urlToModify, options) { + options = options || {}; + + const urlObj = url.parse(urlToModify); + const relativePath = urlObj.pathname; + + if (options.withoutSubdirectory) { + const subDir = getSubdir(); + + if (!subDir) { + return relativePath; + } + + const subDirRegex = new RegExp('^' + subDir); + return relativePath.replace(subDirRegex, ''); + } + + return relativePath; + } + + function relativeToAbsolute(url) { + if (!url.startsWith('/') || url.startsWith('//')) { + return url; + } + + return createUrl(url, true); + } + + const utils = {}; + + utils.absoluteToRelative = absoluteToRelative; + utils.relativeToAbsolute = relativeToAbsolute; + utils.makeAbsoluteUrls = makeAbsoluteUrls; + utils.getProtectedSlugs = getProtectedSlugs; + utils.getSubdir = getSubdir; + utils.urlJoin = urlJoin; + utils.urlFor = urlFor; + utils.isSSL = isSSL; + utils.replacePermalink = replacePermalink; + utils.redirectToAdmin = redirectToAdmin; + utils.redirect301 = redirect301; + utils.createUrl = createUrl; + utils.deduplicateDoubleSlashes = deduplicateDoubleSlashes; + utils.getApiPath = getApiPath; + utils.getVersionPath = getVersionPath; + utils.getBlogUrl = getBlogUrl; + utils.getSiteUrl = getBlogUrl; + + /** + * If you request **any** image in Ghost, it get's served via + * http://your-blog.com/content/images/2017/01/02/author.png + * + * /content/images/ is a static prefix for serving images! + * + * But internally the image is located for example in your custom content path: + * my-content/another-dir/images/2017/01/02/author.png + */ + utils.STATIC_IMAGE_URL_PREFIX = config.staticImageUrlPrefix; + + return utils; +}; diff --git a/packages/url-utils/lib/is-ssl.js b/packages/url-utils/lib/is-ssl.js new file mode 100644 index 000000000..012792836 --- /dev/null +++ b/packages/url-utils/lib/is-ssl.js @@ -0,0 +1,8 @@ +const url = require('url'); + +function isSSL(urlToParse) { + var protocol = url.parse(urlToParse).protocol; + return protocol === 'https:'; +} + +module.exports = isSSL; diff --git a/packages/url-utils/lib/replace-permalink.js b/packages/url-utils/lib/replace-permalink.js new file mode 100644 index 000000000..2ce0e5102 --- /dev/null +++ b/packages/url-utils/lib/replace-permalink.js @@ -0,0 +1,48 @@ +const _ = require('lodash'); +const moment = require('moment-timezone'); + +/** + * creates the url path for a post based on blog timezone and permalink pattern + */ +function replacePermalink(permalink, resource, timezone = 'UTC') { + let output = permalink, + primaryTagFallback = 'all', + publishedAtMoment = moment.tz(resource.published_at || Date.now(), timezone), + permalinkLookUp = { + year: function () { + return publishedAtMoment.format('YYYY'); + }, + month: function () { + return publishedAtMoment.format('MM'); + }, + day: function () { + return publishedAtMoment.format('DD'); + }, + author: function () { + return resource.primary_author.slug; + }, + primary_author: function () { + return resource.primary_author ? resource.primary_author.slug : primaryTagFallback; + }, + primary_tag: function () { + return resource.primary_tag ? resource.primary_tag.slug : primaryTagFallback; + }, + slug: function () { + return resource.slug; + }, + id: function () { + return resource.id; + } + }; + + // replace tags like :slug or :year with actual values + output = output.replace(/(:[a-z_]+)/g, function (match) { + if (_.has(permalinkLookUp, match.substr(1))) { + return permalinkLookUp[match.substr(1)](); + } + }); + + return output; +} + +module.exports = replacePermalink; diff --git a/packages/url-utils/package.json b/packages/url-utils/package.json new file mode 100644 index 000000000..b5c8ec596 --- /dev/null +++ b/packages/url-utils/package.json @@ -0,0 +1,30 @@ +{ + "name": "@tryghost/url-utils", + "version": "0.0.0", + "repository": "https://github.com/TryGhost/Ghost-SDKs/tree/master/packages/url-utils", + "author": "Ghost Foundation", + "license": "MIT", + "main": "lib/index.js", + "scripts": { + "dev": "echo \"Implement me!\"", + "test": "NODE_ENV=testing mocha './test/**/*.test.js'", + "lint": "eslint . --ext .js --cache", + "posttest": "yarn lint" + }, + "files": [ + "lib/" + ], + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "mocha": "6.1.4", + "should": "13.2.3", + "sinon": "7.3.2" + }, + "dependencies": { + "cheerio": "0.22.0", + "lodash": "4.17.11", + "moment-timezone": "0.5.25" + } +} diff --git a/packages/url-utils/test/.eslintrc.js b/packages/url-utils/test/.eslintrc.js new file mode 100644 index 000000000..edb330863 --- /dev/null +++ b/packages/url-utils/test/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: ['ghost'], + extends: [ + 'plugin:ghost/test', + ] +}; diff --git a/packages/url-utils/test/utils.test.js b/packages/url-utils/test/utils.test.js new file mode 100644 index 000000000..26698af67 --- /dev/null +++ b/packages/url-utils/test/utils.test.js @@ -0,0 +1,992 @@ +// Switch these lines once there are useful utils +// const testUtils = require('./utils'); +require('./utils'); + +const sinon = require('sinon'); +const moment = require('moment-timezone'); +const urlUtils = require('../lib/index'); + +const constants = { + ONE_YEAR_S: 31536000 +}; + +describe('Url', function () { + const defaultAPIVersions = { + all: ['v0.1', 'v2'], + v2: { + admin: 'v2/admin', + content: 'v2/content', + members: 'v2/members' + }, + 'v0.1': { + admin: 'v0.1', + content: 'v0.1' + } + }; + + describe('absoluteToRelative', function () { + it('default', function () { + urlUtils().absoluteToRelative('http://myblog.com/test/').should.eql('/test/'); + }); + + it('with subdir', function () { + urlUtils().absoluteToRelative('http://myblog.com/blog/test/').should.eql('/blog/test/'); + }); + + it('with subdir, but request without', function () { + const utils = urlUtils({ + url: 'http://myblog.com/blog/' + }); + + utils.absoluteToRelative('http://myblog.com/blog/test/', {withoutSubdirectory: true}) + .should.eql('/test/'); + }); + + it('with subdir, but request without', function () { + const utils = urlUtils({ + url: 'http://myblog.com/blog' + }); + utils.absoluteToRelative('http://myblog.com/blog/test/', {withoutSubdirectory: true}) + .should.eql('/test/'); + }); + }); + + describe('relativeToAbsolute', function () { + it('default', function () { + const utils = urlUtils({ + url: 'http://myblog.com/' + }); + utils.relativeToAbsolute('/test/').should.eql('http://myblog.com/test/'); + }); + + it('with subdir', function () { + const utils = urlUtils({ + url: 'http://myblog.com/blog/' + }); + utils.relativeToAbsolute('/test/').should.eql('http://myblog.com/blog/test/'); + }); + + it('should not convert absolute url', function () { + urlUtils().relativeToAbsolute('http://anotherblog.com/blog/').should.eql('http://anotherblog.com/blog/'); + }); + + it('should not convert absolute url', function () { + urlUtils().relativeToAbsolute('http://anotherblog.com/blog/').should.eql('http://anotherblog.com/blog/'); + }); + + it('should not convert schemeless url', function () { + urlUtils().relativeToAbsolute('//anotherblog.com/blog/').should.eql('//anotherblog.com/blog/'); + }); + }); + + describe('getProtectedSlugs', function () { + it('defaults', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/', + slugs: { + protected: ['ghost', 'rss', 'amp'] + } + }); + + utils.getProtectedSlugs().should.eql(['ghost', 'rss', 'amp']); + }); + + it('url has subdir', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog', + slugs: { + protected: ['ghost', 'rss', 'amp'] + } + }); + + utils.getProtectedSlugs().should.eql(['ghost', 'rss', 'amp', 'blog']); + }); + }); + + describe('getSubdir', function () { + it('url has no subdir', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/' + }); + utils.getSubdir().should.eql(''); + }); + + it('url has subdir', function () { + let utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog' + }); + utils.getSubdir().should.eql('/blog'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog/' + }); + utils.getSubdir().should.eql('/blog'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/my/blog' + }); + utils.getSubdir().should.eql('/my/blog'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/my/blog/' + }); + utils.getSubdir().should.eql('/my/blog'); + }); + + it('should not return a slash for subdir', function () { + let utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + utils.getSubdir().should.eql(''); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/' + }); + utils.getSubdir().should.eql(''); + }); + }); + + describe('urlJoin', function () { + it('should deduplicate slashes', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/' + }); + utils.urlJoin('/', '/my/', '/blog/').should.equal('/my/blog/'); + utils.urlJoin('/', '//my/', '/blog/').should.equal('/my/blog/'); + utils.urlJoin('/', '/', '/').should.equal('/'); + }); + + it('should not deduplicate slashes in protocol', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/' + }); + utils.urlJoin('http://myurl.com', '/rss').should.equal('http://myurl.com/rss'); + utils.urlJoin('https://myurl.com/', '/rss').should.equal('https://myurl.com/rss'); + }); + + it('should permit schemeless protocol', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/' + }); + utils.urlJoin('/', '/').should.equal('/'); + utils.urlJoin('//myurl.com', '/rss').should.equal('//myurl.com/rss'); + utils.urlJoin('//myurl.com/', '/rss').should.equal('//myurl.com/rss'); + utils.urlJoin('//myurl.com//', 'rss').should.equal('//myurl.com/rss'); + utils.urlJoin('', '//myurl.com', 'rss').should.equal('//myurl.com/rss'); + }); + + it('should deduplicate subdir', function () { + let utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog' + }); + utils.urlJoin('blog', 'blog/about').should.equal('blog/about'); + utils.urlJoin('blog/', 'blog/about').should.equal('blog/about'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/my/blog' + }); + utils.urlJoin('my/blog', 'my/blog/about').should.equal('my/blog/about'); + utils.urlJoin('my/blog/', 'my/blog/about').should.equal('my/blog/about'); + }); + + it('should handle subdir matching tld', function () { + const utils = urlUtils({ + url: 'http://ghost.blog/blog' + }); + utils.urlJoin('ghost.blog/blog', 'ghost/').should.equal('ghost.blog/blog/ghost/'); + utils.urlJoin('ghost.blog', 'blog', 'ghost/').should.equal('ghost.blog/blog/ghost/'); + }); + }); + + describe('urlFor', function () { + it('should return the home url with no options', function () { + let utils = urlUtils({ + url: 'http://ghost-blog.com/' + }); + utils.urlFor().should.equal('/'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog' + }); + utils.urlFor().should.equal('/blog/'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog/' + }); + utils.urlFor().should.equal('/blog/'); + }); + + it('should return home url when asked for', function () { + var testContext = 'home'; + + let utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + utils.urlFor(testContext).should.equal('/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/'); + utils.urlFor(testContext, {secure: true}, true).should.equal('https://my-ghost-blog.com/'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/' + }); + utils.urlFor(testContext).should.equal('/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/'); + utils.urlFor(testContext, {secure: true}, true).should.equal('https://my-ghost-blog.com/'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog' + }); + utils.urlFor(testContext).should.equal('/blog/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/blog/'); + utils.urlFor(testContext, {secure: true}, true).should.equal('https://my-ghost-blog.com/blog/'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog/' + }); + utils.urlFor(testContext).should.equal('/blog/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/blog/'); + utils.urlFor(testContext, {secure: true}, true).should.equal('https://my-ghost-blog.com/blog/'); + + // Output blog url without trailing slash + utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + utils.urlFor(testContext).should.equal('/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/'); + utils.urlFor(testContext, { + secure: true, + trailingSlash: false + }, true).should.equal('https://my-ghost-blog.com'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/' + }); + utils.urlFor(testContext).should.equal('/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/'); + utils.urlFor(testContext, { + secure: true, + trailingSlash: false + }, true).should.equal('https://my-ghost-blog.com'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog' + }); + utils.urlFor(testContext).should.equal('/blog/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/blog/'); + utils.urlFor(testContext, { + secure: true, + trailingSlash: false + }, true).should.equal('https://my-ghost-blog.com/blog'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog/' + }); + utils.urlFor(testContext).should.equal('/blog/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/blog/'); + utils.urlFor(testContext, { + secure: true, + trailingSlash: false + }, true).should.equal('https://my-ghost-blog.com/blog'); + }); + + it('should handle weird cases by always returning /', function () { + const utils = urlUtils({ + url: 'http://ghost-blog.com' + }); + utils.urlFor('').should.equal('/'); + utils.urlFor('post', {}).should.equal('/'); + utils.urlFor('post', {post: {}}).should.equal('/'); + utils.urlFor(null).should.equal('/'); + utils.urlFor(undefined).should.equal('/'); + utils.urlFor({}).should.equal('/'); + utils.urlFor({relativeUrl: ''}).should.equal('/'); + utils.urlFor({relativeUrl: null}).should.equal('/'); + utils.urlFor({relativeUrl: undefined}).should.equal('/'); + }); + + it('should return url for a random path when asked for', function () { + var testContext = {relativeUrl: '/about/'}; + + let utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + utils.urlFor(testContext).should.equal('/about/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/about/'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog' + }); + utils.urlFor(testContext).should.equal('/blog/about/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/blog/about/'); + + testContext.secure = true; + utils.urlFor(testContext, true).should.equal('https://my-ghost-blog.com/blog/about/'); + + testContext.secure = false; + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/blog/about/'); + + testContext.secure = false; + + utils = urlUtils({ + url: 'https://my-ghost-blog.com' + }); + utils.urlFor(testContext, true).should.equal('https://my-ghost-blog.com/about/'); + }); + + it('should deduplicate subdirectories in paths', function () { + var testContext = {relativeUrl: '/blog/about/'}; + + let utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + utils.urlFor(testContext).should.equal('/blog/about/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/blog/about/'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog' + }); + utils.urlFor(testContext).should.equal('/blog/about/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/blog/about/'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog/' + }); + utils.urlFor(testContext).should.equal('/blog/about/'); + utils.urlFor(testContext, true).should.equal('http://my-ghost-blog.com/blog/about/'); + }); + + it('should return url for an image when asked for', function () { + var testContext = 'image', + testData; + + let utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + + testData = {image: '/content/images/my-image.jpg'}; + utils.urlFor(testContext, testData).should.equal('/content/images/my-image.jpg'); + utils.urlFor(testContext, testData, true).should.equal('http://my-ghost-blog.com/content/images/my-image.jpg'); + + testData = {image: 'http://placekitten.com/500/200'}; + utils.urlFor(testContext, testData).should.equal('http://placekitten.com/500/200'); + utils.urlFor(testContext, testData, true).should.equal('http://placekitten.com/500/200'); + + testData = {image: '/blog/content/images/my-image2.jpg'}; + utils.urlFor(testContext, testData).should.equal('/blog/content/images/my-image2.jpg'); + // We don't make image urls absolute if they don't look like images relative to the image path + utils.urlFor(testContext, testData, true).should.equal('/blog/content/images/my-image2.jpg'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog/' + }); + + testData = {image: '/content/images/my-image3.jpg'}; + utils.urlFor(testContext, testData).should.equal('/content/images/my-image3.jpg'); + // We don't make image urls absolute if they don't look like images relative to the image path + utils.urlFor(testContext, testData, true).should.equal('/content/images/my-image3.jpg'); + + testData = {image: '/blog/content/images/my-image4.jpg'}; + utils.urlFor(testContext, testData).should.equal('/blog/content/images/my-image4.jpg'); + utils.urlFor(testContext, testData, true).should.equal('http://my-ghost-blog.com/blog/content/images/my-image4.jpg'); + + // Test case for blogs with optional https - + // they may be configured with http url but the actual connection may be over https (#8373) + utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + testData = {image: '/content/images/my-image.jpg', secure: true}; + utils.urlFor(testContext, testData, true).should.equal('https://my-ghost-blog.com/content/images/my-image.jpg'); + }); + + it('should return a url for a nav item when asked for it', function () { + var testContext = 'nav', + testData; + + let utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + + testData = {nav: {url: 'http://my-ghost-blog.com/'}}; + utils.urlFor(testContext, testData).should.equal('http://my-ghost-blog.com/'); + + testData = {nav: {url: 'http://my-ghost-blog.com/short-and-sweet/'}}; + utils.urlFor(testContext, testData).should.equal('http://my-ghost-blog.com/short-and-sweet/'); + + testData = {nav: {url: 'http://my-ghost-blog.com//short-and-sweet/'}, secure: true}; + utils.urlFor(testContext, testData).should.equal('https://my-ghost-blog.com/short-and-sweet/'); + + testData = {nav: {url: 'http://my-ghost-blog.com:3000/'}}; + utils.urlFor(testContext, testData).should.equal('http://my-ghost-blog.com:3000/'); + + testData = {nav: {url: 'http://my-ghost-blog.com:3000/short-and-sweet/'}}; + utils.urlFor(testContext, testData).should.equal('http://my-ghost-blog.com:3000/short-and-sweet/'); + + testData = {nav: {url: 'http://sub.my-ghost-blog.com/'}}; + utils.urlFor(testContext, testData).should.equal('http://sub.my-ghost-blog.com/'); + + testData = {nav: {url: '//sub.my-ghost-blog.com/'}}; + utils.urlFor(testContext, testData).should.equal('//sub.my-ghost-blog.com/'); + + testData = {nav: {url: 'mailto:sub@my-ghost-blog.com/'}}; + utils.urlFor(testContext, testData).should.equal('mailto:sub@my-ghost-blog.com/'); + + testData = {nav: {url: '#this-anchor'}}; + utils.urlFor(testContext, testData).should.equal('#this-anchor'); + + testData = {nav: {url: 'http://some-external-page.com/my-ghost-blog.com'}}; + utils.urlFor(testContext, testData).should.equal('http://some-external-page.com/my-ghost-blog.com'); + + testData = {nav: {url: 'http://some-external-page.com/stuff-my-ghost-blog.com-around'}}; + utils.urlFor(testContext, testData).should.equal('http://some-external-page.com/stuff-my-ghost-blog.com-around'); + + testData = {nav: {url: 'mailto:marshmallow@my-ghost-blog.com'}}; + utils.urlFor(testContext, testData).should.equal('mailto:marshmallow@my-ghost-blog.com'); + + utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog' + }); + testData = {nav: {url: 'http://my-ghost-blog.com/blog/'}}; + utils.urlFor(testContext, testData).should.equal('http://my-ghost-blog.com/blog/'); + + testData = {nav: {url: 'http://my-ghost-blog.com/blog/short-and-sweet/'}}; + utils.urlFor(testContext, testData).should.equal('http://my-ghost-blog.com/blog/short-and-sweet/'); + + testData = {nav: {url: 'http://my-ghost-blog.com:3000/blog/'}}; + utils.urlFor(testContext, testData).should.equal('http://my-ghost-blog.com:3000/blog/'); + + testData = {nav: {url: 'http://my-ghost-blog.com:3000/blog/short-and-sweet/'}}; + utils.urlFor(testContext, testData).should.equal('http://my-ghost-blog.com:3000/blog/short-and-sweet/'); + + testData = {nav: {url: 'http://sub.my-ghost-blog.com/blog/'}}; + utils.urlFor(testContext, testData).should.equal('http://sub.my-ghost-blog.com/blog/'); + + testData = {nav: {url: '//sub.my-ghost-blog.com/blog/'}}; + utils.urlFor(testContext, testData).should.equal('//sub.my-ghost-blog.com/blog/'); + }); + + it('sitemap: should return other known paths when requested', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + utils.urlFor('sitemap_xsl').should.equal('/sitemap.xsl'); + utils.urlFor('sitemap_xsl', true).should.equal('http://my-ghost-blog.com/sitemap.xsl'); + }); + + it('admin: relative', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + + utils.urlFor('admin').should.equal('/ghost/'); + }); + + it('admin: url is http', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + + utils.urlFor('admin', true).should.equal('http://my-ghost-blog.com/ghost/'); + }); + + it('admin: custom admin url is set', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com', + adminUrl: 'https://admin.my-ghost-blog.com' + }); + + utils.urlFor('admin', true).should.equal('https://admin.my-ghost-blog.com/ghost/'); + }); + + it('admin: blog is on subdir', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog' + }); + + utils.urlFor('admin', true).should.equal('http://my-ghost-blog.com/blog/ghost/'); + }); + + it('admin: blog is on subdir', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog/' + }); + + utils.urlFor('admin', true).should.equal('http://my-ghost-blog.com/blog/ghost/'); + }); + + it('admin: blog is on subdir', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog' + }); + + utils.urlFor('admin').should.equal('/blog/ghost/'); + }); + + it('admin: blog is on subdir', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog', + adminUrl: 'http://something.com' + }); + + utils.urlFor('admin', true).should.equal('http://something.com/blog/ghost/'); + }); + + it('admin: blog is on subdir', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog', + adminUrl: 'http://something.com/blog' + }); + + utils.urlFor('admin', true).should.equal('http://something.com/blog/ghost/'); + }); + + it('admin: blog is on subdir', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog', + adminUrl: 'http://something.com/blog/' + }); + + utils.urlFor('admin', true).should.equal('http://something.com/blog/ghost/'); + }); + + it('admin: blog is on subdir', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog/', + adminUrl: 'http://something.com/blog' + }); + + utils.urlFor('admin', true).should.equal('http://something.com/blog/ghost/'); + }); + + ['v0.1', 'v2'].forEach((apiVersion) => { + function getApiPath(options) { + const baseAPIPath = '/ghost/api/'; + + switch (options.version) { + case 'v0.1': + return `${baseAPIPath}v0.1/`; + case 'v2': + if (options.versionType === 'admin') { + return `${baseAPIPath}v2/admin/`; + } else { + return `${baseAPIPath}v2/content/`; + } + default: + return `${baseAPIPath}v0.1/`; + } + } + + describe(`for api version: ${apiVersion}`, function () { + it('api: should return admin url is set', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com', + adminUrl: 'https://something.de', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {version: apiVersion, versionType: 'content'}, true) + .should.eql(`https://something.de${getApiPath({version: apiVersion, versionType: 'content'})}`); + }); + + it('api: url has subdir', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {version: apiVersion, versionType: 'content'}, true) + .should.eql(`http://my-ghost-blog.com/blog${getApiPath({version: apiVersion, versionType: 'content'})}`); + }); + + it('api: relative path is correct', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {version: apiVersion, versionType: 'content'}) + .should.eql(getApiPath({version: apiVersion, versionType: 'content'})); + }); + + it('api: relative path with subdir is correct', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com/blog', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {version: apiVersion, versionType: 'content'}) + .should.eql(`/blog${getApiPath({version: apiVersion, versionType: 'content'})}`); + }); + + it('api: should return http if config.url is http', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {version: apiVersion, versionType: 'content'}, true) + .should.eql(`http://my-ghost-blog.com${getApiPath({version: apiVersion, versionType: 'content'})}`); + }); + + it('api: should return https if config.url is https', function () { + const utils = urlUtils({ + url: 'https://my-ghost-blog.com', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {version: apiVersion, versionType: 'content'}, true) + .should.eql(`https://my-ghost-blog.com${getApiPath({version: apiVersion, versionType: 'content'})}`); + }); + + it('api: with cors, blog url is http: should return no protocol', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {cors: true, version: apiVersion, versionType: 'content'}, true) + .should.eql(`//my-ghost-blog.com${getApiPath({version: apiVersion, versionType: 'content'})}`); + }); + + it('api: with cors, admin url is http: cors should return no protocol', function () { + const utils = urlUtils({ + url: 'http://my-ghost-blog.com', + adminUrl: 'http://admin.ghost.example', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {cors: true, version: apiVersion, versionType: 'content'}, true) + .should.eql(`//admin.ghost.example${getApiPath({version: apiVersion, versionType: 'content'})}`); + }); + + it('api: with cors, admin url is https: should return with protocol', function () { + const utils = urlUtils({ + url: 'https://my-ghost-blog.com', + adminUrl: 'https://admin.ghost.example', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {cors: true, version: apiVersion, versionType: 'content'}, true) + .should.eql(`https://admin.ghost.example${getApiPath({version: apiVersion, versionType: 'content'})}`); + }); + + it('api: with cors, blog url is https: should return with protocol', function () { + const utils = urlUtils({ + url: 'https://my-ghost-blog.com', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {cors: true, version: apiVersion, versionType: 'content'}, true) + .should.eql(`https://my-ghost-blog.com${getApiPath({version: apiVersion, versionType: 'content'})}`); + }); + + it('api: with stable version, blog url is https: should return stable content api path', function () { + const utils = urlUtils({ + url: 'https://my-ghost-blog.com', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {cors: true, version: apiVersion, versionType: 'content'}, true) + .should.eql(`https://my-ghost-blog.com${getApiPath({version: apiVersion, versionType: 'content'})}`); + }); + + it('api: with stable version and admin true, blog url is https: should return stable admin api path', function () { + const utils = urlUtils({ + url: 'https://my-ghost-blog.com', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {cors: true, version: apiVersion, versionType: 'admin'}, true) + .should.eql(`https://my-ghost-blog.com${getApiPath({version: apiVersion, versionType: 'admin'})}`); + }); + + it('api: with just version and no version type returns correct api path', function () { + const utils = urlUtils({ + url: 'https://my-ghost-blog.com', + apiVersions: defaultAPIVersions + }); + + utils + .urlFor('api', {cors: true, version: apiVersion}, true) + .should.eql(`https://my-ghost-blog.com${getApiPath({version: apiVersion})}`); + }); + }); + }); + + it('api: with active version, blog url is https: should return active content api path', function () { + const utils = urlUtils({ + url: 'https://my-ghost-blog.com', + apiVersions: defaultAPIVersions + }); + + utils.urlFor('api', {cors: true, version: 'v2', versionType: 'content'}, true).should.eql('https://my-ghost-blog.com/ghost/api/v2/content/'); + }); + + it('api: with active version and admin true, blog url is https: should return active admin api path', function () { + const utils = urlUtils({ + url: 'https://my-ghost-blog.com', + apiVersions: defaultAPIVersions + }); + + utils.urlFor('api', {cors: true, version: 'v2', versionType: 'admin'}, true).should.eql('https://my-ghost-blog.com/ghost/api/v2/admin/'); + }); + }); + + describe('replacePermalink', function () { + it('permalink is /:slug/, timezone is default', function () { + const testData = { + slug: 'short-and-sweet' + }; + const postLink = '/short-and-sweet/'; + + urlUtils().replacePermalink('/:slug/', testData).should.equal(postLink); + }); + + it('permalink is /:year/:month/:day/:slug/, blog timezone is Los Angeles', function () { + const testData = { + slug: 'short-and-sweet', + published_at: new Date('2016-05-18T06:30:00.000Z') + }; + const timezone = 'America/Los_Angeles'; + const postLink = '/2016/05/17/short-and-sweet/'; + + urlUtils().replacePermalink('/:year/:month/:day/:slug/', testData, timezone).should.equal(postLink); + }); + + it('permalink is /:year/:month/:day/:slug/, blog timezone is Asia Tokyo', function () { + const testData = { + slug: 'short-and-sweet', + published_at: new Date('2016-05-18T06:30:00.000Z') + }; + const timezone = 'Asia/Tokyo'; + const postLink = '/2016/05/18/short-and-sweet/'; + + urlUtils().replacePermalink('/:year/:month/:day/:slug/', testData, timezone).should.equal(postLink); + }); + + it('permalink is /:year/:id/:author/, TZ is LA', function () { + const testData = { + id: 3, + primary_author: {slug: 'joe-blog'}, + slug: 'short-and-sweet', + published_at: new Date('2016-01-01T00:00:00.000Z') + }; + const timezone = 'America/Los_Angeles'; + const postLink = '/2015/3/joe-blog/'; + + urlUtils().replacePermalink('/:year/:id/:author/', testData, timezone).should.equal(postLink); + }); + + it('permalink is /:year/:id:/:author/, TZ is Berlin', function () { + const testData = { + id: 3, + primary_author: {slug: 'joe-blog'}, + slug: 'short-and-sweet', + published_at: new Date('2016-01-01T00:00:00.000Z') + }; + const timezone = 'Europe/Berlin'; + const postLink = '/2016/3/joe-blog/'; + + urlUtils().replacePermalink('/:year/:id/:author/', testData, timezone).should.equal(postLink); + }); + + it('permalink is /:primary_tag/:slug/ and there is a primary_tag', function () { + const testData = { + slug: 'short-and-sweet', + primary_tag: {slug: 'bitcoin'} + }; + const timezone = 'Europe/Berlin'; + const postLink = '/bitcoin/short-and-sweet/'; + + urlUtils().replacePermalink('/:primary_tag/:slug/', testData, timezone).should.equal(postLink); + }); + + it('permalink is /:primary_tag/:slug/ and there is NO primary_tag', function () { + const testData = { + slug: 'short-and-sweet' + }; + const timezone = 'Europe/Berlin'; + const postLink = '/all/short-and-sweet/'; + + urlUtils().replacePermalink('/:primary_tag/:slug/', testData, timezone).should.equal(postLink); + }); + + it('shows "undefined" for unknown route segments', function () { + const testData = { + slug: 'short-and-sweet' + }; + const timezone = 'Europe/Berlin'; + const postLink = '/undefined/short-and-sweet/'; + + urlUtils().replacePermalink('/:tag/:slug/', testData, timezone).should.equal(postLink); + }); + + it('post is not published yet', function () { + const testData = { + id: 3, + slug: 'short-and-sweet', + published_at: null + }; + const timezone = 'Europe/London'; + + const nowMoment = moment().tz('Europe/London'); + + let postLink = '/YYYY/MM/DD/short-and-sweet/'; + + postLink = postLink.replace('YYYY', nowMoment.format('YYYY')); + postLink = postLink.replace('MM', nowMoment.format('MM')); + postLink = postLink.replace('DD', nowMoment.format('DD')); + + urlUtils().replacePermalink('/:year/:month/:day/:slug/', testData, timezone).should.equal(postLink); + }); + }); + + describe('isSSL', function () { + it('detects https protocol correctly', function () { + urlUtils().isSSL('https://my.blog.com').should.be.true(); + urlUtils().isSSL('http://my.blog.com').should.be.false(); + urlUtils().isSSL('http://my.https.com').should.be.false(); + }); + }); + + describe('redirects', function () { + it('performs 301 redirect correctly', function (done) { + var res = {}; + const utils = urlUtils({ + url: 'http://my-ghost-blog.com', + redirectCacheMaxAge: constants.ONE_YEAR_S + }); + + res.set = sinon.spy(); + + res.redirect = function (code, path) { + code.should.equal(301); + path.should.eql('my/awesome/path'); + res.set.calledWith({'Cache-Control': 'public, max-age=' + constants.ONE_YEAR_S}).should.be.true(); + + done(); + }; + + utils.redirect301(res, 'my/awesome/path'); + }); + + it('performs an admin 301 redirect correctly', function (done) { + var res = {}; + const utils = urlUtils({ + url: 'http://my-ghost-blog.com', + redirectCacheMaxAge: constants.ONE_YEAR_S + }); + + res.set = sinon.spy(); + + res.redirect = function (code, path) { + code.should.equal(301); + path.should.eql('/ghost/#/my/awesome/path/'); + res.set.calledWith({'Cache-Control': 'public, max-age=' + constants.ONE_YEAR_S}).should.be.true(); + + done(); + }; + + utils.redirectToAdmin(301, res, '#/my/awesome/path'); + }); + + it('performs an admin 302 redirect correctly', function (done) { + var res = {}; + + const utils = urlUtils({ + url: 'http://my-ghost-blog.com', + redirectCacheMaxAge: constants.ONE_YEAR_S + }); + + res.set = sinon.spy(); + + res.redirect = function (path) { + path.should.eql('/ghost/#/my/awesome/path/'); + res.set.called.should.be.false(); + + done(); + }; + + utils.redirectToAdmin(302, res, '#/my/awesome/path'); + }); + }); + + describe('make absolute urls ', function () { + const siteUrl = 'http://my-ghost-blog.com'; + const itemUrl = 'my-awesome-post'; + + const utils = urlUtils({ + url: 'http://my-ghost-blog.com' + }); + + it('[success] does not convert absolute URLs', function () { + var html = '', + result = utils.makeAbsoluteUrls(html, siteUrl, itemUrl).html(); + + result.should.match(//); + }); + it('[failure] does not convert protocol relative `//` URLs', function () { + var html = '', + result = utils.makeAbsoluteUrls(html, siteUrl, itemUrl).html(); + + result.should.match(//); + }); + it('[failure] does not convert internal links starting with "#"', function () { + var html = '', + result = utils.makeAbsoluteUrls(html, siteUrl, itemUrl).html(); + + result.should.match(//); + }); + it('[success] converts a relative URL', function () { + var html = '', + result = utils.makeAbsoluteUrls(html, siteUrl, itemUrl).html(); + + result.should.match(//); + }); + it('[success] converts a relative URL including subdirectories', function () { + var html = '', + result = utils.makeAbsoluteUrls(html, 'http://my-ghost-blog.com/blog', itemUrl).html(); + + result.should.match(//); + }); + + it('asset urls only', function () { + let html = ''; + let result = utils.makeAbsoluteUrls(html, siteUrl, itemUrl, {assetsOnly: true}).html(); + + result.should.match(//); + result.should.match(//); + + html = ''; + result = utils.makeAbsoluteUrls(html, siteUrl, itemUrl, {assetsOnly: true}).html(); + + result.should.match(//); + + html = ''; + result = utils.makeAbsoluteUrls(html, siteUrl, itemUrl, {assetsOnly: true}).html(); + + result.should.match(//); + + html = ''; + result = utils.makeAbsoluteUrls(html, siteUrl, itemUrl, {assetsOnly: true}).html(); + + result.should.match(//); + + html = ''; + result = utils.makeAbsoluteUrls(html, siteUrl, itemUrl, {assetsOnly: true}).html(); + + result.should.match(//); + }); + }); +}); diff --git a/packages/url-utils/test/utils/assertions.js b/packages/url-utils/test/utils/assertions.js new file mode 100644 index 000000000..7364ee8aa --- /dev/null +++ b/packages/url-utils/test/utils/assertions.js @@ -0,0 +1,11 @@ +/** + * Custom Should Assertions + * + * Add any custom assertions to this file. + */ + +// Example Assertion +// should.Assertion.add('ExampleAssertion', function () { +// this.params = {operator: 'to be a valid Example Assertion'}; +// this.obj.should.be.an.Object; +// }); diff --git a/packages/url-utils/test/utils/index.js b/packages/url-utils/test/utils/index.js new file mode 100644 index 000000000..0d67d86ff --- /dev/null +++ b/packages/url-utils/test/utils/index.js @@ -0,0 +1,11 @@ +/** + * Test Utilities + * + * Shared utils for writing tests + */ + +// Require overrides - these add globals for tests +require('./overrides'); + +// Require assertions - adds custom should assertions +require('./assertions'); diff --git a/packages/url-utils/test/utils/overrides.js b/packages/url-utils/test/utils/overrides.js new file mode 100644 index 000000000..90203424e --- /dev/null +++ b/packages/url-utils/test/utils/overrides.js @@ -0,0 +1,10 @@ +// This file is required before any test is run + +// Taken from the should wiki, this is how to make should global +// Should is a global in our eslint test config +global.should = require('should').noConflict(); +should.extend(); + +// Sinon is a simple case +// Sinon is a global in our eslint test config +global.sinon = require('sinon'); diff --git a/yarn.lock b/yarn.lock index 918db39cb..415b39065 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1156,11 +1156,21 @@ binary-extensions@^1.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== +bluebird@3.5.5: + version "3.5.5" + resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.5.tgz#a8d0afd73251effbbd5fe384a77d73003c17a71f" + integrity sha512-5am6HnnfN+urzt4yfg7IgTbotDjIT/u8AJpEt0sIU9FtXfVeezXAPKswrG+xKUCOYAINpSdgZVDU6QFh+cuH3w== + bluebird@^3.5.3: version "3.5.4" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.4.tgz#d6cc661595de30d5b3af5fcedd3c0b3ef6ec5714" integrity sha512-FG+nFEZChJrbQ9tIccIfZJBz3J7mLrAhxakAbnrJWn8d7aKOC+LWifa0G+p4ZqKp4y13T7juYvdhq9NzKdsrjw== +boolbase@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha1-aN/1++YMUes3cl6p4+0xDcwed24= + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -1296,6 +1306,28 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +cheerio@0.22.0: + version "0.22.0" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-0.22.0.tgz#a9baa860a3f9b595a6b81b1a86873121ed3a269e" + integrity sha1-qbqoYKP5tZWmuBsahocxIe06Jp4= + dependencies: + css-select "~1.2.0" + dom-serializer "~0.1.0" + entities "~1.1.1" + htmlparser2 "^3.9.1" + lodash.assignin "^4.0.9" + lodash.bind "^4.1.4" + lodash.defaults "^4.0.1" + lodash.filter "^4.4.0" + lodash.flatten "^4.2.0" + lodash.foreach "^4.3.0" + lodash.map "^4.4.0" + lodash.merge "^4.4.0" + lodash.pick "^4.2.1" + lodash.reduce "^4.4.0" + lodash.reject "^4.4.0" + lodash.some "^4.4.0" + chokidar@^2.0.4: version "2.1.5" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.5.tgz#0ae8434d962281a5f56c72869e79cb6d9d86ad4d" @@ -1464,6 +1496,21 @@ cross-spawn@^6.0.0, cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" +css-select@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-1.2.0.tgz#2b3a110539c5355f1cd8d314623e870b121ec858" + integrity sha1-KzoRBTnFNV8c2NMUYj6HCxIeyFg= + dependencies: + boolbase "~1.0.0" + css-what "2.1" + domutils "1.5.1" + nth-check "~1.0.1" + +css-what@2.1: + version "2.1.3" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.3.tgz#a6d7604573365fe74686c3f311c56513d88285f2" + integrity sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg== + cssom@0.3.x, cssom@^0.3.4: version "0.3.6" resolved "https://registry.yarnpkg.com/cssom/-/cssom-0.3.6.tgz#f85206cee04efa841f3c5982a74ba96ab20d65ad" @@ -1608,6 +1655,19 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-serializer@0, dom-serializer@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.1.tgz#1ec4059e284babed36eec2941d4a970a189ce7c0" + integrity sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA== + dependencies: + domelementtype "^1.3.0" + entities "^1.1.1" + +domelementtype@1, domelementtype@^1.3.0, domelementtype@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" + integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== + domexception@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/domexception/-/domexception-1.0.1.tgz#937442644ca6a31261ef36e3ec677fe805582c90" @@ -1615,6 +1675,29 @@ domexception@^1.0.1: dependencies: webidl-conversions "^4.0.2" +domhandler@^2.3.0: + version "2.4.2" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-2.4.2.tgz#8805097e933d65e85546f726d60f5eb88b44f803" + integrity sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA== + dependencies: + domelementtype "1" + +domutils@1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.5.1.tgz#dcd8488a26f563d61079e48c9f7b7e32373682cf" + integrity sha1-3NhIiib1Y9YQeeSMn3t+Mjc2gs8= + dependencies: + dom-serializer "0" + domelementtype "1" + +domutils@^1.5.1: + version "1.7.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-1.7.0.tgz#56ea341e834e06e6748af7a1cb25da67ea9f8c2a" + integrity sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg== + dependencies: + dom-serializer "0" + domelementtype "1" + dtrace-provider@~0.8: version "0.8.7" resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.7.tgz#dc939b4d3e0620cfe0c1cd803d0d2d7ed04ffd04" @@ -1659,6 +1742,11 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" +entities@^1.1.1, entities@~1.1.1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" + integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== + es-abstract@^1.11.0, es-abstract@^1.5.1, es-abstract@^1.7.0: version "1.13.0" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" @@ -2155,7 +2243,7 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -ghost-ignition@^3.0.0, ghost-ignition@^3.0.4: +ghost-ignition@3.1.0, ghost-ignition@^3.0.0, ghost-ignition@^3.0.4: version "3.1.0" resolved "https://registry.yarnpkg.com/ghost-ignition/-/ghost-ignition-3.1.0.tgz#4a0d7f0a15e54fbc0e16d398da62ff68737e4e23" integrity sha512-ae0r/yBJDo9SkeLfy0ecrHVqO0gt9l07wiCOWmdzdvbmoU24AB881PjymnM/DLP9SY6E33mLYtT8K3ximgieLg== @@ -2325,6 +2413,18 @@ html-encoding-sniffer@^1.0.2: dependencies: whatwg-encoding "^1.0.1" +htmlparser2@^3.9.1: + version "3.10.1" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f" + integrity sha512-IgieNijUMbkDovyoKObU1DUhm1iwNYE/fuifEoEHfd1oZKZDaONBSkal7Y01shxsM49R4XaMdGez3WnF9UfiCQ== + dependencies: + domelementtype "^1.3.1" + domhandler "^2.3.0" + domutils "^1.5.1" + entities "^1.1.1" + inherits "^2.0.1" + readable-stream "^3.1.1" + http-signature@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" @@ -2374,7 +2474,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@~2.0.3: +inherits@2, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= @@ -2867,6 +2967,36 @@ lodash-es@^4.17.11: resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" integrity sha512-DHb1ub+rMjjrxqlB3H56/6MXtm1lSksDp2rA2cNWjG8mlDUYFhUj3Di2Zn5IwSU87xLv8tNIQ7sSwE/YOX/D/Q== +lodash.assignin@^4.0.9: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.assignin/-/lodash.assignin-4.2.0.tgz#ba8df5fb841eb0a3e8044232b0e263a8dc6a28a2" + integrity sha1-uo31+4QesKPoBEIysOJjqNxqKKI= + +lodash.bind@^4.1.4: + version "4.2.1" + resolved "https://registry.yarnpkg.com/lodash.bind/-/lodash.bind-4.2.1.tgz#7ae3017e939622ac31b7d7d7dcb1b34db1690d35" + integrity sha1-euMBfpOWIqwxt9fX3LGzTbFpDTU= + +lodash.defaults@^4.0.1: + version "4.2.0" + resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c" + integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw= + +lodash.filter@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.filter/-/lodash.filter-4.6.0.tgz#668b1d4981603ae1cc5a6fa760143e480b4c4ace" + integrity sha1-ZosdSYFgOuHMWm+nYBQ+SAtMSs4= + +lodash.flatten@^4.2.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.flatten/-/lodash.flatten-4.4.0.tgz#f31c22225a9632d2bbf8e4addbef240aa765a61f" + integrity sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8= + +lodash.foreach@^4.3.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.foreach/-/lodash.foreach-4.5.0.tgz#1a6a35eace401280c7f06dddec35165ab27e3e53" + integrity sha1-Gmo16s5AEoDH8G3d7DUWWrJ+PlM= + lodash.includes@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f" @@ -2897,17 +3027,47 @@ lodash.isstring@^4.0.1: resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= +lodash.map@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3" + integrity sha1-dx7Hg540c9nEzeKLGTlMNWL09tM= + +lodash.merge@^4.4.0: + version "4.6.1" + resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.1.tgz#adc25d9cb99b9391c59624f379fbba60d7111d54" + integrity sha512-AOYza4+Hf5z1/0Hztxpm2/xiPZgi/cjMqdnKTUWTBSKchJlxXXuUSxCCl8rJlf4g6yww/j6mA8nC8Hw/EZWxKQ== + lodash.once@^4.0.0: version "4.1.1" resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= +lodash.pick@^4.2.1: + version "4.4.0" + resolved "https://registry.yarnpkg.com/lodash.pick/-/lodash.pick-4.4.0.tgz#52f05610fff9ded422611441ed1fc123a03001b3" + integrity sha1-UvBWEP/53tQiYRRB7R/BI6AwAbM= + +lodash.reduce@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b" + integrity sha1-8atrg5KZrUj3hKu/R2WW8DuRTTs= + +lodash.reject@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.reject/-/lodash.reject-4.6.0.tgz#80d6492dc1470864bbf583533b651f42a9f52415" + integrity sha1-gNZJLcFHCGS79YNTO2UfQqn1JBU= + +lodash.some@^4.4.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/lodash.some/-/lodash.some-4.6.0.tgz#1bb9f314ef6b8baded13b549169b2a945eb68e4d" + integrity sha1-G7nzFO9ri63tE7VJFpsqlF62jk0= + lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash@^4.16.4, lodash@^4.17.11: +lodash@4.17.11, lodash@^4.16.4, lodash@^4.17.11: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== @@ -3115,7 +3275,14 @@ mocha@6.1.4: yargs-parser "13.0.0" yargs-unparser "1.5.0" -moment@^2.10.6, moment@^2.15.2, moment@^2.18.1: +moment-timezone@0.5.25: + version "0.5.25" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.25.tgz#a11bfa2f74e088327f2cd4c08b3e7bdf55957810" + integrity sha512-DgEaTyN/z0HFaVcVbSyVCUU6HeFdnNC3vE4c9cgu2dgMTvjBUBdBzWfasTBmAW45u5OIMeCJtU8yNjM22DHucw== + dependencies: + moment ">= 2.9.0" + +"moment@>= 2.9.0", moment@^2.10.6, moment@^2.15.2, moment@^2.18.1: version "2.24.0" resolved "https://registry.yarnpkg.com/moment/-/moment-2.24.0.tgz#0d055d53f5052aa653c9f6eb68bb5d12bf5c2b5b" integrity sha512-bV7f+6l2QigeBBZSM/6yTNq4P2fNpSWj/0e7jQcy87A8e7o2nAfP/34/2ky5Vw4B9S446EtIhodAzkFCcR4dQg== @@ -3320,6 +3487,13 @@ npmlog@^4.0.2: gauge "~2.7.3" set-blocking "~2.0.0" +nth-check@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" + integrity sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg== + dependencies: + boolbase "~1.0.0" + number-is-nan@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d" @@ -3669,6 +3843,15 @@ readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^3.1.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc" + integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + readdirp@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" @@ -4265,6 +4448,13 @@ string-width@^3.0.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string_decoder@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.2.0.tgz#fe86e738b19544afe70469243b2a1ee9240eae8d" + integrity sha512-6YqyX6ZWEYguAxgZzHGL7SsCeGx3V2TtOTqZz1xSTSWnqsbWwbptafNyvf/ACquZUXV3DANr5BDIwNYe1mN42w== + dependencies: + safe-buffer "~5.1.0" + string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" @@ -4553,7 +4743,7 @@ use@^3.1.0: resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" integrity sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ== -util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=