Source code for kavalai.text

"""
Copyright 2026 OÜ KAVAL AI (registry code 17393877)

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

Text for a RAG index: HTML or markdown in, chunks to embed out.

:func:`parse_html` reduces a page to its title, its visible text as
:class:`Block` objects that each carry the heading path above them, its links
and its robots directives, in one pass of the standard-library HTML parser.
:func:`chunk_blocks` packs blocks into :class:`Chunk` objects of about
``target_chars`` and never more than ``max_chars``, splitting an oversized
block at line breaks, then at sentence ends, then between words, and cutting
only a single over-long word. :func:`chunk_markdown` reads markdown into the
same blocks and packs them with the same function.

The module uses the standard library only, so it runs under Pyodide, and every
step is linear in its input: no regular expression is involved, and the
parser's bookkeeping is amortised constant work per tag.

The boilerplate rules are parameters of :func:`parse_html`; the defaults are
the module constants :data:`DROP_TAGS`, :data:`CHROME_TAGS`,
:data:`DROP_ROLES`, :data:`CONTENT_TAGS` and :data:`ROBOTS_NAMES`. Fetching
a page, reading PDF and detecting the language are outside its scope.
"""

from collections.abc import Iterable
from dataclasses import dataclass
from html.parser import HTMLParser
from typing import Optional
from urllib.parse import urljoin

__all__ = [
    "CHROME_TAGS",
    "CONTENT_TAGS",
    "DROP_ROLES",
    "DROP_TAGS",
    "HEADING_SEPARATOR",
    "MAX_CHARS",
    "ROBOTS_NAMES",
    "TARGET_CHARS",
    "Block",
    "Chunk",
    "ParsedPage",
    "chunk_blocks",
    "chunk_markdown",
    "parse_html",
]

HEADING_SEPARATOR = " › "
"""Joins the headings of a heading path: ``"Products › Pricing"``."""

TARGET_CHARS = 1200
"""The chunk size :func:`chunk_blocks` packs towards."""

MAX_CHARS = 2000
"""The chunk size :func:`chunk_blocks` never exceeds. Small embedding models
truncate at 512 tokens, so a much longer chunk would be embedded only in part."""

DROP_TAGS = frozenset(
    {
        "script",
        "style",
        "noscript",
        "template",
        "svg",
        "iframe",
        "canvas",
        "object",
        "nav",
        "dialog",
        "button",
        "select",
        "textarea",
    }
)
"""Elements whose text is never content: code, fallbacks, navigation and form
controls. Their links are still collected."""

CHROME_TAGS = frozenset({"header", "footer", "aside"})
"""Elements that are page chrome outside a content element and part of it
inside one: an article's own ``<header>`` holds its title and byline, the
page's holds the logo and the menu."""

DROP_ROLES = frozenset(
    {
        "navigation",
        "banner",
        "contentinfo",
        "complementary",
        "search",
        "dialog",
        "alertdialog",
        "menu",
        "menubar",
    }
)
"""ARIA landmark roles that mark boilerplate on any element."""

CONTENT_TAGS = frozenset({"main", "article"})
"""Elements that mark a page's content. An element whose ``role`` names one of
them counts as that element."""

ROBOTS_NAMES = frozenset({"robots"})
"""``<meta name>`` values whose ``content`` is read as robots directives."""

PERMALINK_GLYPHS = "¶"
"""Characters removed from all text by default: the pilcrow that Sphinx and
MkDocs attach to every heading as a permalink."""

_HEADING_LEVELS = {f"h{level}": level for level in range(1, 7)}
_BLOCK_TAGS = frozenset(
    {
        "address",
        "article",
        "aside",
        "blockquote",
        "body",
        "caption",
        "center",
        "dd",
        "details",
        "dialog",
        "div",
        "dl",
        "dt",
        "fieldset",
        "figcaption",
        "figure",
        "footer",
        "form",
        "header",
        "hgroup",
        "legend",
        "li",
        "main",
        "menu",
        "nav",
        "ol",
        "p",
        "pre",
        "search",
        "section",
        "summary",
        "table",
        "tbody",
        "tfoot",
        "thead",
        "tr",
        "ul",
    }
)
_BLOCK_KINDS = {
    "li": "item",
    "dt": "item",
    "dd": "item",
    "tr": "row",
    "pre": "code",
    "blockquote": "quote",
}
_CELL_TAGS = frozenset({"td", "th"})
_VOID_TAGS = frozenset(
    {
        "area",
        "base",
        "br",
        "col",
        "embed",
        "hr",
        "img",
        "input",
        "link",
        "meta",
        "param",
        "source",
        "track",
        "wbr",
    }
)
_CELL_SEPARATOR = " | "

