From bdd382a0b69b0a6cf908c9f6dc8a3e10812dc75f Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Thu, 29 Oct 2026 04:54:25 +0800 Subject: [PATCH 1/3] Update build.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 构建网站时把按 GitHub 规则写的标题锚点换成 Docsify 生成的 id,文章目录和文中的锚点链接都能跳到对应的标题;代码块按围栏的长度配对,链接文字中可以有方括号;首页加入学习路线的分类卡片,侧边栏只显示链接。 --- website/builder/build.py | 61 ++++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/website/builder/build.py b/website/builder/build.py index 3fa57e5b..28a24ee8 100644 --- a/website/builder/build.py +++ b/website/builder/build.py @@ -10,10 +10,12 @@ import posixpath import re import shutil import tempfile +import unicodedata import urllib.parse +from collections import Counter from pathlib import Path -from site_assets import copy_referenced_assets, optimize_images, write_manifest, write_search_index +from site_assets import copy_referenced_assets, heading_slug, optimize_images, write_manifest, write_search_index from check import check BASE = Path(__file__).resolve().parents[1] @@ -24,10 +26,13 @@ CACHE = BASE / '.cache/site-assets' VERSION_FILE = BASE / 'source-version.json' SOURCE_COMMIT = json.loads(VERSION_FILE.read_text())['commit'] if VERSION_FILE.is_file() else 'unknown' IMAGE_BY_NAME = {} +ANCHORS = {} SOURCE_IMAGE_HOST = 'cs-notes-1256109796.cos.ap-guangzhou.myqcloud.com' IMAGE = re.compile(r'(]*?\bsrc=["\'])([^"\']+)(["\'])', re.I) MD_IMAGE = re.compile(r'(!\[[^\]]*\]\()((?:[^()\s]|\([^()\n]*\))*)(\))') -MD_LINK = re.compile(r'(?`{3,}|~{3,})[^\n]*\n[\s\S]*?^(?P=fence)[`~]*[ \t]*$|', re.M) HTML_LINK = re.compile(r'(]*?\bhref=["\'])([^"\']+)(["\'])', re.I) @@ -58,6 +63,34 @@ def repository_images(): return result +def github_slug(text): + text = re.sub(r'<[^>]+>', '', text).strip().lower().replace('\\', '') + return ''.join(c for c in text if c in ' -_' or unicodedata.category(c)[0] in 'LN' + or unicodedata.category(c) == 'Mn').replace(' ', '-') + + +def anchor_map(path): + """Map GitHub heading anchors to the ids Docsify gives the same headings.""" + result, github, docsify, fence = {}, Counter(), Counter(), None + for line in re.sub(r'', '', path.read_text()).splitlines(): + marker = re.match(r'^\s*(`{3,}|~{3,})', line) + if marker: + fence = marker[1][0] if fence is None else (None if marker[1][0] == fence else fence) + continue + heading = re.match(r'^(#{1,6})\s+(.+?)(?:\s+#+)?\s*$', line) if fence is None else None + if heading: + slug = github_slug(heading[2]) + count = github[slug] + github[slug] += 1 + result.setdefault(slug + (f'-{count}' if count else ''), heading_slug(heading[2], docsify)) + return result + + +def site_anchor(path, fragment): + fragment = urllib.parse.unquote(fragment) + return ANCHORS.get(path, {}).get(fragment, fragment) + + def local_target(url, source, html_link=False): url = html.unescape(url) parsed = urllib.parse.urlsplit(url) @@ -75,6 +108,8 @@ def local_target(url, source, html_link=False): path = candidates[0].relative_to(ROOT).as_posix() elif parsed.netloc: return url + elif not path and url.startswith('#') and len(url) > 1: + return '#' + site_anchor(source.relative_to(ROOT).as_posix(), url[1:]) elif not path or url.startswith(('#', 'mailto:', 'data:', '//')): return url elif not path.startswith('/'): @@ -86,14 +121,14 @@ def local_target(url, source, html_link=False): if not html_link: result = '/' + urllib.parse.quote(path, safe='/') if parsed.fragment: - result += '#' + urllib.parse.quote(urllib.parse.unquote(parsed.fragment), safe='') + result += '#' + urllib.parse.quote(site_anchor(path, parsed.fragment), safe='') return result if path == 'README.md': route = '/' else: route = '/' + urllib.parse.quote(path[:-3], safe='/') if parsed.fragment: - route += '?id=' + urllib.parse.quote(urllib.parse.unquote(parsed.fragment), safe='') + route += '?id=' + urllib.parse.quote(site_anchor(path, parsed.fragment), safe='') return '/#' + route return '/' + urllib.parse.quote(path, safe='/') @@ -114,13 +149,17 @@ def render_markdown(path): part = MD_IMAGE.sub(image, part) part = MD_LINK.sub(lambda m: f'[{m[1]}]({local_target(m[2], path)})', part) return HTML_LINK.sub(lambda m: m[1] + local_target(m[2], path, html_link=True) + m[3], part) - parts = re.split(r'(```[^\n]*\n[\s\S]*?```|)', content) - return ''.join(part if i % 2 else convert(part) for i, part in enumerate(parts)) + result, last = [], 0 + for match in VERBATIM.finditer(content): + result += [convert(content[last:match.start()]), match[0]] + last = match.end() + return ''.join(result + [convert(content[last:])]) def build(optimize=True): - global IMAGE_BY_NAME + global IMAGE_BY_NAME, ANCHORS IMAGE_BY_NAME = repository_images() + ANCHORS = {p.relative_to(ROOT).as_posix(): anchor_map(p) for p in sources()} external = image_urls() replacements = {url: local_target(url, ROOT / 'README.md') for url in external} reused = {url: path for url, path in replacements.items() if path.startswith('/')} @@ -136,20 +175,20 @@ def build(optimize=True): target.parent.mkdir(parents=True, exist_ok=True) target.write_text(render_markdown(path)) homepage = (dist / 'README.md').read_text() - emojis = {'pencil2': '✏️', 'computer': '💻', 'cloud': '☁️', 'floppy_disk': '💾', + emojis = {'rocket': '🚀', 'pencil2': '✏️', 'computer': '💻', 'cloud': '☁️', 'floppy_disk': '💾', 'coffee': '☕', 'bulb': '💡', 'art': '🎨', 'wrench': '🔧', 'watermelon': '🍉', 'memo': '📝'} for name, symbol in emojis.items(): homepage = re.sub(r'^(## ):' + name + r':\s*', r'\1', homepage, flags=re.M) homepage = homepage.replace('#' + name + '-', '#') homepage = homepage.replace(':' + name + ':', symbol) - topics = [('算法', '✏️'), ('操作系统', '💻'), ('网络', '☁️'), ('面向对象', '🎨'), + topics = [('学习路线', '🚀'), ('算法', '✏️'), ('操作系统', '💻'), ('网络', '☁️'), ('面向对象', '🎨'), ('数据库', '💾'), ('Java', '☕'), ('系统设计', '💡'), ('工具', '🔧'), ('后记', '📝')] cards = '' - homepage = re.sub(r'^\| 算法[^\n]*\n\|[^\n]*\n\|[^\n]*', cards, homepage, count=1, flags=re.M) + homepage = re.sub(r'^\| (?:学习路线|算法)[^\n]*\n\|[^\n]*\n\|[^\n]*', cards, homepage, count=1, flags=re.M) homepage = homepage.replace('width="200px"', 'class="site-logo" width="200px"', 1) (dist / 'README.md').write_text('# CS-Notes\n\n' + homepage) note_paths = ['/' + str(p.relative_to(ROOT).with_suffix('')) for p in sorted((ROOT / 'notes').glob('*.md'))] @@ -161,7 +200,7 @@ def build(optimize=True): break sidebar.extend(['', '- **' + label + '**']) elif line.startswith('- ['): - sidebar.append(' ' + line) + sidebar.append(' ' + re.match(r'- \[[^\]]+\]\([^)]+\)', line).group(0)) (dist / '_sidebar.md').write_text('\n'.join(sidebar) + '\n') (dist / '_404.md').write_text('# 页面暂未找到\n\n请从目录或搜索查找笔记。\n\n[返回首页](/)\n') (dist / '.nojekyll').touch() From b40fbabbada2827140599ce54c55473fb0ff151f Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Thu, 29 Oct 2026 04:54:25 +0800 Subject: [PATCH 2/3] Update site_assets.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 计算标题 id 时先按 marked 的规则转义引号等字符,与浏览器中生成的 id 一致,站内搜索的结果能定位到这类标题。 --- website/builder/site_assets.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/website/builder/site_assets.py b/website/builder/site_assets.py index b68af64c..49e5a818 100644 --- a/website/builder/site_assets.py +++ b/website/builder/site_assets.py @@ -29,9 +29,30 @@ def plain_text(text): return re.sub(r'\s+', ' ', html.unescape(text)).strip() +def marked_inline(title): + # Docsify slugs the HTML that marked renders for the heading: backslash escapes are + # resolved, code spans and link text are kept, and text is HTML-escaped, so a quote + # becomes " and leaves "quot" in the slug. + def escape(text): + text = re.sub(r'&(?!#?\w+;)', '&', text) + return text.replace('<', '<').replace('>', '>').replace('"', '"').replace("'", ''') + out = [] + for part in re.split(r'(`+[^`]*`+|<\/?[A-Za-z][^>]*>)', title): + if part.startswith('`'): + out.append(escape(part.strip('`'))) + elif part.startswith('<'): + out.append(part) + else: + part = re.sub(r'!?\[([^\]]*)\]\([^)]*\)', r'\1', part) + part = re.sub(r'\\([!-/:-@\[-`{-~])', r'\1', part) + out.append(escape(part.replace('**', '').replace('__', ''))) + return ''.join(out) + + def heading_slug(title, counts): # Match the pinned Docsify 4.13.1 slugger, including numeric and duplicate headings. title = title.replace('{docsify-ignore}', '').replace('{docsify-ignore-all}', '') + title = marked_inline(title) slug = PUNCTUATION.sub('', re.sub(r'<[^>]+>', '', title.strip()).lower()) slug = re.sub(r'-+', '-', re.sub(r'\s', '-', slug)) slug = re.sub(r'^(\d)', r'_\1', slug) From 67fbb2bb0963ed0d2252aa70cea704b1ad4e95bd Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Thu, 29 Oct 2026 04:54:25 +0800 Subject: [PATCH 3/3] Update test_site_assets.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充带引号、反斜杠转义的标题 id 的测试。 --- website/builder/test_site_assets.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/website/builder/test_site_assets.py b/website/builder/test_site_assets.py index 8a78f66a..04f747ae 100644 --- a/website/builder/test_site_assets.py +++ b/website/builder/test_site_assets.py @@ -16,6 +16,9 @@ class SearchIndexTests(unittest.TestCase): self.assertEqual(heading_slug('三、检索增强生成', counts), '三、检索增强生成') self.assertEqual(heading_slug('TCP 拥塞控制', counts), 'tcp-拥塞控制') self.assertEqual(heading_slug('TCP 拥塞控制', counts), 'tcp-拥塞控制-1') + # marked 先把引号转义成 ",Docsify 再去掉标点,留下 quot(Edge 渲染的 Java 基础页核对过) + self.assertEqual(heading_slug('new String("abc")', counts), 'new-stringquotabcquot') + self.assertEqual(heading_slug('1\\. 数据', counts), '_1-数据') def test_headings_in_code_do_not_change_real_anchors(self): doc = search_document(Path('示例.md'), '# 示例\n\n```python\n# TCP 拥塞控制\n```\n\n## TCP 拥塞控制\n慢开始\n')