Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

### Adds

* Adds `stripUrlAccents` option in `@apostrophecms/i18n` module to globally control whether accents are stripped from URLs. When set to `true`, all URLs (slugs) will have accents from Latin characters removed on document creation and updates. No existing documents are modified automatically; this only affects new or updated documents. A new task `node app @apostrophecms/i18n:strip-slug-accents` is provided to update existing document slugs and attachment `name` props in the database when needed.
* Translation strings added for the layout- and layout-column-widgets.

### Changes
Expand Down
12 changes: 10 additions & 2 deletions modules/@apostrophecms/doc/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,11 @@ module.exports = {
'@apostrophecms/doc-type:beforePublish': {
testPermissions(req, info) {
if (info.options.permissions !== false) {
if (!self.apos.permission.can(req, info.options.autopublishing ? 'edit' : 'publish', info.draft)) {
if (!self.apos.permission.can(
req,
info.options.autopublishing ? 'edit' : 'publish',
info.draft
)) {
throw self.apos.error('forbidden');
}
}
Expand All @@ -217,7 +221,11 @@ module.exports = {
manager.ensureSlug(doc);
_.each(manager.schema, function (field) {
if (field.sortify) {
doc[field.name + 'Sortified'] = self.apos.util.sortify(doc[field.name] ? doc[field.name] : '');
doc[field.name + 'Sortified'] = self.apos.util.sortify(
doc[field.name]
? doc[field.name]
: ''
);
}
});
if (options.setUpdatedAtAndBy !== false) {
Expand Down
60 changes: 57 additions & 3 deletions modules/@apostrophecms/i18n/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ module.exports = {
i18n: {
ns: 'apostrophe',
browser: true
}
},
// If true, slugifying will strip accents from Latin characters
stripUrlAccents: false
},
async init(self) {
self.defaultNamespace = 'default';
Expand Down Expand Up @@ -677,7 +679,8 @@ module.exports = {
debug: self.debug,
show: self.show,
action: self.action,
crossDomainClipboard: req.session && req.session.aposCrossDomainClipboard
crossDomainClipboard: req.session && req.session.aposCrossDomainClipboard,
stripUrlAccents: self.options.stripUrlAccents
};
if (req.session && req.session.aposCrossDomainClipboard) {
req.session.aposCrossDomainClipboard = null;
Expand Down Expand Up @@ -734,6 +737,9 @@ module.exports = {
}
return locale;
},
shouldStripAccents(req) {
return self.options.stripUrlAccents === true;
},
addLocalizeModal() {
self.apos.modal.add(
`${self.__meta.name}:localize`,
Expand All @@ -757,7 +763,7 @@ module.exports = {
}
req.baseUrlWithPrefix = `${req.baseUrl}${self.apos.prefix}`;
req.absoluteUrl = req.baseUrlWithPrefix + req.url;
req.prefix = `${req.baseUrlWithPrefix}${self.locales[req.locale].prefix || ''}`;
req.prefix = `${req.baseUrlWithPrefix}${self.locales[req.locale]?.prefix || ''}`;
if (!req.baseUrl) {
// Always set for bc, but in the absence of locale hostnames we
// set it later so it is not part of req.prefix
Expand Down Expand Up @@ -1251,6 +1257,54 @@ module.exports = {
console.log(`Due to conflicts, kept ${kept} documents from ${keep}`);
}
}
},
'strip-slug-accents': {
usage: 'Remove Latin accent characters from all document slugs and attachment names. Usage: node app @apostrophecms/i18n:strip-slug-accents',
async task() {
let docChanged = 0;
let attachmentChanged = 0;

await self.apos.migration.eachDoc({}, 5, async doc => {
const slug = doc.slug;
const req = self.apos.task.getAdminReq({
locale: doc.aposLocale?.split(':')[0] || self.defaultLocale
});
if (!self.shouldStripAccents(req)) {
return;
}

doc.slug = _.deburr(doc.slug);
if (slug !== doc.slug) {
const manager = self.apos.doc.getManager(doc.type);
if (!manager) {
return;
}
await manager.update(req, doc, { permissions: false });
docChanged++;
self.apos.util.log(`Updated doc [${req.locale}] "${slug}" -> "${doc.slug}"`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During my testing I saw this

Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-13"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-09"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-06"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-13"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-06"
Updated doc [en] "/@copy-of-copy-of-copy-of-template-02" -> "/@template-05"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-11"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-09"
Updated doc [en] "/@copy-of-copy-of-template-02" -> "/@template-04"
Updated doc [en] "/@copy-of-copy-of-copy-of-template-02" -> "/@template-05"
Updated doc [en] "/@copy-of-template-02" -> "/@template-03"
Updated doc [en] "/@copy-of-copy-of-template-02" -> "/@template-04"
Updated doc [en] "/@template-02" -> "/@template-02"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-11"
Updated doc [en] "/@template-02" -> "/@template-02"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-10"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-10"
Updated doc [en] "@template-article-03" -> "@template-article-03"
Updated doc [en] "/@copy-of-template-02" -> "/@template-03"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-12"
Updated doc [fr] "c-est-l-été-déjà" -> "c-est-l-ete-deja"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-12"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-07"
Updated doc [fr] "c-est-l-été-déjà" -> "c-est-l-ete-deja"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-07"
Updated doc [en] "@template-article-03" -> "@template-article-03"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-08"
Updated doc [en] "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-08"
Updated 28 document slug(s) and 0 attachment name(s).

There are too many changes I think. Something seems off.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely off. This is very very off "/@copy-of-copy-of-copy-of-copy-of-copy-of-copy-of-template-02" -> "/@template-08" (completely different slugs). I'll be investigating

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to deburr instead of slugify. Could you please retest with the DB that showed those results?

}
});

const req = self.apos.task.getAdminReq();
await self.apos.attachment.each({}, 10, async (attachment) => {
if (!self.shouldStripAccents(req)) {
return;
}
const slug = _.deburr(attachment.name);
if (slug !== attachment.name) {
await self.apos.attachment.db.updateOne(
{ _id: attachment._id },
{ $set: { name: slug } }
);
attachmentChanged++;
self.apos.util.log(`Updated attachment "${attachment.name}" -> "${slug}"`);
}
});

self.apos.util.log(
`Updated ${docChanged} document slug(s) and ${attachmentChanged} attachment name(s).`
);
}
}
};
}
Expand Down
7 changes: 7 additions & 0 deletions modules/@apostrophecms/schema/ui/apos/logic/AposInputSlug.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// errors.
import { klona } from 'klona';
import sluggo from 'sluggo';
import { deburr } from 'lodash';
import AposInputMixin from 'Modules/@apostrophecms/schema/mixins/AposInputMixin';
import { debounceAsync } from 'Modules/@apostrophecms/ui/utils';

Expand Down Expand Up @@ -48,6 +49,9 @@ export default {
},
localePrefix() {
return this.field.page && apos.i18n.locales[apos.i18n.locale].prefix;
},
stripAccents() {
return apos.i18n.stripUrlAccents === true;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having this here will make it difficult to have this feature enabled/disabled per locales.

Copy link
Contributor Author

@myovchev myovchev Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one of the main showstoppers for per locale feature, which we don't implement with the current PR.

}
},
watch: {
Expand Down Expand Up @@ -192,6 +196,9 @@ export default {
}

let slug = sluggo(s, options);
if (this.stripAccents) {
slug = deburr(slug);
}
if (preserveDash) {
slug += '-';
}
Expand Down
15 changes: 13 additions & 2 deletions modules/@apostrophecms/util/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -291,8 +291,18 @@ module.exports = {
// ONE punctuation character normally forbidden in slugs may
// optionally be permitted by specifying it via options.allow.
// The separator may be changed via options.separator.
// By default, the i18n.options.stripUrlAccents option is honored;
// having stripAccents passed as an option takes precedence.
slugify(s, options) {
return require('sluggo')(s, options);
const { stripAccents, ...opts } = options || {};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new option is a challenge because we need to update all the usages of the slugify method, in our modules but our clients needs to do the same. There is a default value, but this is some kind of a breaking change, mainly because it requires changes to the code for the behavior to remain consistent.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The accent cleaning behavior can't break BC because it's a new feature. This code is fully BC.

const slug = require('sluggo')(s, opts);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. We should put that require statement at the top.
  2. Is the order of operations important here? slugify then strip accents vs strip accents then slugify.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving the require is out of context for this PR. (Many other utils does it).
The current order of operation makes sense. You don't want to change the behavior of sluggo with pre-filtering the input, but to filter the output of the sluggo.

const shouldStripAccents = (typeof stripAccents !== 'undefined')
? stripAccents
: self.apos.i18n.options.stripUrlAccents;
if (shouldStripAccents) {
return _.deburr(slug);
}
return slug;
},
// Returns a string that, when used for indexes, behaves
// similarly to MySQL's default behavior for sorting, plus a little
Expand Down Expand Up @@ -938,7 +948,8 @@ module.exports = {
// ONE punctuation character normally forbidden in slugs may
// optionally be permitted by specifying it via options.allow.
// The separator may be changed via options.separator.

// By default, the i18n.options.stripUrlAccents option is honored;
// having stripAccents passed as an option takes precedence.
slugify: function(string, options) {
return self.slugify(string, options);
},
Expand Down
121 changes: 121 additions & 0 deletions test/docs.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ describe('Docs', function() {

afterEach(async function () {
await apos.doc.db.deleteMany({ type: 'test-people' });
await apos.doc.db.deleteMany({ type: 'test-page' });
await apos.lock.db.deleteMany({});
});

Expand Down Expand Up @@ -1036,6 +1037,126 @@ describe('Docs', function() {
assert(timestamps.doc === timestamps.expected);
});

it('should preserve latin accents by default (piece)', async function () {
const req = apos.task.getReq();
const object = {
title: 'C\'est déjà l\'été',
visibility: 'public',
type: 'test-people',
firstName: 'Janis',
lastName: 'Joplin',
age: 27,
alive: false,
updatedAt: '2018-08-29T12:57:03.685Z',
cacheInvalidatedAt: '2019-08-29T12:57:03.685Z'
};

await apos.doc.insert(req, object);

const doc = await apos.doc.db.findOne({ title: 'C\'est déjà l\'été' });
assert.equal(doc.slug, 'c-est-déjà-l-été');
});

it('should remove latin accents when configured to do so (piece)', async function () {
const req = apos.task.getReq();
const object = {
title: 'C\'est déjà l\'été',
visibility: 'public',
type: 'test-people',
firstName: 'Janis',
lastName: 'Joplin',
age: 27,
alive: false,
updatedAt: '2018-08-29T12:57:03.685Z',
cacheInvalidatedAt: '2019-08-29T12:57:03.685Z'
};
const originalSetting = apos.i18n.options.stripUrlAccents;
apos.i18n.options.stripUrlAccents = true;

await apos.doc.insert(req, object);

const doc = await apos.doc.db.findOne({ title: 'C\'est déjà l\'été' });
apos.i18n.options.stripUrlAccents = originalSetting;

assert.equal(doc.slug, 'c-est-deja-l-ete');
});

it('should remove latin accents when converting schema fields (piece)', async function () {
const req = apos.task.getReq();
const input = {
title: 'C\'est déjà l\'été',
slug: 'c-est-déjà-l-été',
visibility: 'public',
type: 'test-people',
firstName: 'Janis',
lastName: 'Joplin',
age: 27,
alive: false,
updatedAt: '2018-08-29T12:57:03.685Z',
cacheInvalidatedAt: '2019-08-29T12:57:03.685Z'
};
const originalSetting = apos.i18n.options.stripUrlAccents;
apos.i18n.options.stripUrlAccents = true;

const manager = apos.doc.getManager('test-people');
const page = manager.newInstance();
await manager.convert(req, input, page);
apos.i18n.options.stripUrlAccents = originalSetting;

assert.equal(page.slug, 'c-est-deja-l-ete');
});

it('should preserve latin accents by default (page)', async function () {
const req = apos.task.getReq();
const object = {
title: 'C\'est déjà l\'été',
visibility: 'public',
type: 'test-page'
};

await apos.doc.insert(req, object);

const doc = await apos.doc.db.findOne({ title: 'C\'est déjà l\'été' });
assert.equal(doc.slug, '/c-est-déjà-l-été');
});

it('should remove latin accents when configured to do so (page)', async function () {
const req = apos.task.getReq();
const object = {
title: 'C\'est déjà l\'été',
visibility: 'public',
type: 'test-page'
};
const originalSetting = apos.i18n.options.stripUrlAccents;
apos.i18n.options.stripUrlAccents = true;

await apos.doc.insert(req, object);

const doc = await apos.doc.db.findOne({ title: 'C\'est déjà l\'été' });
apos.i18n.options.stripUrlAccents = originalSetting;

assert.equal(doc.slug, '/c-est-deja-l-ete');
});

it('should remove latin accents when converting schema fields (page)', async function () {
const req = apos.task.getReq();
const input = {
title: 'C\'est déjà l\'été',
slug: '/c-est-déjà-l-été',
visibility: 'public',
type: 'test-page'
};
const originalSetting = apos.i18n.options.stripUrlAccents;
apos.i18n.options.stripUrlAccents = true;

const manager = apos.doc.getManager('test-page');
const page = manager.newInstance();
await manager.convert(req, input, page);
apos.i18n.options.stripUrlAccents = originalSetting;

assert.equal(page.slug, '/c-est-deja-l-ete');
});

/// ///
// CACHING
/// ///
Expand Down
Loading