mirror of
https://github.com/Abdess/retroarch_system.git
synced 2026-11-11 12:33:53 -06:00
feat: give assistants each page as markdown
The build writes <page>/index.md beside each page, named in its head, and llms-full.txt. llms.txt and the setup skill say how to answer from the site, not from memory, and the setup_bios_files WebMCP tool gives a setup's folder and exact file names from the picker data.
This commit is contained in:
1 parent
ab9fe76de2
commit
a7a27028ab
8 files changed
+493
-2
No files matched your search
@@ -47,6 +47,7 @@ on:
|
||||
- "scripts/safeparse.py"
|
||||
- "scripts/sect233r1.py"
|
||||
- "scripts/siteagents.py"
|
||||
- "scripts/site_markdown.py"
|
||||
- "scripts/sitebootstrap.py"
|
||||
- "scripts/sitecollection.py"
|
||||
- "scripts/sitediagrams.py"
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Each built page as Markdown, for the assistants that read the site.
|
||||
|
||||
An assistant asked to help with BIOS files reads a page and answers from
|
||||
it. The HTML carries navigation, icons, buttons and scripts around the
|
||||
words; the Markdown copy carries the words, the tables, the commands and
|
||||
the links, at `<page>/index.md` beside `<page>/index.html`. Every page's
|
||||
head names it (`<link rel="alternate" type="text/markdown">`, set in
|
||||
website/astro.config.mjs), and llms.txt says the convention.
|
||||
|
||||
`llms-full.txt` joins the Markdown of the pages a setup is done with:
|
||||
the start pages, the guides and each setup's page.
|
||||
|
||||
Run by the Astro build once the pages are written (astro.config.mjs),
|
||||
on the built site:
|
||||
|
||||
python scripts/site_markdown.py website/dist
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from html.parser import HTMLParser
|
||||
from pathlib import Path
|
||||
|
||||
SITE = "https://abdess.github.io"
|
||||
BASE = "/retrobios/"
|
||||
# Elements whose content is never words a reader needs.
|
||||
SKIPPED = frozenset({"script", "style", "svg", "button", "nav", "template", "noscript", "video"})
|
||||
HEADINGS = {f"h{n}": n for n in range(1, 7)}
|
||||
# The pages llms-full.txt joins, by path under the site, in reading order.
|
||||
FULL_PAGES = ("", "which-pack/", "wiki/what-are-bios-files/", "wiki/first-install/",
|
||||
"wiki/troubleshooting/", "wiki/faq/", "wiki/installer/", "wiki/legal/",
|
||||
"wiki/privacy/")
|
||||
|
||||
|
||||
class _Converter(HTMLParser):
|
||||
"""The Markdown of a page's article: its title and its content."""
|
||||
|
||||
def __init__(self, page_url: str) -> None:
|
||||
super().__init__(convert_charrefs=True)
|
||||
self.page_url = page_url
|
||||
self.out: list[str] = []
|
||||
self.skip = 0
|
||||
self.inside = 0
|
||||
self.depth_stack: list[str] = []
|
||||
self.lists: list[list[int]] = []
|
||||
self.pre = 0
|
||||
self.links: list[str | None] = []
|
||||
self.row: list[str] | None = None
|
||||
self.cell: list[str] | None = None
|
||||
self.table: list[list[str]] | None = None
|
||||
self.header_rows = 0
|
||||
self.title = ""
|
||||
self._in_title = False
|
||||
# A tab's name by its id, read from the tab list, said above its
|
||||
# panel: a command under the wrong OS is the answer an assistant
|
||||
# gets wrong.
|
||||
self.tab_names: dict[str, str] = {}
|
||||
self._tab: str | None = None
|
||||
self._card = False
|
||||
|
||||
# Output
|
||||
|
||||
def _emit(self, text: str) -> None:
|
||||
if self.cell is not None:
|
||||
self.cell.append(text)
|
||||
else:
|
||||
self.out.append(text)
|
||||
|
||||
def _block(self) -> None:
|
||||
if self.cell is not None:
|
||||
self.cell.append(" ")
|
||||
return
|
||||
joined = "".join(self.out)
|
||||
if joined and not joined.endswith("\n\n"):
|
||||
self.out.append("\n" if joined.endswith("\n") else "\n\n")
|
||||
|
||||
# Parsing
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
attr = {key: value or "" for key, value in attrs}
|
||||
classes = set(attr.get("class", "").split())
|
||||
if tag == "h1" and attr.get("id") == "_top":
|
||||
self._in_title = True
|
||||
if not self.inside:
|
||||
if "sl-markdown-content" in classes:
|
||||
self.inside = 1
|
||||
self.depth_stack = [tag]
|
||||
return
|
||||
if tag not in ("br", "img", "hr", "input", "meta", "link", "source", "wbr"):
|
||||
self.depth_stack.append(tag)
|
||||
if attr.get("role") == "tablist":
|
||||
self.skip += 1
|
||||
if tag == "a" or attr.get("role") == "tab":
|
||||
pass
|
||||
return
|
||||
if self.skip and attr.get("role") == "tab" and attr.get("id"):
|
||||
self._tab = attr["id"]
|
||||
self.tab_names[self._tab] = ""
|
||||
self.skip += 1
|
||||
return
|
||||
if attr.get("role") == "tabpanel" and not self.skip:
|
||||
self._block()
|
||||
name = self.tab_names.get(attr.get("aria-labelledby", ""), "").strip()
|
||||
if name:
|
||||
self._emit(f"**{' '.join(name.split())}:**")
|
||||
self._block()
|
||||
return
|
||||
if self.skip or tag in SKIPPED or attr.get("aria-hidden") == "true" \
|
||||
or classes & {"sr-only", "sl-anchor-link", "copy", "rb-anchor"}:
|
||||
if tag not in ("br", "img", "hr", "input", "wbr"):
|
||||
self.skip += 1
|
||||
return
|
||||
if self.pre:
|
||||
# Inside a code block a line is a div, never a paragraph.
|
||||
if tag == "div" and "ec-line" in classes and not "".join(self.out[-1:]).endswith("```\n"):
|
||||
self._emit("\n")
|
||||
return
|
||||
if tag in HEADINGS:
|
||||
self._block()
|
||||
self._emit("#" * HEADINGS[tag] + " ")
|
||||
elif tag in ("p", "div", "section", "figure", "details", "blockquote", "dl"):
|
||||
self._block()
|
||||
elif tag == "summary":
|
||||
self._block()
|
||||
self._emit("**")
|
||||
elif tag in ("ul", "ol"):
|
||||
self._block()
|
||||
self.lists.append([0 if tag == "ol" else -1])
|
||||
elif tag == "li":
|
||||
if self.out and not "".join(self.out[-1:]).endswith("\n"):
|
||||
self._emit("\n")
|
||||
level = len(self.lists) - 1
|
||||
marker = "-"
|
||||
if self.lists and self.lists[-1][0] >= 0:
|
||||
self.lists[-1][0] += 1
|
||||
marker = f"{self.lists[-1][0]}."
|
||||
self._emit(" " * max(level, 0) + marker + " ")
|
||||
elif tag == "pre":
|
||||
self._block()
|
||||
self.pre += 1
|
||||
self._emit("```\n")
|
||||
elif tag == "code" and not self.pre:
|
||||
self._emit("`")
|
||||
elif tag in ("strong", "b"):
|
||||
self._emit("**")
|
||||
elif tag in ("em", "i"):
|
||||
self._emit("*")
|
||||
elif tag == "a" and "rb-card" in classes:
|
||||
# A card of a grid: one list line each, its name then what it
|
||||
# says, instead of one run-on line of links.
|
||||
href = attr.get("href", "")
|
||||
href = SITE + href if href.startswith("/") else href
|
||||
self._block()
|
||||
self._emit("- [")
|
||||
self.links.append(href or None)
|
||||
self._card = True
|
||||
elif tag == "span" and self._card and self.links:
|
||||
self._emit(": ")
|
||||
elif tag == "a":
|
||||
href = attr.get("href", "")
|
||||
if href.startswith("#"):
|
||||
href = self.page_url + href
|
||||
elif href.startswith("/"):
|
||||
href = SITE + href
|
||||
self.links.append(href or None)
|
||||
if href:
|
||||
self._emit("[")
|
||||
elif tag == "img":
|
||||
alt = attr.get("alt", "").strip()
|
||||
if alt:
|
||||
self._emit(alt)
|
||||
elif tag == "table":
|
||||
self._block()
|
||||
self.table, self.header_rows = [], 0
|
||||
elif tag == "tr" and self.table is not None:
|
||||
self.row = []
|
||||
elif tag in ("td", "th") and self.row is not None:
|
||||
self.cell = []
|
||||
if tag == "th" and not self.row:
|
||||
self.header_rows = max(self.header_rows, len(self.table or []) + 1)
|
||||
elif tag == "dt":
|
||||
self._block()
|
||||
self._emit("**")
|
||||
elif tag == "dd":
|
||||
self._emit(": ")
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if tag == "h1" and self._in_title:
|
||||
self._in_title = False
|
||||
if not self.inside:
|
||||
return
|
||||
if self.depth_stack:
|
||||
self.depth_stack.pop()
|
||||
if not self.depth_stack:
|
||||
self.inside = 0
|
||||
return
|
||||
if self.skip:
|
||||
if tag not in ("br", "img", "hr", "input", "wbr"):
|
||||
self.skip -= 1
|
||||
if tag == "a" and self._tab:
|
||||
self._tab = None
|
||||
return
|
||||
if self.pre and tag != "pre":
|
||||
return
|
||||
if tag in HEADINGS or tag in ("p", "section", "figure", "details", "blockquote", "dl"):
|
||||
self._block()
|
||||
elif tag == "summary":
|
||||
self._emit("**")
|
||||
self._block()
|
||||
elif tag in ("ul", "ol"):
|
||||
if self.lists:
|
||||
self.lists.pop()
|
||||
self._block()
|
||||
elif tag == "pre":
|
||||
self.pre -= 1
|
||||
self._emit("\n```")
|
||||
self._block()
|
||||
elif tag == "code" and not self.pre:
|
||||
self._emit("`")
|
||||
elif tag in ("strong", "b", "dt"):
|
||||
self._emit("**")
|
||||
elif tag in ("em", "i"):
|
||||
self._emit("*")
|
||||
elif tag == "a":
|
||||
href = self.links.pop() if self.links else None
|
||||
if href:
|
||||
self._emit(f"]({href})")
|
||||
if self._card:
|
||||
self._card = False
|
||||
self._emit("\n")
|
||||
elif tag in ("td", "th") and self.cell is not None and self.row is not None:
|
||||
self.row.append(" ".join("".join(self.cell).split()).replace("|", "\\|"))
|
||||
self.cell = None
|
||||
elif tag == "tr" and self.row is not None and self.table is not None:
|
||||
self.table.append(self.row)
|
||||
self.row = None
|
||||
elif tag == "table" and self.table is not None:
|
||||
rows, self.table = self.table, None
|
||||
if rows:
|
||||
width = max(len(r) for r in rows)
|
||||
rows = [r + [""] * (width - len(r)) for r in rows]
|
||||
head = rows[0] if self.header_rows else [""] * width
|
||||
body = rows[1:] if self.header_rows else rows
|
||||
lines = ["| " + " | ".join(head) + " |", "|" + "---|" * width]
|
||||
lines += ["| " + " | ".join(r) + " |" for r in body]
|
||||
self.out.append("\n".join(lines))
|
||||
self._block()
|
||||
elif tag == "br":
|
||||
self._emit("\n" if self.pre else " \n")
|
||||
|
||||
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
self.handle_starttag(tag, attrs)
|
||||
if tag not in ("img", "br", "hr", "input", "wbr", "meta", "link", "source"):
|
||||
self.handle_endtag(tag)
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self._in_title and not self.inside:
|
||||
self.title += data
|
||||
return
|
||||
if self._tab is not None:
|
||||
self.tab_names[self._tab] += data
|
||||
if not self.inside or self.skip:
|
||||
return
|
||||
if self.pre:
|
||||
# The whitespace between <pre> and its first line is no line.
|
||||
if data.strip() or not "".join(self.out[-1:]).endswith("```\n"):
|
||||
self._emit(data)
|
||||
return
|
||||
text = re.sub(r"\s+", " ", data)
|
||||
if text.strip() or (text and self.out and not "".join(self.out[-1:]).endswith((" ", "\n"))):
|
||||
self._emit(text)
|
||||
|
||||
def markdown(self) -> str:
|
||||
body = "".join(self.out)
|
||||
body = re.sub(r"[ \t]+\n", "\n", body)
|
||||
body = re.sub(r"\n{3,}", "\n\n", body).strip()
|
||||
title = " ".join(self.title.split())
|
||||
return (f"# {title}\n\n" if title else "") + f"Source: {self.page_url}\n\n{body}\n"
|
||||
|
||||
|
||||
def page_markdown(html: str, page_url: str) -> str:
|
||||
converter = _Converter(page_url)
|
||||
converter.feed(html)
|
||||
converter.close()
|
||||
return converter.markdown()
|
||||
|
||||
|
||||
def convert_site(dist: Path) -> int:
|
||||
"""Write index.md beside every page's index.html, then llms-full.txt."""
|
||||
written = 0
|
||||
for html_path in sorted(dist.rglob("index.html")):
|
||||
relative = html_path.parent.relative_to(dist).as_posix()
|
||||
page = "" if relative == "." else f"{relative}/"
|
||||
url = f"{SITE}{BASE}{page}"
|
||||
text = page_markdown(html_path.read_text(encoding="utf-8"), url)
|
||||
(html_path.parent / "index.md").write_text(text, encoding="utf-8")
|
||||
written += 1
|
||||
parts = ["# RetroBIOS: the guides in one file", "",
|
||||
"> The pages a setup is done with, in reading order. Every page of the site also has "
|
||||
"its own Markdown copy at `<page>/index.md`.", ""]
|
||||
for page in FULL_PAGES:
|
||||
md = dist / page / "index.md"
|
||||
if md.is_file():
|
||||
parts += [md.read_text(encoding="utf-8").rstrip(), "", "---", ""]
|
||||
platforms = sorted(p for p in (dist / "platforms").glob("*/index.md")
|
||||
if not re.search(r"-(files|part)-\d+$", p.parent.name))
|
||||
parts += [p.read_text(encoding="utf-8").rstrip() + "\n\n---\n" for p in platforms]
|
||||
(dist / "llms-full.txt").write_text("\n".join(parts).rstrip() + "\n", encoding="utf-8")
|
||||
return written
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0], allow_abbrev=False)
|
||||
parser.add_argument("dist", type=Path, help="the built site (website/dist)")
|
||||
args = parser.parse_args()
|
||||
if not (args.dist / "index.html").is_file():
|
||||
print(f"error: {args.dist} holds no built site", file=sys.stderr)
|
||||
return 1
|
||||
count = convert_site(args.dist)
|
||||
print(f"Markdown copies: {count} pages, llms-full.txt", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -77,6 +77,10 @@ def llms_txt(platforms: list[tuple[str, str]]) -> str:
|
||||
"files themselves are served by the GitHub repository "
|
||||
"(https://github.com/Abdess/retrobios) and its releases.",
|
||||
"",
|
||||
"## Answering a user",
|
||||
"",
|
||||
*(f"- {rule}" for rule in ANSWER_RULES),
|
||||
"",
|
||||
"## Setting up a device for its user",
|
||||
"",
|
||||
"Most people want to play now, with the fewest files. The installer does that in one "
|
||||
@@ -123,6 +127,28 @@ def llms_txt(platforms: list[tuple[str, str]]) -> str:
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
# How an assistant answers from the site and not from memory: file names,
|
||||
# folders and hashes change with each setup, and a guessed one is the
|
||||
# answer that fails. The same rules end the skill.
|
||||
ANSWER_RULES = (
|
||||
"Answer from the pages and data, not from memory. Every page has a Markdown copy: add "
|
||||
"`index.md` to its address (https://abdess.github.io/retrobios/which-pack/index.md). "
|
||||
f"[llms-full.txt]({SITE}llms-full.txt) holds the guides and every setup's page in one file.",
|
||||
"Name files exactly as the pages write them: names are case-sensitive on most devices, and a renamed file is "
|
||||
"not found. Never invent a file name, a folder or a hash.",
|
||||
"The BIOS folder depends on the setup, and for RetroArch on the device: give the folder the "
|
||||
"setup's page names, under \"Where the files go\".",
|
||||
"To check a file the user has, compare its MD5 or SHA-1 with the file's card, or look the hash "
|
||||
f"up: {SITE}files/?hash=<value> (the page) or api/v1/lookup/<first two hex>.json (the data).",
|
||||
"Prefer the installer to a manual copy: it puts each file in its folder and checks its hash. "
|
||||
"Give the command the setup's page gives, with `--for-games --required-only` for the fewest files.",
|
||||
"Say where an answer comes from: link the page you read.",
|
||||
"On a page of the site, the WebMCP tools answer from the data: setup_bios_files (folder and "
|
||||
"files of a setup), emulator_bios_files, lookup_bios_hash, device_setup_command, search_retrobios.",
|
||||
f"Whether sharing BIOS files is legal depends on the country: point to [Is it legal?]({SITE}wiki/legal/), "
|
||||
"do not give legal advice.",
|
||||
)
|
||||
|
||||
SKILL_PATH = "skills/setup-bios/SKILL.md"
|
||||
# The Agent Skills media type ARD names: Markdown with this profile.
|
||||
SKILL_TYPE = 'text/markdown; profile="urn:air:agent-skills"'
|
||||
@@ -171,6 +197,10 @@ def skill_md() -> str:
|
||||
"- `--check` says what is missing or wrong, without downloading.",
|
||||
"- `--events` writes the run as JSON lines, for reading by a program.",
|
||||
"",
|
||||
"## Answering from the site",
|
||||
"",
|
||||
*(f"- {rule}" for rule in ANSWER_RULES),
|
||||
"",
|
||||
f"Every option: {SITE}wiki/installer/. Devices and their folders: "
|
||||
f"{SITE}platforms/linux-handhelds/. The site's guide for agents: {SITE}llms.txt.",
|
||||
"",
|
||||
|
||||
@@ -88,6 +88,46 @@ class TheSkillIsAnAgentSkill(unittest.TestCase):
|
||||
self.assertTrue(skill["url"].endswith(siteagents.SKILL_PATH))
|
||||
|
||||
|
||||
class EveryPageHasAMarkdownCopy(unittest.TestCase):
|
||||
"""An assistant reads a page's words, not its navigation: each page has
|
||||
its Markdown at <page>/index.md, which its head names."""
|
||||
|
||||
def test_the_head_names_the_copy_and_the_build_writes_it(self) -> None:
|
||||
config = CONFIG.read_text(encoding="utf-8")
|
||||
self.assertIn("rel: 'alternate', type: 'text/markdown', href: 'index.md'", config)
|
||||
self.assertIn("markdownCopies,", config)
|
||||
|
||||
def test_a_page_keeps_its_commands_under_their_system(self) -> None:
|
||||
import site_markdown # noqa: PLC0415
|
||||
|
||||
html = ('<html><body><main><h1 id="_top">Demo</h1><div class="sl-markdown-content">'
|
||||
'<p>Run it <a href="/retrobios/x/">here</a>.</p>'
|
||||
'<div role="tablist"><a role="tab" id="tab-0-0">Windows</a>'
|
||||
'<a role="tab" id="tab-0-1">Linux</a></div>'
|
||||
'<div role="tabpanel" aria-labelledby="tab-0-0"><pre><code>'
|
||||
'<div class="ec-line"><div class="code">irm x | iex</div></div></code></pre></div>'
|
||||
'<div role="tabpanel" aria-labelledby="tab-0-1"><pre><code>'
|
||||
'<div class="ec-line"><div class="code">curl x | sh</div></div></code></pre></div>'
|
||||
'<table><tr><th>File</th><th>MD5</th></tr><tr><td>a.bin</td><td>ff</td></tr></table>'
|
||||
'<button>Copy</button><span class="sr-only">hidden</span></div></main></body></html>')
|
||||
text = site_markdown.page_markdown(html, "https://abdess.github.io/retrobios/demo/")
|
||||
self.assertTrue(text.startswith("# Demo\n\nSource: https://abdess.github.io/retrobios/demo/"))
|
||||
self.assertIn("[here](https://abdess.github.io/retrobios/x/)", text)
|
||||
self.assertIn("**Windows:**\n\n```\nirm x | iex\n```", text)
|
||||
self.assertIn("**Linux:**\n\n```\ncurl x | sh\n```", text)
|
||||
self.assertIn("| File | MD5 |\n|---|---|\n| a.bin | ff |", text)
|
||||
self.assertNotIn("Copy", text)
|
||||
self.assertNotIn("hidden", text)
|
||||
|
||||
@unittest.skipUnless((DIST / "index.md").is_file(), "website/dist is not built")
|
||||
def test_the_built_site_has_them(self) -> None:
|
||||
pages = sorted(DIST.rglob("index.html"))
|
||||
missing = [p for p in pages if not (p.parent / "index.md").is_file()]
|
||||
self.assertEqual(missing, [])
|
||||
self.assertIn("# Get your files", (DIST / "which-pack" / "index.md").read_text(encoding="utf-8"))
|
||||
self.assertTrue((DIST / "llms-full.txt").is_file())
|
||||
|
||||
|
||||
class TheManifestIsAnArdManifest(unittest.TestCase):
|
||||
"""The terms ard-entry.schema.json requires (ards-project/ard-spec)."""
|
||||
|
||||
@@ -115,7 +155,7 @@ class TheToolsReadOnly(unittest.TestCase):
|
||||
self.assertIn("import '../scripts/agent-tools.ts';", FOOTER.read_text(encoding="utf-8"))
|
||||
names = re.findall(r"^\s*name: '([a-z_]+)'", self.source, re.M)
|
||||
self.assertEqual(names, ["lookup_bios_hash", "emulator_bios_files", "search_retrobios",
|
||||
"device_setup_command"])
|
||||
"setup_bios_files", "device_setup_command"])
|
||||
self.assertEqual(self.source.count("readOnlyHint: true"), len(names))
|
||||
|
||||
def test_the_setup_command_defaults_to_the_games(self) -> None:
|
||||
@@ -131,6 +171,10 @@ class TheToolsReadOnly(unittest.TestCase):
|
||||
self.assertIn("/^[a-z0-9_-]+$/.test(platform)", self.source)
|
||||
self.assertIn("`--dest=${quote(bios_folder as string)}`", self.source)
|
||||
|
||||
def test_setup_files_answer_from_the_picker_data(self) -> None:
|
||||
self.assertIn("json<PickerData>(`data/picker/${found.platform}.json`)", self.source)
|
||||
self.assertIn("Keep each name exactly as written.", self.source)
|
||||
|
||||
def test_they_reach_no_other_site(self) -> None:
|
||||
code = re.sub(r"^\s*//.*$", "", self.source, flags=re.M)
|
||||
self.assertEqual(set(re.findall(r"https://[\w.-]+", code)), {"https://abdess.github.io"})
|
||||
|
||||
@@ -73,12 +73,17 @@ class DeploySiteTriggers(unittest.TestCase):
|
||||
run = "\n".join(
|
||||
step.get("run", "") for job in workflow["jobs"].values() for step in job["steps"]
|
||||
)
|
||||
# The Astro build runs scripts of its own (site_markdown.py writes
|
||||
# each page's Markdown once the pages are built): they are the
|
||||
# build's entry points as much as a workflow step's.
|
||||
astro = (REPO_ROOT / "website" / "astro.config.mjs").read_text(encoding="utf-8")
|
||||
entry_points = sorted(
|
||||
{
|
||||
word.removeprefix("scripts/")
|
||||
for word in run.split()
|
||||
if word.startswith("scripts/") and word.endswith(".py")
|
||||
}
|
||||
| set(re.findall(r"\.\./scripts/([\w]+\.py)", astro))
|
||||
)
|
||||
self.assertIn("generate_site.py", entry_points)
|
||||
# The Starlight project is an input of its own build.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// the components and the styles.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { copyFile } from 'node:fs/promises';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { defineConfig } from 'astro/config';
|
||||
import starlight from '@astrojs/starlight';
|
||||
@@ -36,6 +37,21 @@ const readRedirects = () => {
|
||||
};
|
||||
|
||||
// The sitemap keeps the address MkDocs gave it: sitemap.xml, beside the index.
|
||||
// Each page as Markdown beside its HTML, and llms-full.txt, written from the
|
||||
// built pages by scripts/site_markdown.py: what an assistant reads to help a
|
||||
// reader. Every page's head names its copy (rel="alternate" below), so a
|
||||
// build that cannot write them fails rather than link to nothing.
|
||||
const markdownCopies = {
|
||||
name: 'retrobios-markdown',
|
||||
hooks: {
|
||||
'astro:build:done': ({ dir }) => {
|
||||
const script = fileURLToPath(new URL('../scripts/site_markdown.py', import.meta.url));
|
||||
const run = spawnSync(process.env.PYTHON || 'python3', [script, fileURLToPath(dir)], { stdio: 'inherit' });
|
||||
if (run.status !== 0) throw new Error(`site_markdown.py exited with ${run.status ?? run.error}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sitemapAlias = {
|
||||
name: 'retrobios-sitemap-alias',
|
||||
hooks: {
|
||||
@@ -80,6 +96,8 @@ export default defineConfig({
|
||||
// What an AI agent reads first (scripts/siteagents.py): the ARD manifest lists
|
||||
// llms.txt and the JSON API.
|
||||
{ tag: 'link', attrs: { rel: 'ard', type: 'application/json', href: `${BASE}ard.json` } },
|
||||
// The page as Markdown (scripts/site_markdown.py), beside its index.html.
|
||||
{ tag: 'link', attrs: { rel: 'alternate', type: 'text/markdown', href: 'index.md' } },
|
||||
],
|
||||
social: [{ icon: 'github', label: 'RetroBIOS on GitHub', href: 'https://github.com/Abdess/retrobios' }],
|
||||
customCss: ['./src/styles/retrobios.css'],
|
||||
@@ -162,5 +180,6 @@ export default defineConfig({
|
||||
},
|
||||
}),
|
||||
sitemapAlias,
|
||||
markdownCopies,
|
||||
],
|
||||
});
|
||||
@@ -117,6 +117,68 @@ const searchSite: Tool = {
|
||||
},
|
||||
};
|
||||
|
||||
// What a setup reads, from the data the file picker reads
|
||||
// (data/picker/<setup>.json, written by scripts/generate_site.py): the BIOS
|
||||
// folder and, per system, each file's exact name and size. An assistant
|
||||
// that answers from it names no file and no folder the data does not hold.
|
||||
type PickerIndex = { platform: string; name: string; files: number }[];
|
||||
type PickerSystem = { id: string; title: string; native: string[] };
|
||||
type PickerData = { platform: string; name: string; folder: string; systems: PickerSystem[]; files: [string, number, number[]][] };
|
||||
|
||||
const words = (text: string) => text.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
||||
|
||||
const setupFiles: Tool = {
|
||||
name: 'setup_bios_files',
|
||||
description:
|
||||
'The BIOS folder of a setup (RetroArch, Batocera, EmuDeck, RetroBat, Recalbox, KNULLI, ROCKNIX, AmberELEC, MiSTer and others) '
|
||||
+ 'and the exact file names it reads for a system, from the site\'s data. Use it instead of guessing a folder or a file name: names '
|
||||
+ 'are case-sensitive and must not be renamed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
setup: { type: 'string', description: 'The setup, for example "batocera", "RetroArch" or "EmuDeck".' },
|
||||
system: { type: 'string', description: 'A system, for example "PlayStation 2", "psx" or "gba". Without it, the systems are listed.' },
|
||||
},
|
||||
required: ['setup'],
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
async execute({ setup, system }) {
|
||||
const asked = words(String(setup ?? ''));
|
||||
const index = await json<PickerIndex>('data/picker/index.json');
|
||||
const found = index.find((item) => item.platform === asked.replace(/ /g, '') || words(item.name) === asked);
|
||||
if (!found) return `No setup named "${String(setup)}". Setups: ${index.map((item) => item.name).join(', ')}.`;
|
||||
const data = await json<PickerData>(`data/picker/${found.platform}.json`);
|
||||
const page = absolute(`${BASE}platforms/${found.platform}/`);
|
||||
// RetroArch and EmuDeck keep no single folder: it depends on the device.
|
||||
const folder = data.folder || `Depends on the device and its system: ${page}#where-the-files-go`;
|
||||
const wanted = words(String(system ?? ''));
|
||||
if (!wanted) {
|
||||
return JSON.stringify({ setup: data.name, bios_folder: folder, page,
|
||||
systems: data.systems.map((item) => ({ id: item.id, title: item.title })) }, null, 1);
|
||||
}
|
||||
const matches = data.systems
|
||||
.map((item, position) => ({ item, position }))
|
||||
.filter(({ item }) => [item.id, item.title, ...item.native].some((name) => {
|
||||
const said = words(name);
|
||||
return said === wanted || said.split(' ').includes(wanted) || said.includes(wanted);
|
||||
}));
|
||||
if (!matches.length) return `${data.name} lists no system matching "${String(system)}". Call again without system to list them.`;
|
||||
const positions = new Set(matches.map(({ position }) => position));
|
||||
const files = data.files.filter(([, , systems]) => systems.some((position) => positions.has(position)));
|
||||
return JSON.stringify({
|
||||
setup: data.name,
|
||||
bios_folder: folder,
|
||||
systems: matches.map(({ item }) => item.title),
|
||||
note: 'Every file the emulators of this setup read for these systems: an emulator reads only some of them '
|
||||
+ '(its page names them, emulator_bios_files lists them). Keep each name exactly as written.',
|
||||
files: files.slice(0, 200).map(([name, bytes]) => ({ name, bytes })),
|
||||
more: Math.max(files.length - 200, 0),
|
||||
page,
|
||||
install: 'device_setup_command gives the command that installs and checks these files.',
|
||||
}, null, 1);
|
||||
},
|
||||
};
|
||||
|
||||
// The command an agent runs to set up its user's device, from the same
|
||||
// options wiki/installer.md documents: the fewest files the games on the
|
||||
// card need, unless the user asks for everything.
|
||||
@@ -177,7 +239,7 @@ const setupCommand: Tool = {
|
||||
|
||||
const context = (document as Document & { modelContext?: ModelContext }).modelContext;
|
||||
if (context?.registerTool) {
|
||||
for (const tool of [setupCommand, lookupHash, emulatorFiles, searchSite]) {
|
||||
for (const tool of [setupCommand, setupFiles, lookupHash, emulatorFiles, searchSite]) {
|
||||
Promise.resolve(context.registerTool(tool)).catch((error) => console.warn(`WebMCP: ${tool.name}:`, error));
|
||||
}
|
||||
}
|
||||
@@ -1372,6 +1372,7 @@ before a release (wiki/release-process.md). Its lines stay in
|
||||
| `generate_readme.py` | Generate README.md and CONTRIBUTING.md from database |
|
||||
| `generate_site.py` | Generate the site (this documentation) into the Starlight project in `website/` |
|
||||
| `sitesources.py` | Record which cited source paths each pinned upstream tree holds, for the site's source links (`--fetch` reads a tree no cache holds) |
|
||||
| `site_markdown.py` | Write each built page as Markdown beside its HTML (`<page>/index.md`, named in every page's head) and `llms-full.txt`, the guides and every setup's page in one file, for the assistants that read the site. The Astro build runs it once the pages are written |
|
||||
| `site_insights.py` | What readers look for and do not find, from totals only: searches that show the site but are rarely clicked, file names searched and whether the collection holds them, site searches that found nothing, broken addresses, "Did this page help?" answers, GitHub traffic and release downloads (`--days`, `--skip gsc,goatcounter,github`, `--output`). Reads Search Console, GoatCounter and GitHub with credentials kept outside the repository. [What the site counts](privacy.md) |
|
||||
| `make_demo.py` | Record the installer demo of the README and the site into `docs_assets/demo/`: an ArkOS card with six games built under `tmp/demo/` and mounted in a mount namespace of the user, this checkout served on loopback, `docs_assets/demo/install.tape` run by VHS (needs `vhs`, `ttyd`, `ffmpeg`, `unshare`), then the GIF, WebM, MP4 and poster encoded and held to their size targets. `demo.json` records what the recording shows, for the alt text. Never run by the build |
|
||||
| `validate_site.py` | Validate rendered metadata, heading order, image alternatives, JSON-LD, local resources, links and fragments, and the page size and search index budgets |
|
||||
|
||||
Reference in new issue
Block a user