apostrophe 4.24.0 → 4.24.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.24.1 (2025-12-04)
|
|
4
|
+
|
|
5
|
+
### Fixes
|
|
6
|
+
|
|
7
|
+
* Fixes soft-redirect module to decode the URL path before matching historic URLs, ensuring proper matching of accents, Cyrillic, and other non-ASCII characters in old URLs. While this was not a new issue, the fix is of new importance now that a migration path to eliminate accent marks in existing slugs has been introduced in version 4.24.0.
|
|
8
|
+
|
|
3
9
|
## 4.24.0 (2025-11-25)
|
|
4
10
|
|
|
5
11
|
### Adds
|
|
@@ -1262,32 +1262,59 @@ module.exports = {
|
|
|
1262
1262
|
usage: 'Remove Latin accent characters from all document slugs. Usage: node app @apostrophecms/i18n:strip-slug-accents',
|
|
1263
1263
|
async task() {
|
|
1264
1264
|
let docChanged = 0;
|
|
1265
|
+
const docErrors = [];
|
|
1266
|
+
if (!self.shouldStripAccents()) {
|
|
1267
|
+
self.apos.util.log('The option `stripUrlAccents` is not enabled. Aborting.');
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1265
1270
|
|
|
1266
1271
|
await self.apos.migration.eachDoc({}, 5, async doc => {
|
|
1267
|
-
const
|
|
1268
|
-
const
|
|
1269
|
-
locale: doc.aposLocale?.split(':')[0] || self.defaultLocale
|
|
1270
|
-
});
|
|
1271
|
-
if (!self.shouldStripAccents()) {
|
|
1272
|
-
return;
|
|
1273
|
-
}
|
|
1272
|
+
const oldSlug = doc.slug;
|
|
1273
|
+
const newSlug = _.deburr(doc.slug);
|
|
1274
1274
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1275
|
+
try {
|
|
1276
|
+
if (oldSlug === newSlug) {
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
await self.apos.doc.db.updateOne(
|
|
1280
|
+
{ _id: doc._id },
|
|
1281
|
+
{ $set: { slug: newSlug } }
|
|
1282
|
+
);
|
|
1283
|
+
docChanged++;
|
|
1284
|
+
self.apos.util.log(`[${doc.type}] [${doc.aposLocale}] "${oldSlug}" -> "${newSlug}"`);
|
|
1285
|
+
} catch (e) {
|
|
1286
|
+
const isUniqueIndexError = (e && e.code === 11000) || /E11000/.test(e?.message || '');
|
|
1287
|
+
const message = isUniqueIndexError
|
|
1288
|
+
? `[ERROR] Duplicate slug "${newSlug}"`
|
|
1289
|
+
: `[ERROR] Failed "${newSlug}" slug update: ${e.message}`;
|
|
1290
|
+
docErrors.push({
|
|
1291
|
+
message,
|
|
1292
|
+
data: {
|
|
1293
|
+
docId: doc._id,
|
|
1294
|
+
oldSlug,
|
|
1295
|
+
newSlug
|
|
1296
|
+
},
|
|
1297
|
+
stack: isUniqueIndexError
|
|
1298
|
+
? ''
|
|
1299
|
+
: e.stack.split('\n').slice(1).map(line => line.trim())
|
|
1300
|
+
});
|
|
1282
1301
|
}
|
|
1283
|
-
await manager.update(req, doc, { permissions: false });
|
|
1284
|
-
docChanged++;
|
|
1285
|
-
self.apos.util.log(`Updated doc [${req.locale}] "${slug}" -> "${doc.slug}"`);
|
|
1286
1302
|
});
|
|
1287
1303
|
|
|
1304
|
+
if (docErrors.length) {
|
|
1305
|
+
for (const err of docErrors) {
|
|
1306
|
+
self.apos.util.error(err.message, err.data, err.stack);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1288
1310
|
self.apos.util.log(
|
|
1289
|
-
|
|
1311
|
+
`${docChanged} updated, ${docErrors.length} failed.`
|
|
1290
1312
|
);
|
|
1313
|
+
|
|
1314
|
+
// Ensure proper exit code for the task runner
|
|
1315
|
+
if (docErrors.length) {
|
|
1316
|
+
throw new Error('Some documents failed to update their slugs.');
|
|
1317
|
+
}
|
|
1291
1318
|
}
|
|
1292
1319
|
}
|
|
1293
1320
|
};
|
|
@@ -40,7 +40,12 @@ module.exports = {
|
|
|
40
40
|
return {
|
|
41
41
|
'@apostrophecms/page:notFound': {
|
|
42
42
|
async notFoundRedirect(req) {
|
|
43
|
-
|
|
43
|
+
// Save the raw, encoded path, no change here, only the var name
|
|
44
|
+
const rawPathname = parseurl.original(req).pathname;
|
|
45
|
+
// URL decode the pathname for matching historic URLs
|
|
46
|
+
// so that e.g. accents, Cyrillic, and other non-ASCII characters
|
|
47
|
+
// are properly handled.
|
|
48
|
+
const urlPathname = decodeURI(rawPathname);
|
|
44
49
|
|
|
45
50
|
const doc = await self.apos.doc
|
|
46
51
|
.find(req, {
|
package/package.json
CHANGED
package/test/i18n.js
CHANGED
|
@@ -289,7 +289,7 @@ describe('static i18n', function() {
|
|
|
289
289
|
assert.strictEqual(browserData.adminLocale, 'fr');
|
|
290
290
|
});
|
|
291
291
|
|
|
292
|
-
it('should replace accented characters in slugs
|
|
292
|
+
it('should replace accented characters in slugs when configured', async function () {
|
|
293
293
|
await t.destroy(apos);
|
|
294
294
|
apos = await t.create({
|
|
295
295
|
root: module,
|
|
@@ -313,6 +313,14 @@ describe('static i18n', function() {
|
|
|
313
313
|
}
|
|
314
314
|
}
|
|
315
315
|
});
|
|
316
|
+
await apos.doc.db.deleteMany({
|
|
317
|
+
type: {
|
|
318
|
+
$in: [
|
|
319
|
+
'default-page',
|
|
320
|
+
'test-piece'
|
|
321
|
+
]
|
|
322
|
+
}
|
|
323
|
+
});
|
|
316
324
|
// Create content while accents are NOT stripped so we have accented slugs
|
|
317
325
|
apos.i18n.options.stripUrlAccents = false;
|
|
318
326
|
|
|
@@ -325,15 +333,6 @@ describe('static i18n', function() {
|
|
|
325
333
|
title: 'C\'est déjà l\'été'
|
|
326
334
|
});
|
|
327
335
|
|
|
328
|
-
// Also add a page that already uses the non-accented slug to verify
|
|
329
|
-
// that the accent-stripping task de-duplicates slugs appropriately
|
|
330
|
-
const nonAccentedExisting = await apos.doc.insert(req, {
|
|
331
|
-
type: 'default-page',
|
|
332
|
-
visibility: 'public',
|
|
333
|
-
title: 'C\'est deja l\'ete',
|
|
334
|
-
slug: '/c-est-deja-l-ete'
|
|
335
|
-
});
|
|
336
|
-
|
|
337
336
|
// Add a piece with accented characters in its title so slug preserves accents
|
|
338
337
|
await apos.doc.insert(req, {
|
|
339
338
|
type: 'test-piece',
|
|
@@ -349,12 +348,6 @@ describe('static i18n', function() {
|
|
|
349
348
|
assert(pageBefore);
|
|
350
349
|
assert.equal(pageBefore.slug, '/c-est-déjà-l-été');
|
|
351
350
|
|
|
352
|
-
const nonAccentedPageBefore = await apos.doc.db.findOne({
|
|
353
|
-
_id: nonAccentedExisting._id
|
|
354
|
-
});
|
|
355
|
-
assert(nonAccentedPageBefore);
|
|
356
|
-
assert.equal(nonAccentedPageBefore.slug, '/c-est-deja-l-ete');
|
|
357
|
-
|
|
358
351
|
const pieceBefore = await apos.doc.db.findOne({
|
|
359
352
|
type: 'test-piece',
|
|
360
353
|
title: 'Café au lait'
|
|
@@ -369,18 +362,7 @@ describe('static i18n', function() {
|
|
|
369
362
|
// Verify that the slugs and attachment names have been updated correctly
|
|
370
363
|
const pageAfter = await apos.doc.db.findOne({ _id: pageBefore._id });
|
|
371
364
|
assert(pageAfter);
|
|
372
|
-
|
|
373
|
-
// existing non-accented page. Ensure de-duplication added a suffix.
|
|
374
|
-
assert(pageAfter.slug.startsWith('/c-est-deja-l-ete'));
|
|
375
|
-
assert.notEqual(pageAfter.slug, '/c-est-deja-l-ete');
|
|
376
|
-
assert(/\/c-est-deja-l-ete\d+$/.test(pageAfter.slug));
|
|
377
|
-
|
|
378
|
-
// Verify the pre-existing non-accented page kept its slug unchanged
|
|
379
|
-
const nonAccentedPageAfter = await apos.doc.db.findOne({
|
|
380
|
-
_id: nonAccentedPageBefore._id
|
|
381
|
-
});
|
|
382
|
-
assert(nonAccentedPageAfter);
|
|
383
|
-
assert.equal(nonAccentedPageAfter.slug, '/c-est-deja-l-ete');
|
|
365
|
+
assert.equal(pageAfter.slug, '/c-est-deja-l-ete');
|
|
384
366
|
|
|
385
367
|
const pieceAfter = await apos.doc.db.findOne({ _id: pieceBefore._id });
|
|
386
368
|
assert(pieceAfter);
|
|
@@ -389,6 +371,194 @@ describe('static i18n', function() {
|
|
|
389
371
|
// Restore default for other tests
|
|
390
372
|
apos.i18n.options.stripUrlAccents = false;
|
|
391
373
|
});
|
|
374
|
+
|
|
375
|
+
it('should report duplicated slug errors', async function () {
|
|
376
|
+
await t.destroy(apos);
|
|
377
|
+
apos = await t.create({
|
|
378
|
+
root: module,
|
|
379
|
+
modules: {
|
|
380
|
+
'@apostrophecms/i18n': {
|
|
381
|
+
options: {
|
|
382
|
+
stripUrlAccents: true,
|
|
383
|
+
locales: {
|
|
384
|
+
en: {},
|
|
385
|
+
fr: {
|
|
386
|
+
prefix: '/fr'
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
'default-page': {
|
|
392
|
+
extend: '@apostrophecms/page-type'
|
|
393
|
+
},
|
|
394
|
+
'test-piece': {
|
|
395
|
+
extend: '@apostrophecms/piece-type'
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
await apos.doc.db.deleteMany({
|
|
400
|
+
type: {
|
|
401
|
+
$in: [
|
|
402
|
+
'default-page',
|
|
403
|
+
'test-piece'
|
|
404
|
+
]
|
|
405
|
+
}
|
|
406
|
+
});
|
|
407
|
+
apos.i18n.options.stripUrlAccents = false;
|
|
408
|
+
const req = apos.task.getReq();
|
|
409
|
+
|
|
410
|
+
await apos.doc.insert(req, {
|
|
411
|
+
type: 'default-page',
|
|
412
|
+
visibility: 'public',
|
|
413
|
+
title: 'C\'est déjà l\'été'
|
|
414
|
+
});
|
|
415
|
+
const nonAccentedExisting = await apos.doc.insert(req, {
|
|
416
|
+
type: 'default-page',
|
|
417
|
+
visibility: 'public',
|
|
418
|
+
title: 'C\'est deja l\'ete',
|
|
419
|
+
slug: '/c-est-deja-l-ete'
|
|
420
|
+
});
|
|
421
|
+
const pageBefore = await apos.doc.db.findOne({
|
|
422
|
+
type: 'default-page',
|
|
423
|
+
title: 'C\'est déjà l\'été'
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
assert(pageBefore);
|
|
427
|
+
assert.equal(pageBefore.slug, '/c-est-déjà-l-été');
|
|
428
|
+
|
|
429
|
+
const nonAccentedPageBefore = await apos.doc.db.findOne({
|
|
430
|
+
_id: nonAccentedExisting._id
|
|
431
|
+
});
|
|
432
|
+
assert(nonAccentedPageBefore);
|
|
433
|
+
assert.equal(nonAccentedPageBefore.slug, '/c-est-deja-l-ete');
|
|
434
|
+
|
|
435
|
+
apos.i18n.options.stripUrlAccents = true;
|
|
436
|
+
await assert.rejects(
|
|
437
|
+
async () => {
|
|
438
|
+
await apos.task.invoke('@apostrophecms/i18n:strip-slug-accents');
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
message: 'Some documents failed to update their slugs.'
|
|
442
|
+
}
|
|
443
|
+
);
|
|
444
|
+
apos.i18n.options.stripUrlAccents = false;
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should redirect accent-preserving URLs to their stripped versions after running the accent task', async function () {
|
|
448
|
+
await t.destroy(apos);
|
|
449
|
+
apos = await t.create({
|
|
450
|
+
root: module,
|
|
451
|
+
modules: {
|
|
452
|
+
'@apostrophecms/i18n': {
|
|
453
|
+
options: {
|
|
454
|
+
stripUrlAccents: true,
|
|
455
|
+
locales: {
|
|
456
|
+
en: {},
|
|
457
|
+
fr: {
|
|
458
|
+
prefix: '/fr'
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
'default-page': {
|
|
464
|
+
extend: '@apostrophecms/page-type'
|
|
465
|
+
},
|
|
466
|
+
'test-piece': {
|
|
467
|
+
extend: '@apostrophecms/piece-type'
|
|
468
|
+
},
|
|
469
|
+
'test-piece-page': {
|
|
470
|
+
extend: '@apostrophecms/piece-page-type',
|
|
471
|
+
options: {
|
|
472
|
+
pieceType: 'test-piece'
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
await apos.doc.db.deleteMany({
|
|
478
|
+
type: {
|
|
479
|
+
$in: [
|
|
480
|
+
'default-page',
|
|
481
|
+
'test-piece',
|
|
482
|
+
'test-piece-page'
|
|
483
|
+
]
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
apos.i18n.options.stripUrlAccents = false;
|
|
488
|
+
|
|
489
|
+
const req = apos.task.getReq();
|
|
490
|
+
const jar = apos.http.jar();
|
|
491
|
+
const parentId = '_home';
|
|
492
|
+
|
|
493
|
+
const accentedPage = await apos.page.insert(req, parentId, 'lastChild', {
|
|
494
|
+
type: 'default-page',
|
|
495
|
+
visibility: 'public',
|
|
496
|
+
title: 'C\'est déjà l\'été'
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const pieceIndexPage = await apos.page.insert(req, parentId, 'lastChild', {
|
|
500
|
+
type: 'test-piece-page',
|
|
501
|
+
visibility: 'public',
|
|
502
|
+
title: 'Test Piece Page',
|
|
503
|
+
slug: '/test-piece'
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const piece = await apos.doc.insert(req, {
|
|
507
|
+
type: 'test-piece',
|
|
508
|
+
visibility: 'public',
|
|
509
|
+
title: 'Café au lait'
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const encodedOldPageUrl = encodeURI(accentedPage.slug);
|
|
513
|
+
const oldPieceSlug = piece.slug;
|
|
514
|
+
const pieceIndexSlug = pieceIndexPage.slug;
|
|
515
|
+
const encodedOldPieceUrl = encodeURI(`${pieceIndexSlug}/${oldPieceSlug}`);
|
|
516
|
+
|
|
517
|
+
// Visit the legacy URLs before stripping accents so historic
|
|
518
|
+
// redirects exist.
|
|
519
|
+
await apos.http.get(encodedOldPageUrl, {
|
|
520
|
+
followRedirect: false,
|
|
521
|
+
fullResponse: true,
|
|
522
|
+
redirect: 'manual',
|
|
523
|
+
jar
|
|
524
|
+
});
|
|
525
|
+
await apos.http.get(encodedOldPieceUrl, {
|
|
526
|
+
followRedirect: false,
|
|
527
|
+
fullResponse: true,
|
|
528
|
+
redirect: 'manual',
|
|
529
|
+
jar
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
apos.i18n.options.stripUrlAccents = true;
|
|
533
|
+
await apos.task.invoke('@apostrophecms/i18n:strip-slug-accents');
|
|
534
|
+
|
|
535
|
+
const updatedPage = await apos.doc.db.findOne({ _id: accentedPage._id });
|
|
536
|
+
const updatedPiece = await apos.doc.db.findOne({ _id: piece._id });
|
|
537
|
+
assert(updatedPage);
|
|
538
|
+
assert(updatedPiece);
|
|
539
|
+
|
|
540
|
+
const pageResponse = await apos.http.get(encodedOldPageUrl, {
|
|
541
|
+
followRedirect: false,
|
|
542
|
+
fullResponse: true,
|
|
543
|
+
redirect: 'manual',
|
|
544
|
+
jar
|
|
545
|
+
});
|
|
546
|
+
assert.strictEqual(pageResponse.status, 302);
|
|
547
|
+
assert.strictEqual(pageResponse.headers.location, `${apos.http.getBase()}${updatedPage.slug}`);
|
|
548
|
+
|
|
549
|
+
const pieceResponse = await apos.http.get(encodedOldPieceUrl, {
|
|
550
|
+
followRedirect: false,
|
|
551
|
+
fullResponse: true,
|
|
552
|
+
redirect: 'manual',
|
|
553
|
+
jar
|
|
554
|
+
});
|
|
555
|
+
assert.strictEqual(pieceResponse.status, 302);
|
|
556
|
+
assert.strictEqual(
|
|
557
|
+
pieceResponse.headers.location,
|
|
558
|
+
`${apos.http.getBase()}${pieceIndexSlug}/${updatedPiece.slug}`
|
|
559
|
+
);
|
|
560
|
+
apos.i18n.options.stripUrlAccents = false;
|
|
561
|
+
});
|
|
392
562
|
});
|
|
393
563
|
|
|
394
564
|
describe('private locales', function() {
|