gen_llms.py 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  1. """Generate llms.txt and llms-full.txt for the built site.
  2. Adapted from django-div's scripts/llms.py. Zensical has no plugin API yet and
  3. no llms.txt support, so this runs as a post-build step.
  4. Awesome Django is a single long page: README.md is the source of truth and
  5. docs/README.md is a symlink to it, so neither the GitHub README nor the site
  6. can drift from what this reads. Everything else -- name, summary, URL -- comes
  7. from zensical.toml.
  8. llms.txt an index: an H1, a blockquote summary, then the category
  9. headings from the README as links to their on-site anchors.
  10. llms-full.txt the complete text of the list, verbatim.
  11. See https://llmstxt.org/ for the format.
  12. Usage: python scripts/gen_llms.py [site_dir]
  13. """
  14. from __future__ import annotations
  15. import re
  16. import sys
  17. import tomllib
  18. from pathlib import Path
  19. ROOT = Path(__file__).resolve().parent.parent
  20. CONFIG = ROOT / "zensical.toml"
  21. README = ROOT / "README.md"
  22. # Headings that structure the page but are not categories to link to.
  23. SKIP_HEADINGS = {"Contents", "Footnotes"}
  24. def config() -> dict:
  25. return tomllib.loads(CONFIG.read_text())["project"]
  26. def slugify(text: str) -> str:
  27. """Anchor id for a heading, matching python-markdown's toc extension:
  28. lowercase, strip everything but word chars/space/hyphen, spaces -> hyphens.
  29. """
  30. slug = text.strip().lower()
  31. slug = re.sub(r"[^\w\s-]", "", slug)
  32. return re.sub(r"[\s]+", "-", slug)
  33. def readme_body() -> str:
  34. """The README with its doctoc-generated TOC block removed.
  35. The TOC is navigation doctoc rewrites on every build; the headings it
  36. points at are the real content, so keeping both would just duplicate the
  37. category list.
  38. """
  39. body = README.read_text()
  40. return re.sub(
  41. r"<!-- START doctoc.*?<!-- END doctoc[^>]*-->\n?",
  42. "",
  43. body,
  44. flags=re.DOTALL,
  45. )
  46. def headings(body: str) -> list[tuple[int, str]]:
  47. """(level, text) for each ``##``/``###`` heading, TOC/footnotes aside.
  48. Fenced code blocks are skipped so a ``#`` comment inside one is never
  49. mistaken for a heading.
  50. """
  51. found = []
  52. in_fence = False
  53. for line in body.splitlines():
  54. if line.lstrip().startswith("```"):
  55. in_fence = not in_fence
  56. continue
  57. if in_fence:
  58. continue
  59. match = re.match(r"^(#{2,3})\s+(.*)$", line)
  60. if not match:
  61. continue
  62. text = match.group(2).strip()
  63. if text in SKIP_HEADINGS:
  64. continue
  65. found.append((len(match.group(1)), text))
  66. return found
  67. def build_llms_txt(project: dict, body: str) -> str:
  68. base_url = project.get("site_url", "").rstrip("/")
  69. lines = [
  70. f"# {project['site_name']}",
  71. "",
  72. f"> {project['site_description']}.",
  73. "",
  74. f"- The complete list is available as text at {base_url}/llms-full.txt.",
  75. "",
  76. "## Categories",
  77. "",
  78. ]
  79. seen: dict[str, int] = {}
  80. for level, text in headings(body):
  81. # python-markdown disambiguates a repeated heading id with _1, _2, ...
  82. slug = slugify(text)
  83. count = seen.get(slug, 0)
  84. seen[slug] = count + 1
  85. anchor = slug if count == 0 else f"{slug}_{count}"
  86. indent = " " * (level - 2) # ## flush left, ### indented one step
  87. lines.append(f"{indent}- [{text}]({base_url}/#{anchor})")
  88. lines += [
  89. "",
  90. "## Optional",
  91. "",
  92. f"- [Source]({project['repo_url']}): the repository and"
  93. " contribution guidelines.",
  94. ]
  95. return "\n".join(lines) + "\n"
  96. def build_llms_full_txt(project: dict, body: str) -> str:
  97. base_url = project.get("site_url", "").rstrip("/")
  98. header = [
  99. f"# {project['site_name']} - Full Text",
  100. "",
  101. f"> {project['site_description']}.",
  102. "",
  103. f"- An index of links is available at {base_url}/llms.txt.",
  104. f"- Source: {project['repo_url']}",
  105. "",
  106. "---",
  107. "",
  108. ]
  109. return "\n".join(header) + body.strip() + "\n"
  110. def main() -> int:
  111. site = Path(sys.argv[1] if len(sys.argv) > 1 else "site")
  112. if not site.is_dir():
  113. print(f"error: {site} does not exist -- build the site first", file=sys.stderr)
  114. return 1
  115. project = config()
  116. body = readme_body()
  117. for name, text in (
  118. ("llms.txt", build_llms_txt(project, body)),
  119. ("llms-full.txt", build_llms_full_txt(project, body)),
  120. ):
  121. target = site / name
  122. target.write_text(text, encoding="utf-8")
  123. print(f"wrote {target} ({target.stat().st_size:,} bytes)")
  124. return 0
  125. if __name__ == "__main__":
  126. raise SystemExit(main())