diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/beta-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/beta-features.tsx index cf9d866528f..86045dc5fff 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/beta-features.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/beta-features.tsx @@ -3,26 +3,62 @@ import LabItem from './lab-item'; import NiceModal from '@ebay/nice-modal-react'; import React, {useState} from 'react'; import YamlFileEditorModal from './yaml-file-editor-modal'; -import {ActionList, Button, Dropzone} from '@tryghost/shade/components'; +import {ActionList, Button, Dropzone, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@tryghost/shade/components'; import {Inline, Stack} from '@tryghost/shade/primitives'; import {downloadRedirects, useUploadRedirects} from '@tryghost/admin-x-framework/api/redirects'; import {downloadRoutes, useUploadRoutes} from '@tryghost/admin-x-framework/api/routes'; -import {getSettingValue} from '@tryghost/admin-x-framework/api/settings'; +import {getSettingValue, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; import {toast} from 'sonner'; import {useGlobalData} from '../../../providers/global-data-provider'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; const IS_AUTOMATIONS_BETA_ACTIVE = true; +const SLUG_SEPARATORS = [ + { + value: '-', + label: 'Dashes [-]', + hint: 'The default mode (e.g. /example-ghost-post/).' + }, + { + value: '_', + label: 'Underscores [_]', + hint: 'A mode with better readability and clearer distinction (e.g. /example_ghost_post/).' + }, + { + value: ' ', + label: 'Spaces [ ]', + hint: 'A natural mode, but might look foreign in URL:s (eg. /example ghost post/). Also, many browsers will show spaces as %20 in the URL:s.' + } +]; + +const renderSlugSeparatorOptions = () => { + return SLUG_SEPARATORS.map(option => ( + + + {option.label} + + {option.hint} + + + + )); +}; + +const getSlugSeparatorOptionLabel = (value: string) => + SLUG_SEPARATORS.find(option => option.value === value)?.label; + const BetaFeatures: React.FC = () => { const {settings} = useGlobalData(); const {mutateAsync: uploadRedirects} = useUploadRedirects(); const {mutateAsync: uploadRoutes} = useUploadRoutes(); + const {mutateAsync: editSettings} = useEditSettings(); const handleError = useHandleError(); const [redirectsUploading, setRedirectsUploading] = useState(false); const [routesUploading, setRoutesUploading] = useState(false); const labs = JSON.parse(getSettingValue(settings, 'labs') || '{}'); const isAutomationsEnabled = !!labs.automations; + const slugSeparator = getSettingValue(settings, 'slug_separator') || '-'; const openRedirectsEditor = () => { NiceModal.show(YamlFileEditorModal, { @@ -73,6 +109,25 @@ const BetaFeatures: React.FC = () => { action={} detail={<>Adds the excerpt input below the post title in the editor} title='Show post excerpt inline' /> + + + + } + detail={<>Use Unicode letters and numbers in URL slugs instead of transliterating them (e.g /smörgåsbord/ instead of /smorgasbord/), which may add benefits for SEO. You can also select another slug separator to adjust the look of the URL:s.} + title='International slugs' /> } detail={<>Enable support for CashApp, iDEAL, Bancontact, and others. Learn more →} diff --git a/apps/admin-x-settings/src/typings.d.ts b/apps/admin-x-settings/src/typings.d.ts index d44b415fc6e..9ced7ae7d18 100644 --- a/apps/admin-x-settings/src/typings.d.ts +++ b/apps/admin-x-settings/src/typings.d.ts @@ -1,5 +1,8 @@ declare module '@tryghost/limit-service' declare module '@tryghost/nql' +declare module '@tryghost/string' { + export function slugify(string: string, options?: {requiredChangesOnly?: boolean, unicodeSlugs?: boolean, slugSeparator?: string}): string; +} declare module '*.svg' { // eslint-disable-next-line @typescript-eslint/no-require-imports diff --git a/apps/admin/src/vite-env.d.ts b/apps/admin/src/vite-env.d.ts index c4b1a0c5da7..0ae39510dab 100644 --- a/apps/admin/src/vite-env.d.ts +++ b/apps/admin/src/vite-env.d.ts @@ -3,6 +3,6 @@ declare module '@tryghost/limit-service' declare module '@tryghost/nql' declare module '@tryghost/string' { - export function slugify(string: string, options?: {requiredChangesOnly?: boolean}): string; + export function slugify(string: string, options?: {requiredChangesOnly?: boolean, unicodeSlugs?: boolean, slugSeparator?: string}): string; } declare module '@tryghost/koenig-lexical' diff --git a/apps/ember-admin/app/components/tags/tag-form.js b/apps/ember-admin/app/components/tags/tag-form.js index b08df779c2b..99be75f8013 100644 --- a/apps/ember-admin/app/components/tags/tag-form.js +++ b/apps/ember-admin/app/components/tags/tag-form.js @@ -136,7 +136,7 @@ export default class TagForm extends Component { // Generate slug based on name for new tag when empty if (property === 'name' && tag.isNew && !this.hasChangedSlug) { - let slugValue = slugify(newValue); + let slugValue = slugify(newValue, {unicodeSlugs: this.feature.unicodeSlugs, slugSeparator: (this.feature.unicodeSlugs ? this.settings.slugSeparator : undefined)}); if (/^#/.test(newValue)) { slugValue = 'hash-' + slugValue; } diff --git a/apps/ember-admin/app/controllers/lexical-editor.js b/apps/ember-admin/app/controllers/lexical-editor.js index 0298c5f1dce..b654b1ea054 100644 --- a/apps/ember-admin/app/controllers/lexical-editor.js +++ b/apps/ember-admin/app/controllers/lexical-editor.js @@ -956,7 +956,7 @@ export default class LexicalEditorController extends Controller { // Update the slug unless the slug looks to be a custom slug or the title is a default/has been cleared out if ( - (currentSlug && slugify(currentTitle) !== currentSlug) + (currentSlug && slugify(currentTitle, {unicodeSlugs: this.feature.unicodeSlugs, slugSeparator: (this.feature.unicodeSlugs ? this.settings.slugSeparator : undefined)}) !== currentSlug) && !(currentTitle === DEFAULT_TITLE || currentTitle?.endsWith(DUPLICATED_POST_TITLE_SUFFIX)) ) { return; diff --git a/apps/ember-admin/app/models/setting.js b/apps/ember-admin/app/models/setting.js index b54be2618f7..e17daf9047e 100644 --- a/apps/ember-admin/app/models/setting.js +++ b/apps/ember-admin/app/models/setting.js @@ -132,6 +132,11 @@ export default Model.extend(ValidationEngine, { */ transistor: attr('boolean'), + /** + * Settings for Labs options + */ + slugSeparator: attr('string'), + // HACK - not a real model attribute but a workaround for Ember Data not // exposing meta from save responses _meta: attr() diff --git a/apps/ember-admin/app/services/feature.js b/apps/ember-admin/app/services/feature.js index b6676032660..790013f7607 100644 --- a/apps/ember-admin/app/services/feature.js +++ b/apps/ember-admin/app/services/feature.js @@ -82,6 +82,7 @@ export default class FeatureService extends Service { @feature('adminUIRefresh') adminUIRefresh; @feature('lexicalIndicators') lexicalIndicators; @feature('editorExcerpt') editorExcerpt; + @feature('unicodeSlugs') unicodeSlugs; @feature('memberDetailsReact') memberDetailsReact; @feature('previewByTier') previewByTier; @feature('paywallImprovements') paywallImprovements; diff --git a/apps/ember-admin/app/services/slug-generator.js b/apps/ember-admin/app/services/slug-generator.js index e77481274a1..937efca994a 100644 --- a/apps/ember-admin/app/services/slug-generator.js +++ b/apps/ember-admin/app/services/slug-generator.js @@ -9,6 +9,8 @@ const {resolve} = RSVP; export default class SlugGeneratorService extends Service { @service ghostPaths; @service ajax; + @service feature; + @service settings; generateSlug(slugType, textToSlugify, modelId) { let url; @@ -18,14 +20,19 @@ export default class SlugGeneratorService extends Service { } // We already do a partial slugify at the client side to prevent issues with Pro returning a 404 page because of invalid (encoded) characters (a newline, %0A, for example) - let name = encodeURIComponent(slugify(textToSlugify)); + let name = encodeURIComponent(slugify(textToSlugify, {unicodeSlugs: this.feature.unicodeSlugs, slugSeparator: (this.feature.unicodeSlugs ? this.settings.slugSeparator : undefined)})); if (modelId) { url = this.get('ghostPaths.url').api('slugs', slugType, name, modelId); } else { url = this.get('ghostPaths.url').api('slugs', slugType, name); } - return this.ajax.request(url).then((response) => { + return this.ajax.request(url, { + data: { + unicodeSlugs: this.feature.unicodeSlugs, + slugSeparator: (this.feature.unicodeSlugs ? this.settings.slugSeparator : undefined) + } + }).then((response) => { let [firstSlug] = response.slugs; let {slug} = firstSlug; diff --git a/ghost/core/core/frontend/helpers/navigation.js b/ghost/core/core/frontend/helpers/navigation.js index ccfb8db6c18..32ebc6a2ae3 100644 --- a/ghost/core/core/frontend/helpers/navigation.js +++ b/ghost/core/core/frontend/helpers/navigation.js @@ -7,6 +7,8 @@ const errors = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); const {slugify} = require('@tryghost/string'); const _ = require('lodash'); +const labs = require('../../shared/labs'); +const {settingsCache} = require('../services/proxy'); const messages = { invalidData: 'navigation data is not an object or is a function', @@ -75,7 +77,7 @@ module.exports = function navigation(options) { const out = {}; out.current = _isCurrentUrl(e.url, currentUrl); out.label = e.label; - out.slug = slugify(e.label); + out.slug = slugify(e.label, {unicodeSlugs: labs.isSet('unicodeSlugs'), slugSeparator: (labs.isSet('unicodeSlugs') ? settingsCache.get('slug_separator') : undefined)}); out.url = e.url; return out; }); diff --git a/ghost/core/core/frontend/services/data/entry-lookup.js b/ghost/core/core/frontend/services/data/entry-lookup.js index fce43bb8968..175c623c020 100644 --- a/ghost/core/core/frontend/services/data/entry-lookup.js +++ b/ghost/core/core/frontend/services/data/entry-lookup.js @@ -48,6 +48,11 @@ function entryLookup(postUrl, routerOptions, locals, {giftToken} = {}) { options.context.giftToken = giftToken; } + // In case this is a slug we normalize it to make sure it matches the form that the database uses + if (params.slug) { + params.slug = params.slug.normalize('NFC'); + } + return (api[routerOptions.query.controller] || api[routerOptions.query.resource]) .read(_.extend(_.pick(params, 'slug', 'id'), options)) .then(function then(result) { diff --git a/ghost/core/core/frontend/services/data/fetch-data.js b/ghost/core/core/frontend/services/data/fetch-data.js index e5cf0d91605..c32309141a0 100644 --- a/ghost/core/core/frontend/services/data/fetch-data.js +++ b/ghost/core/core/frontend/services/data/fetch-data.js @@ -89,6 +89,11 @@ async function fetchData(pathOptions, routerOptions, locals) { postQuery.options.limit = pathOptions.limit; } + // In case this is a slug we normalize it to make sure it matches the form that the database uses + if (pathOptions.slug) { + pathOptions.slug = pathOptions.slug.normalize('NFC'); + } + // CASE: always fetch post entries // The filter can in theory contain a "%s" e.g. filter="primary_tag:%s" promises.push(processQuery(postQuery, pathOptions.slug, locals)); diff --git a/ghost/core/core/frontend/services/routing/controllers/channel.js b/ghost/core/core/frontend/services/routing/controllers/channel.js index 7ef3c8a1fdc..97efc67fb8e 100644 --- a/ghost/core/core/frontend/services/routing/controllers/channel.js +++ b/ghost/core/core/frontend/services/routing/controllers/channel.js @@ -1,7 +1,6 @@ const debug = require('@tryghost/debug')('services:routing:controllers:channel'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); -const security = require('@tryghost/security'); const themeEngine = require('../../theme-engine'); const dataService = require('../../data'); const renderer = require('../../rendering'); @@ -25,7 +24,7 @@ module.exports = function channelController(req, res, next) { const pathOptions = { page: req.params.page !== undefined ? req.params.page : 1, - slug: req.params.slug ? security.string.safe(req.params.slug) : undefined + slug: req.params.slug }; if (pathOptions.page) { diff --git a/ghost/core/core/frontend/services/routing/controllers/collection.js b/ghost/core/core/frontend/services/routing/controllers/collection.js index 565caa7cb36..0c1defd793d 100644 --- a/ghost/core/core/frontend/services/routing/controllers/collection.js +++ b/ghost/core/core/frontend/services/routing/controllers/collection.js @@ -2,7 +2,6 @@ const _ = require('lodash'); const debug = require('@tryghost/debug')('services:routing:controllers:collection'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); -const security = require('@tryghost/security'); const {routerManager} = require('../'); const themeEngine = require('../../theme-engine'); const renderer = require('../../rendering'); @@ -24,7 +23,7 @@ module.exports = function collectionController(req, res, next) { const pathOptions = { page: req.params.page !== undefined ? req.params.page : 1, - slug: req.params.slug ? security.string.safe(req.params.slug) : undefined + slug: req.params.slug }; if (pathOptions.page) { diff --git a/ghost/core/core/frontend/services/routing/controllers/rss.js b/ghost/core/core/frontend/services/routing/controllers/rss.js index 19761d68769..8a3b04c602a 100644 --- a/ghost/core/core/frontend/services/routing/controllers/rss.js +++ b/ghost/core/core/frontend/services/routing/controllers/rss.js @@ -1,7 +1,6 @@ const _ = require('lodash'); const debug = require('@tryghost/debug')('services:routing:controllers:rss'); const url = require('url'); -const security = require('@tryghost/security'); const settingsCache = require('../../../../shared/settings-cache'); const rssService = require('../../rss'); const renderer = require('../../rendering'); @@ -29,7 +28,7 @@ module.exports = function rssController(req, res, next) { const pathOptions = { page: 1, // required for fetchData - slug: req.params.slug ? security.string.safe(req.params.slug) : undefined + slug: req.params.slug }; // CASE: Ghost is using an rss cache - normalize the URL for use as a key diff --git a/ghost/core/core/server/api/endpoints/slugs.js b/ghost/core/core/server/api/endpoints/slugs.js index c2ddd12b326..1d3811bcc4d 100644 --- a/ghost/core/core/server/api/endpoints/slugs.js +++ b/ghost/core/core/server/api/endpoints/slugs.js @@ -22,7 +22,9 @@ const controller = { options: [ 'include', 'type', - 'id' + 'id', + 'unicodeSlugs', + 'slugSeparator' ], data: [ 'name' @@ -36,6 +38,12 @@ const controller = { }, id: { required: false + }, + unicodeSlugs: { + required: false + }, + slugSeparator: { + required: false } }, data: { @@ -45,7 +53,7 @@ const controller = { } }, async query(frame) { - const slug = await models.Base.Model.generateSlug(allowedTypes[frame.options.type], frame.data.name, {status: 'all', modelId: frame.options.id}); + const slug = await models.Base.Model.generateSlug(allowedTypes[frame.options.type], frame.data.name, {status: 'all', modelId: frame.options.id, unicodeSlugs: frame.options.unicodeSlugs, slugSeparator: frame.options.slugSeparator}); if (!slug) { throw new errors.InternalServerError({ message: tpl(messages.couldNotGenerateSlug) diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js b/ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js index 666c61db64e..a22cfaeac63 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/input/settings.js @@ -88,6 +88,7 @@ const EDITABLE_SETTINGS = [ 'explore_ping', 'explore_ping_growth', 'indexnow_api_key', + 'slug_separator', 'transistor', 'transistor_portal_enabled', 'transistor_portal_heading', diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js b/ghost/core/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js index 018bd2fcee6..17e653573fb 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/input/utils/settings-key-group-mapper.js @@ -40,6 +40,7 @@ const keyGroupMapping = { twitter_image: 'site', twitter_title: 'site', twitter_description: 'site', + slug_separator: 'site', active_theme: 'theme', is_private: 'private', password: 'private', diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js b/ghost/core/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js index d3dec36d9f0..be6739fc93b 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/input/utils/settings-key-type-mapper.js @@ -32,6 +32,7 @@ const keyTypeMapping = { twitter_image: 'string', twitter_title: 'string', twitter_description: 'string', + slug_separator: 'string', active_theme: 'string', password: 'string', public_hash: 'string', diff --git a/ghost/core/core/server/data/exporter/export-filename.js b/ghost/core/core/server/data/exporter/export-filename.js index 7ba248bf8d0..b001c8e6d56 100644 --- a/ghost/core/core/server/data/exporter/export-filename.js +++ b/ghost/core/core/server/data/exporter/export-filename.js @@ -1,7 +1,7 @@ const _ = require('lodash'); const logging = require('@tryghost/logging'); const errors = require('@tryghost/errors'); -const security = require('@tryghost/security'); +const {slugify} = require('@tryghost/string'); const models = require('../../models'); const path = require('path'); @@ -22,7 +22,7 @@ const exportFileName = async function exportFileName(options) { const settingsTitle = await models.Settings.findOne({key: 'title'}, _.merge({}, modelOptions, _.pick(options, 'transacting'))); if (settingsTitle) { - title = security.string.safe(settingsTitle.get('value')) + '.'; + title = slugify(settingsTitle.get('value')) + '.'; } return title + 'ghost.' + datetime + '.json'; diff --git a/ghost/core/core/server/data/schema/default-settings/default-settings.json b/ghost/core/core/server/data/schema/default-settings/default-settings.json index 1104908fce6..fbd187b7959 100644 --- a/ghost/core/core/server/data/schema/default-settings/default-settings.json +++ b/ghost/core/core/server/data/schema/default-settings/default-settings.json @@ -265,6 +265,15 @@ } }, "type": "string" + }, + "slug_separator": { + "defaultValue": "-", + "validations": { + "isLength": { + "max": 1 + } + }, + "type": "string" } }, "theme": { diff --git a/ghost/core/core/server/models/base/plugins/generate-slug.js b/ghost/core/core/server/models/base/plugins/generate-slug.js index f56bc6f2eb3..d2d698782f9 100644 --- a/ghost/core/core/server/models/base/plugins/generate-slug.js +++ b/ghost/core/core/server/models/base/plugins/generate-slug.js @@ -1,5 +1,5 @@ const _ = require('lodash'); -const security = require('@tryghost/security'); +const {slugify} = require('@tryghost/string'); const urlUtils = require('../../../../shared/url-utils').default; @@ -70,7 +70,7 @@ module.exports = function (Bookshelf) { }); }; - slug = security.string.safe(base, options); + slug = slugify(base, { requiredChangesOnly: options.importing, unicodeSlugs: options.unicodeSlugs, slugSeparator: options.slugSeparator }); // 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 @@ -125,4 +125,6 @@ module.exports = function (Bookshelf) { * @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) + * @property {boolean} [unicodeSlugs] Don't perform optional transliteration, e.g. keep smörgåsbord as it is instead of turning it into smorgasbord + * @property {string} [slugSeparator] Separator to be used for the slugs, can be ` `, `_` or `-`, defaults to `-` */ diff --git a/ghost/core/core/server/models/post.js b/ghost/core/core/server/models/post.js index e59db48fedc..810e24f4408 100644 --- a/ghost/core/core/server/models/post.js +++ b/ghost/core/core/server/models/post.js @@ -10,6 +10,7 @@ const htmlToPlaintext = require('@tryghost/html-to-plaintext'); const ghostBookshelf = require('./base'); const config = require('../../shared/config'); const settingsCache = require('../../shared/settings-cache'); +const labs = require('../../shared/labs'); const limitService = require('../services/limits'); const mobiledocLib = require('../lib/mobiledoc'); const lexicalLib = require('../lib/lexical'); @@ -552,6 +553,7 @@ Post = ghostBookshelf.Model.extend({ const publishedAt = this.get('published_at'); const publishedAtHasChanged = this.hasDateChanged('published_at', {beforeWrite: true}); const generatedFields = ['html', 'plaintext']; + const slugSeparator = settingsCache.get('slug_separator'); let tagsToSave; const ops = []; @@ -627,7 +629,7 @@ Post = ghostBookshelf.Model.extend({ tag.slug = await ghostBookshelf.Model.generateSlug( Tag, tag.slug, - {skipDuplicateChecks: true} + {skipDuplicateChecks: true, unicodeSlugs: labs.isSet('unicodeSlugs'), slugSeparator: (labs.isSet('unicodeSlugs') ? slugSeparator : undefined)} ); } @@ -846,14 +848,16 @@ Post = ghostBookshelf.Model.extend({ ops.push(function updateSlug() { // Pass the new slug through the generator to strip illegal characters, detect duplicates return ghostBookshelf.Model.generateSlug(Post, self.get('title'), - {status: 'all', transacting: options.transacting, importing: options.importing}) + {status: 'all', transacting: options.transacting, importing: options.importing, unicodeSlugs: labs.isSet('unicodeSlugs'), slugSeparator: (labs.isSet('unicodeSlugs') ? slugSeparator : undefined)}) .then(function then(slug) { // After the new slug is found, do another generate for the old title to compare it to the old slug return ghostBookshelf.Model.generateSlug(Post, prevTitle, - {status: 'all', transacting: options.transacting, importing: options.importing} + {status: 'all', transacting: options.transacting, importing: options.importing, unicodeSlugs: labs.isSet('unicodeSlugs'), slugSeparator: (labs.isSet('unicodeSlugs') ? slugSeparator : undefined)} ).then(function prevTitleSlugGenerated(prevTitleSlug) { // If the old slug is the same as the slug that was generated from the old title - // then set a new slug. If it is not the same, means was set by the user + // then set a new slug. If it is not the same, means was set by the user or that + // it was created with older or different options for the slug generator. In + // these cases, the old slug should be kept. if (prevTitleSlug === prevSlug) { self.set({slug: slug}); } @@ -866,7 +870,7 @@ Post = ghostBookshelf.Model.extend({ if (self.hasChanged('slug') || !self.get('slug')) { // Pass the new slug through the generator to strip illegal characters, detect duplicates return ghostBookshelf.Model.generateSlug(Post, self.get('slug') || self.get('title'), - {status: 'all', transacting: options.transacting, importing: options.importing}) + {status: 'all', transacting: options.transacting, importing: options.importing, unicodeSlugs: labs.isSet('unicodeSlugs'), slugSeparator: (labs.isSet('unicodeSlugs') ? slugSeparator : undefined)}) .then(function then(slug) { self.set({slug: slug}); }); diff --git a/ghost/core/core/server/models/tag.js b/ghost/core/core/server/models/tag.js index 31177ad0a80..6b4c6a6f44c 100644 --- a/ghost/core/core/server/models/tag.js +++ b/ghost/core/core/server/models/tag.js @@ -2,6 +2,8 @@ const ghostBookshelf = require('./base'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); const urlUtils = require('../../shared/url-utils').default; +const settingsCache = require('../../shared/settings-cache'); +const labs = require('../../shared/labs'); const messages = { tagNotFound: 'Tag not found.' @@ -109,6 +111,8 @@ Tag = ghostBookshelf.Model.extend({ ghostBookshelf.Model.prototype.onSaving.apply(this, arguments); + const slugSeparator = settingsCache.get('slug_separator'); + // Support tag creation with `posts: [{..., tags: [{slug: 'new'}]}]` // In that situation we have a slug but no name so validation will fail // unless we set one automatically. Re-using slug for name matches our @@ -125,7 +129,11 @@ Tag = ghostBookshelf.Model.extend({ if (this.hasChanged('slug') || (!this.get('slug') && this.get('name'))) { // Pass the new slug through the generator to strip illegal characters, detect duplicates return ghostBookshelf.Model.generateSlug(Tag, this.get('slug') || this.get('name'), - {transacting: options.transacting}) + { + transacting: options.transacting, + unicodeSlugs: labs.isSet('unicodeSlugs'), + slugSeparator: (labs.isSet('unicodeSlugs') ? slugSeparator : undefined) + }) .then(function then(slug) { self.set({slug: slug}); }); diff --git a/ghost/core/core/server/models/user.js b/ghost/core/core/server/models/user.js index e97d222bc91..c3fe4ab95ec 100644 --- a/ghost/core/core/server/models/user.js +++ b/ghost/core/core/server/models/user.js @@ -2,6 +2,8 @@ const validator = require('@tryghost/validator'); const ObjectId = require('bson-objectid').default; const ghostBookshelf = require('./base'); const baseUtils = require('./base/utils'); +const settingsCache = require('../../shared/settings-cache'); +const labs = require('../../shared/labs'); const limitService = require('../services/limits'); const tpl = require('@tryghost/tpl'); const errors = require('@tryghost/errors'); @@ -225,6 +227,8 @@ User = ghostBookshelf.Model.extend({ ghostBookshelf.Model.prototype.onSaving.apply(this, arguments); + const slugSeparator = settingsCache.get('slug_separator'); + /** * Bookshelf call order: * - onSaving @@ -264,7 +268,9 @@ User = ghostBookshelf.Model.extend({ { status: 'all', transacting: options.transacting, - shortSlug: !self.get('slug') + shortSlug: !self.get('slug'), + unicodeSlugs: labs.isSet('unicodeSlugs'), + slugSeparator: (labs.isSet('unicodeSlugs') ? slugSeparator : undefined) }) .then(function then(slug) { self.set({slug: slug}); diff --git a/ghost/core/core/shared/labs.js b/ghost/core/core/shared/labs.js index 50d03af3d65..51602d2721a 100644 --- a/ghost/core/core/shared/labs.js +++ b/ghost/core/core/shared/labs.js @@ -41,6 +41,7 @@ const GA_FEATURES = [ const PUBLIC_BETA_FEATURES = [ 'superEditors', 'editorExcerpt', + 'unicodeSlugs', 'additionalPaymentMethods' ]; diff --git a/ghost/core/test/unit/frontend/services/data/entry-lookup.test.js b/ghost/core/test/unit/frontend/services/data/entry-lookup.test.js index 312cd6dd773..3fc869f8365 100644 --- a/ghost/core/test/unit/frontend/services/data/entry-lookup.test.js +++ b/ghost/core/test/unit/frontend/services/data/entry-lookup.test.js @@ -196,6 +196,33 @@ describe('Unit - frontend/data/entry-lookup', function () { assert.equal(lookup.isEditURL, false); }); }); + + it('can access unicode based slugs, even if NFD normalization is used', function () { + const sectionRouterOptions = { + permalinks: '/articles/:section-:slug/:options(edit)?/', + query: {controller: 'posts', resource: 'posts'} + }; + + const nfcNormalizedSlug = 'sample-smörgåstårta-スモルゴストータ'.normalize('NFC'); + const nfdNormalizedSlug = 'sample-smörgåstårta-スモルゴストータ'.normalize('NFD'); + assert.notEqual(nfcNormalizedSlug, nfdNormalizedSlug); + + postsReadStub.resolves({ + posts: [ + testUtils.DataGenerator.forKnex.createPost({ + url: '/articles/news-' + nfcNormalizedSlug + '/', + slug: nfcNormalizedSlug + }) + ] + }); + + return data.entryLookup('http://127.0.0.1:2369/articles/news-' + nfdNormalizedSlug + '/', sectionRouterOptions, locals).then(function (lookup) { + sinon.assert.calledOnce(postsReadStub); + assert.strictEqual(postsReadStub.firstCall.args[0].slug, nfcNormalizedSlug); + assertExists(lookup.entry); + assert.equal(lookup.isEditURL, false); + }); + }); }); describe('permalink param matching', function () {