diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c380e58 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,34 @@ +# EditorConfig helps developers define and maintain consistent coding styles between different editors and IDEs. +# Requires EditorConfig JetBrains Plugin - http://github.com/editorconfig/editorconfig-jetbrains + +# Set this file as the topmost .editorconfig +# (multiple files can be used, and are applied starting from current document location) +root = true + +[{package.json}] +indent_style = space +indent_size = 2 + +# Use bracketed regexp to target specific file types or file locations +[*.{js,json}] + +# Use hard or soft tabs ["tab", "space"] +indent_style = space + +# Size of a single indent [an integer, "tab"] +indent_size = tab + +# Number of columns representing a tab character [an integer] +tab_width = 4 + +# Line breaks representation ["lf", "cr", "crlf"] +end_of_line = lf + +# ["latin1", "utf-8", "utf-16be", "utf-16le"] +charset = utf-8 + +# Remove any whitespace characters preceding newline characters ["true", "false"] +trim_trailing_whitespace = true + +# Ensure file ends with a newline when saving ["true", "false"] +insert_final_newline = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..4ebc8ae --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +coverage diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..9847fb4 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,6 @@ +{ + "extends": "vgno", + "ecmaFeatures": { + "generators": true + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6de9f24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +*.log +.DS_Store +coverage diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..586a559 --- /dev/null +++ b/.npmignore @@ -0,0 +1,3 @@ +test +.* +coverage diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..cfcb29a --- /dev/null +++ b/.travis.yml @@ -0,0 +1,7 @@ +language: node_js +node_js: + - 0.12 + - 4 + - 5 +after_script: + - cat coverage/lcov.info | node_modules/.bin/coveralls diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3eedbdf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Verdens Gang AS + +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/README.md b/README.md new file mode 100644 index 0000000..f7e72a1 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# koa-add-trailing-slashes + +Koa middleware that adds trailing slashes to a URL if it not already has it. + +[![Build Status](https://img.shields.io/travis/vgno/koa-add-trailing-slashes/master.svg?style=flat-square)](http://travis-ci.org/vgno/koa-add-trailing-slashes) [![Coverage Status](https://img.shields.io/coveralls/vgno/koa-add-trailing-slashes/master.svg?style=flat-square)](https://coveralls.io/r/vgno/koa-add-trailing-slashes) [![npm](https://img.shields.io/npm/v/koa-add-trailing-slashes.svg?style=flat-square)](https://www.npmjs.com/package/koa-add-trailing-slashes) + +## Installation +``` +npm install koa-add-trailing-slashes +``` + +## API +```js +var koa = require('koa'); +var app = koa(); +app.use(require('koa-add-trailing-slashes')(opts)); +``` + +* `opts` options object. + +### Options + + - `index` - Default file name, defaults to 'index.html'. Will automatically add slashes to folders that contain this index file, expected to be used with `koa-static`. Set to false to disable this. + +## Example +```js +var koa = require('koa'); +var addTrailingSlashes = require('koa-add-trailing-slashes'); + +var app = koa(); + +app.use(addTrailingSlashes()); + +app.use(function *(){ + this.body = 'Hello World'; +}); + +app.listen(3000); +``` + +## Important +Make sure this us added before an eventual [koa-static](https://github.com/koajs/static) to make sure requests to files are not changed and managed correctly. + +Will not rewrite the URL if a `body` has been set in general. The special case being if the `body` is the index file described above. + +For example if the path in the browser is `/foo` and `koa-static` resolves that to `foo/index.html` internally and `opts.index` is not disabled the path will end up as `/foo/`. + +## License +MIT diff --git a/index.js b/index.js new file mode 100644 index 0000000..105491d --- /dev/null +++ b/index.js @@ -0,0 +1,52 @@ +'use strict'; + +module.exports = addTrailingSlashes; + +function addTrailingSlashes(opts) { + opts = opts || {}; + + if (opts.index !== false) { + opts.index = opts.index || 'index.html'; + } + + return function* (next) { + yield next; + var url = getBaseUrl(this.originalUrl, this.url); + + if (noBodyOrIndex(this.body, this.path, opts.index) && missingSlash(url, this.path)) { + var query = this.url.slice(this.path.length); + var path = this.path.substring(1); + + if (!path.length) { + path = '/'; + } else { + path += '/'; + } + + this.status = 301; + this.redirect(url + path + query); + } + }; +} + +function getFilename(path) { + return path.replace(/^.*[\\\/]/, ''); +} + +function getBaseUrl(original, url) { + var noInitalSlash = url.substring(1).length; + if (noInitalSlash !== 0) { + return original.slice(0, -noInitalSlash); + } + + return original; +} + +function noBodyOrIndex(body, path, index) { + return !body || + (index && body.path && getFilename(body.path) === index && getFilename(body.path) !== getFilename(path)); +} + +function missingSlash(url, path) { + return path.slice(-1) !== '/' || url !== '/' && url.slice(-1) !== '/'; +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0f43242 --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "koa-add-trailing-slashes", + "version": "1.0.0", + "description": "Koa middleware that makes sure all requests have trailing slashes", + "main": "index.js", + "scripts": { + "lint": "eslint .", + "test": "npm run lint && npm run test:coverage", + "test:unit": "mocha test", + "test:coverage": "node --harmony node_modules/istanbul/lib/cli.js cover _mocha -- test" + }, + "repository": { + "type": "git", + "url": "https://github.com/vgno/koa-add-trailing-slashes.git" + }, + "keywords": [ + "koa", + "middleware", + "slash", + "trailing", + "redirect" + ], + "author": "VG", + "license": "MIT", + "devDependencies": { + "coveralls": "~2.11.6", + "eslint": "~1.10.3", + "eslint-config-vgno": "~5.0.0", + "expect": "~1.13.4", + "istanbul": "~0.4.2", + "mocha": "~2.3.4" + } +} diff --git a/test/index.js b/test/index.js new file mode 100644 index 0000000..45a8c9e --- /dev/null +++ b/test/index.js @@ -0,0 +1,168 @@ +'use strict'; + +var expect = require('expect'); +var addTrailingSlashes = require('../index.js'); + +describe('koa-add-trailing-slashes', function() { + describe('redirect', function() { + it('should redirect on simple url and url has no trailing slash', function() { + var mockData = createMock('/foo', '/foo', '/foo'); + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock.calls[0].arguments[0]).toEqual('/foo/'); + expect(mockData.mockThis.status).toBe(301); + }); + + it('should redirect on simple url with query and url has no trailing slash', function() { + var mockData = createMock('/foo', '/foo?hello=world', '/foo?hello=world'); + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock.calls[0].arguments[0]).toEqual('/foo/?hello=world'); + expect(mockData.mockThis.status).toBe(301); + }); + + it('should redirect when using for example koa-mount and url has no trailing slash', function() { + var mockData = createMock('/foo', '/foo', '/bar/foo'); + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock.calls[0].arguments[0]).toEqual('/bar/foo/'); + expect(mockData.mockThis.status).toBe(301); + }); + + it('should redirect when using for example koa-mount is on root and url has no trailing slash', function() { + var mockData = createMock('/', '/', '/foo'); + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock.calls[0].arguments[0]).toEqual('/foo/'); + expect(mockData.mockThis.status).toBe(301); + }); + + it('should redirect when using for example koa-mount with query and url has no trailing slash', function() { + var mockData = createMock('/foo', '/foo?hello=world', '/bar/foo?hello=world'); + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock.calls[0].arguments[0]).toEqual('/bar/foo/?hello=world'); + expect(mockData.mockThis.status).toBe(301); + }); + + it('should redirect when file is index.html, from koa-static', function() { + var mockData = createMock('/foo', '/foo', '/foo'); + mockData.mockThis.body = {content: 'some content', path: '/some/path/that/is/served/index.html'}; + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock.calls[0].arguments[0]).toEqual('/foo/'); + expect(mockData.mockThis.status).toBe(301); + }); + }); + + describe('not redirect', function() { + it('should not redirect on simple url that is the root', function() { + var mockData = createMock('/', '/', '/'); + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock).toNotHaveBeenCalled(); + expect(mockData.mockThis.status).toBe(undefined); + }); + + it('should not redirect on simple url and url has trailing slash', function() { + var mockData = createMock('/foo/', '/foo/', '/foo/'); + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock).toNotHaveBeenCalled(); + expect(mockData.mockThis.status).toBe(undefined); + }); + + it('should not redirect on simple url with query and url has trailing slash', function() { + var mockData = createMock('/foo/', '/foo/?hello=world', '/foo/?hello=world'); + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock).toNotHaveBeenCalled(); + expect(mockData.mockThis.status).toBe(undefined); + }); + + it('should not redirect when using for example koa-mount is on root and url has trailing slash', function() { + var mockData = createMock('/', '/', '/foo/'); + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock).toNotHaveBeenCalled(); + expect(mockData.mockThis.status).toBe(undefined); + }); + + it('should not redirect when using for example koa-mount and url has trailing slash', function() { + var mockData = createMock('/foo/', '/foo/', '/bar/foo/'); + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock).toNotHaveBeenCalled(); + expect(mockData.mockThis.status).toBe(undefined); + }); + + it('should not redirect when body is defined', function() { + var mockData = createMock('/foo', '/foo', '/bar/foo'); + mockData.mockThis.body = 'some content'; + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock).toNotHaveBeenCalled(); + expect(mockData.mockThis.status).toBe(undefined); + }); + + it('should not redirect when the file and path is index.html, from koa-static', function() { + var mockData = createMock('/foo/index.html', '/foo/index.html', '/foo/index.html'); + mockData.mockThis.body = {content: 'some content', path: '/some/path/that/is/served/index.html'}; + var addTrailingSlashesMock = addTrailingSlashes().bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock).toNotHaveBeenCalled(); + expect(mockData.mockThis.status).toBe(undefined); + }); + + it('should not redirect when file is index.html, from koa-static, and options set to false', function() { + var mockData = createMock('/foo', '/foo', '/foo'); + mockData.mockThis.body = {content: 'some content', path: '/some/path/that/is/served/index.html'}; + var addTrailingSlashesMock = addTrailingSlashes({index: false}).bind(mockData.mockThis); + var addTrailingSlashesMockGenerator = addTrailingSlashesMock(); + addTrailingSlashesMockGenerator.next(); + addTrailingSlashesMockGenerator.next(); + expect(mockData.redirectMock).toNotHaveBeenCalled(); + expect(mockData.mockThis.status).toBe(undefined); + }); + }); +}); + +function createMock(path, url, originalUrl) { + var redirectMock = expect.createSpy(); + return { + redirectMock: redirectMock, + mockThis: { + path: path, + url: url, + originalUrl: originalUrl, + status: undefined, + redirect: redirectMock + } + }; +}