mkdocs-ultralytics-plugin 0.2.0__tar.gz → 0.2.2__tar.gz

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.
Files changed (16) hide show
  1. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/PKG-INFO +1 -1
  2. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/mkdocs_ultralytics_plugin.egg-info/PKG-INFO +1 -1
  3. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/plugin/__init__.py +1 -1
  4. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/plugin/main.py +21 -0
  5. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/plugin/postprocess.py +30 -7
  6. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/plugin/processor.py +148 -38
  7. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/plugin/utils.py +15 -46
  8. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/LICENSE +0 -0
  9. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/README.md +0 -0
  10. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/mkdocs_ultralytics_plugin.egg-info/SOURCES.txt +0 -0
  11. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/mkdocs_ultralytics_plugin.egg-info/dependency_links.txt +0 -0
  12. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/mkdocs_ultralytics_plugin.egg-info/entry_points.txt +0 -0
  13. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/mkdocs_ultralytics_plugin.egg-info/requires.txt +0 -0
  14. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/mkdocs_ultralytics_plugin.egg-info/top_level.txt +0 -0
  15. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/pyproject.toml +0 -0
  16. {mkdocs_ultralytics_plugin-0.2.0 → mkdocs_ultralytics_plugin-0.2.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs-ultralytics-plugin
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: An MkDocs plugin that provides Ultralytics Docs customizations at https://docs.ultralytics.com.
5
5
  Author-email: Glenn Jocher <hello@ultralytics.com>
6
6
  Maintainer-email: Ultralytics <hello@ultralytics.com>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mkdocs-ultralytics-plugin
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: An MkDocs plugin that provides Ultralytics Docs customizations at https://docs.ultralytics.com.
5
5
  Author-email: Glenn Jocher <hello@ultralytics.com>
6
6
  Maintainer-email: Ultralytics <hello@ultralytics.com>
@@ -1,6 +1,6 @@
1
1
  # Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
4
4
 
5
5
  from .main import MetaPlugin
6
6
  from .postprocess import postprocess_site
@@ -2,9 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from pathlib import Path
6
+
5
7
  from mkdocs.config import config_options
6
8
  from mkdocs.plugins import BasePlugin
7
9
 
10
+ import plugin.processor as processor
8
11
  from plugin.processor import process_html
9
12
 
10
13
 
@@ -26,6 +29,22 @@ class MetaPlugin(BasePlugin):
26
29
  ("add_copy_llm", config_options.Type(bool, default=True)),
27
30
  )
28
31
 
32
+ def __init__(self):
33
+ super().__init__()
34
+ self.git_repo_url = None
35
+ self.git_data = None
36
+
37
+ def on_config(self, config):
38
+ """Prepare git metadata once for all pages if authors/JSON-LD are enabled."""
39
+ if not self.config.get("enabled", True):
40
+ return config
41
+
42
+ if self.config.get("add_authors") or self.config.get("add_json_ld"):
43
+ docs_dir = Path(config["docs_dir"])
44
+ md_files = [str(p) for p in docs_dir.rglob("*.md")] if docs_dir.exists() else []
45
+ self.git_repo_url, self.git_data = processor.build_git_map(md_files)
46
+ return config
47
+
29
48
  def on_post_page(self, output: str, page, config) -> str:
30
49
  """Enhance HTML output by delegating to shared processor."""
31
50
  if not self.config["enabled"]:
@@ -47,6 +66,8 @@ class MetaPlugin(BasePlugin):
47
66
  page_url=page_url,
48
67
  title=title,
49
68
  src_path=page.file.abs_src_path,
69
+ git_data=self.git_data,
70
+ repo_url=self.git_repo_url,
50
71
  default_image=self.config["default_image"],
51
72
  default_author=self.config["default_author"],
52
73
  keywords=keywords,
@@ -3,8 +3,15 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ from collections.abc import Callable
6
7
  from pathlib import Path
7
8
 
9
+ try:
10
+ from ultralytics.utils import TQDM # progress bars
11
+ except ImportError:
12
+ TQDM = None
13
+
14
+ import plugin.processor as processor
8
15
  from plugin.processor import process_html
9
16
 
10
17
 
