From 06e1dcf466cabb6df6c865b370d4d3d0e97ceee2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Hugo=20H=C3=B6rnquist?= <hugo@lysator.liu.se>
Date: Thu, 25 May 2023 17:26:15 +0200
Subject: [PATCH] Rework entire structure.

---
 .flake8                                 |   2 +-
 Makefile                                |   4 +-
 cache.py                                |  39 ---
 muppet/__main__.py                      | 358 ++++++++++++++++++++++++
 muppet/cache.py                         |  55 ++++
 main.py => muppet/format.py             | 163 +----------
 intersperse.py => muppet/intersperse.py |   0
 reflow.py => muppet/puppet/parser.py    |  43 +--
 muppet/puppet/strings.py                |  15 +
 muppet/tabs.py                          |  73 +++++
 highlight.css => static/highlight.css   |   0
 static/hl1.css                          |  40 +++
 static/script.js                        |   3 +
 style.css => static/style.css           |   0
 static/style2.css                       |  36 +++
 tabs.js => static/tabs.js               |   0
 templates/base.html                     |  11 +-
 templates/code_page.html                |   5 +
 templates/index.html                    |  28 ++
 templates/module_index.html             |  20 ++
 20 files changed, 678 insertions(+), 217 deletions(-)
 delete mode 100644 cache.py
 create mode 100644 muppet/__main__.py
 create mode 100644 muppet/cache.py
 rename main.py => muppet/format.py (89%)
 rename intersperse.py => muppet/intersperse.py (100%)
 rename reflow.py => muppet/puppet/parser.py (60%)
 mode change 100755 => 100644
 create mode 100644 muppet/puppet/strings.py
 create mode 100644 muppet/tabs.py
 rename highlight.css => static/highlight.css (100%)
 create mode 100644 static/hl1.css
 create mode 100644 static/script.js
 rename style.css => static/style.css (100%)
 create mode 100644 static/style2.css
 rename tabs.js => static/tabs.js (100%)
 create mode 100644 templates/code_page.html
 create mode 100644 templates/index.html
 create mode 100644 templates/module_index.html

diff --git a/.flake8 b/.flake8
index cc60e25..47be68d 100644
--- a/.flake8
+++ b/.flake8
@@ -1,5 +1,5 @@
 [flake8]
