import argparse import json from abc import abstractmethod from collections.abc import MutableMapping, Sequence from pathlib import Path from typing import Any, cast, NamedTuple, Optional, Union from xml.sax.saxutils import escape, quoteattr from markdown_it.token import Token from markdown_it.utils import OptionsDict from .docbook import DocBookRenderer from .md import Converter class RenderedSection: id: Optional[str] chapters: list[str] def __init__(self, id: Optional[str]) -> None: self.id = id self.chapters = [] class BaseConverter(Converter): _sections: list[RenderedSection] def __init__(self, manpage_urls: dict[str, str]): super().__init__(manpage_urls) self._sections = [] def add_section(self, id: Optional[str], chapters: list[Path]) -> None: self._sections.append(RenderedSection(id)) for chpath in chapters: try: with open(chpath, 'r') as f: self._md.renderer._title_seen = False # type: ignore[attr-defined] self._sections[-1].chapters.append(self._render(f.read())) except Exception as e: raise RuntimeError(f"failed to render manual chapter {chpath}") from e @abstractmethod def finalize(self) -> str: raise NotImplementedError() class ManualDocBookRenderer(DocBookRenderer): # needed to check correctness of chapters. # we may want to use front matter instead of this kind of heuristic. _title_seen = False def _heading_tag(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, env: MutableMapping[str, Any]) -> tuple[str, dict[str, str]]: (tag, attrs) = super()._heading_tag(token, tokens, i, options, env) if self._title_seen: if token.tag == 'h1': assert token.map is not None raise RuntimeError( "only one title heading (# [text...]) allowed per manual chapter " f"but found a second in lines [{token.map[0]}..{token.map[1]}]. " "please remove all such headings except the first, split your " "chapters, or demote the subsequent headings to (##) or lower.", token) return (tag, attrs) self._title_seen = True return ("chapter", attrs | { 'xmlns': "http://docbook.org/ns/docbook", 'xmlns:xlink': "http://www.w3.org/1999/xlink", }) # TODO minimize docbook diffs with existing conversions. remove soon. def paragraph_open(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, env: MutableMapping[str, Any]) -> str: return super().paragraph_open(token, tokens, i, options, env) + "\n " def paragraph_close(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, env: MutableMapping[str, Any]) -> str: return "\n" + super().paragraph_close(token, tokens, i, options, env) def code_block(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, env: MutableMapping[str, Any]) -> str: return f"\n{escape(token.content)}" def fence(self, token: Token, tokens: Sequence[Token], i: int, options: OptionsDict, env: MutableMapping[str, Any]) -> str: info = f" language={quoteattr(token.info)}" if token.info != "" else "" return f"\n{escape(token.content)}" class DocBookSectionConverter(BaseConverter): __renderer__ = ManualDocBookRenderer def finalize(self) -> str: result = [] for section in self._sections: id = "id=" + quoteattr(section.id) if section.id is not None else "" result.append(f'
') result += section.chapters result.append(f'
') return "\n".join(result) class Section: id: Optional[str] = None chapters: list[str] def __init__(self) -> None: self.chapters = [] class SectionAction(argparse.Action): def __call__(self, parser: argparse.ArgumentParser, ns: argparse.Namespace, values: Union[str, Sequence[Any], None], opt_str: Optional[str] = None) -> None: sections = getattr(ns, self.dest) if sections is None: sections = [] sections.append(Section()) setattr(ns, self.dest, sections) class SectionIDAction(argparse.Action): def __call__(self, parser: argparse.ArgumentParser, ns: argparse.Namespace, values: Union[str, Sequence[Any], None], opt_str: Optional[str] = None) -> None: sections = getattr(ns, self.dest) if sections is None: raise argparse.ArgumentError(self, "no active section") sections[-1].id = cast(str, values) class ChaptersAction(argparse.Action): def __call__(self, parser: argparse.ArgumentParser, ns: argparse.Namespace, values: Union[str, Sequence[Any], None], opt_str: Optional[str] = None) -> None: sections = getattr(ns, self.dest) if sections is None: raise argparse.ArgumentError(self, "no active section") sections[-1].chapters.extend(map(Path, cast(Sequence[str], values))) def _build_cli_db_section(p: argparse.ArgumentParser) -> None: p.add_argument('--manpage-urls', required=True) p.add_argument("outfile") p.add_argument("--section", dest="contents", action=SectionAction, nargs=0) p.add_argument("--section-id", dest="contents", action=SectionIDAction) p.add_argument("--chapters", dest="contents", action=ChaptersAction, nargs='+') def _run_cli_db_section(args: argparse.Namespace) -> None: with open(args.manpage_urls, 'r') as manpage_urls: md = DocBookSectionConverter(json.load(manpage_urls)) for section in args.contents: md.add_section(section.id, section.chapters) with open(args.outfile, 'w') as f: f.write(md.finalize()) def build_cli(p: argparse.ArgumentParser) -> None: formats = p.add_subparsers(dest='format', required=True) _build_cli_db_section(formats.add_parser('docbook-section')) def run_cli(args: argparse.Namespace) -> None: if args.format == 'docbook-section': _run_cli_db_section(args) else: raise RuntimeError('format not hooked up', args)