mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-10-23 20:29:06 -05:00
docs: rework wiki copy
# Conflicts: # tests/test_site_funnel.py # wiki/testing-guide.md
This commit is contained in:
commit
d1dd7ef0f5
28 files changed
+1046
-443
No files matched your search
+266
-59
@@ -42,12 +42,20 @@ PLACEHOLDER = re.compile(r"(?m)^([ \t]*)<!--\s*image:\s*([A-Za-z0-9_-]+)\s*-->[
|
||||
PLACEHOLDER_ANYWHERE = re.compile(r"<!--\s*image:\s*([A-Za-z0-9_-]+)\s*-->")
|
||||
|
||||
FONT = "system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif"
|
||||
TITLE_SIZE = 14
|
||||
TEXT_SIZE = 12.5
|
||||
LINE = 17
|
||||
PAD = 10
|
||||
# The stylesheet sets the page font on every <text>, so a path takes the
|
||||
# mono font from an inline style, which outranks it.
|
||||
MONO_STYLE = "font-family: var(--sl-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)"
|
||||
WIDE = 720
|
||||
NARROW = 340
|
||||
# The wide drawing is scaled to a column about 700 px wide: these sizes read
|
||||
# at 14 px of body text there, the size of the prose around the figure.
|
||||
COLUMN = 700
|
||||
TEXT_SIZE = round(14 * WIDE / COLUMN, 1)
|
||||
TITLE_SIZE = round(15 * WIDE / COLUMN, 1)
|
||||
LINE = 20
|
||||
PAD = 12
|
||||
# A box holds a title and at most this many lines: the rest is the caption's.
|
||||
MAX_BOX_LINES = 3
|
||||
|
||||
# Default colours for an SVG read outside the page. The stylesheet replaces
|
||||
# them on the site through the rb-d-* classes.
|
||||
@@ -58,13 +66,20 @@ ACCENT = "#3b3f7a"
|
||||
MUTED = "#546e7a"
|
||||
|
||||
|
||||
class Mono(str):
|
||||
"""A path or a name typed as is: drawn in the mono font on a line of its
|
||||
own, never broken."""
|
||||
|
||||
|
||||
def _width(text: str, size: float, bold: bool = False) -> float:
|
||||
"""Rough advance of a sans-serif string: 0.56 em a character, 0.6 bold."""
|
||||
return len(text) * size * (0.6 if bold else 0.56)
|
||||
"""Rough advance of a string: 0.56 em a character, 0.6 bold or mono."""
|
||||
return len(text) * size * (0.6 if bold or isinstance(text, Mono) else 0.56)
|
||||
|
||||
|
||||
def wrap(text: str, width: float, size: float, bold: bool = False) -> list[str]:
|
||||
"""Greedy word wrap against the estimated advance."""
|
||||
"""Greedy word wrap against the estimated advance. A Mono line stays whole."""
|
||||
if isinstance(text, Mono):
|
||||
return [text]
|
||||
lines: list[str] = []
|
||||
current = ""
|
||||
for word in text.split():
|
||||
@@ -111,10 +126,11 @@ def _text(x: float, y: float, value: str, size: float = TEXT_SIZE,
|
||||
bold: bool = False, anchor: str = "start", cls: str = "") -> str:
|
||||
weight = ' font-weight="600"' if bold else ""
|
||||
klass = f' class="{cls}"' if cls else ""
|
||||
style = f' style="{MONO_STYLE}"' if isinstance(value, Mono) else ""
|
||||
fill = MUTED if cls == "rb-d-muted" else ("#ffffff" if cls == "rb-d-numtext" else INK)
|
||||
return (
|
||||
f'<text x="{_n(x)}" y="{_n(y)}" font-size="{_n(size)}"{weight} '
|
||||
f'text-anchor="{anchor}" fill="{fill}"{klass}>{escape(value)}</text>'
|
||||
f'text-anchor="{anchor}" fill="{fill}"{klass}{style}>{escape(value)}</text>'
|
||||
)
|
||||
|
||||
|
||||
@@ -167,13 +183,19 @@ def _line(points: list[tuple[float, float]], head: bool = True) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _box_height(box: Box, width: float) -> tuple[list[str], list[str], float]:
|
||||
def box_lines(box: Box, width: float) -> tuple[list[str], list[str]]:
|
||||
"""The title and body lines of *box* drawn *width* wide, as wrapped."""
|
||||
inner = width - 2 * PAD
|
||||
title_lines = wrap(box.title, inner - (_badge_side(box.number) + 8 if box.number else 0),
|
||||
TITLE_SIZE, True)
|
||||
body: list[str] = []
|
||||
for line in box.lines:
|
||||
body.extend(wrap(line, inner, TEXT_SIZE))
|
||||
return title_lines, body
|
||||
|
||||
|
||||
def _box_height(box: Box, width: float) -> tuple[list[str], list[str], float]:
|
||||
title_lines, body = box_lines(box, width)
|
||||
height = PAD + len(title_lines) * LINE + (6 if body else 0) + len(body) * LINE + PAD
|
||||
if box.number:
|
||||
height = max(height, PAD * 2 + 20)
|
||||
@@ -187,7 +209,7 @@ def _draw_box(box: Box, x: float, y: float, width: float, height: float,
|
||||
if box.number:
|
||||
out.extend(_badge(x + PAD, y + PAD, box.number))
|
||||
tx += _badge_side(box.number) + 8
|
||||
cy = y + PAD + 13
|
||||
cy = y + PAD + 15
|
||||
for line in title_lines:
|
||||
out.append(_text(tx, cy, line, TITLE_SIZE, True))
|
||||
cy += LINE
|
||||
@@ -216,9 +238,10 @@ def flow(boxes: list[Box], vertical: bool, total: float) -> tuple[float, float,
|
||||
width = (total - gap * (len(boxes) - 1) - 2) / len(boxes)
|
||||
measured = [_box_height(box, width) for box in boxes]
|
||||
height = max(m[2] for m in measured)
|
||||
for index, (box, (title_lines, body, _)) in enumerate(zip(boxes, measured)):
|
||||
for index, (box, (title_lines, body, own)) in enumerate(zip(boxes, measured)):
|
||||
x = 1 + index * (width + gap)
|
||||
out.extend(_draw_box(box, x, 1, width, height, title_lines, body))
|
||||
# A box is as tall as its lines, centred on the row the arrows run along.
|
||||
out.extend(_draw_box(box, x, 1 + (height - own) / 2, width, own, title_lines, body))
|
||||
if index < len(boxes) - 1:
|
||||
mid = 1 + height / 2
|
||||
out.extend(_line([(x + width, mid), (x + width + gap - 1, mid)]))
|
||||
@@ -239,8 +262,8 @@ def columns(boxes: list[Box], vertical: bool, total: float) -> tuple[float, floa
|
||||
width = (total - gap * (len(boxes) - 1) - 2) / len(boxes)
|
||||
measured = [_box_height(box, width) for box in boxes]
|
||||
height = max(m[2] for m in measured)
|
||||
for index, (box, (title_lines, body, _)) in enumerate(zip(boxes, measured)):
|
||||
out.extend(_draw_box(box, 1 + index * (width + gap), 1, width, height, title_lines, body))
|
||||
for index, (box, (title_lines, body, own)) in enumerate(zip(boxes, measured)):
|
||||
out.extend(_draw_box(box, 1 + index * (width + gap), 1, width, own, title_lines, body))
|
||||
return total, height + 2, out
|
||||
|
||||
|
||||
@@ -296,13 +319,28 @@ def registry() -> dict:
|
||||
return yaml.safe_load(handle).get("platforms", {})
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def setups() -> tuple[str, ...]:
|
||||
"""The registered setups, archived ones included: each has its own page,
|
||||
pack and file list, so every count of setups on the site is this one."""
|
||||
from common import list_registered_platforms
|
||||
|
||||
return tuple(sorted(list_registered_platforms(str(ROOT / "platforms"), include_archived=True)))
|
||||
|
||||
|
||||
def setups_named(named: list[str]) -> str:
|
||||
"""*named*, then how many other setups there are: "A, B and 15 more"."""
|
||||
others = len(setups()) - len(named)
|
||||
return ", ".join(named) + (f" and {others} more" if others else "")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def platform_modes() -> dict[str, tuple[str, str, str | None]]:
|
||||
"""name -> (display name, verification mode, hash rule) for every platform."""
|
||||
from common import list_registered_platforms, load_platform_config
|
||||
from common import load_platform_config
|
||||
|
||||
out: dict[str, tuple[str, str, str | None]] = {}
|
||||
for name in sorted(list_registered_platforms(str(ROOT / "platforms"), include_archived=True)):
|
||||
for name in setups():
|
||||
config = load_platform_config(name, str(ROOT / "platforms"))
|
||||
rule = registry().get(name, {}).get("hash_rule") or config.get("hash_rule")
|
||||
out[name] = (config.get("platform", name), config.get("verification_mode", ""), rule)
|
||||
@@ -335,64 +373,70 @@ def emulator_names() -> dict[str, str]:
|
||||
|
||||
|
||||
def _bios_chain_boxes() -> list[Box]:
|
||||
active = sum(1 for entry in registry().values() if entry.get("status") == "active")
|
||||
frontends = [_display(platform) for platform, _ in CHAIN_EXAMPLES]
|
||||
emulators = [emulator_names()[emulator] for _, emulator in CHAIN_EXAMPLES]
|
||||
folders = [f"{_display(platform)}: {_folder(platform)}" for platform, _ in CHAIN_EXAMPLES]
|
||||
platform, _emulator = CHAIN_EXAMPLES[0]
|
||||
return [
|
||||
Box("Your device", ["A PC, a Steam Deck, a handheld or a TV box"], number="1"),
|
||||
Box("Frontend", [
|
||||
"The menu you pick a game from:",
|
||||
f"{', '.join(frontends)} and {active - len(frontends)} more",
|
||||
], number="2"),
|
||||
Box("Emulator", [
|
||||
"Starts the game and reads its BIOS:",
|
||||
", ".join(emulators),
|
||||
], number="3"),
|
||||
Box("BIOS folder", folders + ["The pack or the installer fills it"],
|
||||
Box("Setup", ["The menu you pick a game from"], number="2"),
|
||||
Box("Emulator", ["Starts the game and reads its BIOS"], number="3"),
|
||||
Box("BIOS folder", [f"{_display(platform)} reads", Mono(_folder(platform))],
|
||||
accent=True, number="4"),
|
||||
]
|
||||
|
||||
|
||||
def _bios_chain_caption() -> str:
|
||||
"""The examples the boxes leave out, read from the registry and profiles."""
|
||||
named = setups_named([_display(platform) for platform, _ in CHAIN_EXAMPLES])
|
||||
pairs = ", ".join(
|
||||
f"{_display(platform)} starts {emulator_names()[emulator]}"
|
||||
for platform, emulator in CHAIN_EXAMPLES
|
||||
)
|
||||
folders = ", ".join(f"{_display(platform)} {_folder(platform)}" for platform, _ in CHAIN_EXAMPLES)
|
||||
return (
|
||||
f"Setups: {named}. Examples: {pairs}. "
|
||||
f"BIOS folders: {folders}. The ZIP or the command fills that folder."
|
||||
)
|
||||
|
||||
|
||||
def _install_boxes() -> list[Box]:
|
||||
detected = sum(1 for entry in registry().values() if entry.get("install", {}).get("detect"))
|
||||
return [
|
||||
Box("Run one command", ["The same line on Linux, macOS, Windows and handhelds"], number="1"),
|
||||
Box("Find the setup", [f"Looks for {detected} frontends and their BIOS folders"], number="2"),
|
||||
Box("Read the folder", ["Hashes the files already there"], number="3"),
|
||||
Box("Download the rest", ["Only what is missing or different"], number="4"),
|
||||
Box("Check each file", ["Its SHA-1 must match before it is saved"], accent=True, number="5"),
|
||||
Box("Finds your setup", ["And the folder it reads"], number="1"),
|
||||
Box("Looks at your files", ["The ones already in the folder"], number="2"),
|
||||
Box("Downloads the rest", ["Only what is missing or different"], number="3"),
|
||||
Box("Checks each file", ["Before saving it"], accent=True, number="4"),
|
||||
]
|
||||
|
||||
|
||||
MODE_TEXT = {
|
||||
"existence": ("Name only", "Only the file's name and place count. Its content is not read."),
|
||||
"existence": ("Name only", "Only the name and place count."),
|
||||
"md5": ("MD5 fingerprint", "The content must match an MD5 the platform lists."),
|
||||
"sha1": ("SHA-1 fingerprint", "The content must match a SHA-1 the platform lists."),
|
||||
}
|
||||
|
||||
|
||||
def _mode_boxes() -> list[Box]:
|
||||
def _modes() -> dict[str, list[str]]:
|
||||
"""Mode -> the platforms checking that way, RomM's size rule named."""
|
||||
by_mode: dict[str, list[str]] = {mode: [] for mode in MODE_TEXT}
|
||||
for display, mode, rule in platform_modes().values():
|
||||
label = f"{display} (size, then any hash)" if rule == "size_and_any_hash" else display
|
||||
by_mode.setdefault(mode, []).append(label)
|
||||
return {mode: sorted(names, key=str.casefold) for mode, names in by_mode.items()}
|
||||
|
||||
|
||||
def _mode_boxes() -> list[Box]:
|
||||
"""Each way of checking and how many platforms use it. The names are in
|
||||
the caption: eleven of them do not fit in three lines of a box."""
|
||||
boxes = []
|
||||
for mode, (title, text) in MODE_TEXT.items():
|
||||
names = sorted(by_mode.get(mode, []), key=str.casefold)
|
||||
count = f"{len(names)} platform" + ("" if len(names) == 1 else "s")
|
||||
boxes.append(Box(title, [text, f"{count}: " + ", ".join(names)]))
|
||||
count = len(_modes().get(mode, []))
|
||||
boxes.append(Box(title, [text, f"{count} platform" + ("" if count == 1 else "s")]))
|
||||
return boxes
|
||||
|
||||
|
||||
def _mode_summary() -> str:
|
||||
parts = []
|
||||
for mode, (title, _) in MODE_TEXT.items():
|
||||
names = sorted(
|
||||
(display for display, m, _ in platform_modes().values() if m == mode), key=str.casefold
|
||||
)
|
||||
parts.append(f"{title}: {', '.join(names)}.")
|
||||
return " ".join(parts)
|
||||
return " ".join(
|
||||
f"{title}: {', '.join(_modes().get(mode, []))}."
|
||||
for mode, (title, _) in MODE_TEXT.items()
|
||||
)
|
||||
|
||||
|
||||
# MinUI names a BIOS folder after the tag in parentheses of the ROM folder.
|
||||
@@ -529,21 +573,20 @@ def _figures() -> dict[str, Figure]:
|
||||
"bios-chain": Figure(
|
||||
"bios-chain",
|
||||
"How a BIOS file is used",
|
||||
"A device runs a frontend, the frontend starts an emulator, and the emulator "
|
||||
"reads BIOS files from one folder. The pack or the installer fills that folder.",
|
||||
"The frontend is the menu, the emulator runs the game, and the BIOS folder is the "
|
||||
"one place both look. Filling that folder is all a pack does.",
|
||||
"A device runs a setup, the setup starts an emulator, and the emulator "
|
||||
"reads BIOS files from one folder. The ZIP or the command fills that folder.",
|
||||
_bios_chain_caption(),
|
||||
lambda: flow(chain(), False, WIDE),
|
||||
lambda: flow(chain(), True, NARROW),
|
||||
),
|
||||
"install-flow": Figure(
|
||||
"install-flow",
|
||||
"What the installer does",
|
||||
"Install flow: run the command, the installer finds your setup, reads the BIOS "
|
||||
"folder, downloads what is missing or different, then checks each file before "
|
||||
"What the command does",
|
||||
"The command finds your setup and its BIOS folder, looks at the files already "
|
||||
"there, downloads what is missing or different, then checks each file before "
|
||||
"saving it.",
|
||||
"Running the command again repeats the same steps and downloads only what changed.",
|
||||
lambda: flow(install(), False, WIDE),
|
||||
lambda: grid(install(), 2, WIDE),
|
||||
lambda: flow(install(), True, NARROW),
|
||||
),
|
||||
"verification-modes": Figure(
|
||||
@@ -551,7 +594,7 @@ def _figures() -> dict[str, Figure]:
|
||||
"How each platform checks a file",
|
||||
"Three ways platforms check a BIOS file. " + _mode_summary(),
|
||||
"Each platform's own check, read from its source code. A file that passes on one "
|
||||
"platform can be refused on another.",
|
||||
"platform can be refused on another. " + _mode_summary(),
|
||||
lambda: columns(_mode_boxes(), False, WIDE),
|
||||
lambda: columns(_mode_boxes(), True, NARROW),
|
||||
),
|
||||
@@ -689,9 +732,174 @@ def localize_images(markdown: str, page: Path) -> str:
|
||||
return IMG_SRC.sub(swap, IMAGE_LINK.sub(swap, markdown))
|
||||
|
||||
|
||||
# Installer commands on wiki pages. The page keeps the Linux line, readable
|
||||
# where the Markdown is read as is, and the site shows it in one tab per
|
||||
# system, the Windows form written by the same function as the setup pages'.
|
||||
|
||||
OS_TABS = re.compile(
|
||||
r"(?m)^(?P<indent>[ \t]*)<!--\s*os-tabs(?:\s+windows:\s*(?P<windows>.*?))?\s*-->[ \t]*\n"
|
||||
r"(?P=indent)```(?:sh|bash)[^\n]*\n(?P<body>.*?)\n(?P=indent)```[ \t]*$",
|
||||
re.S,
|
||||
)
|
||||
OS_TAB_KEYS = ("windows", "macos", "linux", "steamdeck")
|
||||
FENCE_TITLES = {"sh": "Terminal", "powershell": "PowerShell"}
|
||||
|
||||
|
||||
def _installer_args(body: str) -> str:
|
||||
"""The options of a one-line install command, continuation lines joined."""
|
||||
from siteguides import INSTALL_BASE
|
||||
|
||||
line = " ".join(part.strip().rstrip("\\").strip() for part in body.splitlines())
|
||||
match = re.fullmatch(rf"curl -fsSL {re.escape(INSTALL_BASE)}/install\.sh \| sh(?: -s -- (.+))?", line)
|
||||
if not match:
|
||||
raise ValueError(f"an os-tabs block holds no install command: {line!r}")
|
||||
return match.group(1) or ""
|
||||
|
||||
|
||||
def expand_commands(markdown: str) -> str:
|
||||
"""Each install command marked `<!-- os-tabs -->` as Windows, macOS,
|
||||
Linux and Steam Deck tabs. `<!-- os-tabs windows: ARGS -->` gives the
|
||||
Windows tab its own options, for a path written another way there."""
|
||||
if "os-tabs" not in markdown:
|
||||
return markdown
|
||||
from siteguides import TERMINALS, install_command
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
indent = match.group("indent")
|
||||
args = _installer_args(match.group("body"))
|
||||
items = []
|
||||
for key in OS_TAB_KEYS:
|
||||
label, _how, shell = TERMINALS[key]
|
||||
own = match.group("windows") if key == "windows" and match.group("windows") else args
|
||||
items.append((label, [f'```{shell} title="{FENCE_TITLES[shell]}"',
|
||||
install_command(shell, own), "```"]))
|
||||
return "\n".join(markup.tabs(items, indent)).rstrip("\n")
|
||||
|
||||
return OS_TABS.sub(replace, markdown)
|
||||
|
||||
|
||||
# The installer's options at a glance, read from its argument parser.
|
||||
|
||||
OPTIONS_MARKER = re.compile(r"(?m)^<!--\s*installer-options\s*-->[ \t]*$")
|
||||
_SEE_LINK = re.compile(r"\(#([a-z0-9-]+)\)")
|
||||
|
||||
|
||||
def _string(node: ast.expr, constants: dict[str, str]) -> str:
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str):
|
||||
return node.value
|
||||
if isinstance(node, ast.JoinedStr):
|
||||
parts = []
|
||||
for value in node.values:
|
||||
if isinstance(value, ast.FormattedValue) and isinstance(value.value, ast.Name):
|
||||
parts.append(constants[value.value.id])
|
||||
else:
|
||||
parts.append(_string(value, constants))
|
||||
return "".join(parts)
|
||||
raise ValueError(f"install.py: an option help that is not a string: {ast.unparse(node)}")
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def installer_options() -> tuple[tuple[str, str], ...]:
|
||||
"""(usage, help) for each option of install.py, in the order --help lists them."""
|
||||
tree = ast.parse((ROOT / "install.py").read_text(encoding="utf-8"))
|
||||
constants = {
|
||||
node.targets[0].id: node.value.value
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.Assign) and isinstance(node.targets[0], ast.Name)
|
||||
and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str)
|
||||
}
|
||||
main = next(node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == "main")
|
||||
options = []
|
||||
for node in ast.walk(main):
|
||||
if not (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "add_argument"):
|
||||
continue
|
||||
flags = [arg.value for arg in node.args if isinstance(arg, ast.Constant)]
|
||||
keywords = {kw.arg: kw.value for kw in node.keywords}
|
||||
action = keywords.get("action")
|
||||
takes_value = not (isinstance(action, ast.Constant)
|
||||
and action.value in ("store_true", "store_false", "version"))
|
||||
metavar = ""
|
||||
if takes_value:
|
||||
given = keywords.get("metavar")
|
||||
metavar = given.value if isinstance(given, ast.Constant) else flags[0].lstrip("-").upper()
|
||||
usage = ", ".join(f"{flag} {metavar}".strip() for flag in flags)
|
||||
options.append((usage, _string(keywords["help"], constants) if "help" in keywords else ""))
|
||||
# argparse's own, added to every parser.
|
||||
options.append(("--help, -h", "show this help message and exit"))
|
||||
return tuple(options)
|
||||
|
||||
|
||||
def _sentences(text: str) -> str:
|
||||
"""A help string as sentences: capitalised, clauses split, a full stop."""
|
||||
parts = [part.strip() for part in text.split("; ")]
|
||||
text = ". ".join(part[0].upper() + part[1:] for part in parts if part)
|
||||
text = re.sub(r"(?<![\w`])(--[a-z][a-z-]*)", r"`\1`", text)
|
||||
return text.replace("|", "\\|") + ("" if text.endswith(".") else ".")
|
||||
|
||||
|
||||
def options_table(markdown: str) -> list[str]:
|
||||
"""One row per option: what it does, and the section that says more,
|
||||
the one the option's row in the page's own Options table sends to."""
|
||||
sections: dict[str, str] = {}
|
||||
for line in markdown.splitlines():
|
||||
flag = re.match(r"\| `(--[a-z-]+)", line)
|
||||
see = _SEE_LINK.search(line)
|
||||
if flag and see:
|
||||
sections[flag.group(1)] = see.group(1)
|
||||
headings = {
|
||||
re.sub(r"[^a-z0-9 -]", "", title.lower()).strip().replace(" ", "-"): title
|
||||
for title in re.findall(r"(?m)^##+ (.+?)(?:\s*\{[^}]*\})?\s*$", markdown)
|
||||
}
|
||||
rows = ["| Option | What it does | More |", "|---|---|---|"]
|
||||
for usage, help_text in installer_options():
|
||||
anchor = sections.get(usage.split()[0].rstrip(","))
|
||||
more = f"[{headings.get(anchor, anchor)}](#{anchor})" if anchor else ""
|
||||
rows.append(f"| `{usage}` | {_sentences(help_text)} | {more} |")
|
||||
return rows
|
||||
|
||||
|
||||
def expand_options(markdown: str) -> str:
|
||||
if "installer-options" not in markdown:
|
||||
return markdown
|
||||
return OPTIONS_MARKER.sub(lambda _m: "\n".join(options_table(markdown)), markdown)
|
||||
|
||||
|
||||
# Example hashes in the documentation stay out of search, so a hash typed in
|
||||
# the search box finds the file card that holds it, not a page quoting it.
|
||||
|
||||
HASH_SPAN = re.compile(r"`[^`\n]*\b(?:[0-9a-f]{64}|[0-9a-f]{40}|[0-9a-f]{32})\b[^`\n]*`")
|
||||
HASH_TEXT = re.compile(r"\b(?:[0-9a-f]{64}|[0-9a-f]{40}|[0-9a-f]{32})\b")
|
||||
FENCE = re.compile(r"(?m)^(?P<indent>[ \t]*)(?P<mark>```|~~~)[^\n]*\n.*?\n(?P=indent)(?P=mark)[ \t]*$", re.S)
|
||||
|
||||
|
||||
def hide_hashes(markdown: str) -> str:
|
||||
"""Wrap each code block and code span quoting a hash in a search exclusion."""
|
||||
if not HASH_TEXT.search(markdown):
|
||||
return markdown
|
||||
attribute = markup.search_exclude()
|
||||
pieces: list[str] = []
|
||||
position = 0
|
||||
for match in FENCE.finditer(markdown):
|
||||
pieces.append(HASH_SPAN.sub(lambda m: f"<span {attribute}>{m.group(0)}</span>",
|
||||
markdown[position:match.start()]))
|
||||
block = match.group(0)
|
||||
if HASH_TEXT.search(block):
|
||||
indent = match.group("indent")
|
||||
block = f"{indent}<div {attribute}>\n\n{block}\n\n{indent}</div>"
|
||||
pieces.append(block)
|
||||
position = match.end()
|
||||
pieces.append(HASH_SPAN.sub(lambda m: f"<span {attribute}>{m.group(0)}</span>",
|
||||
markdown[position:]))
|
||||
return "".join(pieces)
|
||||
|
||||
|
||||
def prepare_page(markdown: str, page: Path) -> str:
|
||||
"""Everything this module does to a page before it is decorated."""
|
||||
return resolve_images(localize_images(markdown, page))
|
||||
markdown = expand_options(expand_commands(resolve_images(localize_images(markdown, page))))
|
||||
if page.parts[:1] == ("wiki",):
|
||||
markdown = hide_hashes(markdown)
|
||||
return markdown
|
||||
|
||||
|
||||
# Sharing cards: one 1200x630 card per section of the site, rendered to PNG
|
||||
@@ -711,8 +919,7 @@ def og_card_svg(section: str, logo_png: bytes) -> str:
|
||||
import base64
|
||||
|
||||
title, tagline = OG_SECTIONS[section]
|
||||
active = sum(1 for entry in registry().values() if entry.get("status") == "active")
|
||||
tagline = tagline.format(frontends=f"RetroArch, Batocera, EmuDeck and {active - 3} more")
|
||||
tagline = tagline.format(frontends=setups_named(["RetroArch", "Batocera", "EmuDeck"]))
|
||||
logo = base64.b64encode(logo_png).decode("ascii")
|
||||
body = [
|
||||
'<rect width="1200" height="630" fill="#2b2e5c"/>',
|
||||
|
||||
Reference in new issue
Block a user