_SKIP, _CONTENT, _BLOCK, _HEADING, _TEMPLATE = 1, 2, 4, 8, 16

_SENTENCE_ENDS = ".!?…।"
_UNSPACED_SENTENCE_ENDS = "。!?"
_CLOSERS = "\"')]»”’」』"


[docs] @dataclass(frozen=True) class Block: """One paragraph-sized run of visible text and the headings above it. Attributes: text: The text, whitespace collapsed; a ``<br>`` is kept as a line break, and a ``code`` block keeps its whitespace. heading: The heading path above the block, joined by :data:`HEADING_SEPARATOR`. For a heading block, the path ends in the heading itself. kind: ``"paragraph"``, ``"heading"``, ``"item"`` (a list item, definition term or description), ``"row"`` (a table row, its cells separated by ``" | "``), ``"code"`` or ``"quote"``. level: The heading level, 1 to 6, for a heading block; 0 otherwise. """ text: str heading: str = "" kind: str = "paragraph" level: int = 0
[docs] @dataclass(frozen=True) class Chunk: """One piece of a document to embed. Attributes: text: What is embedded: the prefix (title and heading path) when one was asked for, a blank line, then the body. heading: The heading path the chunk's blocks sit under. position: The chunk's index in the list it was returned in. """ text: str heading: str position: int
[docs] @dataclass class ParsedPage: """What one HTML document reduces to. Attributes: title: The ``<title>``, whitespace collapsed; the first heading when the page has no title. blocks: The visible text in document order, heading blocks included. links: The ``href`` of every ``<a>`` and ``<area>``, boilerplate included, in document order without repeats; absolute when a base URL was given or the page declares ``<base href>``. noindex: Whether a robots ``<meta>`` tag says ``noindex`` or ``none``. nofollow: Whether a robots ``<meta>`` tag says ``nofollow`` or ``none``. """ title: str blocks: list[Block] links: list[str] noindex: bool = False nofollow: bool = False @property def markdown(self) -> str: """The blocks as markdown: ``#`` headings, ``-`` items, fenced code. The rendering serves RAG chunking and a readable archive, not fidelity: inline formatting and link targets are not reproduced. """ return _render_markdown(self.blocks)
@dataclass class _Record: """A block as the parser first sees it, before the content filter.""" kind: str text: str level: int in_content: bool heading: str content_heading: str class _PageParser(HTMLParser): """The single pass behind :func:`parse_html`. The open-element stack records, per element, what entering it changed (``_SKIP``, ``_CONTENT``, …), so closing it undoes exactly that. An end tag pops down to its element; every element is pushed and popped once, which keeps malformed nesting linear. """ def __init__( self, *, content_tags: frozenset[str], drop_tags: frozenset[str], chrome_tags: frozenset[str], drop_roles: frozenset[str], drop_hidden: bool, strip_chars: str, robots_names: frozenset[str], ): super().__init__(convert_charrefs=True) self.content_tags = content_tags self.drop_tags = drop_tags self.chrome_tags = chrome_tags self.drop_roles = drop_roles self.drop_hidden = drop_hidden self.strip_table = str.maketrans("", "", strip_chars) self.robots_names = robots_names self.records: list[_Record] = [] self.hrefs: list[str] = [] self.base_href: Optional[str] = None self.directives: set[str] = set() self.title_parts: list[str] = [] self._open: list[str] = [] self._flags: list[int] = [] self._open_counts: dict[str, int] = {} self._kinds: list[str] = [] self._skipping = False self._content_depth = 0 self._template_depth = 0 self._in_title = False self._title_done = False self._buffer: list[str] = [] self._line_has_text = False self._heading_level = 0 self._heading_index: Optional[int] = None self._heading_parts: list[str] = [] self._path: dict[int, str] = {} self._content_path: dict[int, str] = {} def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]): attributes = {name: value or "" for name, value in attrs} if tag == "meta": self._read_meta(attributes) return if tag == "base": if self.base_href is None and attributes.get("href", "").strip(): self.base_href = attributes["href"].strip() return if tag in ("a", "area") and not self._template_depth: href = attributes.get("href", "").strip() if href: self.hrefs.append(href) if tag == "title": self._in_title = not self._skipping and not self._title_done return if tag in _VOID_TAGS: self._void_tag(tag) return flags = _TEMPLATE if tag == "template" else 0 if flags: self._template_depth += 1 if self._skipping: self._push(tag, flags) return role = _role(attributes) if self._drops(tag, role, attributes): self._skipping = True self._push(tag, flags | _SKIP) return is_content = tag in self.content_tags or role in self.content_tags if tag in _HEADING_LEVELS: self._start_heading(_HEADING_LEVELS[tag]) flags |= _HEADING elif tag in _BLOCK_TAGS or is_content: self._flush() self._kinds.append(_BLOCK_KINDS.get(tag, "paragraph")) flags |= _BLOCK elif tag in _CELL_TAGS and self._line_has_text: self._buffer.append(_CELL_SEPARATOR) if is_content: self._content_depth += 1 flags |= _CONTENT self._push(tag, flags) def handle_endtag(self, tag: str): if tag == "title": if self._in_title: self._in_title = False self._title_done = True return if tag in _VOID_TAGS: if tag == "br": self._void_tag(tag) return if self._open_counts.get(tag): while self._pop() != tag: pass elif tag in _HEADING_LEVELS and self._heading_index is not None: index = self._heading_index while len(self._open) > index: self._pop() elif tag in _BLOCK_TAGS and not self._skipping: self._flush() def handle_data(self, data: str): data = data.translate(self.strip_table) if self._in_title: self.title_parts.append(data) elif self._skipping: return elif self._heading_index is not None: self._heading_parts.append(data) elif self._kinds and self._kinds[-1] == "code": self._buffer.append(data.replace("\r\n", "\n")) self._line_has_text = self._line_has_text or not data.isspace() else: self._buffer.append(_collapse_keeping_edges(data)) self._line_has_text = self._line_has_text or not data.isspace() def close(self): super().close() if self._heading_index is not None: self._finish_heading() self._flush() def _read_meta(self, attributes: dict[str, str]): if attributes.get("name", "").strip().lower() in self.robots_names: for directive in attributes.get("content", "").split(","): self.directives.add(directive.strip().lower()) def _void_tag(self, tag: str): if self._skipping: return if tag == "br": if self._heading_index is not None: self._heading_parts.append(" ") else: self._buffer.append("\n") self._line_has_text = False elif tag == "hr": self._flush() def _drops(self, tag: str, role: str, attributes: dict[str, str]) -> bool: return ( tag in self.drop_tags or (tag in self.chrome_tags and not self._content_depth) or role in self.drop_roles or (self.drop_hidden and _is_hidden(attributes)) ) def _push(self, tag: str, flags: int): self._open.append(tag) self._flags.append(flags) self._open_counts[tag] = self._open_counts.get(tag, 0) + 1 def _pop(self) -> str: """Close the innermost open element, undoing what opening it changed.""" tag = self._open.pop() flags = self._flags.pop() self._open_counts[tag] -= 1 if flags & _TEMPLATE: self._template_depth -= 1 if flags & _SKIP: self._skipping = False if flags & _HEADING and self._heading_index == len(self._open): self._finish_heading() if flags & _BLOCK: self._flush() self._kinds.pop() if flags & _CONTENT: self._content_depth -= 1 return tag def _start_heading(self, level: int): if self._heading_index is not None: self._finish_heading() self._flush() self._heading_level = level self._heading_index = len(self._open) self._heading_parts = [] def _finish_heading(self): text = " ".join("".join(self._heading_parts).split()) level = self._heading_level in_content = self._content_depth > 0 self._heading_index = None self._heading_parts = [] _set_heading(self._path, level, text) if in_content: _set_heading(self._content_path, level, text) if text: self.records.append( _Record( kind="heading", text=text, level=level, in_content=in_content, heading=_join_path(self._path), content_heading=_join_path(self._content_path), ) ) def _flush(self): if not self._buffer: return raw = "".join(self._buffer) self._buffer.clear() self._line_has_text = False kind = self._kinds[-1] if self._kinds else "paragraph" text = _tidy_code(raw) if kind == "code" else _tidy_lines(raw) if text: self.records.append( _Record( kind=kind, text=text, level=0, in_content=self._content_depth > 0, heading=_join_path(self._path), content_heading=_join_path(self._content_path), ) )
[docs] def parse_html( html: str, *, base_url: Optional[str] = None, content_tags: Iterable[str] = CONTENT_TAGS, drop_tags: Iterable[str] = DROP_TAGS, chrome_tags: Iterable[str] = CHROME_TAGS, drop_roles: Iterable[str] = DROP_ROLES, drop_hidden: bool = True, strip_chars: str = PERMALINK_GLYPHS, robots_names: Iterable[str] = ROBOTS_NAMES, ) -> ParsedPage: """Reduce an HTML document to its title, text blocks, links and robots meta. When the page has text inside a content element (``content_tags``), only that text is kept and only the headings inside content elements form the heading paths; otherwise the whole visible page is kept. Args: html: The document. base_url: The URL the document was fetched from; relative links are resolved against it, or against ``<base href>`` when the page declares one. content_tags: Elements that mark the page's content. drop_tags: Elements whose text is dropped wherever they occur. chrome_tags: Elements whose text is dropped outside a content element and kept inside one. drop_roles: ``role`` values whose element's text is dropped. drop_hidden: Whether to drop elements that are not rendered: the ``hidden`` attribute (except ``until-found``), ``aria-hidden="true"`` and an inline ``display: none`` or ``visibility: hidden``. strip_chars: Characters removed from all text. robots_names: ``<meta name>`` values whose directives are read; add a crawler's own token (``"mybot"``) to honour directives addressed to it. Returns: The parsed page. """ parser = _PageParser( content_tags=frozenset(content_tags), drop_tags=frozenset(drop_tags), chrome_tags=frozenset(chrome_tags), drop_roles=frozenset(drop_roles), drop_hidden=drop_hidden, strip_chars=strip_chars, robots_names=frozenset(name.lower() for name in robots_names), ) parser.feed(html) parser.close() records = parser.records if any(r.in_content and r.kind != "heading" for r in records): blocks = [ Block(r.text, r.content_heading, r.kind, r.level) for r in records if r.in_content ] else: blocks = [Block(r.text, r.heading, r.kind, r.level) for r in records] title = " ".join("".join(parser.title_parts).split()) if not title: title = next((b.text for b in blocks if b.kind == "heading"), "") directives = parser.directives return ParsedPage( title=title, blocks=blocks, links=_resolve_links(parser.hrefs, base_url, parser.base_href), noindex="noindex" in directives or "none" in directives, nofollow="nofollow" in directives or "none" in directives, )
[docs] def chunk_blocks( blocks: Iterable[Block], *, title: str = "", target_chars: Optional[int] = TARGET_CHARS, max_chars: Optional[int] = MAX_CHARS, prefix: bool = True, ) -> list[Chunk]: """Pack blocks into chunks of about ``target_chars``, at most ``max_chars``. Consecutive blocks under the same heading path are joined by a blank line until the next one would pass ``target_chars``; a new heading path always starts a new chunk. A block longer than the space left under ``max_chars`` is split at line breaks, then at sentence ends, then between words, and a single word longer than that is cut. Heading blocks contribute through the heading path, not as body text; blocks with no text are skipped. With ``prefix``, every chunk begins with the title and its heading path (``"Acme › Products › Pricing"``) and a blank line, so a chunk retrieved on its own still says what it is about. A heading path that already begins with the title does not repeat it. ``max_chars`` counts the prefix, which is shortened to at most half of it. Args: blocks: The blocks, in document order. title: The document title for the prefix. target_chars: The size chunks are packed towards; ``None`` packs up to ``max_chars``. A target above ``max_chars`` is lowered to it. max_chars: The size no chunk exceeds; ``None`` never splits a block, so each heading section packs to ``target_chars`` or, with both ``None``, stays one chunk. prefix: Whether to begin each chunk with the title and heading path. Returns: The chunks, ``position`` numbered from 0. Raises: ValueError: A size below 1. """ for name, value in (("target_chars", target_chars), ("max_chars", max_chars)): if value is not None and value < 1: raise ValueError(f"{name} must be at least 1, got {value}") if max_chars is not None: target_chars = min(target_chars or max_chars, max_chars) chunks: list[Chunk] = [] heading: Optional[str] = None head = "" body: list[str] = [] size = 0 body_target: Optional[int] = None body_max: Optional[int] = None def emit(): nonlocal size if body: text = "\n\n".join(body) chunks.append( Chunk( text=f"{head}\n\n{text}" if head else text, heading=heading or "", position=len(chunks), ) ) body.clear() size = 0 for block in blocks: text = block.text.strip() if block.kind == "heading" or not text: continue if block.heading != heading: emit() heading = block.heading head = _prefix(title.strip(), heading, max_chars) if prefix else "" overhead = len(head) + 2 if head else 0 body_max = None if max_chars is None else max_chars - overhead body_target = ( None if target_chars is None else max(1, target_chars - overhead) ) for piece in _split_text(text, body_target, body_max): if body and body_target is not None: if size + 2 + len(piece) > body_target: emit() size = size + 2 + len(piece) if body else len(piece) body.append(piece) emit() return chunks
[docs] def chunk_markdown( markdown: str, *, title: str = "", target_chars: Optional[int] = TARGET_CHARS, max_chars: Optional[int] = MAX_CHARS, prefix: bool = True, ) -> list[Chunk]: """Chunk markdown with :func:`chunk_blocks`. ATX headings (``#`` to ``######``) form the heading path; a blank line ends a paragraph block, and a fenced code block (```````` or ``~~~``) is one block whose ``#`` lines are not headings. The keyword arguments are those of :func:`chunk_blocks`. """ return chunk_blocks( _markdown_blocks(markdown), title=title, target_chars=target_chars, max_chars=max_chars, prefix=prefix, )
def _role(attributes: dict[str, str]) -> str: """The first token of ``role``, which is the one a browser applies.""" tokens = attributes.get("role", "").split() return tokens[0].lower() if tokens else "" def _is_hidden(attributes: dict[str, str]) -> bool: if "hidden" in attributes and attributes["hidden"].lower() != "until-found": return True if attributes.get("aria-hidden", "").strip().lower() == "true": return True style = "".join(attributes.get("style", "").lower().split()) return "display:none" in style or "visibility:hidden" in style def _collapse_keeping_edges(data: str) -> str: """Collapse whitespace to single spaces, keeping one at either edge. ``"a <b>b</b>"`` must stay two words and ``"a<b>b</b>"`` one, so the space at a text node's edge is significant. """ collapsed = " ".join(data.split()) if not collapsed: return " " if data else "" if data[0].isspace(): collapsed = " " + collapsed if data[-1].isspace(): collapsed += " " return collapsed def _tidy_lines(raw: str) -> str: lines = (" ".join(line.split()) for line in raw.split("\n")) return "\n".join(line for line in lines if line) def _tidy_code(raw: str) -> str: lines = [line.rstrip() for line in raw.split("\n")] return "\n".join(lines).strip("\n") def _set_heading(path: dict[int, str], level: int, text: str): for deeper in [k for k in path if k >= level]: del path[deeper] if text: path[level] = text def _join_path(path: dict[int, str]) -> str: return HEADING_SEPARATOR.join(path[k] for k in sorted(path)) def _resolve_links( hrefs: list[str], base_url: Optional[str], base_href: Optional[str] ) -> list[str]: """Absolute, de-duplicated links; an unparseable ``href`` is dropped.""" base = base_url if base_href: base = _join_url(base_url, base_href) if base_url else base_href links: list[str] = [] for href in hrefs: link = _join_url(base, href) if base else href if link: links.append(link) return list(dict.fromkeys(links)) def _join_url(base: Optional[str], href: str) -> Optional[str]: try: return urljoin(base, href) except ValueError: return None def _prefix(title: str, heading: str, max_chars: Optional[int]) -> str: if ( heading and title and (heading == title or heading.startswith(title + HEADING_SEPARATOR)) ): head = heading else: head = HEADING_SEPARATOR.join(part for part in (title, heading) if part) if max_chars is not None: limit = max_chars // 2 - 2 if limit < 1: return "" if len(head) > limit: head = head[: limit - 1] + "…" return head def _split_text(text: str, target: Optional[int], maximum: Optional[int]) -> list[str]: """``text`` in pieces of at most ``maximum``, each packed towards ``target``.""" if maximum is None or len(text) <= maximum: return [text] return _split(text, target or maximum, maximum, 0) def _split(text: str, target: int, maximum: int, level: int) -> list[str]: """Split at line breaks (level 0), sentences (1), words (2), then hard (3). A part that fits ``maximum`` is not split further; the parts of a level are then packed back together up to ``target``. """ if len(text) <= maximum: return [text] if level == 1: return [ piece.strip() for piece in _pack(_sentence_units(text, target, maximum), "", target) ] if level == 0: parts, separator = text.split("\n"), "\n" elif level == 2: parts, separator = text.split(), " " else: return [text[i : i + target] for i in range(0, len(text), target)] units: list[str] = [] for part in parts: part = part.strip() if part: units.extend(_split(part, target, maximum, level + 1)) return _pack(units, separator, target) def _sentence_units(text: str, target: int, maximum: int) -> list[str]: """The sentences of ``text``, each keeping one space before it if it had any. Sentences are packed back without a separator, so text that had no space between its sentences (Chinese, Japanese) gets none; the space before a sentence is counted against the limit only where it stays inside a piece. """ units: list[str] = [] for sentence in _sentences(text): pieces = _split(sentence.strip(), target, maximum, 2) if units and sentence[0].isspace(): pieces[0] = " " + pieces[0] units.extend(pieces) return units def _sentences(text: str) -> list[str]: """Split after sentence-ending punctuation. ``.``, ``!``, ``?`` and ``…`` end a sentence when whitespace follows them, after any closing quotes or brackets; the ideographic full stop and its kin end one without a space. """ parts: list[str] = [] start = 0 i = 0 n = len(text) while i < n: char = text[i] if char in _SENTENCE_ENDS or char in _UNSPACED_SENTENCE_ENDS: j = i + 1 while j < n and ( text[j] in _CLOSERS or text[j] in _SENTENCE_ENDS or text[j] in _UNSPACED_SENTENCE_ENDS ): j += 1 if char in _UNSPACED_SENTENCE_ENDS or j == n or text[j].isspace(): parts.append(text[start:j]) start = j i = j else: i += 1 if start < n: parts.append(text[start:]) return parts def _pack(units: list[str], separator: str, limit: int) -> list[str]: """Join consecutive units while the result stays within ``limit``.""" pieces: list[str] = [] current: list[str] = [] size = 0 for unit in units: if current and size + len(separator) + len(unit) > limit: pieces.append(separator.join(current)) current = [] size = size + len(separator) + len(unit) if current else len(unit) current.append(unit) if current: pieces.append(separator.join(current)) return pieces def _markdown_blocks(markdown: str) -> list[Block]: """Read markdown into heading, paragraph and code blocks.""" blocks: list[Block] = [] path: dict[int, str] = {} heading = "" lines: list[str] = [] fence = "" def flush(kind: str = "paragraph"): text = "\n".join(lines).strip("\n") lines.clear() if text.strip(): blocks.append(Block(text, heading, kind)) for line in markdown.splitlines(): stripped = line.strip() if fence: lines.append(line) if len(stripped) >= len(fence) and not stripped.strip(fence[0]): fence = "" flush("code") continue marker = _fence_marker(stripped) if marker: flush() fence = marker lines.append(line) continue level, text = _atx_heading(line) if level: flush() _set_heading(path, level, text) heading = _join_path(path) if text: blocks.append(Block(text, heading, "heading", level)) elif not stripped: flush() else: lines.append(line.rstrip()) flush("code" if fence else "paragraph") return blocks def _fence_marker(stripped: str) -> str: """The opening fence (three or more backticks or tildes), or ``""``.""" for char in "`~": if stripped.startswith(char * 3): return stripped[: len(stripped) - len(stripped.lstrip(char))] return "" def _atx_heading(line: str) -> tuple[int, str]: """``(level, text)`` of an ATX heading line; level 0 for any other line.""" body = line.lstrip(" ") if len(line) - len(body) > 3: return 0, "" level = len(body) - len(body.lstrip("#")) rest = body[level:] if not 1 <= level <= 6 or (rest and rest[0] not in " \t"): return 0, "" text = rest.strip() closed = text.rstrip("#") if closed != text and (not closed or closed[-1] in " \t"): text = closed.rstrip() return level, text def _render_markdown(blocks: list[Block]) -> str: parts: list[str] = [] previous = "" for block in blocks: if parts: tight = block.kind == previous and block.kind in ("item", "row") parts.append("\n" if tight else "\n\n") parts.append(_render_block(block)) previous = block.kind return "".join(parts) def _render_block(block: Block) -> str: if block.kind == "heading": return f"{'#' * block.level} {block.text}" if block.kind == "item": return "- " + block.text.replace("\n", "\n ") if block.kind == "quote": return "\n".join(f"> {line}" for line in block.text.split("\n")) if block.kind == "code": fence = "`" * max(3, _longest_run(block.text, "`") + 1) return f"{fence}\n{block.text}\n{fence}" return block.text def _longest_run(text: str, char: str) -> int: longest = run = 0 for current in text: run = run + 1 if current == char else 0 longest = max(longest, run) return longest