From b1494d78c31e85b2b966fd52ff4efad528e03e03 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Hugo=20H=C3=B6rnquist?= <hugo@lysator.liu.se>
Date: Mon, 3 Jul 2023 22:58:08 +0200
Subject: [PATCH] Harden typesystem + misc.

Misc includes:
    - at least one more category
    - basic sidebars
---
 muppet/format.py                              | 154 ++---
 muppet/gather.py                              |  11 +-
 muppet/markdown.py                            |   4 +-
 muppet/output.py                              | 557 ++++++++++++------
 muppet/puppet/__init__.py                     |   9 +
 muppet/puppet/parser.py                       |   9 +-
 muppet/puppet/strings.py                      |  34 --
 muppet/puppet/strings/__init__.py             | 402 +++++++++++++
 muppet/puppet/strings/__main__.py             |  29 +
 muppet/puppet/strings/internal.py             | 260 ++++++++
 muppet/tabs.py                                |   2 +-
 muppet/templates.py                           |  81 +++
 mypy.ini                                      |   1 +
 static-src/_sidebar.scss                      |  17 +
 static-src/style.scss                         |  25 +
 templates/base.html                           |  21 +-
 templates/code_page.html                      |   6 +
 templates/index.html                          |  12 +-
 templates/module_index.html                   |  26 +-
 .../snippets/ResourceType-index-entry.html    |  15 +
 .../snippets/ResourceType-list-entry.html     |  10 +
 templates/{ => snippets}/tabset.html          |   0
 22 files changed, 1369 insertions(+), 316 deletions(-)
 create mode 100644 muppet/puppet/__init__.py
 delete mode 100644 muppet/puppet/strings.py
 create mode 100644 muppet/puppet/strings/__init__.py
 create mode 100644 muppet/puppet/strings/__main__.py
 create mode 100644 muppet/puppet/strings/internal.py
 create mode 100644 muppet/templates.py
 create mode 100644 static-src/_sidebar.scss
 create mode 100644 templates/snippets/ResourceType-index-entry.html
 create mode 100644 templates/snippets/ResourceType-list-entry.html
 rename templates/{ => snippets}/tabset.html (100%)

diff --git a/muppet/format.py b/muppet/format.py
index bf107ad..48d692c 100644
--- a/muppet/format.py
+++ b/muppet/format.py
@@ -33,10 +33,22 @@ from .data import (
     declaration,
     render,
 )
+
 from .data.html import (
     HTMLRenderer,
 )
 
