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 () {