-extend-ignore = D105
+extend-ignore = D105,D107
 per-file-ignores =
 	tests/*: D103
 max-line-length = 99
diff --git a/Makefile b/Makefile
index 0327d59..64bc5e8 100644
--- a/Makefile
+++ b/Makefile
@@ -16,8 +16,8 @@ index.html: $(CACHE_DIR)/output.json *.py
 	python3 main.py $< > $@
 
 check:
-	flake8 *.py
-	mypy *.py
+	flake8 muppet
+	mypy -p muppet
 
 test:
 	python -m pytest
diff --git a/cache.py b/cache.py
deleted file mode 100644
index d46342d..0000000
--- a/cache.py
+++ /dev/null
@@ -1,39 +0,0 @@
-"""
-Run puppet parse if files have changed.
-
-Non-changed file are fetched from the cache.
-(Cache currently implemneted on redis).
-"""
-
-import hashlib
-import json
-import subprocess
-import redis
-from typing import Any
-
-r = redis.Redis(host='localhost')
-
-
-def parse_puppet_for_real(s: str) -> bytes:
-    """Parse the given puppetstring into a json representation."""
-    cmd = subprocess.run('puppet parser dump --format json'.split(' '),
-                         input=s.encode('UTF-8'),
-                         check=True,
-                         capture_output=True)
-    return cmd.stdout
-
-
-def parse_puppet(s: str) -> dict[str, Any]:
-    """Parse the given puppet string into a json representation, but use a cache."""
-    hash = hashlib.sha256(s.encode('UTF-8'))
-    digest = hash.hexdigest()
-    if record := r.get(digest):
-        data = json.loads(record)
-    else:
-        almost_data = parse_puppet_for_real(s)
-        hash = hashlib.sha256(almost_data)
-        r.set(digest, almost_data)
-        data = json.loads(almost_data)
-    if type(data) != dict:
-        raise Exception("Weird data returned from puppet parse")
-    return data
diff --git a/muppet/__main__.py b/muppet/__main__.py
new file mode 100644
index 0000000..21593dd
--- /dev/null
+++ b/muppet/__main__.py
@@ -0,0 +1,358 @@
+"""New, better, entry point."""
+
+import argparse
+import os
+import os.path
+from dataclasses import dataclass
+import hashlib
+import sys
+from jinja2 import (
+    Environment,
+    # PackageLoader,
+    FileSystemLoader,
+)
+import pathlib
+import json
+from typing import (
+    Any,
+    TypeVar,
+    Callable,
+    TypedDict,
+)
+from collections.abc import (
+    Iterable,
+    Sequence,
+)
+
+from .cache import Cache
+from .puppet.strings import puppet_strings
+from .format import format_class
+
+jinja = Environment(
+    loader=FileSystemLoader('templates'),
+    autoescape=False,
+)
+
+parser = argparse.ArgumentParser(
+        prog='puppet-doc configure',
+        description='Sets up puppet doc')
+
+parser.add_argument('--env', action='store')
+
+args = parser.parse_args()
+
+env = args.env or '/etc/puppetlabs/code/modules'
+
+cache = Cache('/home/hugo/.cache/puppet-doc')
+
+
+@dataclass
+class ModuleEntry:
+    """
+    One entry in a module.
+
+    Parameters:
+        name - local name of the module, should always be the basename
+               of path
+        path - Absolute path in the filesystem where the module can be
+               found.
+        strings_output - output of `puppet strings`.
+    """
+
+    name: str
+    path: str
+    strings_output: bytes
+
+    def file(self, path: str) -> str:
+        """Return the absolute path of a path inside the module."""
+        return os.path.join(self.path, path)
+
+
+def get_puppet_strings(path: str) -> bytes:
+    """
+    Run puppet string, but check cache first.
+
+    The cache uses the contents of metadata.json as its key,
+    so any updates without an updated metadata.json wont't be
+    detected.
+
+    Hashing the entire contents of the module was tested, but was to
+    slow.
+    """
+    try:
+        with open(os.path.join(path, 'metadata.json'), 'rb') as f:
+            data = f.read()
+            key = 'puppet-strings' + hashlib.sha1(data).hexdigest()
+            if parsed := cache.get(key):
+                result = parsed
+            else:
+                result = puppet_strings(path)
+                cache.put(key, result)
+            return 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''
+
+        # try:
+        #     with open(module.file('.git/FETCH_HEAD')) as f:
+        #         st = os.stat(f.fileno())
+        #         st.st_mtime
+        # except FileNotFoundError:
+        #     pass
+
+
+def get_modules(dir: str) -> list[ModuleEntry]:
+    """
+    Enumerate modules in directory.
+
+    The directory should be the modules subdirectory of an environment,
+    e.g. /etc/puppetlabs/code/environments/production/modules.
+    """
+    modules: list[ModuleEntry] = []
+
+    for entry in sorted(list(os.scandir(dir)), key=lambda d: d.name):
+        # TODO Logging
+        print('- entry', entry, file=sys.stderr)
+        name = entry.name
+        path = os.path.join(env, entry)
+        strings_data = get_puppet_strings(path)
+
+        modules.append(ModuleEntry(name, path, strings_data))
+
+    return modules
+
+# --------------------------------------------------
+
+
+pathlib.Path('output').mkdir(exist_ok=True)
+
+
+T = TypeVar('T')
+U = TypeVar('U')
+
+
+def group_by(proc: Callable[[T], U], seq: Sequence[T]) -> dict[U, list[T]]:
+    """
+    Group elements in seq by proc.
+
+    Return a dictionary mapping the result of proc onto lists of each
+    element which evaluated to that key.
+    """
+    d: dict[U, list[T]] = {}
+    for item in seq:
+        key = proc(item)
+        d[key] = (d.get(key) or []) + [item]
+    return d
+
+
+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
+
+
+def setup_index(base: str) -> None:
+    """Create the main index.html file."""
+    template = jinja.get_template('index.html')
+    with open(os.path.join(base, 'index.html'), 'w') as f:
+        f.write(template.render(modules=modules))
+
+
+class IndexItem(TypedDict):
+    """A single list entry in a module index page."""
+
+    name: str
+    file: str
+
+
+class IndexSubcategory(TypedDict):
+    """A subheading on an index page."""
+
+    title: str
+    list: Iterable[IndexItem]
+
+
+class IndexCategory(TypedDict):
+    """A top heading on an index page."""
+
+    title: str
+    list: Iterable[IndexSubcategory]
+
+
+def class_index(class_list: list) -> 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': ({'name': i['name'],
+                      'file': os.path.splitext(i['file'])[0]}
+                     for i in publics),
+        })
+
+    if privates := groups.get(True):
+        lst.append({
+            'title': 'Private Classes',
+            'list': ({'name': i['name'],
+                      'file': os.path.splitext(i['file'])[0]}
+                     for i in privates),
+        })
+
+    return {
+        'title': 'Classes',
+        'list': lst
+    }
+
+
+def defined_types_index(defined_list: list) -> IndexCategory:
+    """
+    Prepare defined types index list.
+
+    These are puppet types introduces by puppet code.
+    Each only has one implemenattion.
+    """
+    groups = group_by(isprivate, defined_list)
+
+    lst: list[IndexSubcategory] = []
+
+    if publics := groups.get(False):
+        lst.append({
+            'title': 'Public Defined Types',
+            'list': ({'name': i['name'],
+                      'file': os.path.splitext(i['file'])[0]}
+                     for i in publics),
+        })
+
+    if privates := groups.get(True):
+        lst.append({
+            'title': 'Private Defined Types',
+            'list': ({'name': i['name'],
+                      'file': os.path.splitext(i['file'])[0]}
+                     for i in privates),
+        })
+
+    return {
+        'title': 'Defined Types',
+        'list': lst
+    }
+
+
+# def type_aliases_index(alias_list: list) -> IndexCategory:
+#     """Prepare type alias index list."""
+#     # TODO
+#
+#
+# def resource_types_index(resource_list: list) -> IndexCategory:
+#     """
+#     Prepare resource type index list.
+#
+#     These are the resource types introduced through ruby. Each can
+#     have multiple implementations.
+#     """
+#     return {}
+
+
+def setup_module_index(base: str, module: ModuleEntry, data: dict[str, Any]) -> 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']
+
+    data['data_type_aliases']
+
+    # content.append({
+    #     'title': 'Type Aliases',
+    #     'list': [
+    #         ]
+    # })
+
+    content.append(defined_types_index(data['defined_types']))
+
+    data['resource_types']
+    data['providers']
+    data['puppet_functions']
+    data['puppet_tasks']
+    data['puppet_plans']
+
+    with open(os.path.join(base, 'index.html'), 'w') as f:
+        f.write(template.render(module_name=module.name,
+                                content=content))
+
+
+def setup_module(base: str, module: ModuleEntry) -> None:
+    """
+    Create all output files for a puppet module.
+
+    Will generate a directory under base for the module.
+    """
+    path = os.path.join(base, module.name)
+    pathlib.Path(path).mkdir(exist_ok=True)
+    if not module.strings_output:
+        return
+    data = json.loads(module.strings_output)
+
+    setup_module_index(path, module, data)
+
+    for puppet_class in data['puppet_classes'] + data['defined_types']:
+        # localpath = puppet_class['name'].split('::')
+        localpath, _ = os.path.splitext(puppet_class['file'])
+        # localdir, _ = os.path.splitext(puppet_class['name'])
+        dir = os.path.join(path, localpath)
+        pathlib.Path(dir).mkdir(parents=True, exist_ok=True)
+        # puppet_class['docstring']
+        # puppet_class['defaults']
+
+        with open(os.path.join(dir, 'source.pp'), 'w') as f:
+            f.write(puppet_class['source'])
+
+        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))
+
+        with open(os.path.join(dir, 'index.html'), 'w') as f:
+            template = jinja.get_template('code_page.html')
+            f.write(template.render(content=format_class(puppet_class)))
+
+        # puppet_class['file']
+        # puppet_class['line']
+
+    for type_alias in data['data_type_aliases']:
+        ...
+
+    os.system("cp -r static output")
+
+    # data['data_type_aliases']
+    # data['defined_types']
+    # data['resource_types']
+
+
+modules = get_modules(env)
+
+setup_index('output')
+for module in modules:
+    # print(module)
+    setup_module('output', module)
diff --git a/muppet/cache.py b/muppet/cache.py
new file mode 100644
index 0000000..a9279d1
--- /dev/null
+++ b/muppet/cache.py
@@ -0,0 +1,55 @@
+"""A simple cache."""
+
+from typing import (
+    Callable,
+    Optional,
+)
+import os.path
+import pathlib
+import hashlib
+
+
+class Cache:
+    """
+    A simple cache.
+
+    This implementation is file system-backed, but its interface
+    should allow any backend.
+    """
+
+    def __init__(self, path: str, *, mkdir: bool = True):
+        self.base = path
+        if mkdir:
+            pathlib.Path(path).mkdir(parents=True, exist_ok=True)
+
+    def put(self, key: str, value: bytes) -> None:
+        """Put content into cache."""
+        with open(os.path.join(self.base, key), 'wb') as f:
+            f.write(value)
+
+    def get(self, key: str) -> Optional[bytes]:
+        """Get item from cache if it exists, or Nothing otherwise."""
+        try:
+            with open(os.path.join(self.base, key), 'rb') as f:
+                return f.read()
+        except FileNotFoundError:
+            return None
+
+    def memoize_function(self,
+                         prefix: str,
+                         func: Callable[[bytes], bytes]) -> Callable[[bytes], bytes]:
+        """Return a new function identical to the one given, but memoized."""
+        def inner(data: bytes) -> bytes:
+            key = prefix + hashlib.sha1(data).hexdigest()
+            if value := self.get(key):
+                return value
+            else:
+                value = func(data)
+                self.put(key, value)
+                return value
+        return inner
+
+    def memoize(self, prefix: str) -> Callable[[Callable[[bytes], bytes]],
+                                               Callable[[bytes], bytes]]:
+        """memoize_function, but as a decorator."""
+        return lambda func: self.memoize_function(prefix, func)
diff --git a/main.py b/muppet/format.py
similarity index 89%
rename from main.py
rename to muppet/format.py
index 942db73..1b190b2 100644
--- a/main.py
+++ b/muppet/format.py
@@ -6,12 +6,9 @@ provided as the first element. This program goes through every
 definition in it, and outputs a complete index.html.
 """
 
-from cache import parse_puppet
 from commonmark import commonmark
-from reflow import traverse
 from subprocess import CalledProcessError
 import html
-import json
 import sys
 from typing import (
     Any,
@@ -22,13 +19,11 @@ from typing import (
 )
 from collections.abc import Sequence
 from dataclasses import dataclass
-from jinja2 import Environment, PackageLoader
-from intersperse import intersperse
 
-env = Environment(
-    loader=PackageLoader('main'),
-    autoescape=False,
-)
+from .puppet.parser import puppet_parser
+from .intersperse import intersperse
+
+parse_puppet = puppet_parser
 
 HashEntry: TypeAlias = Union[Tuple[Literal['=>'], str, Any],
                              Tuple[Literal['+>'], str, Any],
@@ -36,17 +31,6 @@ HashEntry: TypeAlias = Union[Tuple[Literal['=>'], str, Any],
 
 Context: TypeAlias = list['str']
 
-match sys.argv:
-    case [_, d, *_]:
-        filename = d
-    case [_]:
-        filename = 'output.json'
-
-with open(filename) as f:
-    info = json.load(f)
-
-data = info
-
 param_doc: dict[str, str] = {}
 
 
@@ -167,7 +151,6 @@ symbols: dict[str, str] = {
 }
 
 
-
 def handle_case_body(forms: list[dict[str, Any]],
                      indent: int, context: Context) -> Tag:
     """Handle case body when parsing AST."""
@@ -1038,65 +1021,6 @@ def print_docstring(name: str, docstring: dict[str, Any]) -> str:
     return out
 
 
-@dataclass
-class Tab:
-    """
-    A single tab part of a tab group.
-
-    :parameters:
-        title - Display name of the tab
-        id - Internal reference ID, must be globaly unique
-        content - contents of the tab
-    """
-
-    title: str
-    id: str
-    content: str
-
-
-@dataclass
-class TabGroup:
-    """A collection of tabs."""
-
-    id: str
-    tabs: list[Tab]
-
-
-def tab_widget(tabgroup: TabGroup) -> str:
-    """
-    Render a HTML tab widget.
-
-    The argument is the list of tabs, nothing is returned, but instead
-    written to stdout.
-    """
-    template = env.get_template('tabset.html')
-    return template.render(tabset=tabgroup)
-
-
-__counter = 0
-
-
-def next_id(prefix: str = 'id') -> str:
-    """Return a new unique id."""
-    global __counter
-    __counter += 1
-    return f'{prefix}-{__counter}'
-
-
-def tabs(panes: dict[str, str]) -> str:
-    """
-    Build a tab widget from given dictionary.
-
-    Keys are used as tab names, values as tab content.
-    Id's are generated.
-    """
-    tabs = []
-    for title, content in panes.items():
-        tabs.append(Tab(title=title, content=content, id=next_id('tab')))
-
-    return tab_widget(TabGroup(id=next_id('tabgroup'), tabs=tabs))
-
-
 def format_class(d_type: dict[str, Any]) -> str:
     """Format Puppet class."""
     out = ''
@@ -1105,8 +1029,7 @@ def format_class(d_type: dict[str, Any]) -> str:
     print_docstring(name, d_type['docstring'])
 
     out += '<pre><code class="puppet">'
-    tree = parse_puppet(d_type['source'])
-    t = traverse(tree)
+    t = parse_puppet(d_type['source'])
     out += str(parse(t, 0, ['root']))
     out += '</code></pre>'
     return out
@@ -1125,8 +1048,7 @@ def format_type_alias(d_type: dict[str, Any]) -> str:
     out += print_docstring(name, d_type['docstring'])
     out += '\n'
     out += '<pre><code class="puppet">'
-    tree = parse_puppet(d_type['alias_of'])
-    t = traverse(tree)
+    t = parse_puppet(d_type['alias_of'])
     out += str(parse(t, 0, ['root']))
     out += '</code></pre>\n'
     return out
@@ -1140,8 +1062,7 @@ def format_defined_type(d_type: dict[str, Any]) -> str:
     out += print_docstring(name, d_type['docstring'])
 
     out += '<pre><code class="puppet">'
-    tree = parse_puppet(d_type['source'])
-    t = traverse(tree)
+    t = parse_puppet(d_type['source'])
     out += str(parse(t, 0, ['root']))
     out += '</code></pre>\n'
     return out
@@ -1193,8 +1114,7 @@ def format_puppet_function(function: dict[str, Any]) -> str:
     elif t == 'puppet':
         out += '<pre><code class="puppet">'
         try:
-            tree = parse_puppet(function['source'])
-            t = traverse(tree)
+            t = parse_puppet(function['source'])
             out += str(parse(t, 0, ['root']))
         except CalledProcessError as e:
             print(e, file=sys.stderr)
@@ -1213,70 +1133,3 @@ def format_puppet_task() -> str:
 def format_puppet_plan() -> str:
     """Format Puppet plan."""
     return 'TODO format_puppet_plan not implemented'
-
-
-def main() -> None:
-    """Entry point of program."""
-    out = ''
-
-    out += '<h1>Puppet Classes</h1>'
-    for d_type in data['puppet_classes']:
-        out += f'<h2>{d_type["name"]}</h2>'
-        out += tabs({
-            'Formatted': format_class(d_type),
-            'JSON': '<pre><code class="json">' + json.dumps(d_type, indent=2) + '</code></pre>',
-            'Source': '<pre><code class="puppet">' + d_type['source'] + '</code></pre>',
-        })
-        out += '<hr/>'
-
-    out += '<h1>Data Types</h1>'
-
-# TODO
-
-    out += '<h1>Data Type Aliases</h1>'
-    for d_type in data['data_type_aliases']:
-        out += tabs({
-            'Formatted': format_type_alias(d_type),
-            'JSON': '<pre><code class="json">' + json.dumps(d_type, indent=2) + '</code></pre>',
-        })
-
-        out += '<hr/>'
-
-    out += '<h1>Defined Types</h1>'
-    for d_type in data['defined_types']:
-        out += tabs({
-            'Formatted': format_defined_type(d_type),
-            'JSON': '<pre><code class="json">' + json.dumps(d_type, indent=2) + '</code></pre>',
-            'Source': '<pre><code class="puppet">' + d_type['source'] + '</code></pre>',
-        })
-
-        out += '<hr/>'
-
-    out += '<h1>Resource Types</h1>'
-    for r_type in data['resource_types']:
-        out += tabs({
-            'Formatted': format_resource_type(r_type),
-            'JSON': '<pre><code class="json">' + json.dumps(r_type, indent=2) + '</code></pre>',
-        })
-
-    out += '<h1>Puppet Functions</h1>'
-    for function in data['puppet_functions']:
-        out += tabs({
-            'Formatted': format_puppet_function(function),
-            'JSON': '<pre><code class="json">' + json.dumps(function, indent=2) + '</code></pre>',
-            'Source': '<pre><code class="puppet">' + function['source'] + '</code></pre>',
-        })
-
-    out += '<h1>Puppet Tasks</h1>'
-# TODO
-    out += '<h1>Puppet Plans</h1>'
-# TODO
-
-    template = env.get_template('base.html')
-    print(template.render(content=out))
-
-# TODO apache::*
-
-
-if __name__ == '__main__':
-    main()
diff --git a/intersperse.py b/muppet/intersperse.py
similarity index 100%
rename from intersperse.py
rename to muppet/intersperse.py
diff --git a/reflow.py b/muppet/puppet/parser.py
old mode 100755
new mode 100644
similarity index 60%
rename from reflow.py
rename to muppet/puppet/parser.py
index 2d39f23..d446743
--- a/reflow.py
+++ b/muppet/puppet/parser.py
@@ -1,16 +1,19 @@
-#!/usr/bin/env python3
-
 """
-Reword the output of puppet parser to something managable.
-
-Traverse takes a tree as returned by
-`puppet parser dump --format json` (as a python object).
+A Python wrapper around `puppet parser dump`.
 
-It returns a new python object representing the same data, but in a
-more managable format.
+puppet_parser_raw simply runs `puppet parser dump --format json` on
+the given output. `puppet_parser` also reflows the output into
+something managable.
 """
 
+import subprocess
+import json
+
 from typing import Any
+from ..cache import Cache
+
+
+cache = Cache('/home/hugo/.cache/puppet-doc')
 
 
 def tagged_list_to_dict(lst: list[Any]) -> dict[Any, Any]:
@@ -61,13 +64,21 @@ def traverse(tree: Any) -> Any:
         return tree
 
 
-def __main() -> None:
-    import json
-    import sys
-
-    json.dump(traverse(json.load(sys.stdin)), sys.stdout)
-    print()
+@cache.memoize('puppet-parser')
+def puppet_parser_raw(code: bytes) -> bytes:
+    """Parse the given puppetstring into a json representation."""
+    cmd = subprocess.run('puppet parser dump --format json'.split(' '),
+                         input=code,
+                         check=True,
+                         capture_output=True)
+    return cmd.stdout
 
 
-if __name__ == '__main__':
-    __main()
+def puppet_parser(code: str) -> list:
+    """Parse the given puppet string, and reflow it."""
+    data = traverse(json.loads(puppet_parser_raw(code.encode('UTF-8'))))
+    # TODO log output here?
+    if isinstance(data, list):
+        return data
+    else:
+        raise ValueError('Expected well formed tree, got %s', data)
diff --git a/muppet/puppet/strings.py b/muppet/puppet/strings.py
new file mode 100644
index 0000000..f308985
--- /dev/null
+++ b/muppet/puppet/strings.py
@@ -0,0 +1,15 @@
+"""Python wrapper around puppet strings."""
+
+import subprocess
+
+
+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
diff --git a/muppet/tabs.py b/muppet/tabs.py
new file mode 100644
index 0000000..702a491
--- /dev/null
+++ b/muppet/tabs.py
@@ -0,0 +1,73 @@
+"""Thingies for an HTML tab widget."""
+
+from dataclasses import dataclass
+from jinja2 import (
+    Environment,
+    # PackageLoader,
+    FileSystemLoader,
+)
+
+
+env = Environment(
+    loader=FileSystemLoader('templates'),
+    autoescape=False,
+)
+
+
+@dataclass
+class Tab:
+    """
+    A single tab part of a tab group.
+
+    :parameters:
+        title - Display name of the tab
+        id - Internal reference ID, must be globaly unique
+        content - contents of the tab
+    """
+
+    title: str
+    id: str
+    content: str
+
+
+@dataclass
+class TabGroup:
+    """A collection of tabs."""
+
+    id: str
+    tabs: list[Tab]
+
+
+def tab_widget(tabgroup: TabGroup) -> str:
+    """
+    Render a HTML tab widget.
+
+    The argument is the list of tabs, nothing is returned, but instead
+    written to stdout.
+    """
+    template = env.get_template('tabset.html')
+    return template.render(tabset=tabgroup)
+
+
+__counter = 0
+
+
+def next_id(prefix: str = 'id') -> str:
+    """Return a new unique id."""
+    global __counter
+    __counter += 1
+    return f'{prefix}-{__counter}'
+
+
+def tabs(panes: dict[str, str]) -> str:
+    """
+    Build a tab widget from given dictionary.
+
+    Keys are used as tab names, values as tab content.
+    Id's are generated.
+    """
+    tabs = []
+    for title, content in panes.items():
+        tabs.append(Tab(title=title, content=content, id=next_id('tab')))
+
+    return tab_widget(TabGroup(id=next_id('tabgroup'), tabs=tabs))
diff --git a/highlight.css b/static/highlight.css
similarity index 100%
rename from highlight.css
rename to static/highlight.css
diff --git a/static/hl1.css b/static/hl1.css
new file mode 100644
index 0000000..29b8111
--- /dev/null
+++ b/static/hl1.css
@@ -0,0 +1,40 @@
+.qn {
+	color: green;
+}
+
+.var {
+	color: blue;
+}
+
+.define {
+	color: orange;
+}
+
+.name {
+	color: red;
+}
+
+.string {
+	color: olive;
+}
+
+.str-var {
+	color: pink;
+}
+
+.qr {
+	color: darkgreen;
+}
+
+.compound-type {
+	color: lightblue;
+}
+
+.undef {
+	color: lightgray;
+}
+
+.number {
+	color: red;
+}
+
diff --git a/static/script.js b/static/script.js
new file mode 100644
index 0000000..a81098c
--- /dev/null
+++ b/static/script.js
@@ -0,0 +1,3 @@
+document.addEventListener('load', function () {
+	let tab_labels = document.querySelectorAll('[data-tab]')
+});
diff --git a/style.css b/static/style.css
similarity index 100%
rename from style.css
rename to static/style.css
diff --git a/static/style2.css b/static/style2.css
new file mode 100644
index 0000000..163bfdc
--- /dev/null
+++ b/static/style2.css
@@ -0,0 +1,36 @@
+/* Style for tabgroups */
+.tabs {
+	display: grid;
+	grid-template-columns: 1fr;
+	grid-template-rows: auto 1fr;
+	border: 1px solid red;
+}
+
+.tabs menu {
+	display: flex;
+	flex-direction: row;
+	padding: 0;
+	margin: 0;
+}
+
+.tabs menu li {
+	display: block;
+}
+
+
+/*
+.tabs menu li {
+	display: block;
+	background-color: lightgray;
+	border: 1px solid gray;
+}
+*/
+
+.tab {
+	display: none;
+	border: 1px solid green;
+}
+
+.tab.selected {
+	display: block !important;
+}
diff --git a/tabs.js b/static/tabs.js
similarity index 100%
rename from tabs.js
rename to static/tabs.js
diff --git a/templates/base.html b/templates/base.html
index 3ee0ef5..e29d88e 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -3,9 +3,9 @@
 	<head>
 		<meta charset="UTF-8">
 		<meta name="viewport" content="width=device-width, initial-scale=1.0">
