| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157 |
- """Generate llms.txt and llms-full.txt for the built site.
- Adapted from django-div's scripts/llms.py. Zensical has no plugin API yet and
- no llms.txt support, so this runs as a post-build step.
- Awesome Django is a single long page: README.md is the source of truth and
- docs/README.md is a symlink to it, so neither the GitHub README nor the site
- can drift from what this reads. Everything else -- name, summary, URL -- comes
- from zensical.toml.
- llms.txt an index: an H1, a blockquote summary, then the category
- headings from the README as links to their on-site anchors.
- llms-full.txt the complete text of the list, verbatim.
- See https://llmstxt.org/ for the format.
- Usage: python scripts/gen_llms.py [site_dir]
- """
- from __future__ import annotations
- import re
- import sys
- import tomllib
- from pathlib import Path
- ROOT = Path(__file__).resolve().parent.parent
- CONFIG = ROOT / "zensical.toml"
- README = ROOT / "README.md"
- # Headings that structure the page but are not categories to link to.
- SKIP_HEADINGS = {"Contents", "Footnotes"}
- def config() -> dict:
- return tomllib.loads(CONFIG.read_text())["project"]
- def slugify(text: str) -> str:
- """Anchor id for a heading, matching python-markdown's toc extension:
- lowercase, strip everything but word chars/space/hyphen, spaces -> hyphens.
- """
- slug = text.strip().lower()
- slug = re.sub(r"[^\w\s-]", "", slug)
- return re.sub(r"[\s]+", "-", slug)
- def readme_body() -> str:
- """The README with its doctoc-generated TOC block removed.
- The TOC is navigation doctoc rewrites on every build; the headings it
- points at are the real content, so keeping both would just duplicate the
- category list.
- """
- body = README.read_text()
- return re.sub(
- r"<!-- START doctoc.*?<!-- END doctoc[^>]*-->\n?",
- "",
- body,
- flags=re.DOTALL,
- )
- def headings(body: str) -> list[tuple[int, str]]:
- """(level, text) for each ``##``/``###`` heading, TOC/footnotes aside.
- Fenced code blocks are skipped so a ``#`` comment inside one is never
- mistaken for a heading.
- """
- found = []
- in_fence = False
- for line in body.splitlines():
- if line.lstrip().startswith("```"):
- in_fence = not in_fence
- continue
- if in_fence:
- continue
- match = re.match(r"^(#{2,3})\s+(.*)$", line)
- if not match:
- continue
- text = match.group(2).strip()
- if text in SKIP_HEADINGS:
- continue
- found.append((len(match.group(1)), text))
- return found
- def build_llms_txt(project: dict, body: str) -> str:
- base_url = project.get("site_url", "").rstrip("/")
- lines = [
- f"# {project['site_name']}",
- "",
- f"> {project['site_description']}.",
- "",
- f"- The complete list is available as text at {base_url}/llms-full.txt.",
- "",
- "## Categories",
- "",
- ]
- seen: dict[str, int] = {}
- for level, text in headings(body):
- # python-markdown disambiguates a repeated heading id with _1, _2, ...
- slug = slugify(text)
- count = seen.get(slug, 0)
- seen[slug] = count + 1
- anchor = slug if count == 0 else f"{slug}_{count}"
- indent = " " * (level - 2) # ## flush left, ### indented one step
- lines.append(f"{indent}- [{text}]({base_url}/#{anchor})")
- lines += [
- "",
- "## Optional",
- "",
- f"- [Source]({project['repo_url']}): the repository and"
- " contribution guidelines.",
- ]
- return "\n".join(lines) + "\n"
- def build_llms_full_txt(project: dict, body: str) -> str:
- base_url = project.get("site_url", "").rstrip("/")
- header = [
- f"# {project['site_name']} - Full Text",
- "",
- f"> {project['site_description']}.",
- "",
- f"- An index of links is available at {base_url}/llms.txt.",
- f"- Source: {project['repo_url']}",
- "",
- "---",
- "",
- ]
- return "\n".join(header) + body.strip() + "\n"
- def main() -> int:
- site = Path(sys.argv[1] if len(sys.argv) > 1 else "site")
- if not site.is_dir():
- print(f"error: {site} does not exist -- build the site first", file=sys.stderr)
- return 1
- project = config()
- body = readme_body()
- for name, text in (
- ("llms.txt", build_llms_txt(project, body)),
- ("llms-full.txt", build_llms_full_txt(project, body)),
- ):
- target = site / name
- target.write_text(text, encoding="utf-8")
- print(f"wrote {target} ({target.stat().st_size:,} bytes)")
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
|