How-to: Custom ASG Renderer Tutorial

Michael R. Bernstein zopemaven@gmail.com v0.1.0

This tutorial demonstrates how to traverse and render the resolved Abstract Semantic Graph (ASG) produced by *AsciiDoctrine. We will build a complete, object-oriented Markdown Renderer* from scratch using Python.


Understanding the ASG Structure

Once you resolve a parsed AST using the ASGResolver, you can generate a standard python dictionary representation of the ASG by calling asg.to_dict().

The dictionary follows a highly structured, semantic schema matching the official AsciiDoc Language Specification:

  • Blocks: Document, Section, Paragraph, Listing, and List elements have a "type": "block" key, and they contain child blocks in a "blocks" list or child items in an "items" list.

  • Inlines: Text, Span (bold, italic, code), and Ref (links) elements have a "type": "inline" (or "type": "string") key, and contain child inlines under an "inlines" list.


Step-by-Step: Writing a Markdown Renderer

A clean way to write a custom renderer is to use a Visitor Pattern. We will create a MarkdownRenderer class with a render(node) dispatch method that maps node names (document, paragraph, section, span, text) to corresponding render_<name> methods.

The Complete Renderer Implementation

Below is the complete, self-contained, and executable Python code for the Markdown renderer:

import sys
from typing import Dict, List, Any
from asciidoctrine import parse_to_ast
from asciidoctrine.resolver import ASGResolver

class MarkdownRenderer:
    """
    A custom visitor class that traverses an AsciiDoctrine ASG dictionary
    and compiles it into standard Markdown.
    """

    def render(self, node: Dict[str, Any]) -> str:
        if not node:
            return ""

        node_name = node.get("name", "")
        # Dynamically dispatch to render_<node_name> if it exists
        method_name = f"render_{node_name}"
        visitor = getattr(self, method_name, self.generic_render)
        return visitor(node)

    def generic_render(self, node: Dict[str, Any]) -> str:
        # Fallback for unhandled nodes: render child blocks if present
        result = []
        for block in node.get("blocks", []):
            result.append(self.render(block))
        return "\n\n".join(result)

    def render_document(self, node: Dict[str, Any]) -> str:
        # Render all children blocks in the document
        blocks = [self.render(b) for b in node.get("blocks", [])]
        return "\n\n".join(b for b in blocks if b)

    def render_section(self, node: Dict[str, Any]) -> str:
        # ASG sections have a "level" integer and a "title" list of inline nodes
        level = node.get("level", 1)
        # Markdown headings use '#' prefixes matching the level
        header_prefix = "#" * level

        # Render the section title inlines
        title_inlines = node.get("title", [])
        title_text = "".join(self.render(inline) for inline in title_inlines)

        # Render child blocks of this section
        child_blocks = [self.render(b) for b in node.get("blocks", [])]
        rendered_children = "\n\n".join(b for b in child_blocks if b)

        return f"{header_prefix} {title_text}\n\n{rendered_children}".strip()

    def render_paragraph(self, node: Dict[str, Any]) -> str:
        # Render all inline children inside the paragraph
        inlines = [self.render(i) for i in node.get("inlines", [])]
        return "".join(inlines)

    def render_text(self, node: Dict[str, Any]) -> str:
        # Simple leaf text node
        return node.get("value", "")

    def render_span(self, node: Dict[str, Any]) -> str:
        # Spans represent formatting wraps like bold, italic, or monospace
        variant = node.get("variant", "text")
        inlines = [self.render(i) for i in node.get("inlines", [])]
        content = "".join(inlines)

        if variant == "strong":
            return f"**{content}**"
        elif variant == "emphasis":
            return f"*{content}*"
        elif variant == "code":
            return f"`{content}`"
        return content

    def render_listing(self, node: Dict[str, Any]) -> str:
        # Listing/source block representation
        lang = node.get("attributes", {}).get("language", "")
        # Listing contents are list of inline text nodes
        inlines = [self.render(i) for i in node.get("inlines", [])]
        code_content = "".join(inlines).strip()
        return f"```{lang}\n{code_content}\n```"

    def render_list(self, node: Dict[str, Any]) -> str:
        # Unordered or ordered list container
        variant = node.get("variant", "unordered")
        items = [self.render_list_item(item, variant, i) for i, item in enumerate(node.get("items", []))]
        return "\n".join(items)

    def render_list_item(self, item: Dict[str, Any], variant: str, index: int) -> str:
        # Render principal text content of list item
        principal_nodes = item.get("principal", [])
        principal_text = "".join(self.render(p) for principal_nodes in principal_nodes for p in (principal_nodes if isinstance(principal_nodes, list) else [principal_nodes]))
        
        # Render any nested sub-blocks inside list item
        sub_blocks = [self.render(b) for b in item.get("blocks", [])]
        rendered_sub = "\n  ".join(b for b in sub_blocks if b)
        
        prefix = "1." if variant == "ordered" else "*"
        item_text = f"{prefix} {principal_text}"
        if rendered_sub:
            item_text += f"\n  {rendered_sub}"
        return item_text

Running and Verifying

To verify that the renderer works, you can parse a sample AsciiDoc document, resolve it, and feed the dictionary into the MarkdownRenderer:

# Define a sample AsciiDoc source string
asciidoc_source = """
= Sample Document
:author: Jane Doe

== Overview
This is a *bold* word and a `monospace` term in a paragraph.

=== Syntax Parity List
* Admonitions
* Open blocks
* Advanced tables
"""

# 1. Parse & Resolve
ast = parse_to_ast(asciidoc_source)
resolver = ASGResolver(ast)
asg = resolver.resolve(ast)

# 2. Convert to dictionary representation
asg_dict = asg.to_dict()

# 3. Instantiate and execute the custom renderer
renderer = MarkdownRenderer()
markdown_output = renderer.render(asg_dict)

print("Generated Markdown:")
print("-------------------")
print(markdown_output)

Expected Output:

# Overview

This is a **bold** word and a `monospace` term in a paragraph.

## Syntax Parity List

* Admonitions
* Open blocks
* Advanced tables