|
50 | 50 | # own link validation only covers .md targets (a missing image or |
51 | 51 | # directory-style link builds green even under --strict; MkDocs failed the |
52 | 52 | # build), so everything else is validated here. |
53 | | -_LINK = re.compile(r'(\]\()([^)\s]+?)(#[^)\s]*)?( +"[^"]*")?(\))') |
| 53 | +_LINK = re.compile(r'(\]\()([^)\s]+?)(#[^)\s]*)?( +(?:"[^"]*"|\'[^\']*\'|\([^()]*\)))?(\))') |
| 54 | +# CommonMark forms the classifier deliberately rejects rather than models: |
| 55 | +# angle-bracket destinations `](<target>)` and reference-style definitions |
| 56 | +# `[label]: target` (footnote definitions `[^label]:` are a different, |
| 57 | +# supported syntax). Either would otherwise dodge validation by its spelling; |
| 58 | +# failing loud keeps the guarantee without modelling unused syntax. |
| 59 | +_ANGLE_LINK = re.compile(r"\]\(<") |
| 60 | +_REF_DEFINITION = re.compile(r"^[ \t]*\[(?!\^)[^\]]+\]:", flags=re.MULTILINE) |
54 | 61 | # A scheme-prefixed target (https:, mailto:, tel:, ...) is external — the |
55 | 62 | # `://` shorthand misses scheme-only URIs like mailto:. |
56 | 63 | _EXTERNAL = re.compile(r"[a-zA-Z][a-zA-Z0-9+.-]*:") |
|
60 | 67 | # least as long as the opener, unclosed runs to EOF, per CommonMark) and spans |
61 | 68 | # only in the text between fences; a span cannot cross a blank line, so a |
62 | 69 | # stray unpaired backtick cannot swallow the paragraphs (and links) after it. |
| 70 | +# Known approximations of the renderer's block model: 4-space-indented |
| 71 | +# content is treated as prose, because in this corpus indentation is |
| 72 | +# admonition/list body whose links must stay validated — a link in a true |
| 73 | +# indented code block is over-validated (fails loud or gets rewritten in the |
| 74 | +# rendition), never under-validated; and span pairing is bounded by blank |
| 75 | +# lines rather than full block structure. |
63 | 76 | _FENCE = re.compile(r"^[ \t]*(`{3,}|~{3,})") |
64 | 77 | _CODE_SPAN = re.compile(r"(?s)(?<!`)(`+)(?!`)((?:(?!\n[ \t]*\n).)+?)(?<!`)\1(?!`)") |
65 | 78 | # A leading YAML frontmatter block, as MkDocs/Zensical parse it (mkdocs.utils.meta). |
@@ -167,6 +180,24 @@ def include(match: re.Match[str]) -> str: |
167 | 180 | return resolved |
168 | 181 |
|
169 | 182 |
|
| 183 | +def _in_code(code: list[tuple[int, int]], position: int) -> bool: |
| 184 | + """Whether `position` falls inside any code interval.""" |
| 185 | + return any(start <= position < end for start, end in code) |
| 186 | + |
| 187 | + |
| 188 | +def _prose_h1(markdown: str) -> re.Match[str] | None: |
| 189 | + """The first ATX H1 outside code (at most 3 spaces of indent, per CommonMark). |
| 190 | +
|
| 191 | + Code-awareness matters: every resolved `.py` snippet starts with a |
| 192 | + `# path` pointer line that must never win over the page's real H1. |
| 193 | + """ |
| 194 | + code = _code_intervals(markdown) |
| 195 | + for match in re.finditer(r"^ {0,3}# (.+)$", markdown, flags=re.MULTILINE): |
| 196 | + if not _in_code(code, match.start()): |
| 197 | + return match |
| 198 | + return None |
| 199 | + |
| 200 | + |
170 | 201 | def _code_intervals(markdown: str) -> list[tuple[int, int]]: |
171 | 202 | """The character spans of fenced code blocks and inline code spans.""" |
172 | 203 | fences: list[tuple[int, int]] = [] |
@@ -196,11 +227,17 @@ def _rewrite_links(markdown: str, src_uri: str, site_url: str, prose: dict[str, |
196 | 227 | src_dir = posixpath.dirname(src_uri) |
197 | 228 | code = _code_intervals(markdown) |
198 | 229 |
|
| 230 | + rejected = ((_ANGLE_LINK, "angle-bracket link destination"), (_REF_DEFINITION, "reference-style link definition")) |
| 231 | + for pattern, form in rejected: |
| 232 | + for match in pattern.finditer(markdown): |
| 233 | + if not _in_code(code, match.start()): |
| 234 | + raise _BuildError(f"llms_txt: {form} in {src_uri} is not supported here; use a plain inline link") |
| 235 | + |
199 | 236 | def rewrite(match: re.Match[str]) -> str: |
200 | 237 | opening, target, anchor, title, closing = match.groups() |
201 | 238 | if target.startswith("#") or _EXTERNAL.match(target): |
202 | 239 | return match.group(0) # in-page anchor or external URL (https:, mailto:, ...) |
203 | | - if any(start <= match.start() < end for start, end in code): |
| 240 | + if _in_code(code, match.start()): |
204 | 241 | return match.group(0) # illustrative link inside a code block/span |
205 | 242 | if target.startswith("/"): |
206 | 243 | raise _BuildError(f"llms_txt: absolute link target {target!r} in {src_uri}: link the .md source instead") |
@@ -228,10 +265,9 @@ def _title(src_uri: str, nav_title: str | None, meta: dict[str, Any], body: str) |
228 | 265 | return nav_title |
229 | 266 | if isinstance(meta_title := meta.get("title"), str): |
230 | 267 | return meta_title |
231 | | - match = re.search(r"^\s*# (.+)$", body, flags=re.MULTILINE) |
232 | | - if match is None: |
233 | | - raise _BuildError(f"llms_txt: page {src_uri} has no nav title, no title frontmatter, and no H1") |
234 | | - return match.group(1).strip() |
| 268 | + if match := _prose_h1(body): |
| 269 | + return match.group(1).strip() |
| 270 | + raise _BuildError(f"llms_txt: page {src_uri} has no nav title, no title frontmatter, and no H1") |
235 | 271 |
|
236 | 272 |
|
237 | 273 | def generate(site_dir: Path) -> None: |
@@ -268,8 +304,10 @@ def generate(site_dir: Path) -> None: |
268 | 304 | tail = f": {description}" if description else "" |
269 | 305 | index.append(f"- [{title}]({site_url}{md_uri}){tail}") |
270 | 306 |
|
271 | | - # Strip a leading H1 when present: `full` re-titles every page. |
272 | | - body = re.sub(r"\A\s*# .+\n", "", markdown) |
| 307 | + # `full` re-titles every page, so drop its first prose H1 (the |
| 308 | + # same one `_title` falls back to). |
| 309 | + h1 = _prose_h1(markdown) |
| 310 | + body = markdown if h1 is None else markdown[: h1.start()] + markdown[h1.end() :] |
273 | 311 | full += [f"# {title}", "", f"Source: {site_url}{_page_url(src_uri)}", "", body.strip(), ""] |
274 | 312 | index.append("") |
275 | 313 |
|
|
0 commit comments