@@ -12,6 +19,8 @@ def process_html_file(
12
19
  html_path: Path,
13
20
  site_dir: Path,
14
21
  md_index: dict[str, str],
22
+ git_data: dict[str, dict[str, str | dict]] | None,
23
+ repo_url: str | None,
15
24
  site_url: str = "",
16
25
  default_image: str | None = None,
17
26
  default_author: str | None = None,
@@ -24,6 +33,7 @@ def process_html_file(
24
33
  add_css: bool = True,
25
34
  add_copy_llm: bool = True,
26
35
  verbose: bool = False,
36
+ log: Callable[[str], None] | None = print,
27
37
  ) -> bool:
28
38
  """Process a single HTML file by delegating to shared processor.
29
39
 
@@ -35,8 +45,8 @@ def process_html_file(
35
45
  try:
36
46
  html = html_path.read_text(encoding="utf-8")
37
47
  except (UnicodeDecodeError, FileNotFoundError) as e:
38
- if verbose:
39
- print(f"Error reading {html_path}: {e}")
48
+ if verbose and log:
49
+ log(f"Error reading {html_path}: {e}")
40
50
  return False
41
51
 
42
52
  soup = BeautifulSoup(html, "html.parser")
@@ -65,6 +75,8 @@ def process_html_file(
65
75
  page_url=page_url,
66
76
  title=title,
67
77
  src_path=src_path,
78
+ git_data=git_data,
79
+ repo_url=repo_url,
68
80
  default_image=default_image,
69
81
  default_author=default_author,
70
82
  keywords=keywords,
@@ -81,12 +93,10 @@ def process_html_file(
81
93
  # Write back
82
94
  try:
83
95
  html_path.write_text(processed_html, encoding="utf-8")
84
- if verbose:
85
- print(f"Processed: {html_path.relative_to(site_dir)}")
86
96
  return True
87
97
  except (OSError, PermissionError) as e:
88
- if verbose:
89
- print(f"Error writing {html_path}: {e}")
98
+ if verbose and log:
99
+ log(f"Error writing {html_path}: {e}")
90
100
  return False
91
101
 
92
102
 
@@ -129,11 +139,21 @@ def postprocess_site(
129
139
  print(f"Processing {len(html_files)} HTML files in {site_dir}")
130
140
 
131
141
  processed = 0
132
- for html_file in html_files:
142
+ repo_url = None
143
+ git_data = None
144
+ if (add_authors or add_json_ld) and md_index:
145
+ repo_url, git_data = processor.build_git_map(list(md_index.values()))
146
+
147
+ progress = TQDM(html_files, desc="Postprocessing", unit="file", disable=not verbose) if TQDM else None
148
+ log_fn = (progress.write if verbose and progress else print) if verbose else None
149
+ iterator = progress if progress else html_files
150
+ for html_file in iterator:
133
151
  success = process_html_file(
134
152
  html_file,
135
153
  site_dir,
136
154
  md_index,
155
+ git_data,
156
+ repo_url,
137
157
  site_url=site_url,
138
158
  default_image=default_image,
139
159
  default_author=default_author,
@@ -146,9 +166,12 @@ def postprocess_site(
146
166
  add_css=add_css,
147
167
  add_copy_llm=add_copy_llm,
148
168
  verbose=verbose,
169
+ log=log_fn,
149
170
  )
150
171
  if success:
151
172
  processed += 1
173
+ if progress:
174
+ progress.close()
152
175
 
153
176
  print(f"✅ Postprocessing complete: {processed}/{len(html_files)} files processed")
154
177
 
@@ -27,37 +27,47 @@ COPY_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d
27
27
  CHECK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19L21 7l-1.41-1.41L9 16.17z"></path></svg>'
28
28
 
29
29
 
30
- def get_git_info(file_path: str, add_authors: bool = True, default_author: str | None = None) -> dict[str, Any]:
31
- """Retrieve git information including creation/modified dates and optional authors."""
30
+ def get_git_info(
31
+ file_path: str,
32
+ add_authors: bool = True,
33
+ default_author: str | None = None,
34
+ git_data: dict[str, dict[str, Any]] | None = None,
35
+ repo_url: str | None = None,
36
+ ) -> dict[str, Any]:
37
+ """Retrieve git information (dates + optional authors) from precomputed git data."""
32
38
  file_path = str(Path(file_path).resolve())
33
39
  git_info = {
34
40
  "creation_date": DEFAULT_CREATION_DATE,
35
41
  "last_modified_date": DEFAULT_MODIFIED_DATE,
36
42
  }
37
43
 
38
- try:
39
- subprocess.check_output(["git", "rev-parse", "--is-inside-work-tree"], stderr=subprocess.DEVNULL)
40
- creation_output = subprocess.check_output(
41
- ["git", "log", "--reverse", "--pretty=format:%ai", file_path]
42
- ).decode()
43
- creation_date = creation_output.split("\n")[0] if creation_output else ""
44
- last_modified_date = subprocess.check_output(["git", "log", "-1", "--pretty=format:%ai", file_path]).decode()
45
- git_info.update(
46
- {
47
- "creation_date": creation_date or DEFAULT_CREATION_DATE,
48
- "last_modified_date": last_modified_date or DEFAULT_MODIFIED_DATE,
49
- }
50
- )
44
+ if not git_data or file_path not in git_data:
45
+ return git_info
51
46
 
52
- if add_authors:
53
- authors_info = get_github_usernames_from_file(file_path, default_user=default_author)
54
- git_info["authors"] = sorted(
55
- [(author, info["url"], info["changes"], info["avatar"]) for author, info in authors_info.items()],
56
- key=lambda x: x[2],
57
- reverse=True,
58
- )
59
- except (subprocess.CalledProcessError, FileNotFoundError):
60
- pass
47
+ cached = git_data[file_path]
48
+ git_info.update(
49
+ {
50
+ "creation_date": cached.get("creation_date", DEFAULT_CREATION_DATE),
51
+ "last_modified_date": cached.get("last_modified_date", DEFAULT_MODIFIED_DATE),
52
+ }
53
+ )
54
+
55
+ if add_authors and cached.get("emails"):
56
+ git_info["authors"] = sorted(
57
+ [
58
+ (
59
+ author,
60
+ info["url"],
61
+ info["changes"],
62
+ info["avatar"],
63
+ )
64
+ for author, info in get_github_usernames_from_file(
65
+ file_path, default_user=default_author, emails=cached["emails"], repo_url=repo_url
66
+ ).items()
67
+ ],
68
+ key=lambda x: x[2],
69
+ reverse=True,
70
+ )
61
71
 
62
72
  return git_info
63
73
 
@@ -104,6 +114,90 @@ def insert_content(soup: BeautifulSoup, content_to_insert) -> None:
104
114
  md_typeset.append(content_to_insert)
105
115
 
106
116
 
117
+ def build_git_map(file_paths: list[str] | list[Path]) -> tuple[str | None, dict[str, dict[str, Any]]]:
118
+ """Build git metadata for provided files using a single git log pass."""
119
+ git_data: dict[str, dict[str, Any]] = {}
120
+ repo_url: str | None = None
121
+
122
+ if not file_paths:
123
+ return repo_url, git_data
124
+
125
+ try:
126
+ repo_root = Path(
127
+ subprocess.check_output(["git", "rev-parse", "--show-toplevel"], stderr=subprocess.DEVNULL).decode().strip()
128
+ )
129
+ except subprocess.CalledProcessError:
130
+ return repo_url, git_data
131
+
132
+ try:
133
+ github_repo_url = subprocess.check_output(
134
+ ["git", "-C", str(repo_root), "config", "--get", "remote.origin.url"], stderr=subprocess.DEVNULL
135
+ ).decode("utf-8")
136
+ github_repo_url = github_repo_url.strip()
137
+ if github_repo_url.endswith(".git"):
138
+ github_repo_url = github_repo_url[:-4]
139
+ if github_repo_url.startswith("git@"):
140
+ github_repo_url = "https://" + github_repo_url[4:].replace(":", "/")
141
+ repo_url = github_repo_url or None
142
+ except subprocess.CalledProcessError:
143
+ repo_url = None
144
+
145
+ rel_paths = []
146
+ for fp in file_paths:
147
+ path = Path(fp)
148
+ if path.exists():
149
+ try:
150
+ rel_paths.append(path.resolve().relative_to(repo_root))
151
+ except ValueError:
152
+ continue
153
+ if not rel_paths:
154
+ return repo_url, git_data
155
+
156
+ cmd = [
157
+ "git",
158
+ "-C",
159
+ str(repo_root),
160
+ "log",
161
+ "--name-only",
162
+ "--pretty=format:%ad\t%ae",
163
+ "--date=format:%Y-%m-%d %H:%M:%S %z",
164
+ "--",
165
+ *[str(p) for p in rel_paths],
166
+ ]
167
+
168
+ try:
169
+ output = subprocess.check_output(cmd, stderr=subprocess.DEVNULL).decode().splitlines()
170
+ except subprocess.CalledProcessError:
171
+ return repo_url, git_data
172
+
173
+ current_date = None
174
+ current_email = None
175
+ for line in output:
176
+ if not line.strip():
177
+ continue
178
+ parts = line.split("\t")
179
+ if len(parts) == 2:
180
+ current_date, current_email = parts
181
+ continue
182
+
183
+ if current_date and current_email:
184
+ abs_path = (repo_root / line.strip()).resolve()
185
+ key = str(abs_path)
186
+ entry = git_data.setdefault(
187
+ key,
188
+ {
189
+ "creation_date": current_date,
190
+ "last_modified_date": current_date,
191
+ "emails": {},
192
+ },
193
+ )
194
+ entry.setdefault("last_modified_date", current_date)
195
+ entry["creation_date"] = current_date
196
+ entry["emails"][current_email] = entry["emails"].get(current_email, 0) + 1
197
+
198
+ return repo_url, git_data
199
+
200
+
107
201
  def get_css() -> str:
108
202
  """CSS for git info, share buttons, and copy button."""
109
203
  return """
@@ -212,6 +306,8 @@ def process_html(
212
306
  page_url: str,
213
307
  title: str,
214
308
  src_path: str | None = None,
309
+ git_data: dict[str, dict[str, Any]] | None = None,
310
+ repo_url: str | None = None,
215
311
  default_image: str | None = None,
216
312
  default_author: str | None = None,
217
313
  keywords: str | None = None,
@@ -245,17 +341,29 @@ def process_html(
245
341
  if len(desc) >= 10:
246
342
  meta["description"] = desc
247
343
 
344
+ # ---------- IMAGE PICKING (body only, non-AVIF) ----------
248
345
  if add_image:
249
- if first_image := soup.find("img"):
250
- img_src = first_image.get("src", "")
251
- if img_src and (
252
- img_src.startswith(("http://", "https://", "/")) or not img_src.startswith(("javascript:", "data:"))
253
- ):
254
- meta["image"] = img_src
255
- elif youtube_ids := get_youtube_video_ids(soup):
256
- meta["image"] = f"https://img.youtube.com/vi/{youtube_ids[0]}/maxresdefault.jpg"
257
- elif default_image:
258
- meta["image"] = default_image
346
+ search_root = soup.find("article", class_="md-content__inner") or soup.body or soup
347
+
348
+ image_src: str | None = None
349
+ for img in search_root.find_all("img", src=True):
350
+ src = img["src"]
351
+ lower = src.lower()
352
+ if not lower or ".avif" in lower:
353
+ continue
354
+ if lower.startswith(("javascript:", "data:")):
355
+ continue
356
+ image_src = src
357
+ break
358
+
359
+ if not image_src and (youtube_ids := get_youtube_video_ids(soup)):
360
+ image_src = f"https://img.youtube.com/vi/{youtube_ids[0]}/maxresdefault.jpg"
361
+ if not image_src and default_image:
362
+ image_src = default_image
363
+
364
+ if image_src:
365
+ meta["image"] = image_src
366
+ # ---------------------------------------------------------
259
367
 
260
368
  # Add meta tags to head
261
369
  if not soup.find("meta", attrs={"name": "title"}):
@@ -377,15 +485,17 @@ def process_html(
377
485
  """
378
486
  soup.body.append(script)
379
487
 
380
- # Initialize git info with defaults
488
+ # Initialize git info with defaults and only call git when needed (authors or JSON-LD)
381
489
  git_info = {
382
490
  "creation_date": DEFAULT_CREATION_DATE,
383
491
  "last_modified_date": DEFAULT_MODIFIED_DATE,
384
492
  }
493
+ needs_git = (add_authors or add_json_ld) and src_path
385
494
 
386
- # Add git information if source path available
387
- if src_path:
388
- git_info = get_git_info(src_path, add_authors=add_authors, default_author=default_author)
495
+ if needs_git:
496
+ git_info = get_git_info(
497
+ src_path, add_authors=add_authors, default_author=default_author, git_data=git_data, repo_url=repo_url
498
+ )
389
499
 
390
500
  # Only render git footer if we have real git history (not placeholder defaults)
391
501
  has_real_git_data = (
@@ -2,12 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import contextlib
6
5
  import re
7
- import subprocess
8
- from collections import Counter
9
6
  from datetime import datetime
10
7
  from pathlib import Path
8
+ from typing import Any
11
9
 
12
10
  import requests
13
11
  import yaml # YAML is used for its readability and consistency with MkDocs ecosystem
@@ -141,8 +139,13 @@ def get_github_username_from_email(
141
139
  return None, None
142
140
 
143
141
 
144
- def get_github_usernames_from_file(file_path: str, default_user: str | None = None) -> dict[str, dict[str, any]]:
145
- """Fetch GitHub usernames associated with a file using Git Log and Git Blame commands.
142
+ def get_github_usernames_from_file(
143
+ file_path: str,
144
+ default_user: str | None = None,
145
+ emails: dict[str, int] | None = None,
146
+ repo_url: str | None = None,
147
+ ) -> dict[str, dict[str, Any]]:
148
+ """Fetch GitHub usernames associated with a file using provided Git email counts.
146
149
 
147
150
  Args:
148
151
  file_path (str): The path to the file for which GitHub usernames are to be retrieved.
@@ -157,38 +160,13 @@ def get_github_usernames_from_file(file_path: str, default_user: str | None = No
157
160
  - 'avatar' (str): The URL of the author's GitHub avatar.
158
161
 
159
162
  Examples:
160
- >>> print(get_github_usernames_from_file('mkdocs.yml'))
161
- {'username1': {'email': 'user@example.com', 'url': 'https://github.com/username1', 'changes': 5, 'avatar': '...'}}
163
+ >>> print(get_github_usernames_from_file('mkdocs.yml', emails={'user@example.com': 2}))
164
+ {'username1': {'email': 'user@example.com', 'url': 'https://github.com/username1', 'changes': 2, 'avatar': '...'}}
162
165
  """
163
- # Fetch author emails using 'git log'
164
- try:
165
- authors_emails_log = (
166
- subprocess.check_output(["git", "log", "--pretty=format:%ae", Path(file_path).resolve()])
167
- .decode("utf-8")
168
- .split("\n")
169
- )
170
- emails = dict(Counter(authors_emails_log))
171
- except subprocess.CalledProcessError:
172
- emails = {} # Git not available or file not in git repo
173
-
174
- # Fetch author emails using 'git blame'
175
- with contextlib.suppress(Exception):
176
- authors_emails_blame = (
177
- subprocess.check_output(
178
- ["git", "blame", "--line-porcelain", Path(file_path).resolve()],
179
- stderr=subprocess.DEVNULL,
180
- )
181
- .decode("utf-8")
182
- .split("\n")
183
- )
184
- authors_emails_blame = [line.split(" ")[1] for line in authors_emails_blame if line.startswith("author-mail")]
185
- authors_emails_blame = [email.strip("<>") for email in authors_emails_blame]
186
- emails_blame = dict(Counter(authors_emails_blame))
187
-
188
- # Merge the two email lists, adding any missing authors from 'git blame' as a 1-commit change
189
- for email in emails_blame:
190
- if email not in emails:
191
- emails[email] = 1 # Only add new authors from 'git blame' with a 1-commit change
166
+ if emails is None:
167
+ emails = {}
168
+ else:
169
+ emails = dict(emails) # shallow copy to avoid mutating caller data
192
170
 
193
171
  # If no git info found but default_user provided, use default_user
194
172
  if not emails and default_user:
@@ -202,16 +180,7 @@ def get_github_usernames_from_file(file_path: str, default_user: str | None = No
202
180
  else:
203
181
  cache = {}
204
182
 
205
- try:
206
- github_repo_url = (
207
- subprocess.check_output(["git", "config", "--get", "remote.origin.url"]).decode("utf-8").strip()
208
- )
209
- if github_repo_url.endswith(".git"):
210
- github_repo_url = github_repo_url[:-4]
211
- if github_repo_url.startswith("git@"):
212
- github_repo_url = "https://" + github_repo_url[4:].replace(":", "/")
213
- except subprocess.CalledProcessError:
214
- github_repo_url = "https://github.com/ultralytics/ultralytics" # Fallback URL
183
+ github_repo_url = repo_url or "https://github.com/ultralytics/ultralytics"
215
184
 
216
185
  info = {}
217
186
  for email, changes in emails.items():