From 1bdf1c0b2bbf990ea033be418efeda3d11ec8e63 Mon Sep 17 00:00:00 2001
From: Luffy <52o@qq52o.cn>
Date: Mon, 28 Jul 2025 13:39:23 +0800
Subject: [PATCH] fix: improve slug generation
---
src/core/render/compiler/heading.js | 8 +++---
src/core/render/slugify.js | 3 +-
src/plugins/search/search.js | 10 ++-----
test/unit/render-util.test.js | 44 ++++++++++++++++++++++++++---
4 files changed, 49 insertions(+), 16 deletions(-)
diff --git a/src/core/render/compiler/heading.js b/src/core/render/compiler/heading.js
index c00a75976..9ea804a64 100644
--- a/src/core/render/compiler/heading.js
+++ b/src/core/render/compiler/heading.js
@@ -7,9 +7,9 @@ import { slugify } from '../slugify.js';
import { stripUrlExceptId } from '../../router/util.js';
export const headingCompiler = ({ renderer, router, compiler }) =>
- (renderer.heading = function ({ tokens, depth }) {
- const text = this.parser.parseInline(tokens);
- let { str, config } = getAndRemoveConfig(text);
+ (renderer.heading = function ({ tokens, depth, text }) {
+ const parsedText = this.parser.parseInline(tokens);
+ let { str, config } = getAndRemoveConfig(parsedText);
const nextToc = { depth, title: str };
const { content, ignoreAllSubs, ignoreSubHeading } =
@@ -19,7 +19,7 @@ export const headingCompiler = ({ renderer, router, compiler }) =>
nextToc.title = removeAtag(str);
nextToc.ignoreAllSubs = ignoreAllSubs;
nextToc.ignoreSubHeading = ignoreSubHeading;
- const slug = slugify(config.id || str);
+ const slug = slugify(config.id || text);
const url = router.toURL(router.getCurrentPath(), { id: slug });
nextToc.slug = stripUrlExceptId(url);
compiler.toc.push(nextToc);
diff --git a/src/core/render/slugify.js b/src/core/render/slugify.js
index 7a6bf8be5..ff910d684 100644
--- a/src/core/render/slugify.js
+++ b/src/core/render/slugify.js
@@ -12,11 +12,12 @@ export function slugify(str) {
let slug = str
.trim()
+ .normalize('NFKD')
+ .replace(/[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu, '')
.replace(/[A-Z]+/g, lower)
.replace(/<[^>]+>/g, '')
.replace(re, '')
.replace(/\s/g, '-')
- .replace(/-+/g, '-')
.replace(/^(\d)/, '_$1');
let count = cache[slug];
diff --git a/src/plugins/search/search.js b/src/plugins/search/search.js
index 404e23b4b..bb0dfb55d 100644
--- a/src/plugins/search/search.js
+++ b/src/plugins/search/search.js
@@ -1,6 +1,7 @@
import {
getAndRemoveConfig,
getAndRemoveDocsifyIgnoreConfig,
+ removeAtag,
} from '../../core/render/utils.js';
import { markdownToTxt } from './markdown-to-txt.js';
import Dexie from 'dexie';
@@ -110,16 +111,11 @@ export function genIndex(path, content = '', router, depth, indexKey) {
if (token.type === 'heading' && token.depth <= depth) {
const { str, config } = getAndRemoveConfig(token.text);
- const text = getAndRemoveDocsifyIgnoreConfig(token.text).content;
-
- if (config.id) {
- slug = router.toURL(path, { id: slugify(config.id) });
- } else {
- slug = router.toURL(path, { id: slugify(escapeHtml(text)) });
- }
+ slug = router.toURL(path, { id: slugify(config.id || token.text) });
if (str) {
title = getAndRemoveDocsifyIgnoreConfig(str).content;
+ title = removeAtag(title.trim());
}
index[slug] = {
diff --git a/test/unit/render-util.test.js b/test/unit/render-util.test.js
index 6c4218254..3ff0c1a70 100644
--- a/test/unit/render-util.test.js
+++ b/test/unit/render-util.test.js
@@ -168,13 +168,49 @@ describe('core/render/tpl', () => {
describe('core/render/slugify', () => {
test('slugify()', () => {
- const result = slugify(
+ const htmlStrippedSlug = slugify(
'Bla bla bla ',
);
- const result2 = slugify(
+ expect(htmlStrippedSlug).toBe('bla-bla-bla-');
+
+ const nestedHtmlStrippedSlug = slugify(
'Another broken example',
);
- expect(result).toBe('bla-bla-bla-');
- expect(result2).toBe('another-broken-example');
+ expect(nestedHtmlStrippedSlug).toBe('another-broken-example');
+
+ const emojiRemovedSlug = slugify('emoji test ⚠️🔥✅');
+ expect(emojiRemovedSlug).toBe('emoji-test-️');
+
+ const multiSpaceSlug = slugify('Title with multiple spaces');
+ expect(multiSpaceSlug).toBe('title----with---multiple-spaces');
+
+ const numberLeadingSlug = slugify('123abc');
+ expect(numberLeadingSlug).toBe('_123abc');
+
+ const firstDuplicate = slugify('duplicate');
+ expect(firstDuplicate).toBe('duplicate');
+
+ const secondDuplicate = slugify('duplicate');
+ expect(secondDuplicate).toBe('duplicate-1');
+
+ const thirdDuplicate = slugify('duplicate');
+ expect(thirdDuplicate).toBe('duplicate-2');
+
+ const mixedCaseSlug = slugify('This Is Mixed CASE');
+ expect(mixedCaseSlug).toBe('this-is-mixed-case');
+
+ const chinesePreservedSlug = slugify('你好 world');
+ expect(chinesePreservedSlug).toBe('你好-world');
+
+ const specialCharSlug = slugify('C++ vs. Java & Python!');
+ expect(specialCharSlug).toBe('c-vs-java--python');
+
+ const docsifyIgnoreSlug = slugify(
+ 'Ignore Heading ',
+ );
+ expect(docsifyIgnoreSlug).toBe('ignore-heading-');
+
+ const quoteCleanedSlug = slugify('"The content"');
+ expect(quoteCleanedSlug).toBe('the-content');
});
});