├── .editorconfig ├── .gitattributes ├── .gitignore ├── .remarkignore ├── .travis.yml ├── LICENSE ├── README.md ├── index.js ├── package.json ├── test └── test.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.log 3 | .idea 4 | node_modules 5 | coverage 6 | .nyc_output 7 | -------------------------------------------------------------------------------- /.remarkignore: -------------------------------------------------------------------------------- 1 | test/snapshots/**/*.md 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | - 'lts/*' 5 | - 'node' 6 | services: mongodb 7 | after_success: 8 | npm run coverage 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Nick Baugh (http://niftylettuce.com/) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mongoose-slug-plugin 2 | 3 | [![build status](https://img.shields.io/travis/ladjs/mongoose-slug-plugin.svg)](https://travis-ci.org/ladjs/mongoose-slug-plugin) 4 | [![code coverage](https://img.shields.io/codecov/c/github/ladjs/mongoose-slug-plugin.svg)](https://codecov.io/gh/ladjs/mongoose-slug-plugin) 5 | [![code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/sindresorhus/xo) 6 | [![styled with prettier](https://img.shields.io/badge/styled_with-prettier-ff69b4.svg)](https://github.com/prettier/prettier) 7 | [![made with lass](https://img.shields.io/badge/made_with-lass-95CC28.svg)](https://lass.js.org) 8 | [![license](https://img.shields.io/github/license/ladjs/mongoose-slug-plugin.svg)](LICENSE) 9 | 10 | > Slugs for [Mongoose][] with history and [i18n][] support (uses [speakingurl][] by default, but you can use any slug library such as [limax][], [slugify][], [mollusc][], or [slugme][]) 11 | 12 | 13 | ## Table of Contents 14 | 15 | * [Install](#install) 16 | * [Usage](#usage) 17 | * [Static Methods](#static-methods) 18 | * [Options](#options) 19 | * [Slug Tips](#slug-tips) 20 | * [Slug Uniqueness](#slug-uniqueness) 21 | * [Custom Slug Library](#custom-slug-library) 22 | * [Background](#background) 23 | * [Contributors](#contributors) 24 | * [License](#license) 25 | 26 | 27 | ## Install 28 | 29 | [npm][]: 30 | 31 | ```sh 32 | npm install mongoose-slug-plugin 33 | ``` 34 | 35 | [yarn][]: 36 | 37 | ```sh 38 | yarn add mongoose-slug-plugin 39 | ``` 40 | 41 | 42 | ## Usage 43 | 44 | > Add the plugin to your project (it will automatically generate a slug when the document is validated based off the template string passed) 45 | 46 | ```js 47 | const mongooseSlugPlugin = require('mongoose-slug-plugin'); 48 | const mongoose = require('mongoose'); 49 | 50 | const BlogPost = new mongoose.Schema({ 51 | title: String 52 | }); 53 | 54 | BlogPost.plugin(mongooseSlugPlugin, { tmpl: '<%=title%>' }); 55 | 56 | module.exports = mongoose.model('BlogPost', BlogPost); 57 | ``` 58 | 59 | > If you need to render some custom function in the template string for display purposes, such as outputting a formatted date with [dayjs][]: 60 | 61 | ```js 62 | const dayjs = require('dayjs'); 63 | 64 | const mongooseSlugPlugin = require('mongoose-slug-plugin'); 65 | const mongoose = require('mongoose'); 66 | 67 | const BlogPost = new mongoose.Schema({ 68 | title: { type: String, required: true, unique: true }, 69 | posted_at: { type: Date, required: true } 70 | }); 71 | 72 | BlogPost.plugin(mongooseSlugPlugin, { 73 | tmpl: "<%=title%>-<%=dayjs(posted_at).format('YYYY-MM-DD')%>", 74 | locals: { dayjs } 75 | }); 76 | 77 | module.exports = mongoose.model('BlogPost', BlogPost); 78 | ``` 79 | 80 | > If you're using [Koa][], here's an example showing how to lookup a slug or an archived slug and properly 301 redirect: 81 | 82 | ```js 83 | const Koa = require('koa'); 84 | const Router = require('koa-router'); 85 | const Boom = require('boom'); 86 | 87 | const BlogPosts = require('./blog-post'); 88 | 89 | const app = new Koa(); 90 | const router = new Router(); 91 | 92 | router.get('/blog/:slug', async (ctx, next) => { 93 | try { 94 | // lookup the blog post by the slug parameter 95 | const blogPost = await BlogPosts.findOne({ slug: ctx.params.slug }); 96 | 97 | // if we found it then return early and render the blog post 98 | if (blogPost) return ctx.render('blog-post', { title: blogPost.title, blogPost }); 99 | 100 | // check if the slug changed for the post we're trying to lookup 101 | blogPost = await BlogPosts.findOne({ slug_history: ctx.params.slug }); 102 | 103 | // 301 permanent redirect to new blog post slug if it was found 104 | if (blogPost) return ctx.redirect(301, `/blog/${blogPost.slug}`); 105 | 106 | // if no blog post found then throw a nice 404 error 107 | // this assumes that you're using `koa-better-error-handler` 108 | // and also using `koa-404-handler`, but you don't necessarily need to 109 | // since koa automatically sets 404 status code if nothing found 110 | // 111 | // 112 | return next(); 113 | 114 | } catch (err) { 115 | ctx.throw(err); 116 | } 117 | }); 118 | 119 | app.use(router.routes()); 120 | app.listen(3000); 121 | ``` 122 | 123 | > If you're using [Express][], here's an example showing how to lookup a slug or an archived slug and properly 301 redirect: 124 | 125 | ```js 126 | TODO 127 | ``` 128 | 129 | > Note that you also have access to a static function on the model called `getUniqueSlug`. 130 | 131 | This function accepts an `_id` and `str` argument. The `_id` being the ObjectID of the document and `str` being the slug you're searching for to ensure uniqueness. 132 | 133 | This function is used internally by the plugin to recursively ensure uniqueness. 134 | 135 | 136 | ## Static Methods 137 | 138 | If you have to write a script to automatically set slugs across a collection, you can use the `getUniqueSlug` static method this package exposes on models. 139 | 140 | For example, if you want to programmatically set all blog posts to have slugs, run this script (note that you should run the updates serially as the example shows to prevent slug conflicts): 141 | 142 | ```js 143 | const Promise = require('bluebird'); // exposes `Promise.each` 144 | 145 | const BlogPost = require('../app/models/blog-post.js'); 146 | 147 | (async () => { 148 | const blogPosts = await BlogPost.find({}).exec(); 149 | await Promise.each(blogPosts, async blogPost => { 150 | blogPost.slug = null; 151 | blogPost.slug = await BlogPost.getUniqueSlug(blogPost._id, blogPost.title); 152 | return blogPost.save(); 153 | })); 154 | })(); 155 | ``` 156 | 157 | 158 | ## Options 159 | 160 | Here are the default options passed to the plugin: 161 | 162 | * `tmpl` (String) - Required, this should be a [lodash template string][lodash-template-string] (e.g. `<%=title%>` to use the blog post title as the slug) 163 | * `locals` (Object) - Defaults to an empty object, but you can pass a custom object that will be inherited for use in the lodash template string (see above example for how you could use [dayjs][] to render a document's date formatted in the slug) 164 | * `alwaysUpdateSlug` (Boolean) - Defaults to `true` (basically this will re-set the slug to the value it should be based off the template string every time the document is validated (or saved for instance due to pre-save hook in turn calling pre-validate in Mongoose) 165 | * `errorMessage` (String) - Defaults to `Slug was missing or blank`, this is a String that is returned for failed validation (note that it gets translated based off the `this.locale` field if it is set on the document (see [Lad][] for more insight into how this works)) 166 | * `logger` (Object) - defaults to `console`, but you might want to use [Lad's logger][lad-logger] 167 | * `slugField` (String) - defaults to `slug`, this is the field used for storing the slug for the document 168 | * `historyField` (String) - defaults to `slug_history`, this is the field used for storing a document's slug history 169 | * `i18n` (Object|Boolean) - defaults to `false`, but accepts a `i18n` object from [Lad's i18n][i18n] 170 | * `slug` (Function) - Defaults to `speakingurl`, but it is a function that converts a string into a slug (see below [Custom Slug Libary](#custom-slug-library) examples) 171 | * `slugOptions` (Object) - An object of options to pass to the slug function when invoked as specified in `options.slug` 172 | 173 | 174 | ## Slug Tips 175 | 176 | If you're using the default slug library `speakingurl`, then you might want to pass the option `slugOptions: { "'": '' }` in order to fix contractions. 177 | 178 | For example, if your title is "Jason's Blog Post", you probably want the slug to be "jasons-blog-post" as opposed to "jason-s-blog-post". This option will fix that. 179 | 180 | See [pid/speakingurl#105](https://github.com/pid/speakingurl/issues/105) for more information. 181 | 182 | 183 | ## Slug Uniqueness 184 | 185 | If a slug of "foo-bar" already exists, and if we are inserting a new document that also has a slug of "foo-bar", then this new slug will automatically become "foo-bar-1". 186 | 187 | 188 | ## Custom Slug Library 189 | 190 | If you don't want to use the library `speakingurl` for generating slugs (which this package uses by default), then you can pass a custom `slug` function: 191 | 192 | > [limax][] example: 193 | 194 | ```js 195 | const limax = require('limax'); 196 | 197 | BlogPost.plugin(mongooseSlugPlugin, { tmpl: '<%=title%>', slug: limax }); 198 | ``` 199 | 200 | > [slugify][] example: 201 | 202 | ```js 203 | const slugify = require('slugify'); 204 | 205 | BlogPost.plugin(mongooseSlugPlugin, { tmpl: '<%=title%>', slug: slugify }); 206 | ``` 207 | 208 | > [mollusc][] example: 209 | 210 | ```js 211 | const slug = require('mollusc'); 212 | 213 | BlogPost.plugin(mongooseSlugPlugin, { tmpl: '<%=title%>', slug }); 214 | ``` 215 | 216 | > [slugme][] example: 217 | 218 | ```js 219 | const slugme = require('slugme'); 220 | 221 | BlogPost.plugin(mongooseSlugPlugin, { tmpl: '<%=title%>', slug: slugme }); 222 | ``` 223 | 224 | 225 | ## Background 226 | 227 | I created this package despite knowing that other alternatives like it exist for these reasons: 228 | 229 | * No alternative supported i18n localization/translation out of the box 230 | * No alternative used the well-tested and SEO-friendly `speakingurl` package 231 | * No alternative allowed users to pass their own slug library 232 | * No alternative documented how to clearly do a 301 permanent redirect for archived slugs 233 | * No alternative allowed the field names to be customized 234 | * No alternative had decent tests written 235 | 236 | 237 | ## Contributors 238 | 239 | | Name | Website | 240 | | ---------------- | --------------------------------- | 241 | | **Nick Baugh** | | 242 | | **shadowgate15** | | 243 | 244 | 245 | ## License 246 | 247 | [MIT](LICENSE) © [Nick Baugh](http://niftylettuce.com/) 248 | 249 | 250 | ## 251 | 252 | [npm]: https://www.npmjs.com/ 253 | 254 | [yarn]: https://yarnpkg.com/ 255 | 256 | [limax]: https://github.com/lovell/limax 257 | 258 | [slugify]: https://github.com/simov/slugify 259 | 260 | [mollusc]: https://github.com/Zertz/mollusc 261 | 262 | [slugme]: https://github.com/arthurlacoste/js-slug-me 263 | 264 | [i18n]: https://github.com/ladjs/i18n 265 | 266 | [mongoose]: http://mongoosejs.com/ 267 | 268 | [speakingurl]: https://github.com/pid/speakingurl 269 | 270 | [koa]: http://koajs.com/ 271 | 272 | [express]: https://expressjs.com/ 273 | 274 | [lodash-template-string]: https://lodash.com/docs/4.17.4#template 275 | 276 | [lad-logger]: https://github.com/ladjs/logger 277 | 278 | [dayjs]: https://github.com/iamkun/dayjs 279 | 280 | [lad]: https://lad.js.org 281 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const { callbackify } = require('util'); 2 | 3 | const isSANB = require('is-string-and-not-blank'); 4 | const _ = require('lodash'); 5 | const slug = require('speakingurl'); 6 | 7 | // eslint-disable-next-line max-params 8 | const getUniqueSlug = async (config, constructor, _id, str, i = 0) => { 9 | if (!isSANB(str)) throw new Error('The `str` argument was missing'); 10 | const search = i === 0 ? str : config.slug(`${str}-${i}`, config.slugOptions); 11 | const query = { _id: { $ne: _id } }; 12 | query[config.slugField] = search; 13 | if (config.paranoid === 'hidden') query.hidden = { $ne: null }; 14 | const count = await constructor.countDocuments(query); 15 | if (count === 0) return search; 16 | return getUniqueSlug(config, constructor, _id, str, i + 1); 17 | }; 18 | 19 | const mongooseSlugPlugin = (schema, options = {}) => { 20 | const config = { 21 | tmpl: '', 22 | locals: {}, 23 | alwaysUpdateSlug: true, 24 | slug, 25 | errorMessage: 'Slug was missing or blank', 26 | logger: console, 27 | slugField: 'slug', 28 | historyField: 'slug_history', 29 | i18n: false, 30 | slugOptions: {}, 31 | paranoid: false, 32 | ...options 33 | }; 34 | 35 | const obj = {}; 36 | obj[config.slugField] = { 37 | type: String, 38 | index: true, 39 | unique: true, 40 | required: true, 41 | trim: true, 42 | set: val => config.slug(val, config.slugOptions), 43 | validate: { 44 | validator(val) { 45 | const message = 46 | config.i18n && config.i18n.t && this.locale 47 | ? config.i18n.t(config.errorMessage, this.locale) 48 | : config.errorMessage; 49 | if (!isSANB(val)) return Promise.reject(message); 50 | Promise.resolve(true); 51 | } 52 | } 53 | }; 54 | if (config.historyField) { 55 | obj[config.historyField] = [ 56 | { 57 | type: String, 58 | index: true 59 | } 60 | ]; 61 | } 62 | 63 | schema.add(obj); 64 | 65 | schema.pre('validate', async function(next) { 66 | try { 67 | const locals = { ...config.locals, ...this.toObject() }; 68 | const str = _.template(config.tmpl)(locals); 69 | 70 | // set the slug if it is not already set 71 | if (!isSANB(this[config.slugField]) || config.alwaysUpdateSlug) { 72 | this[config.slugField] = config.slug(str, config.slugOptions); 73 | } else { 74 | // slugify the slug in case we set it manually and not in slug format 75 | this[config.slugField] = config.slug( 76 | this[config.slugField], 77 | config.slugOptions 78 | ); 79 | } 80 | 81 | // ensure that the slug is unique 82 | const uniqueSlug = await getUniqueSlug( 83 | config, 84 | this.constructor, 85 | this._id, 86 | this[config.slugField] 87 | ); 88 | this[config.slugField] = uniqueSlug; 89 | 90 | if (config.historyField) { 91 | // create slug history if it does not exist yet 92 | if (!Array.isArray(this[config.historyField])) 93 | this[config.historyField] = []; 94 | 95 | // add the slug to the slug_history 96 | this[config.historyField].push(this[config.slugField]); 97 | 98 | // make the slug history unique 99 | this[config.historyField] = _.uniq(this[config.historyField]); 100 | } 101 | 102 | next(); 103 | } catch (err) { 104 | config.logger.error(err); 105 | if (config.i18n && config.i18n.t && this.locale) 106 | err.message = config.i18n.t(config.errorMessage, this.locale); 107 | else err.message = config.errorMessage; 108 | next(err); 109 | } 110 | }); 111 | 112 | schema.statics.getUniqueSlug = function(_id, str) { 113 | str = config.slug(str, config.slugOptions); 114 | return getUniqueSlug(config, this, _id, str); 115 | }; 116 | 117 | schema.statics.getUniqueSlugCallback = callbackify( 118 | schema.statics.getUniqueSlug 119 | ); 120 | 121 | return schema; 122 | }; 123 | 124 | module.exports = mongooseSlugPlugin; 125 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongoose-slug-plugin", 3 | "description": "Slugs for Mongoose with history and i18n support (uses speakingurl by default, but you can use any slug library such as limax, slugify, mollusc, or slugme)", 4 | "version": "2.1.0", 5 | "author": "Nick Baugh (http://niftylettuce.com/)", 6 | "ava": { 7 | "failFast": true, 8 | "verbose": true 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/ladjs/mongoose-slug-plugin/issues", 12 | "email": "niftylettuce@gmail.com" 13 | }, 14 | "commitlint": { 15 | "extends": [ 16 | "@commitlint/config-conventional" 17 | ] 18 | }, 19 | "contributors": [ 20 | "Nick Baugh (http://niftylettuce.com/)", 21 | "shadowgate15 (https://github.com/shadowgate15)" 22 | ], 23 | "dependencies": { 24 | "is-string-and-not-blank": "^0.0.2", 25 | "lodash": "^4.17.15", 26 | "speakingurl": "^14.0.1" 27 | }, 28 | "devDependencies": { 29 | "@commitlint/cli": "^8.3.5", 30 | "@commitlint/config-conventional": "^8.3.4", 31 | "ava": "^3.8.2", 32 | "codecov": "^3.7.0", 33 | "cross-env": "^7.0.2", 34 | "dayjs": "^1.8.27", 35 | "eslint": "6.x", 36 | "eslint-config-xo-lass": "^1.0.3", 37 | "fixpack": "^3.0.6", 38 | "husky": "^4.2.5", 39 | "lint-staged": "^10.2.4", 40 | "mongoose": "^5.8.3", 41 | "nyc": "^15.0.1", 42 | "remark-cli": "^8.0.0", 43 | "remark-preset-github": "^1.0.1", 44 | "xo": "0.25" 45 | }, 46 | "engines": { 47 | "node": ">=8.3" 48 | }, 49 | "homepage": "https://github.com/ladjs/mongoose-slug-plugin", 50 | "husky": { 51 | "hooks": { 52 | "pre-commit": "lint-staged && npm test", 53 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 54 | } 55 | }, 56 | "keywords": [ 57 | "301", 58 | "302", 59 | "blog", 60 | "boom", 61 | "dashed", 62 | "dasherize", 63 | "dashes", 64 | "express", 65 | "generator", 66 | "get", 67 | "history", 68 | "hyphenize", 69 | "hyphens", 70 | "koa", 71 | "lad", 72 | "limax", 73 | "lodash", 74 | "me", 75 | "mollusc", 76 | "mongo", 77 | "mongodb", 78 | "mongoose", 79 | "mongoosejs", 80 | "plugin", 81 | "post", 82 | "preserve", 83 | "redirect", 84 | "redirection", 85 | "seo", 86 | "slug", 87 | "slugify", 88 | "slugme", 89 | "slugme", 90 | "slugs", 91 | "speaking", 92 | "speakingurl", 93 | "str", 94 | "string", 95 | "template", 96 | "tmpl", 97 | "unique", 98 | "url", 99 | "urls" 100 | ], 101 | "license": "MIT", 102 | "lint-staged": { 103 | "*.js": [ 104 | "xo --fix", 105 | "git add" 106 | ], 107 | "*.md": [ 108 | "remark . -qfo", 109 | "git add" 110 | ], 111 | "package.json": [ 112 | "fixpack", 113 | "git add" 114 | ] 115 | }, 116 | "main": "index.js", 117 | "peerDependencies": { 118 | "mongoose": ">=5.7" 119 | }, 120 | "prettier": { 121 | "singleQuote": true, 122 | "bracketSpacing": true, 123 | "trailingComma": "none" 124 | }, 125 | "remarkConfig": { 126 | "plugins": [ 127 | "preset-github" 128 | ] 129 | }, 130 | "repository": { 131 | "type": "git", 132 | "url": "https://github.com/ladjs/mongoose-slug-plugin" 133 | }, 134 | "scripts": { 135 | "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov", 136 | "lint": "xo && remark . -qfo", 137 | "pretest": "mongo mongoose_slug_plugin --eval 'db.dropDatabase();'", 138 | "test": "npm run lint && npm run test-coverage", 139 | "test-coverage": "cross-env NODE_ENV=test nyc ava" 140 | }, 141 | "xo": { 142 | "prettier": true, 143 | "space": true, 144 | "extends": [ 145 | "xo-lass" 146 | ] 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | const test = require('ava'); 2 | const dayjs = require('dayjs'); 3 | const mongoose = require('mongoose'); 4 | const slug = require('speakingurl'); 5 | 6 | const mongooseSlugPlugin = require('..'); 7 | 8 | mongoose.connect('mongodb://localhost/mongoose_slug_plugin'); 9 | mongoose.Promise = global.Promise; 10 | 11 | const BlogPost = new mongoose.Schema({ 12 | title: { type: String, required: true } 13 | }); 14 | BlogPost.plugin(mongooseSlugPlugin, { tmpl: '<%=title%>' }); 15 | const BlogPosts = mongoose.model('BlogPost', BlogPost); 16 | 17 | const CustomBlogPost = new mongoose.Schema({ 18 | title: { type: String, required: true, unique: true }, 19 | posted_at: { type: Date, required: true } 20 | }); 21 | CustomBlogPost.plugin(mongooseSlugPlugin, { 22 | tmpl: "<%=title%>-<%=dayjs(posted_at).format('YYYY-MM-DD')%>", 23 | locals: { dayjs } 24 | }); 25 | const CustomBlogPosts = mongoose.model('CustomBlogPost', CustomBlogPost); 26 | 27 | test('custom locals', async t => { 28 | const title = 'custom locals'; 29 | const posted_at = new Date(); 30 | const blogPost = await CustomBlogPosts.create({ title, posted_at }); 31 | t.is( 32 | blogPost.slug, 33 | slug(`${title}-${dayjs(posted_at).format('YYYY-MM-DD')}`) 34 | ); 35 | }); 36 | 37 | test('preserve slug history', async t => { 38 | const title = 'preserve slug history'; 39 | let blogPost = await BlogPosts.create({ title }); 40 | t.is(blogPost.slug, slug(title)); 41 | t.deepEqual(blogPost.toObject().slug_history, [slug(title)]); 42 | blogPost.title = 'new slug to be preserved'; 43 | blogPost = await blogPost.save(); 44 | t.is(blogPost.slug, slug('new slug to be preserved')); 45 | t.deepEqual( 46 | blogPost.toObject().slug_history.sort(), 47 | [slug('new slug to be preserved'), slug(title)].sort() 48 | ); 49 | }); 50 | 51 | test('increment slugs', async t => { 52 | const title = 'increment slugs'; 53 | const blogPost = await BlogPosts.create({ title }); 54 | const newBlogPost = await BlogPosts.create({ 55 | title, 56 | slug: blogPost.slug 57 | }); 58 | t.is(newBlogPost.slug, `${blogPost.slug}-1`); 59 | const smartBlogPost = await BlogPosts.create({ 60 | title, 61 | slug: newBlogPost.slug 62 | }); 63 | t.is(smartBlogPost.slug, `${blogPost.slug}-2`); 64 | }); 65 | 66 | test('custom error message', async t => { 67 | const Schema = new mongoose.Schema({ title: String }); 68 | Schema.plugin(mongooseSlugPlugin, { 69 | tmpl: '<%=title%>', 70 | errorMessage: 'A custom error message' 71 | }); 72 | const Model = mongoose.model('Custom', Schema); 73 | const err = await t.throwsAsync(() => Model.create({})); 74 | t.is(err.message, 'A custom error message'); 75 | }); 76 | 77 | test('error message is translated', async t => { 78 | const Schema = new mongoose.Schema({ title: String, locale: String }); 79 | Schema.plugin(mongooseSlugPlugin, { 80 | tmpl: '<%=title%>', 81 | errorMessage: 'A custom error message', 82 | i18n: { 83 | t: message => message 84 | } 85 | }); 86 | const Model = mongoose.model('CustomErrorTranslate', Schema); 87 | await t.throwsAsync(() => Model.create({ locale: 'en' }), { 88 | message: 'A custom error message' 89 | }); 90 | }); 91 | 92 | test('custom slug field', async t => { 93 | const Schema = new mongoose.Schema({ title: String }); 94 | Schema.plugin(mongooseSlugPlugin, { 95 | tmpl: '<%=title%>', 96 | alwaysUpdateSlug: false 97 | }); 98 | const Model = mongoose.model('CustomSlugField', Schema); 99 | 100 | const title = 'custom slug field'; 101 | const model = await Model.create({ title, slug: 'this slugged' }); 102 | 103 | t.is(model.slug, slug('this slugged')); 104 | }); 105 | 106 | test('custom slug history', async t => { 107 | const title = 'custom slug history'; 108 | let blogPost = await BlogPosts.create({ title }); 109 | 110 | blogPost.slug_history = undefined; 111 | blogPost = await blogPost.save(); 112 | 113 | t.true(Array.isArray(blogPost.toObject().slug_history)); 114 | }); 115 | 116 | test('getUniqueSlug static', async t => { 117 | const blogPost = await BlogPosts.create({ title: 'getUniqueSlug static' }); 118 | 119 | t.is( 120 | await BlogPosts.getUniqueSlug(blogPost._id, 'this slugged'), 121 | slug('this slugged') 122 | ); 123 | }); 124 | 125 | test('getUniqueSlug static > no str', async t => { 126 | const blogPost = await BlogPosts.create({ title: 'getUniqueSlug no str' }); 127 | 128 | await t.throwsAsync(async () => BlogPosts.getUniqueSlug(blogPost._id), { 129 | message: 'The `str` argument was missing' 130 | }); 131 | }); 132 | 133 | test('getUniqueSlug static > hidden', async t => { 134 | const Schema = new mongoose.Schema({ title: String }); 135 | Schema.plugin(mongooseSlugPlugin, { 136 | paranoid: 'hidden', 137 | tmpl: '<%=title%>' 138 | }); 139 | 140 | const Models = mongoose.model('Hidden', Schema); 141 | 142 | const model = await Models.create({ title: 'getUniqueSlug hidden' }); 143 | 144 | t.is( 145 | await Models.getUniqueSlug(model._id, 'this slugged'), 146 | slug('this slugged') 147 | ); 148 | }); 149 | 150 | test.todo('custom slug function'); 151 | test.todo('custom slug options'); 152 | --------------------------------------------------------------------------------