-		<link type="text/css" rel="stylesheet" href="style.css"/>
-		<link type="text/css" rel="stylesheet" href="highlight.css"/>
-		<link type="text/css" rel="stylesheet" href="style2.css"/>
+		<link type="text/css" rel="stylesheet" href="/static/style.css"/>
+		<link type="text/css" rel="stylesheet" href="/static/highlight.css"/>
+		<link type="text/css" rel="stylesheet" href="/static/style2.css"/>
 		<script src="tabs.js"></script>
 		<noscript>
 			<style>
@@ -19,6 +19,9 @@
 			</style>
 		</noscript>
 	</head>
-	<body>{{ content }}</body>
+	<body>
+		{% block content %}
+		{% endblock %}
+	</body>
 </html>
 {# ft: jinja #}
diff --git a/templates/code_page.html b/templates/code_page.html
new file mode 100644
index 0000000..1bf0fc1
--- /dev/null
+++ b/templates/code_page.html
@@ -0,0 +1,5 @@
+{% extends "base.html" %}
+{% block content %}
+{{ content }}
+{% endblock %}
+{# ft: jinja #}
diff --git a/templates/index.html b/templates/index.html
new file mode 100644
index 0000000..693c6f3
--- /dev/null
+++ b/templates/index.html
@@ -0,0 +1,28 @@
+<!doctype html>
+<html>
+<head>
+	<meta charset="UTF-8"/>
+	<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
+	<title></title>
+	<!--
+	<link type="text/css" rel="stylesheet" href="style.css"/>
+	-->
+<style>
+.error {
+	background-color: red;
+}
+</style>
+</head>
+<body>
+	<ul>
+		{% for module in modules %}
+		<li>
+			<a href="/{{ module.name }}"
+			   class="{{ 'error' if module.strings_output == None }}"
+			   >{{ module.name }}</a>
+		</li>
+		{% endfor %}
+	</ul>
+</body>
+</html>
+
diff --git a/templates/module_index.html b/templates/module_index.html
new file mode 100644
index 0000000..41119ee
--- /dev/null
+++ b/templates/module_index.html
@@ -0,0 +1,20 @@
+{% extends "base.html" %}
+{% block content %}
+<h1>{{ module_name }}</h1>
+
+{% for entry in content %}
+	<h2>{{ entry['title'] }}</h2>
+	{% for subentry in entry['list'] %}
+		<h3>{{ subentry['title'] }}</h3>
+		<ul>
+			{% for item in subentry['list'] %}
+				<li>
+					<a href="{{ item['file'] }}">{{ item['name'] }}</a>
+				</li>
+		{% endfor %}
+		</ul>
+	{% endfor %}
+{% endfor %}
+
+{% endblock %}
+{# ft: jinja #}
-- 
GitLab