TryGhost/Ghost

View on GitHub
ghost/core/core/server/models/base/plugins/generate-slug.js

Summary

Maintainability
C
1 day
Test Coverage
const _ = require('lodash');
const security = require('@tryghost/security');

const urlUtils = require('../../../../shared/url-utils');

/**
 * @type {Bookshelf} Bookshelf
 */
module.exports = function (Bookshelf) {
    Bookshelf.Model = Bookshelf.Model.extend({}, {
        /**
         * ### Generate Slug
         * Create a string to act as the permalink for an object.
         * @param {Bookshelf['Model']} Model Model type to generate a slug for
         * @param {String} base The string for which to generate a slug, usually a title or name
         * @param {GenerateSlugOptions} [options] Options to pass to findOne
         * @return {Promise<String>} Resolves to a unique slug string
         */
        generateSlug: function generateSlug(Model, base, options) {
            let slug;
            let slugTryCount = 1;
            const baseName = Model.prototype.tableName.replace(/s$/, '');

            let longSlug;

            // Look for a matching slug, append an incrementing number if so
            const checkIfSlugExists = function checkIfSlugExists(slugToFind) {
                const args = {slug: slugToFind};

                // status is needed for posts
                if (options && options.status) {
                    args.status = options.status;
                }

                return Model.findOne(args, options).then(function then(found) {
                    let trimSpace;

                    if (!found) {
                        return slugToFind;
                    }

                    slugTryCount += 1;

                    // If we shortened, go back to the full version and try again
                    if (slugTryCount === 2 && longSlug) {
                        slugToFind = longSlug;
                        longSlug = null;
                        slugTryCount = 1;
                        return checkIfSlugExists(slugToFind);
                    }

                    // If this is the first time through, add the hyphen
                    if (slugTryCount === 2) {
                        slugToFind += '-';
                    } else {
                    // Otherwise, trim the number off the end
                        trimSpace = -(String(slugTryCount - 1).length);
                        slugToFind = slugToFind.slice(0, trimSpace);
                    }

                    slugToFind += slugTryCount;

                    return checkIfSlugExists(slugToFind);
                });
            };

            slug = security.string.safe(base, options);

            // the slug may never be longer than the allowed limit of 191 chars, but should also
            // take the counter into count. We reduce a too long slug to 185 so we're always on the
            // safe side, also in terms of checking for existing slugs already.
            if (slug.length > 185) {
            // CASE: don't cut the slug on import
                if (!_.has(options, 'importing') || !options.importing) {
                    slug = slug.slice(0, 185);
                }
            }

            // If it's a user, let's try to cut it down (unless this is a human request)
            if (baseName === 'user' && options && options.shortSlug && slugTryCount === 1 && slug !== 'ghost-owner') {
                longSlug = slug;
                slug = (slug.indexOf('-') > -1) ? slug.slice(0, slug.indexOf('-')) : slug;
            }

            if (!_.has(options, 'importing') || !options.importing) {
            // This checks if the first character of a tag name is a #. If it is, this
            // is an internal tag, and as such we should add 'hash' to the beginning of the slug
                if (baseName === 'tag' && /^#/.test(base)) {
                    slug = 'hash-' + slug;
                }
            }

            // Some keywords cannot be changed
            slug = _.includes(urlUtils.getProtectedSlugs(), slug) ? slug + '-' + baseName : slug;

            // if slug is empty after trimming use the model name
            if (!slug) {
                slug = baseName;
            }

            if (options && options.skipDuplicateChecks === true) {
                return slug;
            }

            // Test for duplicate slugs.
            return checkIfSlugExists(slug);
        }
    });
};

/**
 * @type {import('bookshelf')} Bookshelf
 */

/**
 * @typedef {object} GenerateSlugOptions
 * @property {string} [status] Used for posts, to also filter by post status when generating a slug
 * @property {boolean} [importing] Set to true to don't cut the slug on import
 * @property {boolean} [shortSlug] If it's a user, let's try to cut it down (unless this is a human request)
 * @property {boolean} [skipDuplicateChecks] Don't append unique identifiers when the slug is not unique (this prevents any database queries)
 */