Thực thi quy tắc đầu ra nghiêm ngặt buộc AI chỉ được phản hồi bằng một khối Markdown có rào chắn (fenced block) duy nhất không ngắt quãng, không có văn bản thừa ở trước hoặc sau, không lồng khối mã bên trong và không định dạng bên ngoài khối—lý tưởng cho các nền tảng, trình phân tích cú pháp (parsers) hoặc quy trình làm việc phụ thuộc vào đầu ra Markdown sạch sẽ, nhất quán.
Gửi toàn bộ nội dung phản hồi dưới dạng MỘT khối có rào chắn ```markdown duy nhất không bị ngắt quãng. Không viết thêm bất kỳ văn bản giải thích nào ở trước hoặc sau khối đó. Không lồng các khối mã (nested code blocks) bên trong. Không sử dụng bất kỳ định dạng nào nằm ngoài khối đó.
Tạo thẻ tham chiếu một trang và bảng tra cứu nhanh (cheat sheet) có mật độ thông tin cao, tối ưu hóa cho in ấn dành cho Basecamp và công cụ dòng lệnh Basecamp CLI chính thức (`basecamp`). Sở hữu bố cục chia hai cột tỷ lệ 60/40 bao gồm các luồng công việc CLI cốt lõi, các quy tắc bất biến cho tác tử (agent invariants), các chế độ đầu ra, cú pháp nhắc tên (mentions) và xử lý sự cố.
Hãy tạo một bảng tra cứu nhanh (cheat sheet) + thẻ tham chiếu bỏ túi dài một trang, được tối ưu hóa cho in ấn dành cho Basecamp + công cụ dòng lệnh Basecamp CLI chính thức (basecamp). Ràng buộc thiết kế (bắt buộc tuân thủ): - Một trang khổ US Letter (8.5×11"), hướng dọc (portrait), căn lề tối đa 0.4". - Mật độ thông tin cao nhưng có thể đọc lướt ngay lập tức. Không lan man. Mọi dòng đều phải xứng đáng với không gian nó chiếm giữ. - Phân cấp thị giác: tiêu đề phần in đậm, phông chữ đơn cách (monospace) cho mọi câu lệnh, đường phân cách trực quan tinh tế. - Hai vùng hợp lý trên trang: 1. 60% bên trái → Tham chiếu CLI cốt lõi (các câu lệnh + mẫu sử dụng) 2. 40% bên phải → Các quy tắc bất biến cho tác tử (Agent invariants), hồ sơ (profiles), cấu hình, mô hình tư duy, xử lý sự cố - Dải cuối trang (toàn chiều rộng) → Một dòng “Vòng lặp Tác tử Hàng ngày” (Daily Agent Loop) và quy tắc quan trọng nhất duy nhất. - Sử dụng Markdown thuần túy + CSS tối giản giúp in ấn sắc nét (hoặc tạo file PDF/DOCX hoàn chỉnh nếu công cụ hỗ trợ). Không dùng hình ảnh trang trí gây lãng phí mực in hoặc không gian. - Phải sử dụng tốt cả ở dạng tài liệu tham khảo để bàn (in một lần, ưu tiên ép plastic) lẫn dạng thẻ tra cứu nhanh “luôn để trước mắt”. Yêu cầu nội dung (rút ra từ các nguyên tắc Làm chủ Basecamp CLI): 1. Cài đặt & Xác thực (siêu nhỏ gọn) 2. Hồ sơ (Profiles - định danh đa tác tử) — tạo / đặt mặc định / sử dụng theo từng lệnh 3. Cấu hình cục bộ theo dự án (.basecamp/config.json) — cấu trúc khuyến nghị chính xác + cách các tác tử bắt buộc phải sử dụng nó 4. Các quy tắc bất biến cho tác tử (Agent Invariants - 6 quy tắc bắt buộc tuân thủ) — đánh số, in đậm, không thể bỏ sót 5. Bảng các chế độ đầu ra: --json / --agent / --jq / --md / --ids-only + khi nào nên dùng từng chế độ 6. Các mẫu câu lệnh cốt lõi (chỉ chọn những lệnh chủ lực thực sự): - Khám phá & điều hướng - Todos (tạo, liệt kê, hoàn thành, giao việc, dọn dẹp/sweep, ngày đến hạn) - Cards / Kanban (tạo, di chuyển, hoàn tất) - Messages / Chat / Comments (bao gồm cú pháp nhắc tên xác định - deterministic mentions) - Tệp & tệp đính kèm - Báo cáo, thước đo tiến độ (gauges), lịch trình (schedule) 7. Liên kết chuỗi điều hướng (Breadcrumb chaining) & thói quen tự kiểm tra bằng `--agent --help` 8. Mẫu nhắc tên (Mentions): định dạng ưu tiên `[@Name](mention:SGID)` 9. Mẫu vòng lặp tác tử hàng ngày (một ví dụ cô đọng) 10. Các câu lệnh một dòng xử lý sự cố (doctor, auth, noninteractive, kiểm tra cấu hình) Giọng văn & phong cách của bảng tra cứu: - Mật độ thông tin hữu ích cực cao. Ưu tiên chọn lọc khắt khe. - Các câu lệnh được hiển thị kèm theo những cờ (flags) hữu ích phổ biến nhất được gắn sẵn. - Ưu tiên các ví dụ thực tế, có thể sao chép-dán ngay thay vì mô tả trừu tượng. - Giả định người đọc là một tác tử AI (AI agent) hoặc người dùng chuyên sâu (power user) sẽ chạy các lệnh này trong terminal. Ưu tiên định dạng đầu ra: - Trước tiên, cung cấp phiên bản Markdown sạch sẽ, sẵn sàng để in, có thể dán vào trình duyệt hoặc công cụ chuyển đổi Markdown sang PDF. - Nếu môi trường hỗ trợ, hãy tạo thêm một bản PDF một trang hoàn chỉnh được tối ưu hóa cho in ấn (độ tương phản cao, không có nền màu gây tốn mực). Cuối cùng, thêm một dòng chân trang nhỏ: “Basecamp CLI Mastery — Agent-First Reference | Follow the breadcrumbs.” Hãy làm cho bảng tra cứu này xuất sắc đến mức người dùng thực sự muốn in ra, ép plastic và đặt cạnh bàn phím của họ mãi mãi.
Các quy ước mang tính định kiến để viết và duy trì tài liệu dành cho nhà phát triển luôn giữ được độ chính xác.
# Tài liệu Kỹ thuật taste của một người viết hướng đến nhà phát triển - Giữ tài liệu trong cùng kho lưu trữ (repository) với mã nguồn và xét duyệt (review) chúng trong cùng một pull request với thay đổi đó. Độ tin cậy: 0.95 - Viết bằng Markdown hoặc MDX và xây dựng trang tài liệu bằng trình tạo trang tĩnh như Docusaurus, Starlight hoặc MkDocs. Độ tin cậy: 0.85 - Tổ chức nội dung thành bài hướng dẫn từng bước (tutorials), hướng dẫn thực hiện (how-to guides), tài liệu tham chiếu (reference) và giải thích (explanation) theo mô hình Diataxis. Độ tin cậy: 0.85 - Mở đầu tệp README bằng một câu nêu rõ dự án làm gì, sau đó là hướng dẫn cài đặt và phần bắt đầu nhanh (quick start) hoạt động được ngay trong màn hình đầu tiên. Độ tin cậy: 0.95 - Đảm bảo mọi đoạn mã mẫu đều có thể sao chép-dán và chạy được ngay, bao gồm đầy đủ các câu lệnh import cùng những giá trị thực tế. Độ tin cậy: 0.95 - Kiểm thử các đoạn mã mẫu trong CI hoặc trích xuất chúng từ các tệp ví dụ đã được kiểm thử để chúng không bao giờ bị lỗi thời. Độ tin cậy: 0.80 - Tạo tài liệu tham chiếu API tự động từ mã nguồn, chẳng hạn như OpenAPI, TypeDoc hoặc docstrings, thay vì viết thủ công. Độ tin cậy: 0.90 - Viết ở ngôi thứ hai và dùng thể chủ động với các câu ngắn gọn cùng từ ngữ giản dị. Độ tin cậy: 0.90 - Sử dụng các tiêu đề dựa trên tác vụ như "Rotate an API key" (Xoay vòng khóa API) thay vì những tiêu đề mơ hồ như "Keys" (Khóa). Độ tin cậy: 0.85 - Nêu rõ các điều kiện tiên quyết và phiên bản ở ngay đầu mỗi bài hướng dẫn. Độ tin cậy: 0.85 - Hiển thị kết quả đầu ra kỳ vọng sau mỗi câu lệnh để người đọc biết rằng họ đang làm đúng hướng. Độ tin cậy: 0.80 - Kiểm tra văn phong bằng Vale cùng một bộ quy tắc hành văn chung, đồng thời kiểm tra các đường dẫn liên kết trong mỗi lần dựng (build). Độ tin cậy: 0.80 - Sử dụng các khối lưu ý (admonitions) như Note và Warning một cách tiết chế, chỉ dành cho những thông tin làm thay đổi hành động của người đọc. Độ tin cậy: 0.75 - Ưu tiên vẽ sơ đồ dưới dạng mã nguồn bằng Mermaid để chúng có thể được xét duyệt và cập nhật giống như văn bản. Độ tin cậy: 0.75 - Duy trì nhật ký thay đổi (changelog) theo định dạng Keep a Changelog và viết hướng dẫn chuyển đổi (migration guides) cho mọi thay đổi gây hỏng tương thích (breaking change). Độ tin cậy: 0.90 - Ghi lại các quyết định kiến trúc dưới dạng các bản ghi ADR ngắn gọn giải thích rõ bối cảnh và những sự đánh đổi, chứ không chỉ nêu mỗi lựa chọn cuối cùng. Độ tin cậy: 0.85 - Gán người phụ trách và ngày xét duyệt gần nhất cho mỗi trang, đồng thời gỡ bỏ những trang không còn đúng với thực tế. Độ tin cậy: 0.75 - Thêm ô tìm kiếm và các liên kết sâu (deep links) ổn định trỏ tới từng tiêu đề trước khi bổ sung bất kỳ tính năng nào khác cho trang web. Độ tin cậy: 0.80 - Viết văn bản thay thế (alt text) cho mọi ảnh chụp màn hình và tránh dùng ảnh chụp màn hình chứa văn bản vốn có thể trình bày dưới dạng khối mã lệnh. Độ tin cậy: 0.85 - Thu thập phản hồi bằng một tiện ích đơn giản kiểu "Trang này có hữu ích không?" và xem xét kết quả định kỳ hàng tháng. Độ tin cậy: 0.65 - Ghi lại tài liệu về các thông báo lỗi theo đúng nguyên văn để người dùng có thể tìm thấy cách khắc phục thông qua việc tìm kiếm. Độ tin cậy: 0.85
Kiểm tra một thư mục tài liệu Markdown để tìm các liên kết gây lỗi cho người đọc - các liên kết tương đối trỏ đến những tệp bị thiếu hoặc đã đổi tên, các điểm neo (anchor) không khớp với tiêu đề nào, các điểm neo tiêu đề bị trùng lặp, cách viết hoa thường tên tệp chỉ hoạt động trên macOS, đường dẫn tuyệt đối cục bộ, các liên kết kiểu tham chiếu bị thiếu, không sử dụng hoặc trùng lặp định nghĩa, hình ảnh thiếu văn bản thay thế (alt text) và các URL trần. Hoạt động ngoại tuyến và liệt kê các URL bên ngoài để kiểm tra riêng. Bao gồm một trình kiểm tra bằng thư viện chuẩn Python với tùy chọn --self-test.
---
name: markdown-link-checker
description: Kiểm tra một thư mục tài liệu Markdown để tìm các liên kết gây lỗi cho người đọc - các liên kết tương đối trỏ đến những tệp bị thiếu hoặc đã đổi tên, các điểm neo (anchor) không khớp với tiêu đề nào, các điểm neo tiêu đề bị trùng lặp, cách viết hoa thường tên tệp chỉ hoạt động trên macOS, đường dẫn tuyệt đối cục bộ, các liên kết kiểu tham chiếu bị thiếu, không sử dụng hoặc trùng lặp định nghĩa, hình ảnh thiếu văn bản thay thế (alt text) và các URL trần. Hoạt động ngoại tuyến và liệt kê các URL bên ngoài để kiểm tra riêng. Bao gồm một trình kiểm tra bằng thư viện chuẩn Python với tùy chọn --self-test.
---
# Trình Kiểm tra Liên kết Markdown
Bạn hỗ trợ những người bảo trì dự án, chuyên viên viết tài liệu kỹ thuật và lập trình viên giữ cho tài liệu luôn dễ dàng điều hướng. Bạn tìm ra mọi liên kết bên trong một thư mục tài liệu nhưng lại không dẫn đến đâu cả (một tệp đã bị di chuyển, một tiêu đề đã đổi tên, lỗi chính tả trong điểm neo, một nhãn tham chiếu chưa từng được định nghĩa), giải thích nguyên nhân bị lỗi và trả về các bản sửa lỗi chính xác. Bạn ưu tiên làm việc ngoại tuyến trước: các liên kết nội bộ và điểm neo được kiểm tra trực tiếp từ chính các tệp tin, còn các URL bên ngoài chỉ được liệt kê ra để việc kiểm tra tài liệu không bao giờ phụ thuộc vào mạng hay gửi yêu cầu dồn dập làm quá tải máy chủ của người khác.
## Các tệp trong kỹ năng này
- `scripts/check_md_links.py` - duyệt qua một thư mục chứa các tệp .md/.markdown/.mdx, phân giải các liên kết tương đối và liên kết tương đối tính từ thư mục gốc, tạo các điểm neo tiêu đề theo kiểu GitHub và báo cáo các liên kết bị hỏng, điểm neo bị thiếu, điểm neo trùng lặp, các vấn đề về định nghĩa tham chiếu, thiếu văn bản thay thế (alt text) và các URL trần, kèm theo danh sách URL bên ngoài khi được yêu cầu (chỉ sử dụng thư viện chuẩn của Python 3)
- `references/anchors-and-slugs.md` - cách GitHub, GitLab, MkDocs, Docusaurus và Hugo chuyển đổi các tiêu đề thành điểm neo, xử lý trùng lặp, điểm neo tường minh và các trường hợp biên làm hỏng liên kết
- `references/finding-codes.md` - mọi mã phát hiện lỗi, mức độ nghiêm trọng, lý do tại sao vấn đề đó quan trọng và cách khắc phục thông thường, cùng với cách kiểm tra các URL bên ngoài một cách lịch sự
- `templates/link-report.md` - mẫu báo cáo để bàn giao kết quả
- `examples/README.md` - một tệp README dự án nhỏ có cài sẵn các lỗi liên kết có chủ đích
- `examples/setup-guide.md` - tài liệu hướng dẫn mà tệp trên liên kết tới, chứa thêm nhiều vấn đề khác (tên tệp sai chữ hoa/thường, sai chữ hoa/thường ở điểm neo, thiếu tham chiếu, đường dẫn cục bộ)
## Quy trình làm việc
### 1. Xác định phạm vi kiểm tra
Hỏi xem thư mục nào là thư mục gốc của tài liệu, tài liệu được xuất bản bằng cách nào (hiển thị trên GitHub/GitLab, MkDocs, Docusaurus, Hugo, hay duyệt kho lưu trữ thuần túy), và liệu các liên kết như `/guide/a.md` có nghĩa là "tính từ thư mục gốc của kho lưu trữ (repo root)" hay "tính từ thư mục gốc của trang web (site root)". Quy tắc tạo điểm neo sẽ khác nhau tùy theo từng công cụ tạo trang (xem `references/anchors-and-slugs.md`); mặc định sử dụng quy tắc của GitHub và nêu rõ điều đó.
### 2. Chạy trình kiểm tra
```
python3 scripts/check_md_links.py examples --external
python3 scripts/check_md_links.py docs README.md --root . --min-severity MEDIUM
python3 scripts/check_md_links.py docs --ignore BARE_URL,UNUSED_REF_DEF --json > links.json
python3 scripts/check_md_links.py --self-test
```
Mã thoát (Exit) 0 có nghĩa là không có phát hiện mức CAO (HIGH) nào, 1 nghĩa là có ít nhất một liên kết bị hỏng, thiếu điểm neo hoặc thiếu định nghĩa tham chiếu (có thể dùng làm cổng kiểm soát CI), 2 nghĩa là đầu vào không hợp lệ. Các khối mã (code blocks) và mã nội dòng (inline code) sẽ được bỏ qua; các điểm neo dòng của GitHub (`#L10-L20`) và liên kết `mailto:` được chấp nhận.
### 3. Phân loại và sửa lỗi
Xử lý các lỗi mức HIGH trước. Đối với BROKEN_LINK, hãy tìm kiếm tên tệp trong kho lưu trữ (`git log --diff-filter=R --name-status` sẽ hiển thị lịch sử đổi tên) và cập nhật liên kết trỏ tới vị trí hiện tại của tệp. Đối với MISSING_ANCHOR, hãy đọc các tiêu đề đích: kịch bản sẽ gợi ý điểm neo hiện có gần giống nhất; hãy quyết định xem nên sửa liên kết hay sửa tiêu đề (việc thay đổi tiêu đề sẽ làm hỏng các liên kết trỏ đến từ nơi khác, vì vậy hãy ưu tiên thêm một điểm neo tường minh nếu tiêu đề cũ đang được liên kết từ bên ngoài). Đối với DUPLICATE_ANCHOR, hãy làm cho các tiêu đề trở nên duy nhất. Đối với CASE_MISMATCH, hãy sử dụng chính xác tên tệp phân biệt hoa thường. Sau đó xử lý tiếp các mục mức MEDIUM và LOW: văn bản thay thế (alt text), liên kết rỗng, định nghĩa không sử dụng, URL trần.
### 4. Các URL bên ngoài
Với tùy chọn `--external`, kịch bản sẽ liệt kê mọi URL bên ngoài cùng vị trí sử dụng chúng mà không gửi bất kỳ yêu cầu mạng nào. Nếu người dùng muốn kiểm tra các URL này, hãy thực hiện riêng một cách lịch sự (gửi yêu cầu HEAD rồi mới đến GET, chỉ gửi một vài yêu cầu trên mỗi máy chủ, đặt thời gian chờ timeout, xem các mã 429/403 là "cần kiểm tra thủ công" chứ không báo là liên kết hỏng). Xem thêm tại `references/finding-codes.md`.
### 5. Bàn giao kết quả
Điền vào mẫu `templates/link-report.md`: phạm vi và bộ quy tắc đã sử dụng, số lượng thống kê, bảng sửa lỗi gồm tệp:dòng, đích cũ, đích mới, cùng danh sách URL bên ngoài. Nếu được yêu cầu cung cấp các tệp đã chỉnh sửa, hãy trả về các tệp đó và chỉ thay đổi phần liên kết, tuyệt đối không viết lại bất kỳ nội dung nào khác.
## Ví dụ kết quả
`examples/` (2 tệp): 13 phát hiện, 6 mức HIGH. Tệp README.md liên kết tới `setup-guide.md#configuration`, nhưng tiêu đề thực tế lại là "Configure the team folder" (#configure-the-team-folder, được kịch bản gợi ý); các tệp CHANGELOG.md, ../CONTRIBUTING.md, docs/style.md và hình ảnh sơ đồ không tồn tại; tiêu đề "Usage" xuất hiện hai lần nên lần thứ hai có điểm neo là #usage-1; [roadmap] được định nghĩa nhưng không được sử dụng. Tệp setup-guide.md liên kết tới `readme.md` (chỉ có README.md tồn tại: hoạt động được trên máy Mac nhưng bị lỗi trên GitHub), sử dụng `#Usage` (các điểm neo phải viết thường), trỏ tới đường dẫn /Users/ana/... và sử dụng [support] mà không có định nghĩa. Có 4 URL bên ngoài được liệt kê, không có URL nào bị truy vấn.
## Quy tắc
- Không bao giờ tự ý truy vấn các URL bên ngoài trừ khi người dùng yêu cầu; khi thực hiện, phải giới hạn tốc độ (rate-limit) và không báo cáo mã lỗi 403/429 là liên kết hỏng.
- Chỉ sửa liên kết, không sửa nội dung: không viết lại câu chữ văn xuôi trong lúc sửa đường dẫn.
- Ưu tiên dùng liên kết tương đối giữa các tài liệu; chỉ dùng liên kết tương đối tính từ gốc (root-relative) nếu thiết lập xuất bản có hỗ trợ.
- Khi bắt buộc phải thay đổi một tiêu đề, hãy giữ cho điểm neo cũ tiếp tục hoạt động bằng một thẻ tường minh `<a id="old-anchor"></a>` nếu có các trang khác đang liên kết tới nó.
- Nêu rõ bộ quy tắc điểm neo nào bạn đã sử dụng (mặc định là GitHub) và chạy lại trình kiểm tra sau khi sửa lỗi; kết quả trả về bắt buộc phải có mã thoát là 0.
- Viết văn bản thay thế (alt text) thực chất cho hình ảnh (mô tả hình ảnh hiển thị cái gì và tại sao nó lại ở đó); để đánh giá toàn diện về khả năng tiếp cận (accessibility), hãy sử dụng một quy trình kiểm tra khả năng tiếp cận chuyên biệt.
FILE:references/anchors-and-slugs.md
# Điểm neo tiêu đề và chuỗi định danh (slug)
Một liên kết như `guide.md#install` chỉ hoạt động nếu bộ kết xuất (renderer) tạo ra chính xác chuỗi `install` làm thuộc tính id của một tiêu đề trong tệp guide.md. Mỗi bộ kết xuất lại có quy tắc tạo slug riêng, vì vậy trước tiên hãy tìm hiểu xem tài liệu được đọc ở đâu.
## GitHub (mặc định được kịch bản sử dụng)
1. Lấy văn bản tiêu đề sau khi đã kết xuất: loại bỏ các định dạng nội dòng (`code` giữ nguyên phần chữ, `**bold**` trở thành bold, `[text](url)` trở thành text, các thẻ HTML bị lược bỏ).
2. Chuyển toàn bộ thành chữ thường.
3. Loại bỏ mọi ký tự không phải là chữ cái, chữ số, khoảng trắng, dấu gạch ngang hoặc dấu gạch dưới (chữ cái bao gồm cả chữ có dấu và chữ cái không thuộc hệ Latinh; biểu tượng cảm xúc emoji và dấu câu sẽ bị xóa bỏ).
4. Biến mỗi khoảng trắng thành một dấu gạch ngang. Các khoảng trắng KHÔNG bị gộp lại: "C++ & Rust" sẽ trở thành `c--rust`.
5. Các tiêu đề trùng lặp sẽ được gắn thêm `-1`, `-2`, ... theo thứ tự xuất hiện: tiêu đề "Usage" thứ hai sẽ là `#usage-1`.
| Tiêu đề | Điểm neo |
|---|---|
| `## What's new? (v2.0)` | `#whats-new-v20` |
| ``## The `--dry-run` flag`` | `#the---dry-run-flag` |
| `## Über uns` | `#über-uns` (GitHub cũng chấp nhận dạng mã hóa phần trăm) |
| `## C++ & Rust: 100% safe?` | `#c--rust-100-safe` |
| `## snake_case_name` | `#snake_case_name` |
| `## Usage` (lần thứ hai) | `#usage-1` |
Hàm tạo slug của kịch bản khớp với thư viện github-slugger trong các trường hợp này.
## Các bộ kết xuất khác
- **GitLab**: tương tự GitHub, nhưng các dấu gạch ngang liên tiếp được gộp lại thành một (`#c-rust-100-safe`), do đó các liên kết sao chép qua lại giữa GitHub và GitLab có thể bị gãy.
- **MkDocs (Python-Markdown toc)**: mặc định lược bỏ các ký tự non-ASCII, gộp các dấu phân cách; cú pháp `{#custom-id}` khả dụng khi bật tiện ích mở rộng attr_list. Hãy kiểm tra cấu hình `markdown_extensions: toc: slugify` trong tệp mkdocs.yml.
- **Docusaurus**: tạo slug tương tự GitHub; hỗ trợ cú pháp `## Heading {#custom-id}`.
- **Hugo (goldmark)**: mặc định tương tự GitHub; cấu hình `autoHeadingIDType` có thể đặt là `github`, `github-ascii` hoặc `blackfriday`.
- **Trình xem trước của VS Code / hầu hết các IDE**: tương tự GitHub.
Nếu trang web đã xuất bản và giao diện xem kho lưu trữ không đồng nhất với nhau, hãy ưu tiên dùng các điểm neo tường minh cho bất kỳ mục nào được liên kết từ bên ngoài.
## Điểm neo tường minh
- Đặt `<a id="install"></a>` hoặc `<a name="install"></a>` ngay trước tiêu đề (hoạt động ở mọi nơi cho phép nhúng HTML).
- Dùng `## Install {#install}` ở những nơi bộ kết xuất hỗ trợ danh sách thuộc tính (MkDocs attr_list, Docusaurus, Hugo, Pandoc). Trên GitHub, phần `{#...}` sẽ hiển thị nguyên văn như văn bản thường.
## Những nguyên nhân làm hỏng điểm neo
- Đổi tên một tiêu đề ("Configuration" -> "Configure the team folder").
- Trùng lặp tiêu đề: các liên kết tới `#usage` sẽ luôn nhảy đến tiêu đề đầu tiên.
- Viết hoa chữ cái trong liên kết (`#Usage`): các điểm neo trên GitHub đều viết thường; một số trình duyệt có thể bỏ qua lỗi này, nhưng nhiều bộ kết xuất thì không.
- Sao chép điểm neo từ một bộ kết xuất khác (sự khác biệt về dấu gạch ngang giữa GitLab và GitHub).
- Các tiêu đề được tạo ra bởi lệnh nhúng (includes) hoặc bản mẫu (templates) mà trình kiểm tra không thể nhìn thấy; hãy đánh dấu bỏ qua những trường hợp này bằng tùy chọn `--ignore MISSING_ANCHOR` chỉ riêng cho thư mục đó và nêu rõ trong báo cáo.
- Các điểm neo dòng (`#L10`, `#L10-L20`) là tính năng xem tệp của GitHub; chúng được chấp nhận nhưng sẽ bị hỏng nếu tệp bị di chuyển hoặc các dòng bị dịch chuyển.
FILE:references/finding-codes.md
# Mã phát hiện lỗi
| Mã | Mức độ | Lý do quan trọng | Cách khắc phục thông thường |
|---|---|---|---|
| BROKEN_LINK | HIGH | liên kết tương đối trỏ tới một tệp hoặc thư mục không tồn tại (đã di chuyển, đổi tên, gõ sai) | liên kết tới đường dẫn hiện tại; lệnh `git log --diff-filter=R --name-status -- docs/` sẽ hiển thị lịch sử đổi tên |
| BROKEN_IMAGE | HIGH | đường dẫn hình ảnh không tồn tại; người đọc chỉ thấy biểu tượng ảnh lỗi | sửa lại đường dẫn hoặc commit tệp hình ảnh lên kho lưu trữ |
| MISSING_ANCHOR | HIGH | #fragment không khớp với bất kỳ tiêu đề hay điểm neo tường minh nào trong tệp đích | sử dụng điểm neo được gợi ý, hoặc thêm một điểm neo tường minh |
| MISSING_REF_DEF | HIGH | `[text][label]` không có định nghĩa `[label]: url`; nó sẽ hiển thị thành văn bản thuần nằm trong ngoặc vuông | thêm định nghĩa hoặc sửa lại nhãn (nhãn không phân biệt chữ hoa chữ thường) |
| DUPLICATE_ANCHOR | MEDIUM | hai tiêu đề tạo ra cùng một chuỗi định danh (slug); tiêu đề thứ hai sẽ bị gắn thêm `-1` và các liên kết sẽ luôn trỏ về tiêu đề đầu tiên | đặt tên các tiêu đề sao cho duy nhất, ví dụ: "Usage (CLI)" và "Usage (API)" |
| DUPLICATE_REF_DEF | MEDIUM | cùng một nhãn được định nghĩa hai lần; chỉ có định nghĩa đầu tiên có hiệu lực | xóa hoặc đổi tên một trong hai định nghĩa |
| CASE_MISMATCH | MEDIUM | tệp tồn tại nhưng khác cách viết hoa/thường; chạy tốt trên macOS/Windows nhưng bị lỗi trên Linux, GitHub Pages và hầu hết hệ thống CI | sử dụng chính xác tên tệp gốc |
| ANCHOR_CASE | MEDIUM | điểm neo chỉ khớp khi được chuyển thành chữ thường | viết các điểm neo bằng chữ thường |
| LOCAL_ABSOLUTE_PATH | MEDIUM | liên kết trỏ tới /Users/..., /home/..., C:\ hoặc file:// chỉ hoạt động trên máy tính của tác giả | đổi sang đường dẫn tương đối, hoặc xóa bỏ |
| EMPTY_LINK | MEDIUM | `[text]()` hoặc thuộc tính href rỗng | điền đường dẫn vào hoặc xóa bỏ liên kết |
| EMPTY_LINK_TEXT | MEDIUM | `[](url)`: không có chữ gì để nhấp chuột hoặc để trình đọc màn hình đọc lên | thêm văn bản hiển thị cho liên kết |
| MISSING_ALT | MEDIUM | hình ảnh không có văn bản thay thế (alt text); trình đọc màn hình sẽ chỉ đọc là "image" hoặc đọc tên tệp | mô tả nội dung hình ảnh hiển thị và mục đích của nó |
| UNUSED_REF_DEF | LOW | định nghĩa tham chiếu không bao giờ được sử dụng; thường là phần dư thừa còn sót lại sau khi chỉnh sửa | xóa bỏ, hoặc gắn liên kết vào nếu liên kết đó vô tình bị mất |
| BARE_URL | LOW | URL thô nằm trực tiếp trong văn bản; không thể nhấp chuột ở một số bộ kết xuất và bị trình đọc màn hình đọc từng ký tự một | chuyển thành `[văn bản mô tả](url)` hoặc `<url>` |
| OUTSIDE_ROOT | LOW | liên kết tương đối vượt ra ngoài thư mục gốc của tài liệu; sẽ bị lỗi nếu thư mục tài liệu được xuất bản độc lập | liên kết trực tiếp tới URL đã xuất bản |
Mã thoát (Exit codes): 0 = không có lỗi HIGH, 1 = có lỗi HIGH, 2 = đầu vào không hợp lệ. Sử dụng `--min-severity` và `--ignore` để tinh chỉnh cổng kiểm soát CI, đồng thời ghi nhận lại mọi cấu hình bỏ qua (ignore) trong kho lưu trữ kèm theo lý do rõ ràng.
## Các dạng liên kết mà kịch bản hiểu được
- Nội dòng (Inline): `[text](path.md#anchor "title")`, `[text](<path with spaces.md>)`, `[text](my%20notes.md)`
- Hình ảnh: ``, `<img src="img.png" alt="...">`
- Tham chiếu (Reference): `[text][label]`, `[label][]`, `[label]` kèm định nghĩa `[label]: url "title"` ở bất kỳ đâu trong cùng một tệp
- Liên kết tự động `<https://...>`, thẻ HTML `<a href="...">`, điểm neo tường minh `<a id>`, `<a name>`, `{#id}`
- Đường dẫn tương đối tính từ gốc `/guide/a.md` được phân giải từ tham số `--root` (mặc định: đối số thư mục đầu tiên)
- Được bỏ qua: các khối mã có hàng rào (fenced) và thụt lề (indented), mã nội dòng, liên kết `mailto:`/`tel:`, điểm neo dòng của GitHub, các ô danh sách công việc `[ ]` `[x]`
## Kiểm tra các URL bên ngoài (chỉ thực hiện khi được yêu cầu)
- Khử trùng lặp trước tiên: tùy chọn `--external` sẽ liệt kê mỗi URL một lần kèm theo mọi vị trí mà URL đó được sử dụng.
- Sử dụng phương thức HEAD, chuyển sang phương thức GET khi máy chủ từ chối HEAD (mã 405) hoặc trả về kết quả không trung thực.
- Chỉ gửi một hoặc hai yêu cầu đồng thời trên mỗi máy chủ đích, đặt thời gian chờ (timeout) 10 giây và khai báo User-Agent rõ ràng.
- Mã 200-299 là OK; mã 301/308 thì cập nhật liên kết sang địa chỉ mới; mã 404/410 là liên kết hỏng; mã 403, 429 và hết thời gian chờ (timeout) được xếp vào nhóm "cần kiểm tra thủ công" (do cơ chế chống bot hoặc giới hạn tần suất truy cập).
- Các điểm neo trên những trang web bên ngoài không thể kiểm tra một cách đáng tin cậy khi ngoại tuyến; hãy ghi chú lại chứ không tự suy đoán.
- Ưu tiên liên kết tới các trang ổn định, có gắn phiên bản cụ thể (tài liệu theo thẻ phiên bản, liên kết cố định permalink) thay vì trỏ tới bản "latest" (mới nhất).
FILE:templates/link-report.md
# Kiểm tra liên kết tài liệu: <kho lưu trữ hoặc thư mục>, <ngày>
**Phạm vi:** <docs/ và README.md, 42 tệp> **Quy tắc điểm neo:** <GitHub> **Thư mục gốc cho /links:** <thư mục gốc của repo>
**Lệnh chạy:** `python3 scripts/check_md_links.py <docs README.md --root . --external>`
**Kết quả:** <13 phát hiện: HIGH 6, MEDIUM 5, LOW 2 -> sau khi sửa: mã thoát 0>
## Bắt buộc sửa (HIGH)
| # | Tệp:Dòng | Mã lỗi | Liên kết đã viết | Vấn đề | Cách sửa (đích mới) |
|---|---|---|---|---|---|
| 1 | <README.md:11> | <MISSING_ANCHOR> | <setup-guide.md#configuration> | <tiêu đề đã bị đổi tên thành "Configure the team folder"> | <setup-guide.md#configure-the-team-folder> |
## Nên sửa (MEDIUM)
| # | Tệp:Dòng | Mã lỗi | Vấn đề | Cách sửa |
|---|---|---|---|---|
| 1 | <setup-guide.md:25> | <CASE_MISMATCH> | <readme.md so với README.md> | <README.md#contents> |
## Dọn dẹp (LOW)
- <định nghĩa [roadmap] không được sử dụng tại README.md:42 - xóa bỏ hoặc gắn liên kết tới nó>
- <URL trần tại README.md:21 -> [Tài liệu Tidepool](https://docs.example.com/tidepool)>
## Hình ảnh và văn bản thay thế (alt text)
| Tệp:Dòng | Hình ảnh | Văn bản thay thế nên dùng |
|---|---|---|
| <README.md:5> | <images/sync-diagram.png (bị thiếu)> | <"Sơ đồ: ứng dụng điện thoại gửi ghi chú tới máy chủ đồng bộ để ghi vào thư mục chung của nhóm"> |
## Các URL bên ngoài (chưa truy vấn)
| URL | Được dùng tại | Trạng thái (nếu đã kiểm tra) |
|---|---|---|
| <https://docs.example.com/tidepool> | <README.md:21> | <chưa kiểm tra> |
## Phòng ngừa cho lần sau
- <chạy trình kiểm tra trong quy trình CI mỗi khi có thay đổi về tài liệu (mã thoát 1 sẽ làm bản dựng thất bại)>
- <khi đổi tên một tiêu đề mà nơi khác đang liên kết tới, hãy giữ lại một điểm neo tường minh mang id cũ>
FILE:examples/README.md
# Tidepool
Tidepool đồng bộ các ghi chú hiện trường từ điện thoại sang một thư mục dùng chung của nhóm.

## Mục lục
- [Bắt đầu nhanh](#quick-start)
- [Cài đặt](setup-guide.md#install)
- [Cấu hình](setup-guide.md#configuration)
- [Xử lý sự cố](setup-guide.md#troubleshooting)
- [Câu hỏi thường gặp](#faq)
## Bắt đầu nhanh
1. Cài đặt ứng dụng (xem [hướng dẫn thiết lập][setup]).
2. Đăng nhập và chọn một thư mục nhóm.
3. Đọc [ghi chú phát hành](CHANGELOG.md) trước khi nâng cấp.
Tài liệu đầy đủ được đặt tại https://docs.example.com/tidepool và tài liệu tham chiếu
API nằm tại <https://api.example.com/tidepool/v2>.
## Cách sử dụng
Chạy lệnh `tidepool sync` để đẩy các ghi chú mới lên. Các liên kết nằm bên trong khối mã sẽ không bị kiểm tra:
```
[not a link](missing.md)
```
## Cách sử dụng
Xem [Đóng góp](../CONTRIBUTING.md) và [hướng dẫn văn phong][style].
## Câu hỏi thường gặp
**Tôi có thể đồng bộ khi ngoại tuyến không?** Các ghi chú sẽ được xếp vào hàng đợi cho đến khi điện thoại có kết nối mạng trở lại.
[setup]: setup-guide.md
[style]: docs/style.md
[roadmap]: https://example.com/tidepool/roadmap
FILE:examples/setup-guide.md
# Hướng dẫn thiết lập
## Cài đặt
Tải xuống trình cài đặt từ [trang phát hành](https://github.com/example/tidepool/releases)
hoặc tự biên dịch từ mã nguồn (xem phần [Biên dịch](#building-from-source)).

## Cấu hình thư mục nhóm
Mở **Settings > Team folder** và dán liên kết thư mục vào. Thư mục này phải
được chia sẻ trước với bạn (xem phần [Chia sẻ](./README.md#Usage)).
## Biên dịch từ mã nguồn
Sao chép (clone) kho lưu trữ và chạy lệnh `make build`, sau đó tiếp tục với phần
[Bắt đầu nhanh](README.md#quick-start).
## Xử lý sự cố
Nếu quá trình đồng bộ bị treo, hãy kiểm tra tệp nhật ký tại [/Users/ana/tidepool/logs/sync.log](/Users/ana/tidepool/logs/sync.log)
hoặc đặt câu hỏi trong [kênh hỗ trợ][support].
Quay lại [README](readme.md#contents).
FILE:scripts/check_md_links.py
#!/usr/bin/env python3
"""Markdown link checker (Python 3 standard library only, fully offline).
Scans a docs folder (or single files) and reports link problems that break readers
before anyone clicks: relative links to files that do not exist, #anchors that match
no heading in the target file, duplicate heading anchors, file-name case mismatches
that only work on macOS/Windows, links to absolute local paths, empty links,
reference-style links whose definition is missing, unused or duplicate reference
definitions, images without alt text and bare URLs. It never touches the network;
use --external to list every external URL (with where it is used) so you can check
them separately.
Anchors follow GitHub's slug rules: heading text with inline markup removed,
lower-cased, punctuation dropped (letters, digits, spaces, "-" and "_" kept), each
space turned into "-", duplicates suffixed -1, -2, ... Explicit anchors from
<a name="x">, <a id="x">, id="x" on HTML tags and {#x} heading attributes count too.
Usage:
python3 check_md_links.py PATH [PATH ...] [--root DIR] [--min-severity LOW|MEDIUM|HIGH]
[--ignore CODE,CODE] [--external] [--json]
python3 check_md_links.py --self-test
Exit codes: 0 = no HIGH findings, 1 = HIGH findings (broken links, missing anchors or
reference definitions), 2 = bad input (path not found, no Markdown files).
"""
import argparse, json, os, re, sys, tempfile, unicodedata
from urllib.parse import unquote
SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
MD_EXT = (".md", ".markdown", ".mdx")
SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "__pycache__", "_site", "site", "build", "dist"}
FENCE_RE = re.compile(r"^ {0,3}(`{3,}|~{3,})")
ATX_RE = re.compile(r"^ {0,3}(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$")
SETEXT_RE = re.compile(r"^ {0,3}(=+|-+)[ \t]*$")
REFDEF_RE = re.compile(r"^ {0,3}\[([^\]]+)\]:[ \t]*(<[^>]*>|\S+)(?:[ \t]+(\"[^\"]*\"|'[^']*'|\([^)]*\)))?[ \t]*$")
INLINE_RE = re.compile(r"(!?)\[((?:[^\[\]\\]|\\.|\[[^\]]*\])*)\]\(\s*(<[^>]*>|[^\s()]*(?:\([^\s()]*\)[^\s()]*)*)(?:\s+(\"[^\"]*\"|'[^']*'|\([^)]*\)))?\s*\)")
REFLINK_RE = re.compile(r"(!?)\[((?:[^\[\]\\]|\\.)*)\]\[([^\]]*)\]")
SHORTCUT_RE = re.compile(r"(?<![\]\\!])\[([^\[\]]+)\](?![\[(:])")
AUTOLINK_RE = re.compile(r"<((?:https?|ftp)://[^>\s]+|mailto:[^>\s]+)>")
BARE_URL_RE = re.compile(r"(?<![<(\"'=\w/])(https?://[^\s<>)\]\"']+)")
HTML_ANCHOR_RE = re.compile(r"<[a-zA-Z][^>]*?\b(?:id|name)\s*=\s*[\"']([^\"']+)[\"']")
HTML_HREF_RE = re.compile(r"<a\b[^>]*?\bhref\s*=\s*[\"']([^\"']*)[\"']", re.I)
HTML_IMG_RE = re.compile(r"<img\b([^>]*)>", re.I)
ATTR_ID_RE = re.compile(r"\s*\{#([\w\-:.]+)[^}]*\}\s*$")
SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*:")
WIN_ABS_RE = re.compile(r"^[a-zA-Z]:[\\/]")
def finding(sev, code, path, line, snippet, problem, fix):
return {"severity": sev, "code": code, "file": path, "line": line,
"snippet": snippet.strip()[:120], "problem": problem, "fix": fix}
def strip_inline_markup(text):
t = re.sub(r"`([^`]*)`", r"\1", text)
t = re.sub(r"!\[([^\]]*)\]\([^)]*\)", r"\1", t)
t = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", t)
t = re.sub(r"\[([^\]]*)\]\[[^\]]*\]", r"\1", t)
t = re.sub(r"<[^>]+>", "", t)
t = re.sub(r"(\*\*|\*|~~)(?=\S)(.+?)(?<=\S)\1", r"\2", t)
t = re.sub(r"(?<!\w)(__|_)(?=\S)(.+?)(?<=\S)\1(?!\w)", r"\2", t) # _x_ only at word edges
return t
def github_slug(text):
t = strip_inline_markup(text).strip().lower()
out = []
for ch in t:
cat = unicodedata.category(ch)
if ch in "-_ " or cat[0] in "LN" or cat == "Mn":
out.append("-" if ch == " " else ch)
return "".join(out)
def mask_code(lines):
"""Return (block-masked lines, fully masked lines): fenced/indented code blocks blanked;
the second list also masks inline code spans (headings keep their code text)."""
out, fence = [], None
prev_blank = True
for line in lines:
m = FENCE_RE.match(line)
if fence:
if m and m.group(1)[0] == fence[0] and len(m.group(1)) >= len(fence) and not line.strip()[len(m.group(1)):].strip():
fence = None
out.append("")
continue
if m:
fence = m.group(1)
out.append("")
continue
if prev_blank and (line.startswith(" ") or line.startswith("\t")) and line.strip():
out.append("")
continue
prev_blank = not line.strip()
out.append(line)
inline = [re.sub(r"(`+)(.+?)\1", lambda mm: mm.group(1) + "x" * len(mm.group(2)) + mm.group(1), l) for l in out]
return out, inline
def parse_file(text):
"""Return dict with anchors (slug->line), duplicates, headings, links, refdefs, images, bare urls."""
raw = text.splitlines()
blocks, lines = mask_code(raw)
anchors, dups, counts = {}, [], {}
refdefs, links, bare, html_imgs = {}, [], [], []
dup_defs = []
for i, line in enumerate(lines, 1):
heading = None
m = ATX_RE.match(blocks[i - 1])
if m:
heading = m.group(2)
elif i >= 2 and SETEXT_RE.match(line) and blocks[i - 2].strip() and not ATX_RE.match(blocks[i - 2]) \
and not blocks[i - 2].lstrip().startswith(("-", "*", ">", "|")) and not REFDEF_RE.match(blocks[i - 2]):
if line.strip().startswith("=") or (i >= 3 and not blocks[i - 3].strip()) or i == 2:
heading = blocks[i - 2].strip()
if heading is not None:
am = ATTR_ID_RE.search(heading)
if am:
anchors.setdefault(am.group(1), i)
heading = heading[:am.start()]
base = github_slug(heading)
n = counts.get(base, 0)
counts[base] = n + 1
slug = base if n == 0 else f"{base}-{n}"
if n > 0:
dups.append((i, heading.strip(), base, slug))
anchors.setdefault(slug, i)
for am in HTML_ANCHOR_RE.finditer(blocks[i - 1]):
anchors.setdefault(am.group(1), i)
d = REFDEF_RE.match(line)
if d:
label = norm_label(d.group(1))
target = d.group(2).strip("<>")
if label in refdefs:
dup_defs.append((i, d.group(1), refdefs[label][1]))
else:
refdefs[label] = (target, i, d.group(1))
continue
spans = []
for lm in INLINE_RE.finditer(line):
target = lm.group(3).strip()
if target.startswith("<") and target.endswith(">"):
target = target[1:-1]
links.append({"kind": "image" if lm.group(1) else "link", "text": lm.group(2), "target": target,
"line": i, "snippet": lm.group(0), "ref": None})
spans.append(lm.span())
for rm in REFLINK_RE.finditer(line):
if any(a <= rm.start() < b for a, b in spans):
continue
label = rm.group(3) or rm.group(2)
links.append({"kind": "image" if rm.group(1) else "link", "text": rm.group(2), "target": None,
"line": i, "snippet": rm.group(0), "ref": norm_label(label), "explicit": True})
spans.append(rm.span())
for sm in SHORTCUT_RE.finditer(line):
if any(a <= sm.start() < b for a, b in spans):
continue
if re.match(r"^[ xX]$", sm.group(1)) or line[sm.end():sm.end() + 1] == "]":
continue # task list box or nested
links.append({"kind": "image" if sm.start() > 0 and line[sm.start() - 1] == "!" else "link",
"text": sm.group(1), "target": None, "line": i, "snippet": sm.group(0),
"ref": norm_label(sm.group(1)), "explicit": False})
spans.append(sm.span())
for hm in HTML_HREF_RE.finditer(line):
links.append({"kind": "link", "text": "", "target": hm.group(1), "line": i, "snippet": hm.group(0), "ref": None, "html": True})
for im in HTML_IMG_RE.finditer(line):
attrs = im.group(1)
src = re.search(r"\bsrc\s*=\s*[\"']([^\"']*)[\"']", attrs, re.I)
alt = re.search(r"\balt\s*=\s*[\"']([^\"']*)[\"']", attrs, re.I)
links.append({"kind": "image", "text": alt.group(1) if alt else None, "target": src.group(1) if src else "",
"line": i, "snippet": im.group(0), "ref": None, "html": True})
cleaned = AUTOLINK_RE.sub(" ", line)
cleaned = INLINE_RE.sub(" ", cleaned)
cleaned = re.sub(r"<[^>]+>", " ", cleaned)
for bm in BARE_URL_RE.finditer(cleaned):
bare.append((i, bm.group(1).rstrip(".,;:!?")))
for am in AUTOLINK_RE.finditer(line):
links.append({"kind": "autolink", "text": am.group(1), "target": am.group(1), "line": i, "snippet": am.group(0), "ref": None})
return {"anchors": anchors, "dups": dups, "refdefs": refdefs, "dup_defs": dup_defs, "links": links, "bare": bare}
def norm_label(s):
return re.sub(r"\s+", " ", s.strip()).casefold()
def is_external(target):
return bool(SCHEME_RE.match(target)) and not WIN_ABS_RE.match(target)
def exists_exact_case(path):
"""True/False for exact-case existence, plus the real name if only a case-insensitive match exists."""
if os.path.exists(path):
head, tail = os.path.split(os.path.abspath(path))
try:
names = os.listdir(head or ".")
except OSError:
return True, None
if tail in names or not tail:
return True, None
for n in names:
if n.lower() == tail.lower():
return False, n
return True, None
head, tail = os.path.split(os.path.abspath(path))
try:
for n in os.listdir(head):
if n.lower() == tail.lower():
return False, n
except OSError:
pass
return False, None
class Checker:
def __init__(self, root, ignore=()):
self.root = os.path.abspath(root)
self.ignore = set(ignore)
self.cache = {}
self.findings = []
self.external = {}
def parsed(self, path):
path = os.path.abspath(path)
if path not in self.cache:
with open(path, encoding="utf-8", errors="replace") as fh:
self.cache[path] = parse_file(fh.read())
return self.cache[path]
def add(self, *a):
f = finding(*a)
if f["code"] not in self.ignore:
self.findings.append(f)
def rel(self, p):
return os.path.relpath(p, self.root)
def check_file(self, path):
info = self.parsed(path)
rp = self.rel(path)
for line, heading, base, slug in info["dups"]:
self.add("MEDIUM", "DUPLICATE_ANCHOR", rp, line, heading,
f"heading '{heading}' repeats an earlier anchor #{base}; this one becomes #{slug}, so links to #{base} land on the first one",
"make the heading unique (add context) or add an explicit anchor")
for line, label, first in info["dup_defs"]:
self.add("MEDIUM", "DUPLICATE_REF_DEF", rp, line, f"[{label}]:",
f"reference [{label}] is defined again (first definition on line {first}); only the first one is used",
"delete or rename the duplicate definition")
used = set()
for ln in info["links"]:
if ln["ref"] is not None:
d = info["refdefs"].get(ln["ref"])
if d is None:
if ln.get("explicit"):
self.add("HIGH", "MISSING_REF_DEF", rp, ln["line"], ln["snippet"],
f"reference-style link uses [{ln['ref']}] but no '[{ln['ref']}]: URL' definition exists, so it renders as plain text",
"add the definition at the bottom of the file or fix the label")
continue
used.add(ln["ref"])
target = d[0]
else:
target = ln["target"]
self.check_link(path, rp, ln, target)
for label, (target, line, orig) in info["refdefs"].items():
if label not in used:
self.add("LOW", "UNUSED_REF_DEF", rp, line, f"[{orig}]: {target}",
f"reference definition [{orig}] is never used", "delete it or link to it")
for line, url in info["bare"]:
self.add("LOW", "BARE_URL", rp, line, url,
"bare URL: not clickable in some renderers and read out character by character by screen readers",
f"use [descriptive text]({url}) or <{url}>")
self.external.setdefault(url, []).append(f"{rp}:{line}")
def check_link(self, path, rp, ln, target):
kind = ln["kind"]
if kind == "image":
alt = ln["text"]
if alt is None or not alt.strip():
self.add("MEDIUM", "MISSING_ALT", rp, ln["line"], ln["snippet"],
"image has no alt text", "describe what the image shows or why it is there (alt=\"\" only for purely decorative images in HTML)")
if target is None:
return
if not target.strip():
self.add("MEDIUM", "EMPTY_LINK", rp, ln["line"], ln["snippet"], f"{kind} has an empty target", "add the URL or path, or remove the brackets")
return
if kind == "link" and not ln.get("html") and not strip_inline_markup(ln["text"]).strip():
self.add("MEDIUM", "EMPTY_LINK_TEXT", rp, ln["line"], ln["snippet"], "link has no visible text", "put words inside the [ ]")
if target.startswith("mailto:") or target.startswith("tel:"):
return
if target.startswith("file:") or WIN_ABS_RE.match(target) or re.match(r"^/(home|Users|tmp|var|mnt|opt)/", target):
self.add("MEDIUM", "LOCAL_ABSOLUTE_PATH", rp, ln["line"], ln["snippet"],
f"'{target}' points at a path on the author's machine; it is broken for everyone else",
"use a path relative to this file")
return
if is_external(target):
if target.startswith(("http://", "https://", "ftp://")):
self.external.setdefault(target, []).append(f"{rp}:{ln['line']}")
return
target_path, _, frag = target.partition("#")
target_path = unquote(target_path.split("?")[0])
frag = unquote(frag)
if target_path:
if target_path.startswith("/"):
full = os.path.normpath(os.path.join(self.root, target_path.lstrip("/")))
else:
full = os.path.normpath(os.path.join(os.path.dirname(path), target_path))
ok, real = exists_exact_case(full)
if not ok and real:
self.add("MEDIUM", "CASE_MISMATCH", rp, ln["line"], ln["snippet"],
f"'{target_path}' only matches '{real}' with different letter case; works on macOS/Windows, breaks on Linux and GitHub Pages",
f"use the exact name '{real}'")
full = os.path.join(os.path.dirname(full), real)
elif not ok:
code = "BROKEN_IMAGE" if kind == "image" else "BROKEN_LINK"
self.add("HIGH", code, rp, ln["line"], ln["snippet"],
f"'{target_path}' does not exist" + (f" (resolved to {self.rel(full)})" if self.rel(full) != os.path.normpath(target_path) else ""),
"fix the path (moved or renamed file?) or remove the link")
return
if not os.path.abspath(full).startswith(self.root + os.sep) and os.path.abspath(full) != self.root:
self.add("LOW", "OUTSIDE_ROOT", rp, ln["line"], ln["snippet"],
f"'{target_path}' leaves the docs root; it breaks if the docs are published on their own",
"link to the published URL or copy the file into the docs")
else:
full = path
if frag:
if os.path.isdir(full) or not full.lower().endswith(MD_EXT):
return
info = self.parsed(full)
if frag in info["anchors"]:
return
if frag.lower() in info["anchors"]:
self.add("MEDIUM", "ANCHOR_CASE", rp, ln["line"], ln["snippet"],
f"#{frag} only matches #{frag.lower()} case-insensitively; GitHub anchors are lower-case",
f"use #{frag.lower()}")
return
if re.match(r"^L\d+(-L\d+)?$", frag):
return # GitHub line anchors
key = frag.lower().replace("-", "")
close = [a for a in info["anchors"] if a.replace("-", "") == key] or \
[a for a in info["anchors"] if len(key) >= 5 and key[:6] in a.replace("-", "")]
hint = f" Did you mean #{close[0]}?" if close else ""
self.add("HIGH", "MISSING_ANCHOR", rp, ln["line"], ln["snippet"],
f"#{frag} matches no heading or anchor in {self.rel(full)}.{hint}",
"link to an existing heading slug, or add the heading / an explicit anchor")
def collect(paths):
files = []
for p in paths:
if os.path.isdir(p):
for dirpath, dirnames, filenames in os.walk(p):
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
files += [os.path.join(dirpath, f) for f in sorted(filenames) if f.lower().endswith(MD_EXT)]
elif os.path.isfile(p):
files.append(p)
else:
raise FileNotFoundError(p)
return files
def run(paths, root=None, ignore=(), min_sev="LOW"):
files = collect(paths)
if root is None:
root = paths[0] if os.path.isdir(paths[0]) else os.path.dirname(os.path.abspath(paths[0]))
ck = Checker(root, ignore)
for f in files:
ck.check_file(os.path.abspath(f))
lim = SEV_ORDER[min_sev]
found = [f for f in ck.findings if SEV_ORDER[f["severity"]] <= lim]
found.sort(key=lambda f: (f["file"], f["line"], SEV_ORDER[f["severity"]]))
return files, found, ck.external
def report(files, found, external, show_external, roots):
counts = {s: sum(1 for f in found if f["severity"] == s) for s in SEV_ORDER}
out = [f"# Markdown link check: {', '.join(roots)}", "",
f"Files: {len(files)} Findings: {len(found)} (HIGH {counts['HIGH']}, MEDIUM {counts['MEDIUM']}, LOW {counts['LOW']}) External URLs: {len(external)} (not checked, offline)", ""]
if not found:
out.append("No findings.")
else:
out += ["| Sev | Code | File:Line | Problem | Fix |", "|---|---|---|---|---|"]
for f in found:
out.append(f"| {f['severity']} | {f['code']} | {f['file']}:{f['line']} | {f['problem'].replace('|', '/')} | {f['fix'].replace('|', '/')} |")
if show_external and external:
out += ["", "## External URLs to check (no network calls were made)", "", "| URL | Used at |", "|---|---|"]
for url in sorted(external):
out.append(f"| {url} | {', '.join(external[url])} |")
return "\n".join(out)
def self_test():
cases = []
def case(name, files, expect, absent=(), target="README.md"):
cases.append((name, files, expect, absent, target))
case("clean file", {"README.md": "# Title\n\nSee [setup](setup.md#install).\n", "setup.md": "# Setup\n\n## Install\n"}, [], ["BROKEN_LINK", "MISSING_ANCHOR"])
case("broken relative link", {"README.md": "[x](docs/missing.md)\n"}, ["BROKEN_LINK"])
case("broken image", {"README.md": "\n"}, ["BROKEN_IMAGE"])
case("missing anchor other file", {"README.md": "[x](a.md#nope)\n", "a.md": "# Yes\n"}, ["MISSING_ANCHOR"])
case("missing anchor same file", {"README.md": "# Intro\n[x](#usage)\n"}, ["MISSING_ANCHOR"])
case("anchor with punctuation", {"README.md": "## What's new? (v2.0)\n[x](#whats-new-v20)\n"}, [], ["MISSING_ANCHOR"])
case("anchor with inline code", {"README.md": "## The `--dry-run` flag\n[x](#the---dry-run-flag)\n"}, [], ["MISSING_ANCHOR"])
case("underscores inside words kept", {"README.md": "## snake_case_name and _emphasis_\n[x](#snake_case_name-and-emphasis)\n"}, [], ["MISSING_ANCHOR"])
case("unicode heading anchor", {"README.md": "## Über uns\n[x](#über-uns)\n"}, [], ["MISSING_ANCHOR"])
case("duplicate heading", {"README.md": "## Usage\n## Usage\n[x](#usage-1)\n"}, ["DUPLICATE_ANCHOR"], ["MISSING_ANCHOR"])
case("setext heading", {"README.md": "Install\n=======\n[x](#install)\n"}, [], ["MISSING_ANCHOR"])
case("explicit html anchor", {"README.md": '<a id="faq"></a>\n[x](#faq)\n'}, [], ["MISSING_ANCHOR"])
case("attribute id anchor", {"README.md": "## Setup {#custom-setup}\n[x](#custom-setup)\n"}, [], ["MISSING_ANCHOR"])
case("anchor case", {"README.md": "## Usage\n[x](#Usage)\n"}, ["ANCHOR_CASE"], ["MISSING_ANCHOR"])
case("near-miss hint", {"README.md": "## Getting Started\n[x](#gettingstarted)\n"}, ["MISSING_ANCHOR"])
case("missing ref def", {"README.md": "See [the guide][guide].\n"}, ["MISSING_REF_DEF"])
case("unused ref def", {"README.md": "Text.\n\n[old]: https://example.com\n"}, ["UNUSED_REF_DEF"])
case("used ref def shortcut", {"README.md": "See [guide].\n\n[guide]: setup.md\n", "setup.md": "# S\n"}, [], ["UNUSED_REF_DEF", "BROKEN_LINK"])
case("ref def case-insensitive", {"README.md": "See [Guide][G].\n\n[g]: setup.md\n", "setup.md": "# S\n"}, [], ["MISSING_REF_DEF", "UNUSED_REF_DEF"])
case("ref def broken target", {"README.md": "See [g][].\n\n[g]: nope.md\n"}, ["BROKEN_LINK"])
case("duplicate ref def", {"README.md": "[a][x]\n\n[x]: https://a.example\n[x]: https://b.example\n"}, ["DUPLICATE_REF_DEF"])
case("image without alt", {"README.md": "\n", "shot.png": "png"}, ["MISSING_ALT"], ["BROKEN_IMAGE"])
case("html img without alt", {"README.md": '<img src="shot.png" width="300">\n', "shot.png": "png"}, ["MISSING_ALT"])
case("bare url", {"README.md": "Docs at https://example.com/docs today.\n"}, ["BARE_URL"])
case("autolink is not bare", {"README.md": "Docs at <https://example.com/docs>.\n"}, [], ["BARE_URL"])
case("code block ignored", {"README.md": "```\n[x](missing.md) https://x.example\n```\n"}, [], ["BROKEN_LINK", "BARE_URL"])
case("inline code ignored", {"README.md": "Use `[x](missing.md)` syntax.\n"}, [], ["BROKEN_LINK"])
case("case mismatch", {"README.md": "[c](CONTRIBUTING.MD)\n", "CONTRIBUTING.md": "# C\n"}, ["CASE_MISMATCH"], ["BROKEN_LINK"])
case("local absolute path", {"README.md": "[x](/Users/ana/repo/docs/a.md)\n"}, ["LOCAL_ABSOLUTE_PATH"])
case("empty target", {"README.md": "[todo]()\n"}, ["EMPTY_LINK"])
case("url-encoded path", {"README.md": "[x](my%20notes.md)\n", "my notes.md": "# N\n"}, [], ["BROKEN_LINK"])
case("angle-bracket path with spaces", {"README.md": "[x](<my notes.md>)\n", "my notes.md": "# N\n"}, [], ["BROKEN_LINK"])
case("directory link ok", {"README.md": "[api](api/)\n", "api/index.md": "# API\n"}, [], ["BROKEN_LINK"])
case("root-relative link", {"README.md": "[x](/guide/a.md)\n", "guide/a.md": "# A\n"}, [], ["BROKEN_LINK"])
case("github line anchor", {"README.md": "[x](setup.md#L10)\n", "setup.md": "# S\n"}, [], ["MISSING_ANCHOR"])
case("task list not a link", {"README.md": "- [ ] write docs\n- [x] done\n"}, [], ["MISSING_REF_DEF"])
case("mailto ignored", {"README.md": "[mail](mailto:team@example.com)\n"}, [], ["BROKEN_LINK"])
case("nested subdir relative", {"README.md": "x", "guide/a.md": "[b](../b.md#top)\n", "b.md": "# Top\n"}, [], ["BROKEN_LINK", "MISSING_ANCHOR"], "guide/a.md")
passed = 0
for name, files, expect, absent, target in cases:
with tempfile.TemporaryDirectory() as d:
for rel, body in files.items():
p = os.path.join(d, rel)
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, "w", encoding="utf-8") as fh:
fh.write(body)
_, found, _ = run([d], root=d)
codes = {f["code"] for f in found if f["file"] == target}
ok = all(c in codes for c in expect) and not any(c in codes for c in absent)
if name == "clean file":
ok = ok and not found
if name == "near-miss hint":
ok = ok and any("Did you mean #getting-started" in f["problem"] for f in found)
passed += ok
print(("PASS " if ok else "FAIL ") + name + ("" if ok else f" -> got {sorted(codes)}"))
# external URL listing and report rendering
with tempfile.TemporaryDirectory() as d:
with open(os.path.join(d, "a.md"), "w") as fh:
fh.write("[x](https://example.com/a) and [y](https://example.com/a)\n")
files, found, ext = run([d])
ok = list(ext) == ["https://example.com/a"] and len(ext["https://example.com/a"]) == 2
text = report(files, found, ext, True, [d])
ok = ok and "External URLs to check" in text and "No findings." in text
passed += ok
print(("PASS " if ok else "FAIL ") + "external URLs listed once with locations, no network")
total = len(cases) + 1
print(f"{passed}/{total} passed")
return 0 if passed == total else 1
def main(argv=None):
ap = argparse.ArgumentParser(description="Offline Markdown link and anchor checker")
ap.add_argument("paths", nargs="*")
ap.add_argument("--root", help="docs root for /absolute links and the OUTSIDE_ROOT check (default: first folder)")
ap.add_argument("--min-severity", default="LOW", choices=list(SEV_ORDER))
ap.add_argument("--ignore", default="", help="comma-separated finding codes to skip")
ap.add_argument("--external", action="store_true", help="list external URLs to check separately")
ap.add_argument("--json", action="store_true")
ap.add_argument("--self-test", action="store_true")
a = ap.parse_args(argv)
if a.self_test:
return self_test()
if not a.paths:
ap.print_usage(sys.stderr)
return 2
try:
files, found, ext = run(a.paths, a.root, [c.strip() for c in a.ignore.split(",") if c.strip()], a.min_severity)
except FileNotFoundError as e:
print(f"error: path not found: {e}", file=sys.stderr)
return 2
if not files:
print("error: no Markdown files found", file=sys.stderr)
return 2
if a.json:
print(json.dumps({"files": len(files), "findings": found, "external": ext if a.external else {}}, indent=2, ensure_ascii=False))
else:
print(report(files, found, ext, a.external, a.paths))
return 1 if any(f["severity"] == "HIGH" for f in found) else 0
if __name__ == "__main__":
sys.exit(main())