readwright 0.3.0__py3-none-any.whl
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.
- readwright/__init__.py +8 -0
- readwright/badges.py +323 -0
- readwright/changelog.py +46 -0
- readwright/cli.py +397 -0
- readwright/config.py +230 -0
- readwright/helpers.py +494 -0
- readwright/images.py +217 -0
- readwright/metadata.py +292 -0
- readwright/py.typed +0 -0
- readwright/renderer.py +183 -0
- readwright/templates/base.md.j2 +21 -0
- readwright/templates/partials/badges.md.j2 +1 -0
- readwright/templates/partials/contributing.md.j2 +7 -0
- readwright/templates/partials/donate.md.j2 +1 -0
- readwright/templates/partials/header.md.j2 +10 -0
- readwright/templates/partials/install.md.j2 +46 -0
- readwright/templates/partials/license.md.j2 +5 -0
- readwright/templates/partials/screenshots.md.j2 +5 -0
- readwright/templates/partials/usage.md.j2 +5 -0
- readwright/toc.py +60 -0
- readwright-0.3.0.dist-info/METADATA +188 -0
- readwright-0.3.0.dist-info/RECORD +25 -0
- readwright-0.3.0.dist-info/WHEEL +4 -0
- readwright-0.3.0.dist-info/entry_points.txt +2 -0
- readwright-0.3.0.dist-info/licenses/LICENSE +21 -0
readwright/helpers.py
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""Miscellaneous template helpers: file inclusion, layout, links, project-specific bits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shlex
|
|
10
|
+
import subprocess
|
|
11
|
+
import textwrap
|
|
12
|
+
import tomllib
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import date
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import urlencode
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from readwright.config import Config
|
|
22
|
+
|
|
23
|
+
LOGO_DIRS = ("", "docs", "assets", "docs/assets", "docs/images", "img", "images")
|
|
24
|
+
LOGO_EXTENSIONS = ("svg", "png", "webp", "jpg")
|
|
25
|
+
VIDEO_EXTENSIONS = ("mp4", "webm", "mov", "gif")
|
|
26
|
+
MODS_TOML_PATHS = (
|
|
27
|
+
"src/main/resources/META-INF/neoforge.mods.toml",
|
|
28
|
+
"src/main/resources/META-INF/mods.toml",
|
|
29
|
+
"src/main/templates/META-INF/neoforge.mods.toml",
|
|
30
|
+
)
|
|
31
|
+
SPDX_URLS = "https://spdx.org/licenses/{id}.html"
|
|
32
|
+
UNSPLASH_CDN = "https://images.unsplash.com"
|
|
33
|
+
UNSPLASH_PHOTO = re.compile(r"(?:photo-)?(?P<id>\d{10,}-[0-9a-f]{10,})")
|
|
34
|
+
MY_HA = "https://my.home-assistant.io"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _escape(text: str) -> str:
|
|
38
|
+
return html.escape(text, quote=True)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fenced(text: str, language: str = "") -> str:
|
|
42
|
+
fence = "````" if "```" in text else "```"
|
|
43
|
+
return f"{fence}{language}\n{text.rstrip()}\n{fence}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def md_table(headers: list[str], rows: list[list[str]]) -> str:
|
|
47
|
+
if not rows:
|
|
48
|
+
return ""
|
|
49
|
+
escape = lambda s: str(s).replace("|", "\\|").replace("\n", " ") # noqa: E731
|
|
50
|
+
lines = [
|
|
51
|
+
"| " + " | ".join(headers) + " |",
|
|
52
|
+
"| " + " | ".join("---" for _ in headers) + " |",
|
|
53
|
+
]
|
|
54
|
+
lines += ["| " + " | ".join(escape(c) for c in row) + " |" for row in rows]
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def flatten(data: Any, prefix: str = "") -> list[tuple[str, str]]:
|
|
59
|
+
if isinstance(data, dict):
|
|
60
|
+
items: list[tuple[str, str]] = []
|
|
61
|
+
for key, value in data.items():
|
|
62
|
+
items += flatten(value, f"{prefix}{key}.")
|
|
63
|
+
return items
|
|
64
|
+
return [(prefix.rstrip("."), _scalar(data))]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _scalar(value: Any) -> str:
|
|
68
|
+
if isinstance(value, list | tuple):
|
|
69
|
+
return ", ".join(_scalar(v) for v in value)
|
|
70
|
+
if isinstance(value, bool):
|
|
71
|
+
return "true" if value else "false"
|
|
72
|
+
if value is None:
|
|
73
|
+
return ""
|
|
74
|
+
return f"`{value}`"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Helpers:
|
|
78
|
+
def __init__(self, root: Path, config: Config, warn: Callable[[str], None] | None = None):
|
|
79
|
+
self.root = Path(root)
|
|
80
|
+
self.config = config
|
|
81
|
+
self.warn = warn or (lambda message: None)
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------- files
|
|
84
|
+
def _path(self, path: str) -> Path:
|
|
85
|
+
return self.root / path
|
|
86
|
+
|
|
87
|
+
def include_file(self, path: str) -> str:
|
|
88
|
+
target = self._path(path)
|
|
89
|
+
if not target.is_file():
|
|
90
|
+
self.warn(f"include_file: '{path}' not found")
|
|
91
|
+
return ""
|
|
92
|
+
return target.read_text().rstrip("\n")
|
|
93
|
+
|
|
94
|
+
def code_block(self, path: str, language: str | None = None) -> str:
|
|
95
|
+
content = self.include_file(path)
|
|
96
|
+
if not content:
|
|
97
|
+
return ""
|
|
98
|
+
lang = language if language is not None else self._path(path).suffix.lstrip(".")
|
|
99
|
+
return fenced(content, lang)
|
|
100
|
+
|
|
101
|
+
def snippet(
|
|
102
|
+
self,
|
|
103
|
+
path: str,
|
|
104
|
+
start: str,
|
|
105
|
+
end: str,
|
|
106
|
+
language: str | None = None,
|
|
107
|
+
dedent: bool = True,
|
|
108
|
+
) -> str:
|
|
109
|
+
content = self.include_file(path)
|
|
110
|
+
if not content:
|
|
111
|
+
return ""
|
|
112
|
+
lines = content.splitlines()
|
|
113
|
+
try:
|
|
114
|
+
first = next(i for i, line in enumerate(lines) if start in line) + 1
|
|
115
|
+
last = next(i for i, line in enumerate(lines) if end in line and i >= first)
|
|
116
|
+
except StopIteration:
|
|
117
|
+
self.warn(f"snippet: markers '{start}'..'{end}' not found in {path}")
|
|
118
|
+
return ""
|
|
119
|
+
body = "\n".join(lines[first:last])
|
|
120
|
+
if dedent:
|
|
121
|
+
body = textwrap.dedent(body)
|
|
122
|
+
if language is None:
|
|
123
|
+
language = self._path(path).suffix.lstrip(".")
|
|
124
|
+
return fenced(body, language) if language else body
|
|
125
|
+
|
|
126
|
+
def cli_help(self, command: str, language: str = "text", strip_ansi: bool = True) -> str:
|
|
127
|
+
if not self.config.allow_exec:
|
|
128
|
+
raise PermissionError(
|
|
129
|
+
f"cli_help({command!r}) needs `allow_exec: true` in readme.yaml "
|
|
130
|
+
"(it runs the command during render)"
|
|
131
|
+
)
|
|
132
|
+
env = {**os.environ, "NO_COLOR": "1", "TERM": "dumb", "COLUMNS": "80"}
|
|
133
|
+
try:
|
|
134
|
+
result = subprocess.run(
|
|
135
|
+
shlex.split(command), capture_output=True, text=True, cwd=self.root, env=env
|
|
136
|
+
)
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
message = f"cli_help: executable not found for {command!r}"
|
|
139
|
+
if self.config.strict:
|
|
140
|
+
raise FileNotFoundError(message) from None
|
|
141
|
+
self.warn(message)
|
|
142
|
+
return ""
|
|
143
|
+
output = result.stdout or result.stderr
|
|
144
|
+
if strip_ansi:
|
|
145
|
+
output = re.sub(r"\x1b\[[0-9;]*[A-Za-z]", "", output)
|
|
146
|
+
return fenced(output, language)
|
|
147
|
+
|
|
148
|
+
# --------------------------------------------------------------- tables
|
|
149
|
+
def _load_data(self, path: str) -> Any:
|
|
150
|
+
target = self._path(path)
|
|
151
|
+
if not target.is_file():
|
|
152
|
+
self.warn(f"'{path}' not found")
|
|
153
|
+
return None
|
|
154
|
+
suffix = target.suffix.lower()
|
|
155
|
+
text = target.read_text()
|
|
156
|
+
if suffix == ".toml":
|
|
157
|
+
return tomllib.loads(text)
|
|
158
|
+
if suffix == ".json":
|
|
159
|
+
return json.loads(text)
|
|
160
|
+
return yaml.safe_load(text)
|
|
161
|
+
|
|
162
|
+
def config_table(
|
|
163
|
+
self,
|
|
164
|
+
path: str,
|
|
165
|
+
section: str | None = None,
|
|
166
|
+
headers: tuple[str, str] = ("Key", "Value"),
|
|
167
|
+
) -> str:
|
|
168
|
+
data = self._load_data(path)
|
|
169
|
+
if data is None:
|
|
170
|
+
return ""
|
|
171
|
+
for part in (section or "").split("."):
|
|
172
|
+
if part:
|
|
173
|
+
data = data.get(part, {}) if isinstance(data, dict) else {}
|
|
174
|
+
if not isinstance(data, dict):
|
|
175
|
+
self.warn(f"config_table: {path} section '{section}' is not a mapping")
|
|
176
|
+
return ""
|
|
177
|
+
return md_table(list(headers), [[k, v] for k, v in flatten(data)])
|
|
178
|
+
|
|
179
|
+
def env_table(self, path: str = ".env.example") -> str:
|
|
180
|
+
target = self._path(path)
|
|
181
|
+
if not target.is_file():
|
|
182
|
+
self.warn(f"env_table: '{path}' not found")
|
|
183
|
+
return ""
|
|
184
|
+
rows: list[list[str]] = []
|
|
185
|
+
description: list[str] = []
|
|
186
|
+
for raw in target.read_text().splitlines():
|
|
187
|
+
line = raw.strip()
|
|
188
|
+
if line.startswith("#"):
|
|
189
|
+
description.append(line.lstrip("# ").strip())
|
|
190
|
+
elif "=" in line:
|
|
191
|
+
key, _, value = line.partition("=")
|
|
192
|
+
rows.append(
|
|
193
|
+
[
|
|
194
|
+
f"`{key.strip()}`",
|
|
195
|
+
f"`{value.strip()}`" if value.strip() else "",
|
|
196
|
+
" ".join(description),
|
|
197
|
+
]
|
|
198
|
+
)
|
|
199
|
+
description = []
|
|
200
|
+
else:
|
|
201
|
+
description = []
|
|
202
|
+
return md_table(["Variable", "Default", "Description"], rows)
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------- links
|
|
205
|
+
def gh_link(self, path: str = "", text: str | None = None) -> str:
|
|
206
|
+
base = self.config.project.url
|
|
207
|
+
if not base:
|
|
208
|
+
raise ValueError("gh_link needs project.owner and project.repo")
|
|
209
|
+
url = f"{base}/{path.lstrip('/')}" if path else base
|
|
210
|
+
return f"[{text}]({url})" if text else url
|
|
211
|
+
|
|
212
|
+
def spdx_link(self, license_id: str | None = None) -> str:
|
|
213
|
+
spdx = license_id or self.config.project.license
|
|
214
|
+
if not spdx:
|
|
215
|
+
return ""
|
|
216
|
+
return f"[{spdx}]({SPDX_URLS.format(id=spdx)})"
|
|
217
|
+
|
|
218
|
+
def my_ha_link(self, redirect: str, text: str | None = None, **params: str) -> str:
|
|
219
|
+
url = f"{MY_HA}/redirect/{redirect}/"
|
|
220
|
+
if params:
|
|
221
|
+
url += "?" + urlencode(params)
|
|
222
|
+
if text:
|
|
223
|
+
return f"[{text}]({url})"
|
|
224
|
+
return f"[]({url})"
|
|
225
|
+
|
|
226
|
+
# --------------------------------------------------------------- layout
|
|
227
|
+
@staticmethod
|
|
228
|
+
def details(summary: str, body: str, open: bool = False) -> str:
|
|
229
|
+
attr = " open" if open else ""
|
|
230
|
+
return f"<details{attr}>\n<summary>{summary}</summary>\n\n{body.strip()}\n\n</details>"
|
|
231
|
+
|
|
232
|
+
@staticmethod
|
|
233
|
+
def callout(kind: str, text: str) -> str:
|
|
234
|
+
kinds = {"note", "tip", "important", "warning", "caution"}
|
|
235
|
+
if kind.lower() not in kinds:
|
|
236
|
+
raise ValueError(f"callout kind must be one of {sorted(kinds)}")
|
|
237
|
+
body = "\n".join(f"> {line}" if line else ">" for line in text.strip().splitlines())
|
|
238
|
+
return f"> [!{kind.upper()}]\n{body}"
|
|
239
|
+
|
|
240
|
+
@staticmethod
|
|
241
|
+
def center(content: str) -> str:
|
|
242
|
+
"""Centered block; the blank lines let GitHub render markdown inside the div."""
|
|
243
|
+
return f'<div align="center">\n\n{content.strip()}\n\n</div>'
|
|
244
|
+
|
|
245
|
+
@staticmethod
|
|
246
|
+
def columns(cells: list[str], align: str = "center") -> str:
|
|
247
|
+
row = "\n".join(f'<td align="{align}">\n\n{c.strip()}\n\n</td>' for c in cells)
|
|
248
|
+
return f"<table>\n<tr>\n{row}\n</tr>\n</table>"
|
|
249
|
+
|
|
250
|
+
def _find_asset(self, stem: str, extensions: tuple[str, ...]) -> Path | None:
|
|
251
|
+
for directory in LOGO_DIRS:
|
|
252
|
+
for ext in extensions:
|
|
253
|
+
candidate = self.root / directory / f"{stem}.{ext}"
|
|
254
|
+
if candidate.is_file():
|
|
255
|
+
return candidate
|
|
256
|
+
shots = self.root / self.config.screenshots.dir
|
|
257
|
+
for ext in extensions:
|
|
258
|
+
if (shots / f"{stem}.{ext}").is_file():
|
|
259
|
+
return shots / f"{stem}.{ext}"
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
def logo(self, width: int | None = None, alt: str | None = None, name: str = "logo") -> str:
|
|
263
|
+
alt = alt or self.config.project.name or "logo"
|
|
264
|
+
dark = self._find_asset(f"{name}-dark", LOGO_EXTENSIONS)
|
|
265
|
+
light = self._find_asset(f"{name}-light", LOGO_EXTENSIONS)
|
|
266
|
+
single = self._find_asset(name, LOGO_EXTENSIONS)
|
|
267
|
+
rel = lambda p: p.relative_to(self.root).as_posix() # noqa: E731
|
|
268
|
+
if dark and light:
|
|
269
|
+
if width:
|
|
270
|
+
return (
|
|
271
|
+
"<picture>\n"
|
|
272
|
+
f' <source media="(prefers-color-scheme: dark)" srcset="{rel(dark)}">\n'
|
|
273
|
+
f' <source media="(prefers-color-scheme: light)" srcset="{rel(light)}">\n'
|
|
274
|
+
f' <img src="{rel(light)}" alt="{_escape(alt)}" width="{width}">\n'
|
|
275
|
+
"</picture>"
|
|
276
|
+
)
|
|
277
|
+
return (
|
|
278
|
+
f"}#gh-light-mode-only)}#gh-dark-mode-only)"
|
|
279
|
+
)
|
|
280
|
+
src = single or light or dark
|
|
281
|
+
if src is None:
|
|
282
|
+
self.warn(f"logo: no {name}.{{svg,png,webp,jpg}} found")
|
|
283
|
+
return ""
|
|
284
|
+
if width:
|
|
285
|
+
return f'<img src="{rel(src)}" alt="{_escape(alt)}" width="{width}">'
|
|
286
|
+
return f"})"
|
|
287
|
+
|
|
288
|
+
def video(self, name: str, width: int | None = None, alt: str | None = None) -> str:
|
|
289
|
+
if "://" in name:
|
|
290
|
+
src = name
|
|
291
|
+
else:
|
|
292
|
+
found = self._find_asset(name, VIDEO_EXTENSIONS)
|
|
293
|
+
if found is None:
|
|
294
|
+
self.warn(f"video '{name}' not found")
|
|
295
|
+
return ""
|
|
296
|
+
src = found.relative_to(self.root).as_posix()
|
|
297
|
+
alt = alt or name
|
|
298
|
+
width_attr = f' width="{width}"' if width else ""
|
|
299
|
+
if src.lower().endswith(".gif"):
|
|
300
|
+
return f'<img src="{src}" alt="{_escape(alt)}"{width_attr}>'
|
|
301
|
+
return f'<video src="{src}"{width_attr} controls muted loop></video>'
|
|
302
|
+
|
|
303
|
+
def contributors(self, logins: list[str] | None = None, size: int = 64) -> str:
|
|
304
|
+
if logins is None:
|
|
305
|
+
rc = self.root / ".all-contributorsrc"
|
|
306
|
+
if not rc.is_file():
|
|
307
|
+
self.warn("contributors: pass a list or add .all-contributorsrc")
|
|
308
|
+
return ""
|
|
309
|
+
logins = [c["login"] for c in json.loads(rc.read_text()).get("contributors", [])]
|
|
310
|
+
return " ".join(
|
|
311
|
+
f"[](https://github.com/{login})"
|
|
312
|
+
for login in logins
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# -------------------------------------------------------------- project
|
|
316
|
+
def pyversions_list(self, sep: str = ", ") -> str:
|
|
317
|
+
return sep.join(self.config.project.python_versions)
|
|
318
|
+
|
|
319
|
+
def entry_points_table(self) -> str:
|
|
320
|
+
path = self.root / "pyproject.toml"
|
|
321
|
+
if not path.is_file():
|
|
322
|
+
return ""
|
|
323
|
+
scripts = tomllib.loads(path.read_text()).get("project", {}).get("scripts", {})
|
|
324
|
+
return md_table(
|
|
325
|
+
["Command", "Entry point"], [[f"`{k}`", f"`{v}`"] for k, v in scripts.items()]
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def flow_install_cmd(self) -> str:
|
|
329
|
+
name = self.config.project.flow_plugin or self.config.project.name
|
|
330
|
+
return fenced(f"pm install {name}", "text")
|
|
331
|
+
|
|
332
|
+
def mc_versions(self) -> str:
|
|
333
|
+
return self.config.project.minecraft_version or ""
|
|
334
|
+
|
|
335
|
+
def mod_dependencies(self) -> str:
|
|
336
|
+
for rel in MODS_TOML_PATHS:
|
|
337
|
+
path = self.root / rel
|
|
338
|
+
if path.is_file():
|
|
339
|
+
break
|
|
340
|
+
else:
|
|
341
|
+
self.warn("mod_dependencies: no neoforge.mods.toml / mods.toml found")
|
|
342
|
+
return ""
|
|
343
|
+
data = tomllib.loads(path.read_text())
|
|
344
|
+
rows = []
|
|
345
|
+
for deps in data.get("dependencies", {}).values():
|
|
346
|
+
for dep in deps:
|
|
347
|
+
rows.append(
|
|
348
|
+
[
|
|
349
|
+
f"`{dep.get('modId', '')}`",
|
|
350
|
+
dep.get("type", "required" if dep.get("mandatory", True) else "optional"),
|
|
351
|
+
f"`{dep.get('versionRange', '*')}`",
|
|
352
|
+
]
|
|
353
|
+
)
|
|
354
|
+
return md_table(["Mod", "Type", "Version"], rows)
|
|
355
|
+
|
|
356
|
+
def related_repos(self) -> str:
|
|
357
|
+
rows = []
|
|
358
|
+
for item in self.config.related:
|
|
359
|
+
url = item.url or (
|
|
360
|
+
f"https://github.com/{item.repo}"
|
|
361
|
+
if "/" in item.repo
|
|
362
|
+
else f"https://github.com/{self.config.project.owner}/{item.repo}"
|
|
363
|
+
)
|
|
364
|
+
rows.append([f"[{item.repo}]({url})", item.description])
|
|
365
|
+
return md_table(["Repository", "Description"], rows)
|
|
366
|
+
|
|
367
|
+
# ------------------------------------------------------------- unsplash
|
|
368
|
+
def _utm(self) -> str:
|
|
369
|
+
source = (self.config.project.repo or self.config.project.name or "readwright").replace(
|
|
370
|
+
" ", "_"
|
|
371
|
+
)
|
|
372
|
+
return urlencode({"utm_source": source, "utm_medium": "referral"})
|
|
373
|
+
|
|
374
|
+
def unsplash(
|
|
375
|
+
self,
|
|
376
|
+
photo: str,
|
|
377
|
+
alt: str | None = None,
|
|
378
|
+
width: int | None = 1200,
|
|
379
|
+
height: int | None = None,
|
|
380
|
+
credit: str | None = None,
|
|
381
|
+
user: str | None = None,
|
|
382
|
+
photo_id: str | None = None,
|
|
383
|
+
link: str | None = None,
|
|
384
|
+
quality: int = 80,
|
|
385
|
+
html: bool = False,
|
|
386
|
+
) -> str:
|
|
387
|
+
"""Embed an Unsplash photo by CDN id or images.unsplash.com URL, with attribution.
|
|
388
|
+
|
|
389
|
+
`width`/`height` are what the CDN is asked for; the image is plain markdown unless
|
|
390
|
+
`html=True`, which also sets the <img> width attribute.
|
|
391
|
+
"""
|
|
392
|
+
match = UNSPLASH_PHOTO.search(photo)
|
|
393
|
+
if not match:
|
|
394
|
+
raise ValueError(
|
|
395
|
+
f"unsplash: {photo!r} is not a photo id like 'photo-1518770660439-4636190af475' "
|
|
396
|
+
"or an images.unsplash.com URL (right-click the photo, copy image address)"
|
|
397
|
+
)
|
|
398
|
+
params: dict[str, object] = {"auto": "format", "fit": "crop", "q": quality}
|
|
399
|
+
if width:
|
|
400
|
+
params["w"] = width
|
|
401
|
+
if height:
|
|
402
|
+
params["h"] = height
|
|
403
|
+
src = f"{UNSPLASH_CDN}/photo-{match['id']}?{urlencode(params)}"
|
|
404
|
+
alt = alt or "Photo from Unsplash"
|
|
405
|
+
page = link or (f"https://unsplash.com/photos/{photo_id}" if photo_id else None)
|
|
406
|
+
if html:
|
|
407
|
+
width_attr = f' width="{width}"' if width else ""
|
|
408
|
+
image = f'<img src="{src}" alt="{_escape(alt)}"{width_attr}>'
|
|
409
|
+
if page:
|
|
410
|
+
image = f'<a href="{page}">{image}</a>'
|
|
411
|
+
else:
|
|
412
|
+
image = f""
|
|
413
|
+
if page:
|
|
414
|
+
image = f"[{image}]({page})"
|
|
415
|
+
if not credit:
|
|
416
|
+
self.warn(
|
|
417
|
+
f"unsplash: no credit given for photo {match['id']}; "
|
|
418
|
+
"Unsplash's license requires attribution (pass credit= and user=)"
|
|
419
|
+
)
|
|
420
|
+
return image
|
|
421
|
+
profile = (
|
|
422
|
+
f"https://unsplash.com/@{user}?{self._utm()}"
|
|
423
|
+
if user
|
|
424
|
+
else f"https://unsplash.com/?{self._utm()}"
|
|
425
|
+
)
|
|
426
|
+
site = f"https://unsplash.com/?{self._utm()}"
|
|
427
|
+
if html:
|
|
428
|
+
attribution = (
|
|
429
|
+
f'Photo by <a href="{profile}">{_escape(credit)}</a> on '
|
|
430
|
+
f'<a href="{site}">Unsplash</a>'
|
|
431
|
+
)
|
|
432
|
+
return f"{image}\n<br><sub>{attribution}</sub>"
|
|
433
|
+
return f"{image}\n\n*Photo by [{credit}]({profile}) on [Unsplash]({site})*"
|
|
434
|
+
|
|
435
|
+
def banner(self) -> str:
|
|
436
|
+
cfg = self.config.banner
|
|
437
|
+
if cfg is None:
|
|
438
|
+
return ""
|
|
439
|
+
if cfg.unsplash:
|
|
440
|
+
rendered = self.unsplash(
|
|
441
|
+
cfg.unsplash,
|
|
442
|
+
alt=cfg.alt,
|
|
443
|
+
width=cfg.width,
|
|
444
|
+
height=cfg.height,
|
|
445
|
+
credit=cfg.credit,
|
|
446
|
+
user=cfg.user,
|
|
447
|
+
photo_id=cfg.photo_id,
|
|
448
|
+
link=cfg.link,
|
|
449
|
+
html=cfg.html,
|
|
450
|
+
)
|
|
451
|
+
elif cfg.image:
|
|
452
|
+
alt = cfg.alt or self.config.project.name or "banner"
|
|
453
|
+
if "://" not in cfg.image and not (self.root / cfg.image).is_file():
|
|
454
|
+
self.warn(f"banner image '{cfg.image}' not found")
|
|
455
|
+
if cfg.html:
|
|
456
|
+
width_attr = f' width="{cfg.width}"' if cfg.width else ""
|
|
457
|
+
rendered = f'<img src="{cfg.image}" alt="{_escape(alt)}"{width_attr}>'
|
|
458
|
+
if cfg.link:
|
|
459
|
+
rendered = f'<a href="{cfg.link}">{rendered}</a>'
|
|
460
|
+
else:
|
|
461
|
+
rendered = f""
|
|
462
|
+
if cfg.link:
|
|
463
|
+
rendered = f"[{rendered}]({cfg.link})"
|
|
464
|
+
else:
|
|
465
|
+
self.warn("banner: set either 'unsplash' or 'image'")
|
|
466
|
+
return ""
|
|
467
|
+
return self.center(rendered) if cfg.html else rendered
|
|
468
|
+
|
|
469
|
+
# ----------------------------------------------------------------- meta
|
|
470
|
+
def _git(self, *args: str) -> str:
|
|
471
|
+
result = subprocess.run(
|
|
472
|
+
["git", "-C", str(self.root), *args], capture_output=True, text=True, check=False
|
|
473
|
+
)
|
|
474
|
+
return result.stdout.strip()
|
|
475
|
+
|
|
476
|
+
def git_sha(self, short: bool = True) -> str:
|
|
477
|
+
return self._git("rev-parse", "--short" if short else "HEAD", *(["HEAD"] if short else []))
|
|
478
|
+
|
|
479
|
+
def git_tag(self) -> str:
|
|
480
|
+
return self._git("describe", "--tags", "--abbrev=0")
|
|
481
|
+
|
|
482
|
+
@staticmethod
|
|
483
|
+
def today(fmt: str = "%Y-%m-%d") -> str:
|
|
484
|
+
return date.today().strftime(fmt)
|
|
485
|
+
|
|
486
|
+
def as_globals(self) -> dict[str, Any]:
|
|
487
|
+
names = [
|
|
488
|
+
"include_file", "code_block", "snippet", "cli_help", "config_table", "env_table",
|
|
489
|
+
"gh_link", "spdx_link", "my_ha_link", "details", "callout", "center", "columns",
|
|
490
|
+
"logo", "video", "contributors", "pyversions_list", "entry_points_table",
|
|
491
|
+
"flow_install_cmd", "mc_versions", "mod_dependencies", "related_repos",
|
|
492
|
+
"git_sha", "git_tag", "today", "unsplash", "banner",
|
|
493
|
+
] # fmt: skip
|
|
494
|
+
return {name: getattr(self, name) for name in names}
|