Repository: d0c-s4vage/lookatme
Pull request #10 (2019-11-29 07:23:37)

Adds support for contrib extensions
See the examples/ directory for a calendar contrib extension


Patch:
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..07ab65f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+# editors
+.*.sw[mnop]
+
+# python
+*.pyc
+__pycache__
+.mypy_cache
+.tox
+venv*
+.coverage
+*.egg-info
diff --git a/.travis-ci.yml b/.travis-ci.yml
new file mode 100644
index 0000000..e69de29
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..e69de29
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..e69de29
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..d0cc15b
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,6 @@
+recursive-include tests *.py
+include README.*
+include LICENSE
+include LICENSE*
+include setup.*
+include requirements.txt
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3dde1b3
--- /dev/null
+++ b/README.md
@@ -0,0 +1,10 @@
+# `lookatme`
+
+`lookatme` is an interactive, extensible, terminal-based markdown presentation
+tool.
+
+## Features
+
+* Full support for Markdown syntax including
+* Live terminals embedded directly in slides
+* Syntax highlighting using [Pygments](https://pygments.org/)
diff --git a/examples/calendar_contrib/README.md b/examples/calendar_contrib/README.md
new file mode 100644
index 0000000..d55b723
--- /dev/null
+++ b/examples/calendar_contrib/README.md
@@ -0,0 +1,50 @@
+# Sample Calendar Contrib
+
+This is a sample lookatmecontrib module that overrides code blocks when the
+language is `calendar` to display a text calendar of the current month.
+
+## Example Usage
+
+With this directory on your `PYTHONPATH`, list `calendar` as an extension in
+your slide deck's YAML header:
+
+~~~markdown
+---
+title: An Awesome Presentation
+author: James Johnson
+date: 2019-11-19
+extensions:
+  - calendar
+styles:
+  style: monokai
+  table:
+    column_spacing: 10
+---
+~~~
+
+With the extension available and declared, you can now use `calendar` code
+blocks to display a calendar of the current month on a slide:
+
+~~~markdown
+```calendar
+
+```
+~~~
+
+Full command-line, assuming it is run from the root of the lookatme project:
+
+```
+PYTHONPATH="./examples/calendar_contrib:$PYTHONPATH" python -m lookatme examples/calendar_contrib/example.md
+```
+
+## Future Options
+
+If one desired to build more on top of this toy calendar extension, additional
+options could be entered as yaml within the code block:
+
+```calendar
+from: 2019-05-01
+to: 2019-11-01
+```
+
+**NOTE** These are not implemented in this toy extension example.
diff --git a/examples/calendar_contrib/example.md b/examples/calendar_contrib/example.md
new file mode 100644
index 0000000..737cbd3
--- /dev/null
+++ b/examples/calendar_contrib/example.md
@@ -0,0 +1,11 @@
+---
+title: An Awesome Presentation
+author: James Johnson
+date: 2019-11-19
+extensions:
+  - calendar
+---
+
+```calendar
+
+```
diff --git a/examples/calendar_contrib/lookatmecontrib/__init__.py b/examples/calendar_contrib/lookatmecontrib/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/examples/calendar_contrib/lookatmecontrib/calendar.py b/examples/calendar_contrib/lookatmecontrib/calendar.py
new file mode 100644
index 0000000..daaba41
--- /dev/null
+++ b/examples/calendar_contrib/lookatmecontrib/calendar.py
@@ -0,0 +1,21 @@
+"""
+Defines a calendar extension that overrides code block rendering if the
+language type is calendar
+"""
+
+
+import datetime
+import calendar
+import urwid
+
+
+from lookatme.exceptions import IgnoredByContrib
+
+
+def render_code(token, body, stack, loop):
+    lang = token["lang"] or ""
+    if lang != "calendar":
+        raise IgnoredByContrib()
+    
+    today = datetime.datetime.utcnow()
+    return urwid.Text(calendar.month(today.year, today.month))
diff --git a/examples/tour.md b/examples/tour.md
new file mode 100644
index 0000000..393474d
--- /dev/null
+++ b/examples/tour.md
@@ -0,0 +1,91 @@
+# Markdown Support: Inline
+
+* Styling
+    * *italic*
+    * **bold**
+    * ***bold underline***
+* Inline `code`
+
+---
+
+# Markdown Support: Code Blocks
+
+Code blocks with language syntax highlighting
+
+~~~python
+def a_function(arg1, arg2):
+    """This is a function
+    """
+    print(arg1)
+~~~
+
+---
+
+# Markdown Support: Lists
+
+* Top level
+    * Level 2
+        * Level 3
+            * Level 4
+    * Level 2
+        * Level 3
+            * Level 4
+    * Level 2
+        * Level 3
+            * Level 4
+
+
+---
+
+# Embeddable Terminals
+
+Terminals can be embedded directly into slides!
+
+The markdown below:
+
+~~~md
+```terminal8
+bash -il
+```
+~~~
+
+becomes
+
+```terminal8
+bash -il
+```
+
+---
+
+# Embeddable Terminals: Docker containers
+
+Want to drop directly into a docker container for a clean environment
+in the middle of a slide?
+
+~~~md
+```terminal8
+docker run --rm -it ubuntu:18.04
+```
+~~~
+
+```terminal8
+docker run --rm -it ubuntu:18.04
+```
+
+---
+
+# Embeddable Terminals: Asciinema Replays
+
+Asciinema is an awesome tool for recording and sharing terminal commands.
+If you have asciinema installed (`pip install asciinema`), play back a
+pre-recorded shell session inside of a slide!
+
+~~~md
+```terminal8
+asciinema play https://asciinema.org/a/232377
+```
+~~~
+
+```terminal40
+asciinema play https://asciinema.org/a/232377
+```
diff --git a/lookatme/__init__.py b/lookatme/__init__.py
new file mode 100644
index 0000000..55fa636
--- /dev/null
+++ b/lookatme/__init__.py
@@ -0,0 +1,6 @@
+"""
+This 
+"""
+
+
+VERSION = __version__ = "{{VERSION}}"
diff --git a/lookatme/__main__.py b/lookatme/__main__.py
new file mode 100644
index 0000000..f412ee6
--- /dev/null
+++ b/lookatme/__main__.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+
+"""
+This is the main CLI for lookatme
+"""
+
+
+import click
+import logging
+import os
+import pygments.styles
+import sys
+
+
+from lookatme.parser import Parser
+import lookatme.tui
+import lookatme.log
+import lookatme.config
+import lookatme.themes
+from lookatme.schemas import StyleSchema
+from lookatme.utils import dict_deep_update
+
+
+@click.command("lookatme")
+@click.option("--debug", "debug", is_flag="True", default=False)
+@click.option(
+    "-l",
+    "--log",
+    "log_path",
+    type=click.Path(writable=True),
+    default="/tmp/lookatme.log",
+)
+@click.option(
+    "-t",
+    "--theme",
+    "theme",
+    type=click.Choice(["dark", "light"]),
+    default="dark",
+)
+@click.option(
+    "-s",
+    "--style",
+    "code_style",
+    default=None,
+    type=click.Choice(list(pygments.styles.get_all_styles())),
+)
+@click.option(
+    "--dump-styles",
+    help="Dump the resolved styles that will be used with the presentation to stdout",
+    is_flag=True,
+    default=False,
+)
+@click.argument("input_file", type=click.File(), default=sys.stdin)
+def main(debug, log_path, theme, code_style, dump_styles, input_file):
+    """lookatme - An interactive, terminal-based markdown presentation tool.
+    """
+    if debug:
+        lookatme.config.LOG = lookatme.log.create_log(log_path)
+    else:
+        lookatme.config.LOG = lookatme.log.create_null_log()
+
+    theme_mod = __import__("lookatme.themes." + theme, fromlist=[theme])
+    styles = lookatme.themes.ensure_defaults(theme_mod)
+
+    data = input_file.read()
+    parser = Parser()
+    pres = parser.parse(data)
+
+    lookatme.contrib.load_contribs(pres.meta.extensions)
+
+    if pres.meta.styles is not None:
+        dict_deep_update(styles, pres.meta.styles)
+
+    # now apply any command-line style overrides
+    if code_style is not None:
+        styles["style"] = code_style
+
+    if dump_styles:
+        print(StyleSchema().dumps(styles))
+        return 0
+
+    lookatme.config.STYLE = styles
+
+    lookatme.tui.run_presentation(pres)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/lookatme/config.py b/lookatme/config.py
new file mode 100644
index 0000000..1f9c364
--- /dev/null
+++ b/lookatme/config.py
@@ -0,0 +1,10 @@
+"""
+Config module for lookatme
+"""
+
+
+import lookatme.log
+
+
+LOG = None
+STYLE = None
diff --git a/lookatme/contrib/__init__.py b/lookatme/contrib/__init__.py
new file mode 100644
index 0000000..81ed3ce
--- /dev/null
+++ b/lookatme/contrib/__init__.py
@@ -0,0 +1,75 @@
+"""
+This module handles loading and using lookatme_contriba modules
+
+Contrib modules are directly used 
+"""
+
+
+import contextlib
+
+
+from lookatme.exceptions import IgnoredByContrib
+from . import terminal
+
+
+CONTRIB_MODULES = [
+    terminal,
+]
+
+
+def load_contribs(contrib_names):
+    """Load all contrib modules specified by ``contrib_names``. These should
+    all be namespaced packages under the ``lookatmecontrib`` namespace. E.g.
+    ``lookatmecontrib.calendar`` would be an extension provided by a
+    contrib module, and would be added to an ``extensions`` list in a slide's
+    YAML header as ``calendar``.
+    """
+    if contrib_names is None:
+        return
+
+    errors = []
+    for contrib_name in contrib_names:
+        module_name = f"lookatmecontrib.{contrib_name}"
+        try:
+            mod = __import__(module_name, fromlist=[contrib_name])
+            CONTRIB_MODULES.append(mod)
+        except Exception as e:
+            errors.append(str(e))
+
+    if len(errors) > 0:
+        raise Exception(
+            "Error loading one or more extensions:\n\n" + "\n".join(errors),
+        )
+
+
+def contrib_first(fn):
+    """A decorator that allows contrib modules to override default behavior
+    of lookatme. E.g., a contrib module may override how a table is displayed
+    to enable sorting, or enable displaying images rendered with ANSII color
+    codes and box drawing characters, etc.
+
+    Contrib modules may ignore chances to override default behavior by raising
+    the ``lookatme.contrib.IgnoredByContrib`` exception.
+    """
+    fn_name = fn.__name__
+
+    @contextlib.wraps(fn)
+    def inner(*args, **kwargs):
+        for mod in CONTRIB_MODULES:
+            if not hasattr(mod, fn_name):
+                continue
+            try:
+                return getattr(mod, fn_name)(*args, **kwargs)
+            except IgnoredByContrib:
+                pass
+
+        return fn(*args, **kwargs)
+
+    return inner
+
+
+def shutdown_contribs():
+    """Call the shutdown function on all contrib modules
+    """
+    for mod in CONTRIB_MODULES:
+        getattr(mod, "shutdown", lambda: 1)()
diff --git a/lookatme/contrib/terminal.py b/lookatme/contrib/terminal.py
new file mode 100644
index 0000000..846231c
--- /dev/null
+++ b/lookatme/contrib/terminal.py
@@ -0,0 +1,44 @@
+"""
+This module defines a built-in contrib module that enables terminal embedding
+within a slide.
+"""
+
+
+import shlex
+import urwid
+
+
+from lookatme.exceptions import IgnoredByContrib
+
+
+CREATED_TERMS = []
+
+
+def render_code(token, body, stack, loop):
+    lang = token["lang"] or ""
+    if not lang.startswith("terminal"):
+        raise IgnoredByContrib
+
+    rows = lang.replace("terminal", "").strip()
+    if len(rows) != 0:
+        rows = int(rows)
+
+    term = urwid.Terminal(
+        shlex.split(token["text"].strip()),
+        main_loop=loop,
+        encoding="utf8",
+    )
+    CREATED_TERMS.append(term)
+
+    res = urwid.LineBox(urwid.BoxAdapter(term, height=rows))
+
+    return [
+        urwid.Divider(),
+        res,
+        urwid.Divider(),
+    ]
+
+
+def shutdown():
+    for term in CREATED_TERMS:
+        term.terminate()
diff --git a/lookatme/exceptions.py b/lookatme/exceptions.py
new file mode 100644
index 0000000..2597910
--- /dev/null
+++ b/lookatme/exceptions.py
@@ -0,0 +1,9 @@
+"""
+Exceptions used within lookatme
+"""
+
+class IgnoredByContrib(Exception):
+    """Raised when a contrib module's function chooses to ignore the function
+    call.
+    """
+    pass
diff --git a/lookatme/log.py b/lookatme/log.py
new file mode 100644
index 0000000..7e3ddb4
--- /dev/null
+++ b/lookatme/log.py
@@ -0,0 +1,20 @@
+"""
+Logging module
+"""
+
+
+import logging
+
+
+def create_log(log_path):
+    """Create a new log that writes to log_path
+    """
+    logging.basicConfig(filename=log_path, level=logging.DEBUG)
+    return logging.getLogger("lookatme")
+
+
+def create_null_log():
+    """Create a logging object that does nothing
+    """
+    logging.basicConfig(handlers=[logging.NullHandler()])
+    return logging.getLogger("lookatme")
diff --git a/lookatme/parser.py b/lookatme/parser.py
new file mode 100644
index 0000000..3d5bb47
--- /dev/null
+++ b/lookatme/parser.py
@@ -0,0 +1,102 @@
+"""
+This module defines the parser for the markdown presentation file
+"""
+
+
+from marshmallow import fields, Schema
+import mistune
+import re
+import yaml
+
+
+from lookatme.pres import PresentationMeta, Presentation, Slide
+from lookatme.schemas import MetaSchema
+
+
+class Parser(object):
+    """A parser for markdown presentation files
+    """
+
+    def __init__(self):
+        """Create a new Parser instance
+        """
+
+    def parse(self, input_data):
+        """Parse the provided input data into a Presentation object
+
+        :param str input_data: The input markdown presentation to parse
+        :returns: Presentation
+        """
+        input_data, meta = self.parse_meta(input_data)
+        input_data, slides = self.parse_slides(input_data)
+        return Presentation(meta, slides)
+    
+    def parse_slides(self, input_data):
+        """Parse the Slide out of the input data
+
+        :param str input_data: The input data string
+        :returns: tuple of (remaining_data, slide)
+        """
+        # slides are delimited by ---
+        md = mistune.Markdown()
+
+        state = {}
+        tokens = md.block.parse(input_data, state)
+
+        slides = []
+        curr_slide_tokens = []
+        for token in tokens:
+            # new slide!
+            if token["type"] == "hrule":
+                slide = Slide(curr_slide_tokens, md, len(slides))
+                slides.append(slide)
+                curr_slide_tokens = []
+                continue
+            else:
+                curr_slide_tokens.append(token)
+
+        slides.append(Slide(curr_slide_tokens, md, len(slides)))
+
+        return "", slides
+    
+    def parse_meta(self, input_data):
+        """Parse the PresentationMeta out of the input data
+
+        :param str input_data: The input data string
+        :returns: tuple of (remaining_data, meta)
+        """
+        found_first = False
+        yaml_data = []
+        skipped_chars = 0
+        for line in input_data.split("\n"):
+            skipped_chars += len(line) + 1
+            stripped_line = line.strip()
+
+            is_marker = (re.match(r'----*', stripped_line) is not None)
+            if is_marker:
+                if not found_first:
+                    found_first = True
+                # found the second one
+                else:
+                    break
+
+            if found_first and not is_marker:
+                yaml_data.append(line)
+                continue
+
+            # there was no ----* marker
+            if not found_first and stripped_line != "":
+                break
+
+        if not found_first:
+            return input_data, PresentationMeta()
+        
+        new_input = input_data[skipped_chars:]
+        if len(yaml_data) == 0:
+            return new_input, PresentationMeta()
+
+        yaml_data = "\n".join(yaml_data)
+        data = MetaSchema().loads(yaml_data)
+        meta = PresentationMeta(**data)
+
+        return new_input, meta
diff --git a/lookatme/pres.py b/lookatme/pres.py
new file mode 100644
index 0000000..b845372
--- /dev/null
+++ b/lookatme/pres.py
@@ -0,0 +1,46 @@
+"""
+Defines Presentation specific objects
+"""
+
+
+import mistune
+
+
+class Presentation(object):
+    """Defines a presentation
+    """
+    def __init__(self, meta, slides):
+        """Creates a new Presentation
+        """
+        self.meta = meta
+        self.slides = slides
+
+
+class PresentationMeta(object):
+    """Metadata for the presentation. This holds the parsed YAML header.
+    """
+    def __init__(self, title=None, author=None, date=None, styles=None, extensions=None):
+        """Create a new PresentationMeta instance
+        """
+        self.title = title
+        self.author = author
+        self.date = date
+        self.styles = styles
+        self.extensions = extensions
+
+
+class Slide(object):
+    """This class defines a single slide. It operates on mistune's lexed
+    tokens from the input markdown
+    """
+
+    def __init__(self, tokens, md=None, number=0):
+        """Create a new Slide instance with the provided tokens
+
+        :param list tokens: A list of mistune tokens
+        :param mistune.Markdown md: The markdown instance to use
+        :param int number: The slide number
+        """
+        self.tokens = tokens
+        self.md = md or mistune.Markdown()
+        self.number = number
diff --git a/lookatme/render/__init__.py b/lookatme/render/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lookatme/render/asciinema.py b/lookatme/render/asciinema.py
new file mode 100644
index 0000000..e69de29
diff --git a/lookatme/render/markdown_block.py b/lookatme/render/markdown_block.py
new file mode 100644
index 0000000..66b4bf4
--- /dev/null
+++ b/lookatme/render/markdown_block.py
@@ -0,0 +1,175 @@
+"""
+Defines render functions that render lexed markdown block tokens into urwid
+representations
+"""
+
+
+import pygments
+import pygments.formatters
+import pygments.lexers
+import pygments.styles
+import mistune
+import re
+import shlex
+import urwid
+
+
+import lookatme.config as config
+from lookatme.contrib import contrib_first
+import lookatme.render.pygments as pygments_render
+import lookatme.render.markdown_inline as markdown_inline_renderer
+from lookatme.utils import *
+
+
+def _meta(item):
+    if not hasattr(item, "meta"):
+        meta = {}
+        setattr(item, "meta", meta)
+    else:
+        meta = getattr(item, "meta")
+    return meta
+
+
+def _set_is_list(item, level=1):
+    _meta(item).update({
+        "is_list": True,
+        "list_level": level,
+    })
+
+
+def _is_list(item):
+    return _meta(item).get("is_list", False)
+
+
+def _list_level(item):
+    return _meta(item).get("list_level", 1)
+
+
+@contrib_first
+def render_heading(token, body, stack, loop):
+    if token["level"] == 1:
+        return urwid.Text(("bold", token["text"]), align="center")
+        return urwid.Padding(
+            #urwid.BigText(("bold", token["text"]), font=urwid.HalfBlock5x4Font()),
+            urwid.Text(token["text"]),
+            width="clip",
+            align="center",
+        )
+    else:
+        return urwid.Text(("bold", token["text"]), align="center")
+
+
+@contrib_first
+def render_table(token, body, stack, loop):
+    """Render a table token
+    """
+    from lookatme.widgets.table import Table
+
+    headers = token["header"]
+    aligns = token["align"]
+    cells = token["cells"]
+
+    table = Table(cells, headers=headers, aligns=aligns)
+    return urwid.Padding(table, width=table.total_width + 2, align="center")
+
+
+@contrib_first
+def render_list_start(token, body, stack, loop):
+    res = urwid.Pile(urwid.SimpleFocusListWalker([]))
+
+    in_list = _is_list(stack[-1])
+    list_level = 1
+    if in_list:
+        list_level = _list_level(stack[-1]) + 1
+    _set_is_list(res, list_level)
+    stack.append(res)
+
+    widgets = []
+    if not in_list:
+        widgets.append(urwid.Divider())
+    widgets.append(urwid.Padding(res, left=2))
+    if not in_list:
+        widgets.append(urwid.Divider())
+    return widgets
+
+
+@contrib_first
+def render_list_end(token, body, stack, loop):
+    stack.pop()
+
+
+def _list_item_start(token, body, stack, loop):
+    list_level = _list_level(stack[-1])
+    pile = urwid.Pile(urwid.SimpleFocusListWalker([]))
+
+    bullets = config.STYLE["bullets"]
+    list_bullet = bullets.get(str(list_level), bullets["default"])
+
+    res = urwid.Columns([
+        (2, urwid.Text(("bold", list_bullet + " "))),
+        pile,
+    ])
+    stack.append(pile)
+    return res
+
+
+@contrib_first
+def render_list_item_start(token, body, stack, loop):
+    return _list_item_start(token, body, stack, loop)
+
+
+@contrib_first
+def render_loose_item_start(token, body, stack, loop):
+    return _list_item_start(token, body, stack, loop)
+
+
+@contrib_first
+def render_list_item_end(token, body, stack, loop):
+    stack.pop()
+
+
+@contrib_first
+def render_text(token=None, body=None, stack=None, loop=None, text=None):
+    if text is None:
+        text = token["text"]
+
+    inline_lexer = mistune.InlineLexer(markdown_inline_renderer)
+    res = inline_lexer.output(text)
+    return urwid.Text(res)
+render_paragraph = render_text
+
+
+@contrib_first
+def render_block_quote_start(token, body, stack, loop):
+    pile = urwid.Pile([])
+    stack.append(pile)
+    return [
+        urwid.Divider(),
+        urwid.LineBox(
+            urwid.AttrMap(
+                urwid.Padding(pile, left=2),
+                spec_from_style(config.STYLE["quote_style"]),
+            ),
+            lline="╎", rline="",
+            tline=" ", trcorner="", #tlcorner="",
+            bline=" ", brcorner="", #blcorner="",
+        ),
+        urwid.Divider(),
+    ]
+
+
+@contrib_first
+def render_block_quote_end(token, body, stack, loop):
+    stack.pop()
+
+
+@contrib_first
+def render_code(token, body, stack, loop):
+    lang = token.get("lang", "text") or "text"
+    res = pygments_render.render_text(token["text"], lang=lang)
+
+    return [
+        urwid.Divider(),
+        res,
+        urwid.Divider(),
+    ]
diff --git a/lookatme/render/markdown_inline.py b/lookatme/render/markdown_inline.py
new file mode 100644
index 0000000..649833a
--- /dev/null
+++ b/lookatme/render/markdown_inline.py
@@ -0,0 +1,105 @@
+"""
+Defines render functions that work with mistune's markdown inline lexer render
+interface
+"""
+
+
+import contextlib
+import urwid
+
+
+import lookatme.config as config
+from lookatme.utils import *
+import lookatme.render.pygments as pygments_render
+
+
+options = {}
+
+
+def expanded_styles(fn):
+    @contextlib.wraps(fn)
+    def inner(text):
+        styles = dict(fg="", bg="")
+        if isinstance(text, str):
+            return fn(text, styles)
+        elif isinstance(text, list) and isinstance(text[0], str):
+            return fn(text[0], styles)
+        elif isinstance(text, list) and isinstance(text[0], tuple):
+            attr_spec = text[0][0]
+            styles = dict(fg=attr_spec.foreground, bg=attr_spec.background)
+            text = text[0][1]
+            return fn(text, styles)
+        else:
+            return fn(text, styles)
+    return inner
+
+
+# -------------------------------------------------------------------------
+
+
+def placeholder():
+    """The starting point of the rendering. The final result will be this
+    returned list with all inline markdown tokens translated into urwid objects
+    """
+    return []
+
+
+def render_no_change(text):
+    """Render inline markdown text with no changes
+    """
+    return [text]
+
+
+inline_html = render_no_change
+text = render_no_change
+escape = render_no_change
+
+
+def autolink(link, is_email=False):
+    return render_no_change(link)
+
+
+def inline_html(html):
+    return render_no_change(html)
+
+
+def footnote_ref(key, index):
+    return render_no_change(key)
+
+
+def image(link, title, text):
+    return render_no_change(text or "")
+
+
+def link(link, title, text):
+    return render_no_change(title)
+
+
+@expanded_styles
+def double_emphasis(text, old_styles):
+    return [styled_text(text, "underline", old_styles)]
+
+
+@expanded_styles
+def emphasis(text, old_styles):
+    return [styled_text(text, "italics", old_styles)]
+
+
+@expanded_styles
+def underline(text, old_styles):
+    return [styled_text(text, "underline", old_styles)]
+
+
+@expanded_styles
+def codespan(text, old_styles):
+    res = pygments_render.render_text(" " + text + " ", plain=True)
+    return res
+
+
+def linebreak():
+    return ["\n"]
+
+
+@expanded_styles
+def strikethrough(text, old_styles):
+    return [styled_text(text, "strikethrough", old_styles)]
diff --git a/lookatme/render/pygments.py b/lookatme/render/pygments.py
new file mode 100644
index 0000000..fe1bcab
--- /dev/null
+++ b/lookatme/render/pygments.py
@@ -0,0 +1,148 @@
+"""Pygments related rendering
+"""
+
+
+import urwid
+import pygments
+from pygments.formatter import Formatter
+import urwid
+
+
+import lookatme.config as config
+
+
+def render_text(text, lang="text", style_name=None, plain=False):
+    """Render the provided text with the pygments renderer
+    """
+    if style_name is None:
+        style_name = config.STYLE["style"]
+    style = pygments.styles.get_style_by_name(style_name)
+    style_bg = UrwidFormatter.findclosest(style.background_color.replace("#", ""))
+
+    lexer = pygments.lexers.get_lexer_by_name(lang)
+    formatter = UrwidFormatter(
+        style=style_name,
+        usebg=(style_bg is not None),
+    )
+    code_tokens = lexer.get_tokens(text)
+
+    markup = []
+    for x in formatter.formatgenerator(code_tokens):
+        if style_bg:
+            x[0].background = style_bg
+        markup.append(x)
+
+    if markup[-1][1] == "\n":
+        markup = markup[:-1]
+
+    if markup[-1][1].endswith("\n"):
+        markup[-1] = (markup[-1][0], markup[-1][1][:-1])
+
+    if plain:
+        return markup
+    else:
+        return urwid.AttrMap(urwid.Text(markup), urwid.AttrSpec("default", style_bg))
+
+
+class UrwidFormatter(Formatter):
+    """Formatter that returns [(text,attrspec), ...],
+    where text is a piece of text, and attrspec is an urwid.AttrSpec"""
+    def __init__(self, **options):
+        """Extra arguments:
+        
+        usebold: if false, bold will be ignored and always off
+                default: True
+        usebg: if false, background color will always be 'default'
+                default: True
+        colors: number of colors to use (16, 88, or 256)
+                default: 256"""
+        self.usebold = options.get('usebold',True)
+        self.usebg = options.get('usebg', True)
+        colors = options.get('colors', 256)
+        self.style_attrs = {}
+        Formatter.__init__(self, **options)
+        
+    @property
+    def style(self):
+        return self._style
+    
+    @style.setter
+    def style(self, newstyle):
+        self._style = newstyle
+        self._setup_styles()
+        
+    @staticmethod
+    def _distance(col1, col2):
+        r1, g1, b1 = col1
+        r2, g2, b2 = col2
+        
+        rd = r1 - r2
+        gd = g1 - g2
+        bd = b1 - b2
+        
+        return rd*rd + gd*gd + bd*bd
+    
+    @classmethod
+    def findclosest(cls, colstr, colors=256):
+        """Takes a hex string and finds the nearest color to it.
+        
+        Returns a string urwid will recognize."""
+        
+        rgb = int(colstr, 16)
+        r = (rgb >> 16) & 0xff
+        g = (rgb >> 8) & 0xff
+        b = rgb & 0xff
+        
+        dist = 257 * 257 * 3
+        bestcol = urwid.AttrSpec('h0','default')
+        
+        for i in range(colors):
+            curcol = urwid.AttrSpec('h%d' % i,'default', colors=colors)
+            currgb = curcol.get_rgb_values()[:3]
+            curdist = cls._distance((r,g,b), currgb)
+            if curdist < dist:
+                dist = curdist
+                bestcol = curcol
+        
+        return bestcol.foreground
+    
+    def findclosestattr(self, fgcolstr=None, bgcolstr=None, othersettings='', colors = 256):
+        """Takes two hex colstring (e.g. 'ff00dd') and returns the 
+        nearest urwid style."""
+        fg = bg = 'default'
+        if fgcolstr:
+            fg = self.findclosest(fgcolstr, colors)
+        if bgcolstr:
+            bg = self.findclosest(bgcolstr, colors)
+        if othersettings:
+            fg = fg + ',' + othersettings
+        return urwid.AttrSpec(fg, bg, colors)
+    
+    def _setup_styles(self, colors = 256):
+        """Fills self.style_attrs with urwid.AttrSpec attributes 
+        corresponding to the closest equivalents to the given style."""
+        for ttype, ndef in self.style:
+            fgcolstr = bgcolstr = None
+            othersettings = ''
+            if ndef['color']:
+                fgcolstr = ndef['color']
+            if self.usebg and ndef['bgcolor']:
+                bgcolstr = ndef['bgcolor']
+            if self.usebold and ndef['bold']:
+                othersettings = 'bold'
+            self.style_attrs[str(ttype)] = self.findclosestattr(
+                fgcolstr, bgcolstr, othersettings, colors)
+        
+    def formatgenerator(self, tokensource):
+        """Takes a token source, and generates 
+        (tokenstring, urwid.AttrSpec) pairs"""
+        for (ttype, tstring) in tokensource:
+            while str(ttype) not in self.style_attrs:
+                ttype = ttype[:-1]
+
+            attr = self.style_attrs[str(ttype)]
+            yield attr, tstring
+    
+    def format(self, tokensource, outfile):
+        for (attr, tstring) in self.formatgenerator(tokensource):
+            outfile.write(attr, tstring)
diff --git a/lookatme/schemas.py b/lookatme/schemas.py
new file mode 100644
index 0000000..24e1578
--- /dev/null
+++ b/lookatme/schemas.py
@@ -0,0 +1,93 @@
+"""
+Defines all schemas used in lookatme
+"""
+
+from marshmallow import Schema, fields, validate
+import pygments.styles
+import yaml
+
+
+class NoDatesSafeLoader(yaml.SafeLoader):
+    @classmethod
+    def remove_implicit_resolver(cls, tag_to_remove):
+        """
+        Remove implicit resolvers for a particular tag
+
+        Takes care not to modify resolvers in super classes.
+
+        We want to load datetimes as strings, not dates, because we
+        go on to serialise as json which doesn't have the advanced types
+        of yaml, and leads to incompatibilities down the track.
+        """
+        if not 'yaml_implicit_resolvers' in cls.__dict__:
+            cls.yaml_implicit_resolvers = cls.yaml_implicit_resolvers.copy()
+
+        for first_letter, mappings in cls.yaml_implicit_resolvers.items():
+            cls.yaml_implicit_resolvers[first_letter] = [
+                (tag, regexp) for tag, regexp in mappings if tag != tag_to_remove
+            ]
+NoDatesSafeLoader.remove_implicit_resolver('tag:yaml.org,2002:timestamp')
+
+
+class YamlRender:
+    loads = lambda data: yaml.load(data, Loader=NoDatesSafeLoader)
+    dumps = lambda data: yaml.safe_dump(data)
+
+
+class BulletsSchema(Schema):
+    default = fields.Str(required=True, default="•")
+
+    class Meta:
+        include = {
+            "1": fields.Str(default="•"),
+            "2": fields.Str(default="⁃"),
+            "3": fields.Str(default="◦"),
+            "4": fields.Str(),
+            "5": fields.Str(),
+            "6": fields.Str(),
+            "7": fields.Str(),
+            "8": fields.Str(),
+            "9": fields.Str(),
+            "10": fields.Str(),
+        }
+
+
+class TableSchema(Schema):
+    header_divider = fields.Str(default="━")
+    column_spacing = fields.Int(default=3)
+
+
+class StyleFieldSchema(Schema):
+    fg = fields.Str(default="")
+    bg = fields.Str(default="")
+
+
+class StyleSchema(Schema):
+    """Styles schema for themes and style overrides within presentations
+    """
+    class Meta:
+        render_module = YamlRender
+
+    style = fields.Str(
+        default="monokai",
+        validate=validate.OneOf(list(pygments.styles.get_all_styles())),
+    )
+    bullets = fields.Nested(BulletsSchema, default=BulletsSchema().dump(BulletsSchema()))
+    table = fields.Nested(TableSchema, default=TableSchema().dump(TableSchema()))
+    quote_style = fields.Nested(StyleFieldSchema, default=StyleFieldSchema().dump({
+        "fg": "italics,#aaa",
+        "bg": "default",
+    }))
+
+
+class MetaSchema(Schema):
+    """The schema for presentation metadata
+    """
+    class Meta:
+        render_module = YamlRender
+
+    title = fields.Str()
+    date = fields.Date()
+    author = fields.Str()
+    styles = fields.Nested(StyleSchema, default={})
+    extensions = fields.List(fields.Str(), default=[])
diff --git a/lookatme/themes/__init__.py b/lookatme/themes/__init__.py
new file mode 100644
index 0000000..6e23ec1
--- /dev/null
+++ b/lookatme/themes/__init__.py
@@ -0,0 +1,19 @@
+"""
+Defines the built-in styles for lookatme
+"""
+
+
+from . import dark
+from . import light
+
+
+from lookatme.schemas import StyleSchema
+from lookatme.utils import dict_deep_update
+
+
+def ensure_defaults(mod):
+    """Ensure that all required attributes exist within the provided module
+    """
+    defaults = StyleSchema().dump(StyleSchema())
+    dict_deep_update(defaults, mod.theme)
+    return defaults
diff --git a/lookatme/themes/dark.py b/lookatme/themes/dark.py
new file mode 100644
index 0000000..9cb3f25
--- /dev/null
+++ b/lookatme/themes/dark.py
@@ -0,0 +1,21 @@
+"""
+Defines styles that should look good on dark backgrounds
+"""
+
+
+from collections import defaultdict
+
+theme = {
+    "style": "solarized-dark",
+    "bullets": {
+        "default": "•",
+        "1": "•",
+        "2": "⁃",
+        "3": "◦",
+    },
+    "table": {
+        "header_divider": "━",
+        "column_spacing": 3,
+    },
+    "quote_style": {"fg": "italics,#aaa", "bg":"default"},
+}
diff --git a/lookatme/themes/light.py b/lookatme/themes/light.py
new file mode 100644
index 0000000..e69de29
diff --git a/lookatme/tui.py b/lookatme/tui.py
new file mode 100644
index 0000000..df71301
--- /dev/null
+++ b/lookatme/tui.py
@@ -0,0 +1,162 @@
+"""
+This module defines the text user interface (TUI) for lookatme
+"""
+
+
+import copy
+import urwid
+
+
+import lookatme.config
+import lookatme.contrib
+import lookatme.render.markdown_block as lam_md
+from lookatme.utils import *
+
+
+palette = [
+    ('banner', 'black', 'light gray'),
+    ('streak', 'black', 'dark red'),
+    ('bg', 'black', 'black'),
+]
+
+
+def text(style, data, align="left"):
+    return urwid.Text((style, data), align=align)
+
+
+class CurrentSlide(urwid.Frame):
+    def __init__(self, pres, palette, idx=0):
+        self.slide_body = urwid.Pile(urwid.SimpleListWalker([urwid.Text("test")]))
+        self.slide_title = text("banner", f"  {pres.meta.title}  ", "center")
+
+        self.creation = text("banner", f"  {pres.meta.author} - {pres.meta.date}  ")
+        self.slide_num = text("banner", " test ", "right")
+        self.slide_footer = urwid.Columns([self.creation, self.slide_num])
+
+        self._log = lookatme.config.LOG
+
+        screen = urwid.raw_display.Screen()
+        screen.set_terminal_properties(colors=256)
+        self.loop = urwid.MainLoop(
+            urwid.Padding(self, left=2, right=2),
+            palette,
+            screen=screen,
+            #unhandled_input=self.handle_input,
+        )
+
+        self.pres = pres
+        self.curr_slide = self.pres.slides[idx]
+        self.slide_cache = {}
+        self.update()
+
+        urwid.Frame.__init__(
+            self,
+            urwid.Padding(urwid.Filler(self.slide_body, valign="top", top=2), left=10, right=10),
+            self.slide_title,
+            self.slide_footer,
+        )
+
+    def update_slide_num(self):
+        """Update the slide number
+        """
+        slide_text = "slide {} / {}".format(
+            self.curr_slide.number + 1,
+            len(self.pres.slides),
+        )
+        self.slide_num.set_text(slide_text)
+
+    def update_title(self):
+        """Update the title
+        """
+
+    def update_body(self):
+        """Render the provided slide body
+        """
+        slide_num = self.curr_slide.number
+        if slide_num not in self.slide_cache:
+            stack = [self.slide_body]
+            self.slide_body.contents = []
+            for token in self.curr_slide.tokens:
+                self._log.debug(f"{'  '*len(stack)}Rendering token {token}")
+
+                last_stack = stack[-1]
+                last_stack_len = len(stack)
+
+                #render_token = getattr(lam_md, f"render_{token['type']}", lambda *args: None)
+                render_token = getattr(lam_md, f"render_{token['type']}")
+                res = render_token(token, stack[-1], stack, self.loop)
+                if len(stack) > last_stack_len:
+                    self._propagate_meta(last_stack, stack[-1])
+                if res is None:
+                    continue
+                pile_add(last_stack, res)
+
+            self.slide_cache[slide_num] = list(self.slide_body.contents)
+        else:
+            self.slide_body.contents = self.slide_cache[slide_num]
+
+    def update(self):
+        """
+        """
+        self.update_slide_num()
+        self.update_title()
+        self.update_body()
+
+    def _propagate_meta(self, item1, item2):
+        """Copy the metadata from item1 to item2
+        """
+        meta = getattr(item1, "meta", {})
+        existing_meta = getattr(item2, "meta", {})
+        new_meta = copy.deepcopy(meta)
+        new_meta.update(existing_meta)
+        setattr(item2, "meta", new_meta)
+
+    def keypress(self, size, key):
+        """Handle keypress events
+        """
+        try:
+            res = urwid.Frame.keypress(self, size, key)
+            if res is None:
+                return
+            size, key = res
+        except:
+            pass
+
+        slide_direction = 0
+        if key in ["left", "up", "delete", "h", "k"]:
+            slide_direction = -1
+        elif key in ["right", "down", " ", "j", "l"]:
+            slide_direction = 1
+        elif key in ["q", "Q"]:
+            lookatme.contrib.shutdown_contribs()
+            raise urwid.ExitMainLoop()
+
+        if slide_direction != 0:
+            new_slide_num = self.curr_slide.number + slide_direction
+            if new_slide_num < 0:
+                new_slide_num = 0
+            elif new_slide_num >= len(self.pres.slides):
+                new_slide_num = len(self.pres.slides) - 1
+
+            if new_slide_num == self.curr_slide.number:
+                return
+
+            self.curr_slide = self.pres.slides[new_slide_num]
+            self.update()
+            self.loop.draw_screen()
+            return
+
+    def run(self):
+        self.loop.run()
+
+
+
+def run_presentation(pres, start_slide=0, width=None, height=None):
+    """Run the provided presentation
+
+    :param int start_slide: 0-based slide index
+    :param int width: Width of the presentation - defaults to terminal width
+    :param int height: Width of the presentation - defaults to terminal height
+    """
+    slide = CurrentSlide(pres, palette)
+    slide.run()
diff --git a/lookatme/utils.py b/lookatme/utils.py
new file mode 100644
index 0000000..94c4225
--- /dev/null
+++ b/lookatme/utils.py
@@ -0,0 +1,133 @@
+"""
+"""
+
+
+import urwid
+
+
+def dict_deep_update(to_update, new_vals):
+    """Deeply update the to_update dict with the new_vals
+    """
+    for key, value in new_vals.items():
+        if isinstance(value, dict):
+            node = to_update.setdefault(key, {})
+            dict_deep_update(node, value)
+        else:
+            to_update[key] = value
+
+
+def spec_from_style(style_dict):
+    """Create an urwid.AttrSpec from a {fg:"", bg:""} style dict
+    """
+    return urwid.AttrSpec(style_dict.get("fg", ""), style_dict.get("bg", ""))
+
+
+def get_fg_bg_styles(style):
+    if style is None:
+        return [], []
+
+    def non_empty_split(data):
+        res = [x.strip() for x in data.split(",")]
+        return list(filter(None, res))
+
+    # from lookatme.config.STYLE
+    if isinstance(style, dict):
+        return non_empty_split(style["fg"]), non_empty_split(style["bg"])
+    # just a str will only set the foreground color
+    elif isinstance(style, str):
+        return non_empty_split(style), []
+    elif isinstance(style, urwid.AttrSpec):
+        return non_empty_split(style.foreground), non_empty_split(style.background)
+    else:
+        raise ValueError("Unsupported style value {!r}".format(style))
+
+
+def styled_text(text, new_styles, old_styles=None):
+    """Return a styled text tuple that can be used within urwid.Text.
+
+    .. note::
+
+        If an urwid.Text instance is passed in as the ``text`` parameter,
+        alignment values will be lost and must be explicitly re-added by the
+        caller.
+    """
+    if isinstance(text, urwid.Text):
+        text = text.text
+    elif (isinstance(text, tuple)
+            and isinstance(text[0], urwid.AttrSpec)
+            and isinstance(text[1], urwid.Text)):
+        text = text[1].text
+        old_styles = text[0]
+
+    new_fg, new_bg = get_fg_bg_styles(new_styles)
+    old_fg, old_bg = get_fg_bg_styles(old_styles)
+
+    def join(items):
+        return ",".join(set(items))
+
+    spec = urwid.AttrSpec(
+        join(new_fg + old_fg),
+        join(new_bg + old_bg),
+    )
+    return (spec, text)
+
+
+def pile_add(pile, widgets):
+    """
+    """
+    if not isinstance(widgets, list):
+        widgets = [widgets]
+
+    for w in widgets:
+        if len(pile.contents) > 0 \
+                and isinstance(w, urwid.Divider) \
+                and isinstance(pile.contents[-1][0], urwid.Divider):
+            continue
+        pile.contents.append((w, pile.options()))
+
+
+# Translate raw_text (ansi sequence) to something readable by urwid (attribut and text)
+# from https://github.com/Nanoseb/ncTelegram/blob/29f551ac0e83b1921a6ac697a33fe6eb76ca337a/ncTelegram/ui_msgwidget.py#L335
+def translate_color(raw_text):
+    formated_text = []
+    raw_text = raw_text.decode("utf-8")
+
+    for at in raw_text.split("\x1b["):
+        try:
+            attr, text = at.split("m",1)
+        except:
+            attr = '0'
+            text = at.split("m",1)
+
+        list_attr = [ int(i) for i in attr.split(';') ]
+        list_attr.sort()
+        fg = -1
+        bg = -1
+       
+        for elem in list_attr:
+            if elem <= 29:
+                pass
+            elif elem <= 37:
+                fg = elem - 30
+            elif elem <= 47:
+                bg = elem - 40
+            elif elem <= 94:
+                fg = fg + 8
+            elif elem >= 100 and elem <= 104:
+                bg = bg + 8
+            
+        fgcolor = color_list[fg]
+        bgcolor = color_list[bg]
+
+        if fg < 0:
+            fgcolor = ''
+        if bg < 0:
+            bgcolor = ''
+
+        if list_attr == [0]:
+            fgcolor = ''
+            bgcolor = ''
+
+        formated_text.append((urwid.AttrSpec(fgcolor, bgcolor), text))
+
+    return formated_text
diff --git a/lookatme/widgets/__init__.py b/lookatme/widgets/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/lookatme/widgets/table.py b/lookatme/widgets/table.py
new file mode 100644
index 0000000..f83ae63
--- /dev/null
+++ b/lookatme/widgets/table.py
@@ -0,0 +1,106 @@
+"""
+Defines a basic Table widget for urwid
+"""
+
+
+from collections import defaultdict
+import urwid
+
+
+from lookatme.render.markdown_block import render_text
+import lookatme.config as config
+from lookatme.utils import styled_text
+
+
+class Table(urwid.Pile):
+    """Create a table from a list of headers, alignment values, and rows.
+    """
+
+    def __init__(self, rows, headers=None, aligns=None):
+        """Create a new table
+
+        :param list columns: The rows to use for the table
+        :param list headers: (optional) Headers for the table
+        :param list aligns: (optional) Alignment values for each column
+        """
+        self.table_rows = rows
+        self.table_headers = headers
+        self.table_aligns = aligns
+
+        if headers is not None:
+            self.num_columns = len(headers)
+        elif len(rows) == 0:
+            self.num_columns = len(rows[0])
+        else:
+            raise ValueError(
+                "Invalid table specification: could not determine # of columns"
+            )
+
+        def header_modifier(cell):
+            return urwid.Text(styled_text(cell, "bold"), align=cell.align)
+
+        self.rend_headers = self.create_cells(
+            [self.table_headers], modifier=header_modifier
+        )
+        self.rend_rows = self.create_cells(self.table_rows)
+
+        column_maxes = self.calc_column_maxes()
+
+        cell_spacing = config.STYLE["table"]["column_spacing"]
+        self.total_width = sum(column_maxes.values()) + (
+            cell_spacing * (self.num_columns - 1)
+        )
+
+        # final rows
+        final_rows = []
+
+        # put headers in Columns
+        header_columns = []
+        for idx, header in enumerate(self.rend_headers[0]):
+            header_with_div = urwid.Pile([
+                header,
+                urwid.Divider(config.STYLE["table"]["header_divider"]),
+            ])
+            header_columns.append((column_maxes[idx], header_with_div))
+        final_rows.append(urwid.Columns(header_columns, cell_spacing))
+
+        for row_idx, rend_row in enumerate(self.rend_rows):
+            row_columns = []
+            for idx, rend_cell in enumerate(rend_row):
+                row_columns.append((column_maxes[idx], rend_cell))
+
+            column_row = urwid.Columns(row_columns, cell_spacing)
+            final_rows.append(column_row)
+        
+        urwid.Pile.__init__(self, final_rows)
+
+    def calc_column_maxes(self):
+        column_maxes = defaultdict(int)
+        for row in self.rend_headers + self.rend_rows:
+            for idx, cell in enumerate(row):
+                if idx > self.num_columns:
+                    break
+                column_maxes[idx] = max(column_maxes[idx], len(cell.text))
+        return column_maxes
+
+    def create_cells(self, body_rows, modifier=None):
+        """Create the rows for the body, optionally calling a modifier function
+        on each created cell Text. The modifier must accept an urwid.Text object
+        and must return an urwid.Text object.
+        """
+        res = []
+
+        for row in body_rows:
+            rend_row = []
+            for idx, cell in enumerate(row):
+                if idx >= self.num_columns:
+                    break
+                rend_cell = render_text(text=cell)
+                rend_cell.align = self.table_aligns[idx]
+                if modifier is not None:
+                    rend_cell = modifier(rend_cell)
+                rend_row.append(rend_cell)
+            res.append(rend_row)
+
+        return res
+        return res
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..07acdc4
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
+marshmallow>=3,<4
+Click>=7,<8
+PyYAML>=5,<6
+mistune>=0.8,<1
+urwid>=2,<3
+Pygments>=2,<3
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..4c15458
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,29 @@
+"""
+Setup for lookatme
+"""
+
+
+from setuptools import setup, find_packages
+import os
+
+
+req_path = os.path.join(os.path.dirname(__file__), "requirements.txt")
+with open(req_path, "r") as f:
+    required = f.read().splitlines()
+
+
+setup(
+    name='lookatme', version='{{VERSION}}',
+    description='A command-line presentation tool',
+    author='James Johnson',
+    author_email='d0c.s4vage@gmail.com',
+    packages=find_packages(exclude=["docs", ".gitignore", "README.md"]),
+    install_requires=required,
+    entry_points={
+        "console_scripts": [
+            "lookatme = lookatme.__main__:main",
+            "lam = lookatme.__main__:main",
+            "witnessme = lookatme.__main__:main",
+        ]
+    },
+)
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..72620d1
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,12 @@
+[tox]
+envlist = py36
+
+[testenv]
+# install pytest in the virtualenv where commands will be executed
+deps = -r requirements.txt
+    pytest
+    pytest-mock
+    pytest-cov
+commands =
+    # NOTE: you can run any command line tool here - not just tests
+    pytest --cov lookatme --cov-report term {posargs}


Test patch:
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/test_parse.py b/tests/test_parse.py
new file mode 100644
index 0000000..78740c7
--- /dev/null
+++ b/tests/test_parse.py
@@ -0,0 +1,79 @@
+"""
+This module tests that markdown presentations can be correctly parsed
+"""
+
+
+from lookatme.parser import Parser
+
+
+def test_parse_metadata():
+    """Test that metadata can be correctly parsed out of a markdown
+    presentation
+    """
+    title = "Presentation Title"
+    date = "9999-01-01"
+    author = "The Author"
+
+    input_data = f"""
+---
+title: {title}
+date: {date}
+author: {author}
+---
+remaining
+    """
+
+    parser = Parser()
+    input_data, meta = parser.parse_meta(input_data)
+    assert input_data.strip() == "remaining"
+    assert meta.title == title
+    assert meta.date.year == 9999
+    assert meta.date.month == 1
+    assert meta.date.day == 1
+    assert meta.author == author
+
+
+def test_parse_metadata_empty():
+    """Test that metadata can be correctly parsed out of a markdown
+    presentation
+    """
+    input_data = f"""
+---
+---
+remaining
+    """
+
+    parser = Parser()
+    input_data, meta = parser.parse_meta(input_data)
+    assert input_data.strip() == "remaining"
+    assert meta.title is None
+    assert meta.date is None
+    assert meta.author is None
+
+
+def test_parse_slides():
+    """Test that slide parsing works correctly
+    """
+    input_data = r"""
+# Slide 1
+
+* list
+  * item
+  * item
+  * item
+
+Hello there this is a paragraph
+
+```python
+code block
+```
+
+---
+
+# Slide 2
+
+More text
+    """
+    parser = Parser()
+    slides = parser.parse_slides(input_data)
+    assert len(slides) == 2