+from .puppet.strings import (
+    DataTypeAlias,
+    DefinedType,
+    DocString,
+    Function,
+    PuppetClass,
+    ResourceType,
+    DocStringParamTag,
+    DocStringExampleTag,
+)
+
 parse_puppet = puppet_parser
 
 HashEntry: TypeAlias = Union[Tuple[Literal['=>'], str, Any],
@@ -159,7 +171,11 @@ def handle_case_body(forms: list[dict[str, Any]],
 # - qr
 # - var (except when it's the var declaration)
 
-def parse(form: Any, indent: int, context: list[str]) -> Tag:
+LineFragment: TypeAlias = str | Tag
+Line: TypeAlias = list[LineFragment]
+
+
+def parse(form: Any, indent: int, context: list[str]) -> Markup:
     """
     Print everything from a puppet parse tree.
 
@@ -426,9 +442,6 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
         case ['heredoc', {'text': ['concat', *parts]}]:
             items = ['@("EOF")']
 
-            LineFragment: TypeAlias = str | Tag
-            Line: TypeAlias = list[LineFragment]
-
             lines: list[Line] = [[]]
 
             for part in parts:
@@ -1022,7 +1035,7 @@ def parse(form: Any, indent: int, context: list[str]) -> Tag:
                 return tag(f'[|[{form}]|]', 'parse-error')
 
 
-def format_docstring(name: str, docstring: dict[str, Any]) -> Tuple[str, str]:
+def format_docstring(name: str, docstring: DocString) -> Tuple[str, str]:
     """
     Format docstrings as they appear in some puppet types.
 
@@ -1036,30 +1049,26 @@ def format_docstring(name: str, docstring: dict[str, Any]) -> Tuple[str, str]:
 
     out = ''
 
-    if 'tags' in docstring:
-        param_doc = {tag['name']: tag.get('text') or ''
-                     for tag in docstring['tags']
-                     if tag['tag_name'] == 'param'}
-        tags = docstring['tags']
-    else:
-        param_doc = {}
-        tags = []
+    param_doc = {tag.name: tag.text or ''
+                 for tag in docstring.tags
+                 if isinstance(tag, DocStringParamTag)}
+    tags = docstring.tags
 
     # print(param_doc, file=sys.stderr)
 
     # param_defaults = d_type['defaults']
 
     for t in tags:
-        text = html.escape(t.get('text') or '')
-        if t['tag_name'] == 'summary':
+        text = html.escape(t.text)
+        if t.tag_name == 'summary':
             out += '<em class="summary">'
             out += text
             out += '</em>'
 
     for t in tags:
-        text = html.escape(t.get('text') or '')
-        if t['tag_name'] == 'example':
-            if name := t.get('name'):
+        text = html.escape(t.text)
+        if isinstance(t, DocStringExampleTag):
+            if name := t.name:
                 out += f'<h3>{name}</h3>\n'
             # TODO highlight?
             out += f'<pre class="example"><code class="puppet">{text}</code></pre>\n'
@@ -1075,16 +1084,15 @@ def format_docstring(name: str, docstring: dict[str, Any]) -> Tuple[str, str]:
     #             out += f"<dd>{text}</dd>"
     # out += '</dl>'
 
-    if 'text' in docstring:
-        out += '<div>'
-        out += markdown(docstring['text'])
-        out += '</div>'
+    out += '<div>'
+    out += markdown(docstring.text)
+    out += '</div>'
 
     return (name, out)
 
 
 # TODO @option tags
-def build_param_dict(docstring: dict[str, Any]) -> dict[str, str]:
+def build_param_dict(docstring: DocString) -> dict[str, str]:
     """
     Extract all parameter documentation from a docstring dict.
 
@@ -1099,28 +1107,24 @@ def build_param_dict(docstring: dict[str, Any]) -> dict[str, str]:
         for that key. Undocumented keys (even those with the tag, but
         no text) are ommitted from the resulting dictionary.
     """
-    if tags := docstring.get('tags'):
-        obj = {}
-        for t in tags:
-            if t['tag_name'] == 'param':
-                if text := t.get('text'):
-                    obj[t['name']] = re.sub(r'(NOTE|TODO)',
-                                            r'<mark>\1</mark>',
-                                            markdown(text))
-        return obj
-    else:
-        return {}
+    obj = {}
+    for t in docstring.tags:
+        if isinstance(t, DocStringParamTag):
+            obj[t.name] = re.sub(r'(NOTE|TODO)',
+                                 r'<mark>\1</mark>',
+                                 markdown(t.text))
+    return obj
 
 
-def format_class(d_type: dict[str, Any]) -> Tuple[str, str]:
+def format_class(d_type: DefinedType | PuppetClass) -> Tuple[str, str]:
     """Format Puppet class."""
-    t = parse_puppet(d_type['source'])
+    t = parse_puppet(d_type.source)
     data = parse(t, 0, ['root'])
-    renderer = HTMLRenderer(build_param_dict(d_type['docstring']))
+    renderer = HTMLRenderer(build_param_dict(d_type.docstring))
     out = ''
-    name = d_type['name']
+    name = d_type.name
     # print(name, file=sys.stderr)
-    name, body = format_docstring(name, d_type['docstring'])
+    name, body = format_docstring(name, d_type.docstring)
     out += body
 
     out += '<pre class="highlight-muppet"><code class="puppet">'
@@ -1134,96 +1138,104 @@ def format_type() -> str:
     return 'TODO format_type not implemented'
 
 
-def format_type_alias(d_type: dict[str, Any]) -> Tuple[str, str]:
+def format_type_alias(d_type: DataTypeAlias) -> Tuple[str, str]:
     """Format Puppet type alias."""
     renderer = HTMLRenderer()
     out = ''
-    name = d_type['name']
+    name = d_type.name
     # print(name, file=sys.stderr)
-    title, body = format_docstring(name, d_type['docstring'])
+    title, body = format_docstring(name, d_type.docstring)
     out += body
     out += '\n'
     out += '<pre class="highlight-muppet"><code class="puppet">'
-    t = parse_puppet(d_type['alias_of'])
+    t = parse_puppet(d_type.alias_of)
     data = parse(t, 0, ['root'])
     out += render(renderer, data)
     out += '</code></pre>\n'
     return title, out
 
 
-def format_defined_type(d_type: dict[str, Any]) -> Tuple[str, str]:
+def format_defined_type(d_type: DefinedType) -> Tuple[str, str]:
     """Format Puppet defined type."""
-    renderer = HTMLRenderer(build_param_dict(d_type['docstring']))
+    renderer = HTMLRenderer(build_param_dict(d_type.docstring))
     out = ''
-    name = d_type['name']
+    name = d_type.name
     # print(name, file=sys.stderr)
-    title, body = format_docstring(name, d_type['docstring'])
+    title, body = format_docstring(name, d_type.docstring)
     out += body
 
     out += '<pre class="highlight-muppet"><code class="puppet">'
-    t = parse_puppet(d_type['source'])
+    t = parse_puppet(d_type.source)
     out += render(renderer, parse(t, 0, ['root']))
     out += '</code></pre>\n'
     return title, out
 
 
-def format_resource_type(r_type: dict[str, Any]) -> str:
+def format_resource_type(r_type: ResourceType) -> str:
     """Format Puppet resource type."""
-    name = r_type['name']
+    name = r_type.name
     out = ''
     out += f'<h2>{name}</h2>\n'
-    out += str(r_type['docstring'])
-    if 'properties' in r_type:
-        out += '<h3>Properties</h3>\n'
+    out += str(r_type.docstring)
+
+    out += '<h3>Properties</h3>\n'
+    if props := r_type.properties:
         out += '<ul>\n'
-        for property in r_type['properties']:
-            out += f'<li>{property["name"]}</li>\n'
+        for property in props:
+            out += f'<li>{property.name}</li>\n'
             # description, values, default
         out += '</ul>\n'
+    else:
+        out += '<em>No providers</em>'
 
     out += '<h3>Parameters</h3>\n'
     out += '<ul>\n'
-    for parameter in r_type['parameters']:
-        out += f'<li>{parameter["name"]}</li>\n'
+    for parameter in r_type.parameters:
+        out += f'<li>{parameter.name}</li>\n'
         # description
         # Optional[isnamevar]
     out += '</ul>\n'
 
-    if 'providers' in r_type:
-        out += '<h3>Providers</h3>\n'
-        for provider in r_type['providers']:
-            out += f'<h4>{provider["name"]}</h4>\n'
+    out += '<h3>Providers</h3>\n'
+    if providers := r_type.providers:
+        for provider in providers:
+            out += f'<h4>{provider.name}</h4>\n'
             # TODO
+    else:
+        print('<em>No providers</em>')
 
     return out
 
 
-def format_puppet_function(function: dict[str, Any]) -> str:
+def format_puppet_function(function: Function) -> str:
     """Format Puppet function."""
     out = ''
-    name = function['name']
+    name = function.name
     out += f'<h2>{name}</h2>\n'
-    t = function['type']
-    # docstring = function['docstring']
-    for signature in function['signatures']:
-        signature['signature']
-        signature['docstring']
+    t = function.type
+    # docstring = function.docstring
+    for signature in function.signatures:
+        signature.signature
+        signature.docstring
     if t in ['ruby3x', 'ruby4x']:
         # TODO syntax highlighting
         s = '<pre class="highlight-muppet"><code class="ruby">'
-        s += function["source"]
+        s += function.source
         s += '</code></pre>\n'
         out += s
     elif t == 'puppet':
         out += '<pre class="highlight-muppet"><code class="puppet">'
         try:
-            t = parse_puppet(function['source'])
-            out += str(parse(t, 0, ['root']))
+            source = parse_puppet(function.source)
+            out += str(parse(source, 0, ['root']))
         except CalledProcessError as e:
             print(e, file=sys.stderr)
             print(f"Failed on function: {name}", file=sys.stderr)
 
         out += '</code></pre>\n'
+    else:
+        # TODO do something
+        pass
 
     return out
 
diff --git a/muppet/gather.py b/muppet/gather.py
index 4795553..eb9c6f2 100644
--- a/muppet/gather.py
+++ b/muppet/gather.py
@@ -9,12 +9,13 @@ present in our environment, their metadata, and their output of
 from dataclasses import dataclass
 from typing import (
     Any,
+    Optional,
 )
 import json
 import os.path
 import hashlib
 from glob import glob
-from .puppet.strings import puppet_strings
+from .puppet.strings import puppet_strings, PuppetStrings
 from .cache import Cache
 
 
@@ -38,7 +39,7 @@ class ModuleEntry:
 
     name: str
     path: str
-    strings_output: bytes
+    strings_output: Optional[PuppetStrings]
     metadata: dict[str, Any]
     doc_files: list[str]
 
@@ -47,7 +48,7 @@ class ModuleEntry:
         return os.path.join(self.path, path)
 
 
-def get_puppet_strings(cache: Cache, path: str) -> bytes:
+def get_puppet_strings(cache: Cache, path: str) -> Optional[PuppetStrings]:
     """
     Run puppet string, but check cache first.
 
@@ -67,13 +68,13 @@ def get_puppet_strings(cache: Cache, path: str) -> bytes:
             else:
                 result = puppet_strings(path)
                 cache.put(key, result)
-            return result
+            return PuppetStrings.from_json(json.loads(result))
     except FileNotFoundError:
         # TODO actually run puppet strings again.
         # This is just since without a metadata.json we always get a
         # cache miss, which is slow.
         # return puppet_strings(path)
-        return b''
+        return None
 
         # try:
         #     with open(module.file('.git/FETCH_HEAD')) as f:
diff --git a/muppet/markdown.py b/muppet/markdown.py
index dd7e1ea..f13ea10 100644
--- a/muppet/markdown.py
+++ b/muppet/markdown.py
@@ -7,7 +7,9 @@ and to allow "easy" switching of the markdown engine.
 """
 
 from markdown_it import MarkdownIt
-from mdit_py_plugins.anchors import anchors_plugin
+# Mypy believes that mdit_py_plugins.anchors doesn't explicitly export
+# "anchors_plugin" ([attr-defined]), but it does.
+from mdit_py_plugins.anchors import anchors_plugin  # type: ignore
 
 
 def markdown(text: str) -> str:
diff --git a/muppet/output.py b/muppet/output.py
index e4069ab..016de72 100644
--- a/muppet/output.py
+++ b/muppet/output.py
@@ -7,31 +7,37 @@ Both generates output strings, and writes them to disk.
 import os
 import os.path
 import pathlib
-import json
+# import json
 import html
 from .gather import ModuleEntry
 from jinja2 import (
     Environment,
     FileSystemLoader,
 )
-from .lookup import lookup, Ref
+# from .lookup import lookup, Ref
 from .markdown import markdown
 from .format import (
     format_class,
     format_type_alias,
 )
 from typing import (
-    Any,
-    TypedDict,
-    NotRequired,
-)
-from collections.abc import (
-    Iterable,
+    Optional,
+    Protocol,
 )
 from .util import group_by
-from .puppet.strings import isprivate
+from .puppet.strings import (
+    isprivate,
+    PuppetStrings,
+    ResourceType,
+    DefinedType,
+    DataTypeAlias,
+    PuppetClass,
+)
+
 from .breadcrumbs import breadcrumbs
 from .syntax_highlight import highlight
+from dataclasses import dataclass, field
+from . import templates
 
 
 # TODO replace 'output' with base, or put this somewhere else
@@ -42,6 +48,53 @@ jinja = Environment(
 )
 
 
+class HtmlSerializable(Protocol):
+    """Classes which can be serialized as HTML."""
+
+    def to_html(self) -> str:
+        """Return HTML string."""
+        ...
+
+    def to_html_list(self) -> str:
+        """Return HTML suitable for a list."""
+        ...
+
+
+@dataclass
+class ResourceTypeOutput:
+    """Basic HTML implementation."""
+
+    title: str
+    children: list['HtmlSerializable'] = field(default_factory=list)
+    link: Optional[str] = None
+    summary: Optional[str] = None
+
+    def base(self) -> str:
+        """
+        Return base text of the node.
+
+        If the node has a link, create a hyperlink, otherwise return
+        it's title directly.
+        """
+        if self.link:
+            return f'<a href="{ self.link }">{ self.title }</a>'
+        else:
+            return self.title
+
+    def to_html(self) -> str:
+        """Return HTML string."""
+        # self.__class__.__name__
+        return jinja \
+            .get_template('snippets/ResourceType-index-entry.html') \
+            .render(item=self)
+
+    def to_html_list(self) -> str:
+        """Return HTML suitable for a list."""
+        return jinja \
+            .get_template('snippets/ResourceType-list-entry.html') \
+            .render(item=self)
+
+
 def setup_index(base: str, modules: list[ModuleEntry], *, path_base: str) -> None:
     """
     Create the main index.html file.
@@ -53,35 +106,127 @@ def setup_index(base: str, modules: list[ModuleEntry], *, path_base: str) -> Non
     :param path_base:
         Web path where this module will be deployed
     """
-    template = jinja.get_template('index.html')
     with open(os.path.join(base, 'index.html'), 'w') as f:
-        f.write(template.render(modules=modules,
+        f.write(templates.index(modules=modules,
                                 path_base=path_base))
 
 
-class IndexItem(TypedDict):
-    """A single list entry in a module index page."""
+@dataclass
+class IndexItem:
+    """
+    A concrete type, on an index page.
+
+    This will be something like a class or resource type.
+
+    :param name:
+        Name of the resource or similar
+    :param file:
+        Relative path to the resource.
+    :param summary:
+        One line summary of the resource, will be displayed in the UI.
+    """
 
     name: str
     file: str
-    summary: NotRequired[str]
+    summary: Optional[str] = None
+
+    def base(self) -> str:
+        """Return link to self."""
+        return f'<a href="{self.file}">{ self.name }</a>'
+
+    def to_html(self) -> str:
+        """Convert item to an HTML string."""
+        out: str = ''
+        out += f'<dt>{self.base()}</dt>'
 
+        if self.summary:
+            out += f"<dd>{self.summary}</dd>"
 
-class IndexSubcategory(TypedDict):
-    """A subheading on an index page."""
+        return out
+
+    def to_html_list(self) -> str:
+        """Convert itom to an HTML string sutibale for a list."""
+        out: str = ''
+        out += f'<li>{self.base()}</li>'
+        return out
+
+
+@dataclass
+class IndexSubcategory:
+    """
+    Subheading on index page.
+
+    Will most likely be 'Public' or 'Private' objects for the given
+    top heading.
+    """
 
     title: str
-    list: Iterable[IndexItem]
+    list: list[IndexItem]
 
+    def to_html(self) -> str:
+        """Convert subcategory to an HTML string."""
+        out: str = ''
+        out += f'<h3>{html.escape(self.title)}</h3><dl class="overview-list">'
 
-class IndexCategory(TypedDict):
-    """A top heading on an index page."""
+        for item in self.list:
+            out += item.to_html()
+
+        out += '</dl>'
+
+        return out
+
+    def to_html_list(self) -> str:
+        """Convert itom to an html string suitable for a list."""
+        pass
+        out: str = ''
+        out += f'<li>{html.escape(self.title)}<ul>'
+        for item in self.list:
+            out += item.to_html_list()
+        out += '</ul></li>'
+
+        return out
+
+
+@dataclass
+class IndexCategory:
+    """
+    Top level heading in index.
+
+    This should be something like 'Classes', 'Types', ...
+    Each entry contains a set of subentries, which can either be
+    distinct entries, or sometimes subcategories (such as 'Public' and
+    'Private').
+    """
 
     title: str
-    list: Iterable[IndexSubcategory]
+    list: list[IndexSubcategory]
+
+    def base(self) -> str:
+        """Return HTML escaped title."""
+        return html.escape(self.title)
+
+    def to_html(self) -> str:
+        """Return class as an HTML string."""
+        out: str = ''
+        out += f'<h2>{self.base()}</h2>'
+
+        for item in self.list:
+            out += item.to_html()
+
+        return out
 
+    def to_html_list(self) -> str:
+        """Return class as an HTML string suitable for a list."""
+        out: str = ''
+        out += f'<li>{self.base()}<ul>'
+        for item in self.list:
+            out += item.to_html_list()
+        out += '</ul></li>'
 
-def index_item(obj: dict) -> IndexItem:
+        return out
+
+
+def index_item(obj: PuppetClass | DefinedType) -> IndexItem:
     """
     Format a puppet type declaration into an index entry.
 
@@ -91,50 +236,46 @@ def index_item(obj: dict) -> IndexItem:
         then a summary tag is searched for, and added to the resulting
         object.
     """
-    name = obj['name']
-    summary = lookup(obj) \
-        .ref('docstring') \
-        .ref('tags') \
-        .find(Ref('tag_name') == 'summary') \
-        .ref('text') \
-        .value()
-
-    out: IndexItem = {
-        'file': os.path.splitext(obj['file'])[0],
-        'name': name,
-    }
-
-    if summary:
-        out['summary'] = markdown(summary)
+    name = obj.name
+
+    out: IndexItem = IndexItem(
+        file=os.path.splitext(obj.file)[0],
+        name=name,
+    )
+
+    for tag in obj.docstring.tags:
+        if tag.tag_name == 'summary':
+            out.summary = markdown(tag.text)
+            break
 
     return out
 
 
-def class_index(class_list: list) -> IndexCategory:
+def class_index(class_list: list[PuppetClass]) -> IndexCategory:
     """Prepage class index list."""
     groups = group_by(isprivate, class_list)
 
     lst: list[IndexSubcategory] = []
 
     if publics := groups.get(False):
-        lst.append({
-            'title': 'Public Classes',
-            'list': (index_item(i) for i in publics),
-        })
+        lst.append(IndexSubcategory(
+            title='Public Classes',
+            list=[index_item(i) for i in publics],
+        ))
 
     if privates := groups.get(True):
-        lst.append({
-            'title': 'Private Classes',
-            'list': (index_item(i) for i in privates),
-        })
+        lst.append(IndexSubcategory(
+            title='Private Classes',
+            list=[index_item(i) for i in privates],
+        ))
 
-    return {
-        'title': 'Classes',
-        'list': lst
-    }
+    return IndexCategory(
+        title='Classes',
+        list=lst
+    )
 
 
-def defined_types_index(defined_list: list) -> IndexCategory:
+def defined_types_index(defined_list: list[DefinedType]) -> IndexCategory:
     """
     Prepare defined types index list.
 
@@ -146,47 +287,99 @@ def defined_types_index(defined_list: list) -> IndexCategory:
     lst: list[IndexSubcategory] = []
 
     if publics := groups.get(False):
-        lst.append({
-            'title': 'Public Defined Types',
-            'list': (index_item(i) for i in publics),
-        })
+        lst.append(IndexSubcategory(
+            title='Public Defined Types',
+            list=[index_item(i) for i in publics],
+        ))
 
     if privates := groups.get(True):
-        lst.append({
-            'title': 'Private Defined Types',
-            'list': (index_item(i) for i in privates),
-        })
+        lst.append(IndexSubcategory(
+            title='Private Defined Types',
+            list=[index_item(i) for i in privates],
+        ))
 
-    return {
-        'title': 'Defined Types',
-        'list': lst
-    }
+    return IndexCategory(
+        title='Defined Types',
+        list=lst
+    )
 
 
-def type_aliases_index(alias_list: list) -> IndexCategory:
+def type_aliases_index(alias_list: list[DataTypeAlias]) -> IndexCategory:
     """Prepare type alias index list."""
     groups = group_by(isprivate, alias_list)
     lst: list[IndexSubcategory] = []
     if publics := groups.get(False):
-        lst.append({
-            'title': 'Public Type Aliases',
-            'list': ({'name': i['name'],
-                      'file': os.path.splitext(i['file'])[0]}
-                     for i in publics),
-        })
+        lst.append(IndexSubcategory(
+            title='Public Type Aliases',
+            list=[IndexItem(name=i.name,
+                            file=os.path.splitext(i.file)[0])
+                  for i in publics],
+        ))
 
     if privates := groups.get(True):
-        lst.append({
-            'title': 'Private Type Aliases',
-            'list': ({'name': i['name'],
-                      'file': os.path.splitext(i['file'])[0]}
-                     for i in privates),
-        })
+        lst.append(IndexSubcategory(
+            title='Private Type Aliases',
+            list=[IndexItem(name=i.name,
+                            file=os.path.splitext(i.file)[0])
+                  for i in privates],
+        ))
 
-    return {
-        'title': 'Type Aliases',
-        'list': lst,
-    }
+    return IndexCategory(
+        title='Type Aliases',
+        list=lst,
+    )
+
+
+@dataclass
+class ResourceIndex:
+    """Placeholder."""
+
+    title: str
+    children: list[HtmlSerializable]
+
+    def to_html(self) -> str:
+        """Return something."""
+        out: str = ''
+        out += f'<h2>{self.title}</h2>'
+        out += '<dl>'
+        for child in self.children:
+            out += child.to_html()
+        out += '</dl>'
+        return out
+
+    def to_html_list(self) -> str:
+        """Return something."""
+        out: str = ''
+        out += f'<li>{self.title}<ul>'
+        for child in self.children:
+            out += child.to_html_list()
+        out += '</ul></li>'
+        return out
+
+
+def resource_type_index(resource_types: list[ResourceType]) -> list[HtmlSerializable]:
+    """Generate index for all known resource types."""
+    lst: list[HtmlSerializable] = []
+
+    for resource_type in resource_types:
+        # resource_type['file']
+        # resource_type['docstring']
+
+        items: list[HtmlSerializable] = []
+        if providers := resource_type.providers:
+            for provider in providers:
+                # TODO summary tag?
+                # TODO, instead, render markdown and take first paragraph
+                documentation = provider.docstring.text.split('\n')[0]
+                items.append(ResourceTypeOutput(
+                    title=provider.name,
+                    link=provider.file,
+                    summary=documentation))
+
+        lst.append(ResourceTypeOutput(title=resource_type.name,
+                                      children=items))
+
+    return lst
 
 
 # def resource_types_index(resource_list: list) -> IndexCategory:
@@ -202,52 +395,62 @@ def type_aliases_index(alias_list: list) -> IndexCategory:
 def setup_module_index(*,
                        base: str,
                        module: ModuleEntry,
-                       data: dict[str, Any],
+                       data: PuppetStrings,
                        path_base: str,
                        doc_files: dict[str, str],
                        ) -> None:
     """Create the index file for a specific module."""
-    template = jinja.get_template('module_index.html')
-
-    content = []
-
-    content.append(class_index(data['puppet_classes']))
-
-    data['data_types']
-    content.append({
-        'title': 'Data types not yet implmented',
-        'list': [],
-    })
-
-    content.append(type_aliases_index(data['data_type_aliases']))
-
-    content.append(defined_types_index(data['defined_types']))
-
-    data['resource_types']
-    content.append({
-        'title': 'Resource types not yet implmented',
-        'list': [],
-    })
-    data['providers']
-    content.append({
-        'title': 'Providers not yet implmented',
-        'list': [],
-    })
-    data['puppet_functions']
-    content.append({
-        'title': 'Puppet Functions not yet implmented',
-        'list': [],
-    })
-    data['puppet_tasks']
-    content.append({
-        'title': 'Puppet Tasks not yet implmented',
-        'list': [],
-    })
-    data['puppet_plans']
-    content.append({
-        'title': 'Puppet Plans not yet implmented',
-        'list': [],
-    })
+    content: list[ResourceIndex | IndexCategory] = []
+
+    content.append(class_index(data.puppet_classes))
+
+    # data['data_types']
+    content.append(IndexCategory(
+        title='Data types not yet implmented',
+        list=[],
+    ))
+
+    content.append(type_aliases_index(data.data_type_aliases))
+
+    content.append(defined_types_index(data.defined_types))
+
+    content.append(ResourceIndex(
+        title='Resource Types',
+        children=resource_type_index(data.resource_types)))
+
+    # data['providers']
+    content.append(IndexCategory(
+        title='Providers not yet implmented',
+        list=[],
+    ))
+
+    # data['puppet_functions']
+    content.append(IndexCategory(
+        title='Puppet Functions not yet implmented',
+        list=[],
+    ))
+
+    # templates/
+    # files/
+    # examples or tests/
+    # (spec)/
+    # lib/puppet_x/
+    # lib/facter/
+    # facts.d/
+    # data/
+    # hiera.yaml
+
+    # data['puppet_tasks']
+    content.append(IndexCategory(
+        title='Puppet Tasks not yet implmented',
+        list=[],
+    ))
+
+    # data['puppet_plans']
+    content.append(IndexCategory(
+        title='Puppet Plans not yet implmented',
+        list=[],
+    ))
 
     crumbs = breadcrumbs(
             ('Environment', ''),
@@ -255,12 +458,13 @@ def setup_module_index(*,
             )
 
     with open(os.path.join(base, 'index.html'), 'w') as f:
-        f.write(template.render(module_name=module.name,
-                                module_author=module.metadata['author'],
-                                breadcrumbs=crumbs,
-                                content=content,
-                                path_base=path_base,
-                                doc_files=doc_files.items()))
+        f.write(templates.module_index(
+            module_name=module.name,
+            module_author=module.metadata['author'],
+            breadcrumbs=crumbs,
+            content=content,
+            path_base=path_base,
+            doc_files=list(doc_files.items())))
 
 
 GENERATED_MESSAGE = '<!-- DO NOT EDIT: This document was generated by Puppet Strings -->\n'
@@ -288,11 +492,12 @@ def setup_module(base: str, module: ModuleEntry, *, path_base: str) -> None:
     pathlib.Path(path).mkdir(exist_ok=True)
     if not module.strings_output:
         return
-    data = json.loads(module.strings_output)
 
-    for puppet_class in data['puppet_classes'] + data['defined_types']:
+    data = module.strings_output
+
+    for puppet_class in data.puppet_classes + data.defined_types:
         # localpath = puppet_class['name'].split('::')
-        localpath, _ = os.path.splitext(puppet_class['file'])
+        localpath, _ = os.path.splitext(puppet_class.file)
         dir = os.path.join(path, localpath)
         pathlib.Path(dir).mkdir(parents=True, exist_ok=True)
         # puppet_class['docstring']
@@ -301,65 +506,69 @@ def setup_module(base: str, module: ModuleEntry, *, path_base: str) -> None:
         # TODO option to add .txt extension (for web serverse which
         # treat .pp as application/binary)
         with open(os.path.join(dir, 'source.pp.txt'), 'wb') as f:
-            with open(module.file(puppet_class['file']), 'rb') as g:
+            with open(module.file(puppet_class.file), 'rb') as g:
                 f.write(g.read())
 
+        crumbs = breadcrumbs(
+                ('Environment', ''),
+                module.name,
+                (puppet_class.name,
+                 'manifests/' + '/'.join(puppet_class.name.split('::')[1:])),
+                'This',
+                )
+
         with open(os.path.join(dir, 'source.pp.html'), 'w') as f:
-            template = jinja.get_template('code_page.html')
-            crumbs = breadcrumbs(
-                    ('Environment', ''),
-                    module.name,
-                    (puppet_class['name'],
-                     'manifests/' + '/'.join(puppet_class['name'].split('::')[1:])),
-                    'This',
-                    )
-
-            with open(module.file(puppet_class['file']), 'r') as g:
-                f.write(template.render(title='',
-                                        content=highlight(g.read(), 'puppet'),
-                                        path_base=path_base,
-                                        breadcrumbs=crumbs))
-
-        with open(os.path.join(dir, 'source.json'), 'w') as f:
-            json.dump(puppet_class, f, indent=2)
+
+            with open(module.file(puppet_class.file), 'r') as g:
+                f.write(templates.code_page(
+                    title='',
+                    content=highlight(g.read(), 'puppet'),
+                    path_base=path_base,
+                    breadcrumbs=crumbs))
+
+        # TODO reimplement this?
+        # with open(os.path.join(dir, 'source.json'), 'w') as f:
+        #     json.dump(puppet_class, f, indent=2)
 
         # with open(os.path.join(dir, 'source.pp.html'), 'w') as f:
         #     f.write(format_class(puppet_class))
 
+        crumbs = breadcrumbs(
+                ('Environment', ''),
+                module.name,
+                (puppet_class.name,
+                 'manifests/' + '/'.join(puppet_class.name.split('::')[1:])),
+                )
+
+        title, body = format_class(puppet_class)
         with open(os.path.join(dir, 'index.html'), 'w') as f:
-            template = jinja.get_template('code_page.html')
-            crumbs = breadcrumbs(
-                    ('Environment', ''),
-                    module.name,
-                    (puppet_class['name'],
-                     'manifests/' + '/'.join(puppet_class['name'].split('::')[1:])),
-                    )
-            title, body = format_class(puppet_class)
-            f.write(template.render(title=title,
-                                    content=body,
-                                    path_base=path_base,
-                                    breadcrumbs=crumbs))
+            f.write(templates.code_page(
+                title=title,
+                content=body,
+                path_base=path_base,
+                breadcrumbs=crumbs))
 
         # puppet_class['file']
         # puppet_class['line']
 
-    for type_alias in data['data_type_aliases']:
-        localpath, _ = os.path.splitext(type_alias['file'])
+    for type_alias in data.data_type_aliases:
+        localpath, _ = os.path.splitext(type_alias.file)
         dir = os.path.join(path, localpath)
         pathlib.Path(dir).mkdir(parents=True, exist_ok=True)
 
         with open(os.path.join(dir, 'source.pp.txt'), 'w') as f:
-            f.write(type_alias['alias_of'])
+            f.write(type_alias.alias_of)
 
-        with open(os.path.join(dir, 'source.json'), 'w') as f:
-            json.dump(type_alias, f, indent=2)
+        # TODO reimplement this?
+        # with open(os.path.join(dir, 'source.json'), 'w') as f:
+        #     json.dump(type_alias, f, indent=2)
 
-        template = jinja.get_template('code_page.html')
+        title, body = format_type_alias(type_alias)
         with open(os.path.join(dir, 'index.html'), 'w') as f:
-            title, body = format_type_alias(type_alias)
-            f.write(template.render(title=title,
-                                    content=body,
-                                    path_base=path_base))
+            f.write(templates.code_page(
+                title=title,
+                content=body,
+                path_base=path_base))
 
     # data['data_type_aliases']
     # data['defined_types']
@@ -391,15 +600,15 @@ def setup_module(base: str, module: ModuleEntry, *, path_base: str) -> None:
         else:
             content = '<pre>' + html.escape(raw_content) + '</pre>'
 
-        template = jinja.get_template('content.html')
         crumbs = breadcrumbs(('Environment', ''),
                              module.name,
                              name)
 
         with open(out_path, 'w') as f:
-            f.write(template.render(content=content,
-                                    path_base=path_base,
-                                    breadcrumbs=crumbs))
+            f.write(templates.content(
+                content=content,
+                path_base=path_base,
+                breadcrumbs=crumbs))
 
         doc_files[name] = os.path.join(module.name, name, 'index.html')
 
diff --git a/muppet/puppet/__init__.py b/muppet/puppet/__init__.py
new file mode 100644
index 0000000..d7629f4
--- /dev/null
+++ b/muppet/puppet/__init__.py
@@ -0,0 +1,9 @@
+"""
+Various wrappers around different puppet functionality.
+
+strings
+    wraps ``puppet strings``, and maps it to python types.
+
+parser
+    Wraps ``puppet parser``.
+"""
diff --git a/muppet/puppet/parser.py b/muppet/puppet/parser.py
index d1f95c6..846ce11 100644
--- a/muppet/puppet/parser.py
+++ b/muppet/puppet/parser.py
@@ -24,9 +24,10 @@ def tagged_list_to_dict(lst: list[Any]) -> dict[Any, Any]:
     A tagged list in this context is a list where every even value
     (zero-indexed) is a key, and every odd value is a value.
 
-    :example:
-    >>> tagged_list_to_dict(['a', 1, 'b', 2])
-    {'a': 1, 'b': 2}
+    .. sourcecode:: python
+
+        >>> tagged_list_to_dict(['a', 1, 'b', 2])
+        {'a': 1, 'b': 2}
     """
     return {lst[i]: lst[i+1]
             for i in range(0, len(lst), 2)}
@@ -113,6 +114,8 @@ def __main() -> None:
             inp = sys.stdin
         case [_, file]:
             inp = open(file)
+        case _:
+            raise Exception("This is impossible to rearch")
 
     json.dump(puppet_parser(inp.read()),
               sys.stdout,
diff --git a/muppet/puppet/strings.py b/muppet/puppet/strings.py
deleted file mode 100644
index 0f4930d..0000000
--- a/muppet/puppet/strings.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""Python wrapper around puppet strings."""
-
-import subprocess
-from typing import Any
-
-
-def puppet_strings(path: str) -> bytes:
-    """Run `puppet strings` on puppet module at path."""
-    # TODO adding an --out flag (to not stdout) causes warnings to be
-    # printed to stdout. Warnings
-
-    cmd = subprocess.run('puppet strings generate --format json'.split(' '),
-                         cwd=path,
-                         check=True,
-                         stdout=subprocess.PIPE)
-    return cmd.stdout
-
-
-def isprivate(entry: dict[str, Any]) -> bool:
-    """
-    Is the given puppet declaration marked private.
-
-    Assumes input is a dictionary as returned by puppet strings, one
-    of the entries in (for example) 'puppet_classes'.
-
-    Currently only checks for an "@api private" tag.
-    """
-    if ds := entry.get('docstring'):
-        if tags := ds.get('tags'):
-            for tag in tags:
-                if tag.get('tag_name') == 'api' and \
-                   tag.get('text') == 'private':
-                    return True
-    return False
diff --git a/muppet/puppet/strings/__init__.py b/muppet/puppet/strings/__init__.py
new file mode 100644
index 0000000..25c0650
--- /dev/null
+++ b/muppet/puppet/strings/__init__.py
@@ -0,0 +1,402 @@
+"""
+Python wrapper around puppet strings.
+
+This maps
+`Puppet Strings <https://www.puppet.com/docs/puppet/7/puppet_strings.html>`_
+JSON output onto python objects, using type-level reflection (see
+:class:`Deserializable` for details).
+
+Fields declared without defaults are required, those with defaults are
+optional. Extra fields may be given, but will result in a warning
+being logged (TODO make this configurable).
+
+Puppet Strings output should follow
+`this schema <https://github.com/puppetlabs/puppet-strings/blob/main/JSON.md>`_,
+unfortunately, that document is out of date [#f1]_, so the following
+is mostly based on observation.
+
+Some of the fields defined here have an Optional type, all these can
+be changed to simply having a default value, if that better reflects
+the possibilities of the field. The same is true with the above types
+swaped.
+
+.. rubric:: Footnotes
+
+.. [#f1] It's out of date as of writing this (2023-07-03)
+"""
+
+import subprocess
+from typing import (
+    Any,
+    Optional,
+    Protocol,
+)
+from dataclasses import dataclass, field
+import logging
+from .internal import Deserializable
+
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass(kw_only=True)
+class DocStringTag(Deserializable):
+    """
+    A generic DocString tag.
+
+    note, api, summary
+
+    :param tag_name:
+        Type of the tag, like 'param', 'api', ...
+
+    :param text:
+        Freeform text content of the tag.
+    """
+
+    tag_name:   str
+    text:       str = ''
+
+    types:      Optional[list[str]] = None
+
+    @staticmethod
+    def _key() -> str:
+        raise NotImplementedError()
+
+
+@dataclass(kw_only=True)
+class DocStringParamTag(DocStringTag):
+    """Tag with tag_name set to 'param'."""
+
+    types: list[str]
+    """
+    If tag_name is 'param', then this will be a singleton list of the
+    type (as a string). Untyped fields will be typed as 'Any'.
+    """
+
+    name: str
+    """Parameter name."""
+
+    @staticmethod
+    def _key() -> str:
+        return 'param'
+
+
+@dataclass(kw_only=True)
+class DocStringExampleTag(DocStringTag):
+    """Tag with tag_name set to 'example'."""
+
+    name: str
+    """
+    Most likely name of language of ``text``.
+
+    Will be the empty string if not set.
+    """
+
+    @staticmethod
+    def _key() -> str:
+        return 'example'
+
+
+@dataclass(kw_only=True)
+class DocStringOverloadTag(DocStringTag):
+    """
+    Tag with tag_name set to 'overload'.
+
+    These three will be set for puppet 4.x functions with overloads
+    when tag_name is 'overload'.
+    """
+
+    name: str
+    signature: str
+    docstring: Optional['DocString'] = None
+    defaults: dict[Any, Any] = field(default_factory=dict)
+
+    @staticmethod
+    def _key() -> str:
+        return 'overload'
+
+
+@dataclass(kw_only=True)
+class DocStringOptionTag(DocStringTag):
+    """Tag with tag_name set to 'option'."""
+
+    opt_name:   str
+    opt_text:   str
+    opt_types:  str
+    parent:     str
+    name:       str
+
+    @staticmethod
+    def _key() -> str:
+        return 'option'
+
+
+@dataclass(kw_only=True)
+class DocStringSeeTag(DocStringTag):
+    """Tag with tag_name set to 'see'."""
+
+    name:       str
+
+    @staticmethod
+    def _key() -> str:
+        return 'see'
+
+
+@dataclass(kw_only=True)
+class DocString(Deserializable):
+    """Documentation entry for something."""
+
+    text: str
+    tags: list[DocStringTag] = field(default_factory=list)
+
+    @staticmethod
+    def handle_tags(items: list[dict[str, Any]]) -> list[DocStringTag]:
+        """
+        Parse list of tag dictionaries.
+
+        The type of a tag dictionary depends on the value of
+        ``tag_name``.
+        """
+        result: list[DocStringTag] = []
+        for object in items:
+            # cls: type[DocStringTag]
+            cls: type
+            for c in DocStringTag.__subclasses__():
+                if object['tag_name'] == c._key():
+                    cls = c
+                    break
+            else:
+                cls = DocStringTag
+            try:
+                result.append(cls(**object))
+            except TypeError as e:
+                logger.error("Bad tag set for tag object (class=%s) %s",
+                             cls.__name__, object,
+                             exc_info=e)
+                raise e
+        return result
+
+
+@dataclass(kw_only=True)
+class PuppetClass(Deserializable):
+    """Documentation for a puppet class."""
+
+    name:       str
+    file:       str
+    line:       int
+    inherits:   Optional[str] = None
+    docstring:  DocString
+    defaults:   dict[str, str] = field(default_factory=dict)
+    source:     str
+
+
+@dataclass(kw_only=True)
+class DataType(Deserializable):
+    """Documentation for a data type."""
+
+    name:       str
+    file:       str
+    line:       int
+    docstring:  DocString
+    defaults:   dict[str, str]
+    # source: str
+
+
+@dataclass(kw_only=True)
+class DataTypeAlias(Deserializable):
+    """Documentation for a type alias."""
+
+    name:       str
+    file:       str
+    line:       int
+    docstring:  DocString
+    alias_of:   str
+    # source: str
+
+
+@dataclass(kw_only=True)
+class DefinedType(Deserializable):
+    """Documentation for a defined type."""
+
+    name: str
+    file: str
+    line: int
+    docstring: DocString
+    defaults: dict[str, str] = field(default_factory=dict)
+    source: str
+
+
+@dataclass(kw_only=True)
+class Provider(Deserializable):
+    """Documentation for a resource type provider."""
+
+    name:       str
+    type_name:  str
+    file:       str
+    line:       int
+    docstring:  DocString
+    confines:   dict[str, str] = field(default_factory=dict)
+    features:   list[str] = field(default_factory=list)
+    defaults:   list[Any] = field(default_factory=list)
+    commands:   dict[str, str] = field(default_factory=dict)
+
+
+@dataclass(kw_only=True)
+class ResourceTypeProperty(Deserializable):
+    """Documentation for a property of a resource type."""
+
+    name:         str
+    description:  str
+    values:       Optional[list[str]] = None
+    aliases:      dict[str, str] = field(default_factory=dict)
+    isnamevar:    bool = False
+    default:      Optional[str] = None
+
+    required_features: Optional[str] = None
+    data_type: Optional[str] = None
+
+
+@dataclass(kw_only=True)
+class ResourceTypeParameter(Deserializable):
+    """Documentation for a parameter of a resource type."""
+
+    name:         str
+    description:  Optional[str] = None
+    values:       Optional[list[str]] = None
+    aliases:      Any = field(default_factory=list)
+    isnamevar:    bool = False
+    default:      Optional[str] = None
+
+    required_features: Optional[str] = None
+    data_type: Optional[str] = None
+
+
+@dataclass(kw_only=True)
+class ResourceTypeFeature(Deserializable):
+    """Documentation for a published feature of a resource type."""
+
+    name:         str
+    description:  str
+
+
+@dataclass(kw_only=True)
+class ResourceType(Deserializable):
+    """Documentation for a resource type."""
+
+    name:        str
+    file:        str
+    line:        int
+    docstring:   DocString
+    properties:  list[ResourceTypeProperty] = field(default_factory=list)
+    parameters:  list[ResourceTypeParameter]
+    features:    Optional[list[ResourceTypeFeature]] = None
+    providers:   Optional[list[Provider]] = None
+
+
+@dataclass(kw_only=True)
+class Signature(Deserializable):
+    """Documentation for a function signature."""
+
+    signature: str
+    docstring: DocString
+
+
+@dataclass(kw_only=True)
+class Function(Deserializable):
+    """Documentation for a function."""
+
+    name:        str
+    file:        str
+    line:        int
+    type:        str  # Probably one of 'ruby3x', 'ruby4x', 'puppet'
+    signatures:  list[Signature]
+    docstring:   DocString
+    defaults:    dict[str, str] = field(default_factory=dict)
+    source:      str
+
+
+@dataclass(kw_only=True)
+class Task(Deserializable):
+    """Documentation for a task."""
+
+    name:           str
+    file:           str
+    line:           int
+    docstring:      Optional[DocString] = None
+    source:         str
+    supports_noop:  bool
+    input_method:   Any
+
+
+@dataclass(kw_only=True)
+class Plan(Deserializable):
+    """Documentation for a plan."""
+
+    name:       str
+    file:       str
+    line:       int
+    docstring:  DocString
+    defaults:   dict[str, Any]
+    source:     str
+
+
+@dataclass(kw_only=True)
+class PuppetStrings(Deserializable):
+    """Complete documentation for a Puppet module."""
+
+    puppet_classes:     list[PuppetClass]
+    data_types:         list[DataType]
+    data_type_aliases:  list[DataTypeAlias]
+    defined_types:      list[DefinedType]
+    resource_types:     list[ResourceType]
+    providers:          list[Provider]
+    puppet_functions:   list[Function]
+    puppet_tasks:       list[Task]
+    puppet_plans:       list[Plan]
+
+
+# --------------------------------------------------
+
+
+def puppet_strings(path: str) -> bytes:
+    """
+    Run ``puppet strings`` on puppet module at path.
+
+    Returns a bytes object rather than a :class:`PuppetStrings`
+    object, to efficeiently writing the output to a cache.
+
+    .. code-block:: python
+       :caption: Example Invocation
+
+        >>> PuppetStrings.from_json(puppet_strings("/etc/puppetlabs/code/modules/stdlib"))
+    """
+    # TODO adding an --out flag (to not stdout) causes warnings to be
+    # printed to stdout. Warnings
+
+    cmd = subprocess.run('puppet strings generate --format json'.split(' '),
+                         cwd=path,
+                         check=True,
+                         stdout=subprocess.PIPE)
+    return cmd.stdout
+
+
+class HasDocstring(Protocol):
+    """Something which has a docstring attribute."""
+
+    docstring: DocString
+
+
+def isprivate(entry: HasDocstring) -> bool:
+    """
+    Is the given puppet declaration marked private.
+
+    Assumes input is a dictionary as returned by puppet strings, one
+    of the entries in (for example) 'puppet_classes'.
+
+    Currently only checks for an "@api private" tag.
+    """
+    for tag in entry.docstring.tags:
+        if tag.tag_name == 'api' and \
+           tag.text == 'private':
+            return True
+    return False
diff --git a/muppet/puppet/strings/__main__.py b/muppet/puppet/strings/__main__.py
new file mode 100644
index 0000000..b826955
--- /dev/null
+++ b/muppet/puppet/strings/__main__.py
@@ -0,0 +1,29 @@
+"""
+Loads the output of puppet strings into puppet items.
+
+This is mostly to aid in debugging.
+"""
+
+from . import PuppetStrings
+
+
+def __main() -> None:
+    import json
+    import argparse
+    from pprint import PrettyPrinter
+    pp = PrettyPrinter(compact=True)
+
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--pretty', action='store_true')
+    parser.add_argument('source', type=argparse.FileType('r'))
+    args = parser.parse_args()
+
+    data = PuppetStrings.from_json(json.load(args.source))
+    if args.pretty:
+        pp.pprint(data)
+    else:
+        print(data)
+
+
+if __name__ == '__main__':
+    __main()
diff --git a/muppet/puppet/strings/internal.py b/muppet/puppet/strings/internal.py
new file mode 100644
index 0000000..e88e4ba
--- /dev/null
+++ b/muppet/puppet/strings/internal.py
@@ -0,0 +1,260 @@
+"""
+Setup for automatic deserialization into dataclasses.
+
+This automatically deserializes dictionaries like those returned from
+json objects, into actual python classes, using the dataclass
+typelevel introspection.
+
+Each key in the dataclass should either be a primitive type
+:py:data:`primitive_types`, a class extending from
+:py:class:`Deserializable`, or an optional, list, or dictionary with
+one of the above as it's type.
+
+See :py:meth:`Deserializable.handle` for details about customizing deserialization.
+
+.. code-block:: python
+   :caption: Sample usage.
+
+    @dataclass
+    class OtherItem(Deserializable):
+        x: str
+
+   @dataclass
+   class MyItem(Deserializable):
+       x: str
+       y: Optional[int] = None
+       xs: OtherItem = None
+
+   MyItem.from_json({
+       'x': 'Hello',
+       'xs': {
+           'x': 'World',
+       }
+   })
+
+.. code-block::
+   :caption: result of the above:
+
+   MyItem(x='Hello',
+          y=None,
+          xs=OtherItem(x='World'))
+
+
+.. todo::
+
+    Forwards declarations in type fields sometimes work, and sometimes
+    not. I don't know what causes it. Use with caution.
+"""
+
+import typing
+from typing import (
+    Any,
+    Optional,
+    TypeVar,
+    final,
+)
+from types import GenericAlias, NoneType
+import dataclasses
+from dataclasses import dataclass
+import logging
+
+logger = logging.getLogger(__name__)
+
+
+T = TypeVar('T', bound='Deserializable')
+
+
+primitive_types = {str, int, bool}
+"""Types which are directly allowed as children."""
+
+
+def check_optional(field: dataclasses.Field[Any]) -> Optional[Any]:
+    """
+    Check if field represents an optional field.
+
+    :returns:
+        ``None`` if the object isn't an Optional, and the contained
+        type if it is an Optional.
+    """
+    # Documentation I have seem indicates that the correct way to
+    # check for unions would be
+    # ``isinstance(field.type, types.UnionType)``,
+    # but that always returns False for me.
+    if typing.get_origin(field.type) == typing.Union \
+            and len(typing.get_args(field.type)) == 2:
+        args = typing.get_args(field.type)
+        if args[0] == NoneType:
+            return args[1]
+        if args[1] == NoneType:
+            return args[0]
+        else:
+            return None
+    else:
+        return None
+
+
+@dataclass
+class Deserializable:
+    """
+    Something which can be deserialized from a JSON object.
+
+    This class shouldn't be instansiated directly, but instead
+    subclassed to allow deserialization.
+    """
+
+    @final
+    @classmethod
+    def fields_dict(cls) -> dict[str, dataclasses.Field[Any]]:
+        """Return "this" dataclass fields as a dictionary from name to field."""
+        return {f.name: f for f in dataclasses.fields(cls)}
+
+    @final
+    @classmethod
+    def from_json(cls: type[T], d: dict[str, Any]) -> T:
+        """
+        Load instance of "this" class from given dictionary.
+
+        The name ``from_json`` is thereby slightly missleading.
+
+        :param cls:
+
+        :param d:
+            Dictionary which should be deseriablized.
+
+            If the entry is another Deserializable (or lists of
+            Deserializable, or dictionaries with Deserialiazable
+            values) then ``from_json`` is called recursively.
+
+            Other objects are returned verbatim.
+        """
+        # if (extra := d.keys() - set(x.name for x in dataclasses.fields(cls))) != set():
+
+        params = {}
+        fields = cls.fields_dict()  # allowed keys
+
+        # For each present key
+        for k, v in d.items():
+            if k in fields:
+                try:
+                    params[k] = cls.handle(fields[k], k, v)
+                except TypeError as e:
+                    logger.error('An error occurred while handling class [%s]',
+                                 cls.__name__,
+                                 exc_info=e)
+                    raise e
+            else:
+                msg = "Attempting to set non-existant field [%(field)s] on class [%(cls)s] to value %(value)a"  # noqa: E501
+                logger.warning(msg, {
+                    'field': k,
+                    'cls': cls.__name__,
+                    'value': v
+                })
+
+        try:
+            return cls(**params)
+        except TypeError as e:
+            msg = 'Failed constructing object in from_json. class=%(cls)s, params=%(params)s'
+            logger.error(msg, {
+                'cls': cls.__name__,
+                'params': params,
+            }, exc_info=e)
+
+            raise e
+
+    @final
+    @classmethod
+    def handle(cls, field: dataclasses.Field[Any], key: str, value: Any) -> Any:
+        """
+        Deserialize given field.
+
+        The given value is deserialized according to the type of ``field``.
+        First, any ``Optional`` wrapping is removed (e.g. ``Optional[T] → T``).
+
+        Then, one of the following steps are taken:
+
+        if the class has a method called ``handle_${key}``:
+            Call that method with the value, propagating the result.
+
+        if the type indicates another subclass of ``Deserializable``:
+            Recurse, returing that deserialized value.
+
+        if the type indicates a list:
+            If it's either a primitive type, or another subclass of
+            ``Deserializable``, then each element in deserialized, and
+            collected into a list which is then returned.
+
+        if the type indicates a dictionary:
+            The key type is ignored, and propagated directly, while
+            the value type follows the same rules as for lists (see
+            above).
+
+        if the type is a primitive type (:py:data:`primitive_types`):
+            Return the value directoly
+
+        otherwise:
+            Also return the value directly, but log a warning about
+            unhandled complex data.
+
+        :param field:
+            Specifies the expected type of ``value``, and is used in
+            determining how to proceed.
+        :param key:
+            Key of the value we are deserializing. Is only used for
+            the manual method dispatch, and error messages.
+        :param value:
+            The value to deserialize.
+        :raises TypeError:
+            If there's a mismatch between the type and value a
+            TypeError will most likely be raised. These should
+            preferably be handled somehow.
+        """
+        # Unwrap optional types. This is always ok, since handle is
+        # only called when we have a value, so the None case is
+        # already discarded.
+        if unwrapped := check_optional(field):
+            ttt = unwrapped
+        else:
+            ttt = field.type
+
+        # Check for explicit handle_{key} method in current class
+        if callable(method := getattr(cls, f'handle_{key}', None)):
+            return method(value)
+
+        # If the target class is Deserializable
+        elif isinstance(ttt, type) and issubclass(ttt, Deserializable):
+            return ttt.from_json(value)
+
+        # If the target is a list of Deserializable
+        elif isinstance(ttt, GenericAlias) \
+                and typing.get_origin(ttt) == list:
+            if issubclass(typing.get_args(ttt)[0], Deserializable):
+                typ = typing.get_args(ttt)[0]
+                return [typ.from_json(x) for x in value]
+            elif typing.get_args(ttt)[0] in primitive_types:
+                return value
+
+        # If the target is a dictionary with Deserializable keys
+        elif isinstance(ttt, GenericAlias) \
+                and typing.get_origin(ttt) == dict:
+            if issubclass(typing.get_args(ttt)[1], Deserializable):
+                typ = typing.get_args(ttt)[1]
+                return {k: typ.from_json(v) for (k, v) in value.items()}
+            elif typing.get_args(ttt)[1] in primitive_types:
+                return value
+
+        # If the target is a primitive type
+        elif type(value) in primitive_types:
+            return value
+
+        # if the target is something else weird
+        else:
+            msg = "setting complex variable: %(cls)s[%(field)a] = %(value)a as %(type)s"
+            args = {
+                'cls': cls.__name__,
+                'field': key,
+                'value': value,
+                'type': type(value).__name__,
+            }
+            logger.warning(msg, args)
+
+            return value
diff --git a/muppet/tabs.py b/muppet/tabs.py
index 702a491..d00d17c 100644
--- a/muppet/tabs.py
+++ b/muppet/tabs.py
@@ -45,7 +45,7 @@ def tab_widget(tabgroup: TabGroup) -> str:
     The argument is the list of tabs, nothing is returned, but instead
     written to stdout.
     """
-    template = env.get_template('tabset.html')
+    template = env.get_template('snippets/tabset.html')
     return template.render(tabset=tabgroup)
 
 
diff --git a/muppet/templates.py b/muppet/templates.py
new file mode 100644
index 0000000..492c501
--- /dev/null
+++ b/muppet/templates.py
@@ -0,0 +1,81 @@
+"""
+Function wrappers around jinja templates.
+
+This allows for type checking.
+"""
+
+from typing import (
+    Any,
+    Optional,
+)
+from jinja2 import (
+    Environment,
+    FileSystemLoader,
+)
+from .breadcrumbs import Breadcrumbs
+from .gather import ModuleEntry
+
+jinja = Environment(
+    loader=FileSystemLoader('templates'),
+    autoescape=False,
+)
+
+
+def code_page(*,
+              title: str,
+              content: str,
+              path_base: str,
+              breadcrumbs: Optional[Breadcrumbs] = None) -> str:
+    """Template for a page containing puppet code."""
+    template = jinja.get_template('code_page.html')
+    return template.render(
+            title=title,
+            content=content,
+            path_base=path_base,
+            breadcrumbs=breadcrumbs)
+
+
+def content(*,
+            content: str,
+            path_base: str,
+            breadcrumbs: Optional[Breadcrumbs] = None) -> str:
+    """Template for a page with arbitrary content."""
+    template = jinja.get_template('content.html')
+    return template.render(
+            content=content,
+            path_base=path_base,
+            breadcrumbs=breadcrumbs)
+
+
+def index(*,
+          modules: list[ModuleEntry],
+          path_base: str,
+          breadcrumbs: Optional[Breadcrumbs] = None
+          ) -> str:
+    """Root index file."""
+    template = jinja.get_template('index.html')
+    return template.render(
+            path_base=path_base,
+            modules=modules,
+            breadcrumbs=breadcrumbs)
+
+
+def module_index(
+        *,
+        # content: list[],  # something with to_html_list and to_html
+        content: list[Any],  # TODO something with to_html_list and to_html
+        module_author: str,
+        module_name: str,
+        doc_files: list[tuple[str, str]],
+        path_base: str,
+        breadcrumbs: Optional[Breadcrumbs] = None,
+        ) -> str:
+    """Index for a single module."""
+    template = jinja.get_template('module_index.html')
+    return template.render(
+            content=content,
+            module_author=module_author,
+            module_name=module_name,
+            doc_files=doc_files,
+            path_base=path_base,
+            breadcrumbs=breadcrumbs)
diff --git a/mypy.ini b/mypy.ini
index a47647c..7c7a251 100644
--- a/mypy.ini
+++ b/mypy.ini
@@ -7,4 +7,5 @@ disallow_untyped_defs = True
 disallow_incomplete_defs = True
 check_untyped_defs = True
 warn_return_any = True
+strict = True
 # warn_unrearchable = True
diff --git a/static-src/_sidebar.scss b/static-src/_sidebar.scss
new file mode 100644
index 0000000..c1ca137
--- /dev/null
+++ b/static-src/_sidebar.scss
@@ -0,0 +1,17 @@
+#page-root {
+	display: grid;
+	grid-template-columns: 10ch auto 30ch;
+
+	.sidebar {
+		font-size: 70%;
+		font-family: sans;
+	}
+
+	#left-sidebar {
+		background: lightblue;
+	}
+
+	#right-sidebar {
+		background: pink;
+	}
+}
diff --git a/static-src/style.scss b/static-src/style.scss
index a4846ba..57c4387 100644
--- a/static-src/style.scss
+++ b/static-src/style.scss
@@ -159,6 +159,30 @@ code.json {
 
 /* -------------------------------------------------- */
 
+dl.module-index {
+	display: grid;
+	grid-template-columns: 1fr auto;
+	grid-row-gap: 4pt;
+
+	dt {
+		text-align: right;
+		color: grey;
+	}
+}
+
+/* -------------------------------------------------- */
+
+ul.toc {
+	list-style-type: none;
+	padding-left: 1em;
+}
+
+.toc ul {
+	padding-left: 2em;
+}
+
+/* -------------------------------------------------- */
+
 @import "colorscheme_default";
 
 .highlight-pygments {
@@ -176,3 +200,4 @@ code.json {
 @import "breadcrumb";
 @import "tabset";
 @import "color-headers";
+@import "sidebar";
diff --git a/templates/base.html b/templates/base.html
index c9cbb88..9c8fa46 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -34,16 +34,21 @@ Parameters:
 	<body>
 		<header>
 			{% if breadcrumbs %}
-				<ul class="breadcrumb">
-					{%- for item in breadcrumbs.crumbs -%}
-						<li><a href="{{ path_base }}{{ item.ref }}">{{ item.text }}</a></li>
-					{%- endfor -%}
-					<li>{{ breadcrumbs.final }}</li>
-				</ul>
+				<nav>
+					<ul class="breadcrumb">
+						{%- for item in breadcrumbs.crumbs -%}
+							<li><a href="{{ path_base }}{{ item.ref }}">{{ item.text }}</a></li>
+						{%- endfor -%}
+						<li>{{ breadcrumbs.final }}</li>
+					</ul>
+				</nav>
 			{% endif %}
 		</header>
-		{% block content %}
-		{% endblock %}
+		<div id="page-root">
+			<aside id="left-sidebar" class="sidebar">{% block left_sidebar %}{% endblock %}</aside>
+			<main>{% block content %}{% endblock %}</main>
+			<aside id="right-sidebar" class="sidebar">{% block right_sidebar %}{% endblock %}</aside>
+		</div>
 	</body>
 </html>
 {# ft: jinja #}
diff --git a/templates/code_page.html b/templates/code_page.html
index 989fc5a..2e7f99c 100644
--- a/templates/code_page.html
+++ b/templates/code_page.html
@@ -7,6 +7,12 @@ Parameters:
 		Content of page
 #}
 {% extends "base.html" %}
+{% block left_sidebar %}
+	{# Class list, basically the right sidebar from module index #}
+{% endblock %}
+{% block right_sidebar %}
+	{# All defined variables #}
+{% endblock %}
 {% block content %}
 	<h1><code>{{ title }}</code></h1>
 	<ul class="alternatives">
diff --git a/templates/index.html b/templates/index.html
index 5cd4683..b3f4b6e 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -10,20 +10,22 @@ Parameters:
 {% extends "base.html" %}
 {% block content %}
 	<h1>Muppet Strings</h1>
-	<ul>
+	<dl class="module-index">
 		{% for module in modules %}
-		<li>
+		<dt>
 			{%- if module.metadata.get('name') -%}
 				{{ module.metadata['name'].split('-')[0] }}/
 			{%- endif -%}
 			<a href="{{ module.name }}"
 			   class="{{ 'error' if module.strings_output == None }}"
 			   >{{ module.name }}</a>
+		</dt>
+		<dd>
 			{%- if module.metadata.get('summary') %}
-				&mdash; {{ module.metadata['summary'] }}
+				{{ module.metadata['summary'] }}
 			{%- endif -%}
-		</li>
+		</dd>
 		{% endfor %}
-	</ul>
+	</dl>
 {% endblock %}
 {# ft: jinja #}
diff --git a/templates/module_index.html b/templates/module_index.html
index e8a74db..3448000 100644
--- a/templates/module_index.html
+++ b/templates/module_index.html
@@ -7,6 +7,17 @@ Parameters:
 	content:
 #}
 {% extends "base.html" %}
+{% block left_sidebar %}
+	{# environment list #}
+{% endblock %}
+{% block right_sidebar %}
+	{# Table of contents, including all types #}
+	<ul class="toc">
+		{% for entry in content %}
+			{{ entry.to_html_list() }}
+		{% endfor %}
+	</ul>
+{% endblock %}
 {% block content %}
 <h1>{{ module_author }} / {{ module_name.title() }}</h1>
 
@@ -17,20 +28,7 @@ Parameters:
 </ul>
 
 {% for entry in content %}
-	<h2>{{ entry['title'] }}</h2>
-	{% for subentry in entry['list'] %}
-		<h3>{{ subentry['title'] }}</h3>
-		<ul class="overview-list">
-			{% for item in subentry['list'] %}
-				<li>
-					<a href="{{ item['file'] }}">{{ item['name'] }}</a>
-					{%- if 'summary' in item %}
-						&mdash; {{ item['summary'] }}
-					{%- endif -%}
-				</li>
-		{% endfor %}
-		</ul>
-	{% endfor %}
+	{{ entry.to_html() }}
 {% endfor %}
 
 {% endblock %}
diff --git a/templates/snippets/ResourceType-index-entry.html b/templates/snippets/ResourceType-index-entry.html
new file mode 100644
index 0000000..b08d658
--- /dev/null
+++ b/templates/snippets/ResourceType-index-entry.html
@@ -0,0 +1,15 @@
+{#
+#}
+<dt><a href="#">{{ item.base() }}</a></dt>
+<dd>
+	{% if item.summary %}
+		<!-- TODO docstring.text -->
+	{{ item.summary }}
+	{% endif %}
+	<dl>
+		{% for provider in item.children %}
+		{{ provider.to_html() }}
+		{% endfor %}
+	</dl>
+</dd>
+{# ft:jinja2 #}
diff --git a/templates/snippets/ResourceType-list-entry.html b/templates/snippets/ResourceType-list-entry.html
new file mode 100644
index 0000000..dedb1a0
--- /dev/null
+++ b/templates/snippets/ResourceType-list-entry.html
@@ -0,0 +1,10 @@
+{#
+#}
+<li><a href="#">{{ item.base() }}</a>
+	<ul>
+		{% for provider in item.children %}
+		{{ provider.to_html_list() }}
+		{% endfor %}
+	</ul>
+</li>
+{# ft:jinja2 #}
diff --git a/templates/tabset.html b/templates/snippets/tabset.html
similarity index 100%
rename from templates/tabset.html
rename to templates/snippets/tabset.html
-- 
GitLab