| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | """MkDocs hook + markdown extension to enable the following links to render correctly, |
| 4 | including inside content included via pymdownx.snippets: |
| 5 | |
| 6 | - Relative file links outside of the `docs/` directory, e.g.: |
| 7 | - [Text](../some_file.py) |
| 8 | - [Directory](../../some_directory/) |
| 9 | - GitHub URLs for issues, pull requests, and projects, e.g.: |
| 10 | - Adds GitHub icon before links |
| 11 | - Replaces raw links with descriptive text, |
| 12 | e.g. <...pull/123> -> [Pull Request #123](.../pull/123) |
| 13 | - Works for external repos too by including the `owner/repo` in the link title |
| 14 | |
| 15 | The link replacement runs as a markdown preprocessor (priority 25) so that it executes |
| 16 | after pymdownx.snippets (priority 32) has expanded all included content. |
| 17 | The on_page_markdown hook passes the current page context to the preprocessor before |
| 18 | each page is converted. |
| 19 | """ |
| 20 | |
| 21 | import posixpath |
| 22 | from pathlib import Path |
| 23 | |
| 24 | import regex as re |
| 25 | from markdown import Extension |
| 26 | from markdown.preprocessors import Preprocessor |
| 27 | from mkdocs.config.defaults import MkDocsConfig |
| 28 | from mkdocs.structure.files import Files |
| 29 | from mkdocs.structure.pages import Page |
| 30 | |
| 31 | ROOT_DIR = Path(__file__).parent.parent.parent.parent.resolve() |
| 32 | DOC_DIR = ROOT_DIR / "docs" |
| 33 | |
| 34 | gh_icon = ":octicons-mark-github-16:" |
| 35 | |
| 36 | # Regex pieces |
| 37 | TITLE = r"(?P<title>[^\[\]<>]+?)" |
| 38 | REPO = r"(?P<repo>.+?/.+?)" |
| 39 | TYPE = r"(?P<type>issues|pull|projects)" |
| 40 | NUMBER = r"(?P<number>\d+)" |
| 41 | VERSION = r"[^/\s]+" |
| 42 | PATH = r"(?P<path>[^\s]+?)" |
| 43 | FRAGMENT = r"(?P<fragment>#[^\s]+)?" |
| 44 | URL_GITHUB = f"https://github.com/{REPO}/{TYPE}/{NUMBER}{FRAGMENT}" |
| 45 | RELATIVE = rf"(?!(https?|ftp)://|#){PATH}{FRAGMENT}" |
| 46 | URL_DOCS = f"https://docs.vllm.ai/en/{VERSION}/{PATH}{FRAGMENT}" |
| 47 | |
| 48 | # Common titles to use for GitHub links when none is provided in the link. |
| 49 | TITLES = {"issues": "Issue ", "pull": "Pull Request ", "projects": "Project "} |
| 50 | |
| 51 | # Regex to match GitHub issue, PR, and project links with optional titles. |
| 52 | github_link = re.compile(rf"(\[{TITLE}\]\(|<){URL_GITHUB}(\)|>)") |
| 53 | # Regex to match relative file links with optional titles. |
| 54 | relative_link = re.compile(rf"\[{TITLE}\]\({RELATIVE}\)") |
| 55 | # Regex to match absolute docs.vllm.ai links (should only exist in CLI). |
| 56 | docs_link = re.compile(rf"\[{TITLE}\]\({URL_DOCS}\)") |
| 57 | |
| 58 | |
| 59 | class UrlSchemesPreprocessor(Preprocessor): |
| 60 | """Preprocessor that runs after pymdownx.snippets to process all links.""" |
| 61 | |
| 62 | def __init__(self, md, ext): |
| 63 | super().__init__(md) |
| 64 | self.ext = ext |
| 65 | |
| 66 | def run(self, lines): |
| 67 | page = self.ext.page |
| 68 | files = self.ext.files |
| 69 | if page is None: |
| 70 | return lines |
| 71 | |
| 72 | def replace_relative_link(match: re.Match) -> str: |
| 73 | """Replace relative file links pointing outside the docs dir with URLs.""" |
| 74 | title = match.group("title") |
| 75 | path = match.group("path") |
| 76 | path = ((DOC_DIR / page.file.src_uri).parent / path).resolve() |
| 77 | fragment = match.group("fragment") or "" |
| 78 | |
| 79 | # Check if the path exists and is outside the docs dir |
| 80 | if not path.exists() or path.is_relative_to(DOC_DIR): |
| 81 | return match.group(0) |
| 82 | |
| 83 | # Files and directories have different URL schemes on GitHub |
| 84 | slug = "tree/main" if path.is_dir() else "blob/main" |
| 85 | |
| 86 | path = path.relative_to(ROOT_DIR) |
| 87 | url = f"https://github.com/vllm-project/vllm/{slug}/{path}{fragment}" |
| 88 | return f"[{gh_icon} {title}]({url})" |
| 89 | |
| 90 | def replace_github_link(match: re.Match) -> str: |
| 91 | """Replace GitHub issue, PR, and project links with rich Markdown links.""" |
| 92 | repo = match.group("repo") |
| 93 | type = match.group("type") |
| 94 | number = match.group("number") |
| 95 | # Title and fragment could be None |
| 96 | title = match.group("title") or "" |
| 97 | fragment = match.group("fragment") or "" |
| 98 | |
| 99 | # Use default titles for raw links |
| 100 | if not title: |
| 101 | title = TITLES[type] |
| 102 | if "vllm-project" not in repo: |
| 103 | title += repo |
| 104 | title += f"#{number}" |
| 105 | |
| 106 | url = f"https://github.com/{repo}/{type}/{number}{fragment}" |
| 107 | return f"[{gh_icon} {title}]({url})" |
| 108 | |
| 109 | def replace_docs_link(match: re.Match) -> str: |
| 110 | """Rewrite absolute docs.vllm.ai links as doc-relative links.""" |
| 111 | title = match.group("title") |
| 112 | path = match.group("path").rstrip("/") |
| 113 | fragment = match.group("fragment") or "" |
| 114 | |
| 115 | # vllm.config.<Class> API reference -> mkdocstrings cross-reference |
| 116 | if path == "api/vllm/config" and re.fullmatch( |
| 117 | r"#vllm\.config\.\w+", fragment |
| 118 | ): |
| 119 | ident = fragment[1:] |
| 120 | return f"[`{ident}`][{ident}]" |
| 121 | |
| 122 | # Other docs pages -> link relative to the current page, but only |
| 123 | # when the target is a known docs page (real or generated); leave |
| 124 | # unknown/external URLs untouched. This is correct even when the same |
| 125 | # docstring is also rendered on its API reference page. |
| 126 | src = f"{path.removesuffix('.html')}.md" |
| 127 | if files.get_file_from_path(src) is None: |
| 128 | return match.group(0) |
| 129 | rel = posixpath.relpath(src, posixpath.dirname(page.file.src_uri)) |
| 130 | # Auto-wrapped bare URLs use the URL as their title; make it readable. |
| 131 | if title.startswith("http"): |
| 132 | title = path.removesuffix(".html") |
| 133 | return f"[{title}]({rel}{fragment})" |
| 134 | |
| 135 | markdown = "\n".join(lines) |
| 136 | markdown = github_link.sub(replace_github_link, markdown) |
| 137 | markdown = relative_link.sub(replace_relative_link, markdown) |
| 138 | markdown = docs_link.sub(replace_docs_link, markdown) |
| 139 | return markdown.split("\n") |
| 140 | |
| 141 | |
| 142 | class UrlSchemesExtension(Extension): |
| 143 | """Markdown extension that registers the URL schemes preprocessor.""" |
| 144 | |
| 145 | def __init__(self, **kwargs): |
| 146 | self.page = None |
| 147 | self.files = None |
| 148 | super().__init__(**kwargs) |
| 149 | |
| 150 | def extendMarkdown(self, md): |
| 151 | # Priority 25 runs after pymdownx.snippets (priority 32) |
| 152 | md.preprocessors.register(UrlSchemesPreprocessor(md, self), "url_schemes", 25) |
| 153 | |
| 154 | |
| 155 | # Singleton extension instance shared between the hook and the preprocessor. |
| 156 | _ext = UrlSchemesExtension() |
| 157 | |
| 158 | |
| 159 | def on_config(config: MkDocsConfig) -> MkDocsConfig: |
| 160 | """Register the URL schemes markdown extension.""" |
| 161 | config["markdown_extensions"].append(_ext) |
| 162 | return config |
| 163 | |
| 164 | |
| 165 | def on_page_markdown( |
| 166 | markdown: str, *, page: Page, config: MkDocsConfig, files: Files |
| 167 | ) -> str: |
| 168 | """Pass the current page context to the preprocessor.""" |
| 169 | _ext.page = page |
| 170 | _ext.files = files |
| 171 | return markdown |