Repository: d0c-s4vage/lookatme
Pull request #174 (2022-11-03 01:19:46)

Syncing changes from main


Patch:
diff --git a/.github/workflows/pypi_publish.yml b/.github/workflows/pypi_publish.yml
index 6d23727..1b7f817 100644
--- a/.github/workflows/pypi_publish.yml
+++ b/.github/workflows/pypi_publish.yml
@@ -14,17 +14,16 @@ jobs:
         uses: actions/setup-python@v4
         with:
           python-version: "3.10"
-      - name: Set VERSION number
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+      - name: Fill placeholders
         run: |
           version_num="${{github.ref_name}}"
           # strip the leading v
           version_num=${version_num#v}
-          echo "Replacing {{VERSION}} with '$version_num'"
-          echo "Files with {{VERSION}}:"
-          grep -rl '{{VERSION}}' .
-          grep -rl '{{VERSION}}' . | xargs sed -i "s/{{VERSION}}/$version_num/g"
-          echo "Files with {{VERSION}} after replacing:"
-          grep -rl '{{VERSION}}' . || true
+          bin/fill_placeholders "$version_num"
       - name: Install pypa/build
         run: python -m pip install build --user
       - name: Build binary wheel + tarball
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5dd42d..01af4f7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 # Changelog
 
+- [v2.5.0](#v250)
 - [v2.4.1](#v241)
 - [v2.4.0](#v240)
 - [v2.3.2](#v232)
@@ -21,6 +22,15 @@
 - [v0.2.0](#v020)
 - [v0.1.0](#v010)
 
+## v2.5.0
+
+Adds the `--tutorial` CLI option.
+
+|    type | PR/ticket                                                | author                                       | description                               |
+|--------:|----------------------------------------------------------|----------------------------------------------|-------------------------------------------|
+| feature | [#169](https://github.com/d0c-s4vage/lookatme/issue/169) | [@d0c-s4vage](https://github.com/d0c-s4vage) | Adds the `--tutorial` CLI option          |
+|     bug | [#168](https://github.com/d0c-s4vage/lookatme/issue/168) | [@d0c-s4vage](https://github.com/d0c-s4vage) | Adds documentation for progressive slides |
+
 ## v2.4.1
 
 Bug fix - theme and style override precedence issues. The CLI params `--theme`
diff --git a/README.md b/README.md
index 8978e2b..472a235 100644
--- a/README.md
+++ b/README.md
@@ -24,14 +24,21 @@ tool.
 ## Features
 
 * Markdown rendering
+* Built-in tutorial slides `lookatme --tutorial`
 * Live (input file modification time watching) and manual reloading
 * Live terminals embedded directly in slides
 * Syntax highlighting using [Pygments](https://pygments.org/)
 * Loading external files into code blocks
 * Support for contrib extensions
 * Smart slide splitting
+* Progressive slides with `<!-- stop -->` comments between block elements
 
-### Tour
+### Tutorial
+
+```bash
+pip install --upgrade lookatme
+lookatme --tutorial
+```
 
 General tour
 
@@ -68,25 +75,24 @@ Usage: lookatme [OPTIONS] [INPUT_FILES]...
 Options:
   --debug
   -l, --log PATH
+  --tutorial TEXT                 As a flag: show all tutorials. With a
+                                  value/comma-separated values: show the
+                                  specific tutorials. Use the value 'help' for
+                                  more help
   -t, --theme [dark|light]
-  --style [default|emacs|friendly|colorful|autumn|murphy|manni|monokai|perldoc|pastie|borland|trac|native|fruity|bw|vim|vs|tango|rrt|xcode|igor|paraiso-light|paraiso-dark|lovelace|algol|algol_nu|arduino|rainbow_dash|abap|solarized-dark|solarized-light|sas|stata|stata-light|stata-dark|inkpot]
+  --style [default|emacs|friendly|friendly_grayscale|colorful|autumn|murphy|manni|material|monokai|perldoc|pastie|borland|trac|native|fruity|bw|vim|vs|tango|rrt|xcode|igor|paraiso-light|paraiso-dark|lovelace|algol|algol_nu|arduino|rainbow_dash|abap|solarized-dark|solarized-light|sas|staroffice|stata|stata-light|stata-dark|inkpot|zenburn|gruvbox-dark|gruvbox-light|dracula|one-dark|lilypond|nord|nord-darker|github-dark]
   --dump-styles                   Dump the resolved styles that will be used
                                   with the presentation to stdout
-
   --live, --live-reload           Watch the input filename for modifications
                                   and automatically reload
-
   -s, --safe                      Do not load any new extensions specified in
                                   the source markdown. Extensions specified
                                   via env var or -e are still loaded
-
   --no-ext-warn                   Load new extensions specified in the source
                                   markdown without warning
-
   -i, --ignore-ext-failure        Ignore load failures of extensions
   -e, --exts TEXT                 A comma-separated list of extension names to
                                   automatically load (LOOKATME_EXTS)
-
   --single, --one                 Render the source as a single slide
   --version                       Show the version and exit.
   --help                          Show this message and exit.
diff --git a/bin/fill_placeholders b/bin/fill_placeholders
new file mode 100755
index 0000000..d4626e6
--- /dev/null
+++ b/bin/fill_placeholders
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+
+
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
+THIS_PROG="$0"
+SUBPROGRAM="ls"
+
+. "$DIR"/_utils.sh
+
+
+function show_help {
+    cat <<-EOF
+USAGE: $THIS_PROG VERSION_NUMBER
+
+This script will fill all placeholder values in the lookatme me project.
+Specifically:
+
+* {{VERSION}} will be replaced with the version number (no 'v' prefix!)
+* {{LOOKATME_HELP_OUTPUT}} will be replaced with output from  "lookatme --help"
+* {{LOOKATME_HELP_OUTPUT_INDENTED}} will be replaced with the indented
+  output of "lookatme --help"
+EOF
+}
+
+function replace_in_files {
+    local placeholder="$1"
+    local replacement="$2"
+
+    log "==== Replacing $placeholder"
+    (
+        cd "$DIR"/..
+        files_to_replace=$(grep -rl "$placeholder" $(git ls-files) | grep -v bin/fill_placeholders)
+        python3 <(cat <<-EOF
+import sys
+
+placeholder = sys.argv[1]
+replacement = sys.argv[2]
+files_to_replace = sys.argv[3:]
+
+for file in files_to_replace:
+    with open(file, "r") as f:
+        data = f.read()
+
+    new_data = data.replace(placeholder, replacement)
+    if new_data != data:
+        print("    replacing {}".format(file))
+    with open(file, "w") as f:
+        f.write(new_data)
+EOF
+        ) "$placeholder" "$replacement" $files_to_replace
+    )
+}
+
+function set_version {
+    replace_in_files "{{VERSION}}" "$VERSION"
+}
+
+function set_help_output {
+    local help_output=$(cd "$DIR"/.. ; python3 -m lookatme --help)
+    replace_in_files "{{LOOKATME_HELP_OUTPUT}}" "$help_output"
+
+    local help_output_indented=$(sed 's/^/    /' <<<$help_output)
+    replace_in_files "{{LOOKATME_HELP_OUTPUT_INDENTED}}" "$help_output_indented"
+}
+
+
+function parse_args {
+    VERSION=""
+    while [ $# -ne 0 ] ; do
+        param="$1"
+        shift
+
+        case "$param" in
+            --help|-h)
+                show_help
+                exit 1
+                ;;
+            *)
+                VERSION="$param"
+                ;;
+        esac
+    done
+}
+
+parse_args "$@"
+# needs to come first, can contain {{VERSION}} placeholders
+set_help_output
+set_version
diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst
index 0f1cb50..9de796a 100644
--- a/docs/source/getting_started.rst
+++ b/docs/source/getting_started.rst
@@ -20,37 +20,7 @@ The ``lookatme`` CLI has a few options to control it's behavior:
 
 .. code-block:: text
 
-    Usage: lookatme [OPTIONS] [INPUT_FILES]...
-
-      lookatme - An interactive, terminal-based markdown presentation tool.
-
-      See https://lookatme.readthedocs.io/en/v{{VERSION}} for documentation
-
-    Options:
-      --debug
-      -l, --log PATH
-      -t, --theme [dark|light]
-      --style [default|emacs|friendly|colorful|autumn|murphy|manni|monokai|perldoc|pastie|borland|trac|native|fruity|bw|vim|vs|tango|rrt|xcode|igor|paraiso-light|paraiso-dark|lovelace|algol|algol_nu|arduino|rainbow_dash|abap|solarized-dark|solarized-light|sas|stata|stata-light|stata-dark|inkpot]
-      --dump-styles                   Dump the resolved styles that will be used
-                                      with the presentation to stdout
-
-      --live, --live-reload           Watch the input filename for modifications
-                                      and automatically reload
-
-      -s, --safe                      Do not load any new extensions specified in
-                                      the source markdown. Extensions specified
-                                      via env var or -e are still loaded
-
-      --no-ext-warn                   Load new extensions specified in the source
-                                      markdown without warning
-
-      -i, --ignore-ext-failure        Ignore load failures of extensions
-      -e, --exts TEXT                 A comma-separated list of extension names to
-                                      automatically load (LOOKATME_EXTS)
-
-      --single, --one                 Render the source as a single slide
-      --version                       Show the version and exit.
-      --help                          Show this message and exit.
+{{LOOKATME_HELP_OUTPUT_INDENTED}}
 
 ``--live`` / ``--live-reload``
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
diff --git a/docs/source/index.rst b/docs/source/index.rst
index fba655c..8e8a51b 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -67,6 +67,25 @@ A basic, optional YAML header may be included at the top of the slides:
 
     Some text
 
+Slides can be progressively rendered by adding ``<!-- stop -->`` comments
+between block elements (paragraphs, tables, lists, etc.):
+
+.. code-block:: md
+
+   # Progressive Slide
+
+   <!-- stop -->
+
+   Paragraph 1
+
+   <!-- stop -->
+
+   Paragraph 2
+
+   <!-- stop -->
+
+   Paragraph 3
+
 .. toctree::
   :maxdepth: 2
 
diff --git a/examples/tour.md b/examples/tour.md
index a4bb424..d77b2ae 100644
--- a/examples/tour.md
+++ b/examples/tour.md
@@ -97,6 +97,30 @@ A quote is below:
 
 ---
 
+# Progressive Slides
+
+Add a `<!-- stop -->` comment between paragraphs to progressively render
+the current slide!
+
+For example, the markdown in the codeblock below is used at the end of this
+slide:
+
+```markdown
+paragraph 1
+
+<!-- stop -->
+
+paragraph 2
+```
+
+paragraph 1
+
+<!-- stop -->
+
+paragraph 2
+
+---
+
 # Extensions
 
 lookatme supports extensions that can add additional functionality to lookatme
diff --git a/lookatme/__main__.py b/lookatme/__main__.py
index 54a039b..1040e75 100644
--- a/lookatme/__main__.py
+++ b/lookatme/__main__.py
@@ -16,12 +16,13 @@ import lookatme
 import lookatme.config
 import lookatme.log
 import lookatme.tui
+import lookatme.tutorial
 from lookatme.pres import Presentation
 from lookatme.schemas import StyleSchema
 
 
 @click.command("lookatme")
-@click.option("--debug", "debug", is_flag="True", default=False)
+@click.option("--debug", "debug", is_flag=True, default=False)
 @click.option(
     "-l",
     "--log",
@@ -29,6 +30,18 @@ from lookatme.schemas import StyleSchema
     type=click.Path(writable=True),
     default=os.path.join(tempfile.gettempdir(), "lookatme.log"),
 )
+@click.option(
+    "--tutorial",
+    "tutorial",
+    is_flag=False,
+    flag_value="all",
+    show_default=True,
+    help=(
+        "As a flag: show all tutorials. "
+        "With a value/comma-separated values: show the specific tutorials. "
+        "Use the value 'help' for more help"
+    )
+)
 @click.option(
     "-t",
     "--theme",
@@ -101,6 +114,7 @@ from lookatme.schemas import StyleSchema
     nargs=-1,
 )
 def main(
+    tutorial,
     debug,
     log_path,
     theme,
@@ -126,6 +140,25 @@ def main(
     if len(input_files) == 0:
         input_files = [io.StringIO("")]
 
+    if tutorial:
+        if tutorial == "all":
+            tutors = ["general", "markdown"]
+        else:
+            tutors = [x.strip() for x in tutorial.split(",")]
+
+        theme_mod = __import__("lookatme.themes." + theme, fromlist=[theme])
+        lookatme.config.set_global_style_with_precedence(
+            theme_mod,
+            {},
+            code_style,
+        )
+        tutorial_md = lookatme.tutorial.get_tutorial_md(tutors)
+        if tutorial_md is None:
+            lookatme.tutorial.print_tutorial_help()
+            return 1
+
+        input_files = [io.StringIO(tutorial_md)]
+
     preload_exts = [x.strip() for x in extensions.split(",")]
     preload_exts = list(filter(lambda x: x != "", preload_exts))
     pres = Presentation(
diff --git a/lookatme/config.py b/lookatme/config.py
index 377e089..0963a63 100644
--- a/lookatme/config.py
+++ b/lookatme/config.py
@@ -5,10 +5,14 @@ Config module for lookatme
 
 import logging
 import os
-from typing import Dict, Optional
+from types import ModuleType
+from typing import Any, Dict
+
+import lookatme.themes
+from lookatme.utils import dict_deep_update
 
 LOG = None
-STYLE: Optional[Dict] = None
+STYLE: Dict[str, Any] = {}
 
 
 def get_log() -> logging.Logger:
@@ -18,8 +22,42 @@ def get_log() -> logging.Logger:
 
 
 def get_style() -> Dict:
-    if STYLE is None:
-        raise Exception("STYLE was None!")
+    if not STYLE:
+        raise Exception("STYLE was empty!")
+    return STYLE
+
+
+def get_style_with_precedence(
+    theme_mod: ModuleType,
+    direct_overrides: Dict,
+    style_override: str
+) -> Dict[str, Any]:
+    """Return the resulting style dict from the provided override values.
+    """
+    # style override order:
+    # 1. theme settings
+    styles = lookatme.themes.ensure_defaults(theme_mod)
+    # 2. inline styles from the presentation
+    dict_deep_update(styles, direct_overrides)
+    # 3. CLI style overrides
+    if style_override is not None:
+        styles["style"] = style_override  # type: ignore
+
+    return styles
+
+
+def set_global_style_with_precedence(
+    theme_mod,
+    direct_overrides,
+    style_override
+) -> Dict[str, Any]:
+    """Set the lookatme.config.STYLE value based on the provided override
+    values
+    """
+    global STYLE
+    STYLE = get_style_with_precedence(
+        theme_mod, direct_overrides, style_override)
+
     return STYLE
 
 
diff --git a/lookatme/parser.py b/lookatme/parser.py
index e3c0e68..a4ec754 100644
--- a/lookatme/parser.py
+++ b/lookatme/parser.py
@@ -13,6 +13,7 @@ import markdown_it.token
 import lookatme.config
 from lookatme.schemas import MetaSchema
 from lookatme.slide import Slide
+from lookatme.tutorial import tutor
 
 
 def md_to_tokens(md_text):
@@ -116,11 +117,11 @@ class Parser(object):
             if meta.get("title", "") in ["", None]:
                 meta["title"] = hinfo["title"]
 
-            def slide_split_check(token):
+            def slide_split_check(token):  # type: ignore
                 nonlocal hinfo
                 return is_heading(token) and token["level"] == hinfo["lowest_non_title"]
 
-            def heading_mod(token):
+            def heading_mod(token):  # type: ignore
                 nonlocal hinfo
                 token["level"] = max(
                     token["level"] - (hinfo["title_level"] or 0),
@@ -129,11 +130,10 @@ class Parser(object):
 
             keep_split_token = True
         else:
-
-            def slide_split_check(token):
+            def slide_split_check(token):  # type: ignore
                 return is_hrule(token)
 
-            def heading_mod(token):
+            def heading_mod(_):  # type: ignore
                 pass
 
             keep_split_token = False
@@ -196,6 +196,36 @@ class Parser(object):
                 res.append(token)
         return res
 
+    @tutor(
+        "general",
+        "slides splitting",
+        r"""
+        Slides can be:
+
+        ## Separated by horizontal rules (three or more `*`, `-`, or `_`)
+
+        ```markdown
+        slide 1
+        ***
+        slide 2
+        ```
+
+        ## Split using existing headings ("smart" splitting)
+
+        ```markdown
+        # Slide 1
+
+        # Slide 2
+        ```
+
+        ## Rendered as a single slide with the `--single` or `--one` CLI parameter
+
+        ```bash
+        lookatme --single content.md
+        ```
+        """,
+        order=2,
+    )
     def _scan_for_smart_split(self, tokens):
         """Scan the provided tokens for the number of hrules, and the lowest
         (h1 < h2) header level.
@@ -241,6 +271,31 @@ class Parser(object):
 
         return num_hrules, hinfo
 
+    @tutor(
+        "general",
+        "metadata",
+        r"""
+        The YAML metadata that can be prefixed in slides includes these top level
+        fields:
+
+        ```yaml
+        ---
+        title: "title"
+        date: "date"
+        author: "author"
+        extensions:
+          - extension 1
+          # .. list of extensions
+        styles:
+          # .. nested style fields ..
+        ---
+        ```
+
+        > **NOTE** The `styles` field will be explained in detail with each markdown
+        > element.
+        """,
+        order=3,
+    )
     def parse_meta(self, input_data) -> Tuple[AnyStr, Dict]:
         """Parse the PresentationMeta out of the input data
 
@@ -281,6 +336,24 @@ class Parser(object):
         data = MetaSchema().loads_partial_styles(yaml_data, partial=True)
         return new_input, data
 
+    @tutor(
+        "general",
+        "progressive slides",
+        r"""
+        Slides can be progressively displayed by inserting `<!-- stop -->`
+        comments between block elemtents (as in, inline within some other
+        markdown element).
+
+        <TUTOR:EXAMPLE>
+        This will display first, and after you press advance ...
+
+        <!-- stop -->
+
+        This will display!
+        </TUTOR:EXAMPLE>
+        """,
+        order=2,
+    )
     def _create_slides(self, tokens, number):
         """Iterate on tokens and create slides out of them. Can create multiple
         slides if the tokens contain progressive slide delimiters.
diff --git a/lookatme/pres.py b/lookatme/pres.py
index 8e020b5..7e5e871 100644
--- a/lookatme/pres.py
+++ b/lookatme/pres.py
@@ -14,9 +14,27 @@ import lookatme.prompt
 import lookatme.themes
 import lookatme.tui
 from lookatme.parser import Parser
-from lookatme.utils import dict_deep_update
+from lookatme.tutorial import tutor
 
 
+@tutor(
+    "general",
+    "intro",
+    r"""
+    `lookatme` is a terminal-based markdown presentation tool.
+
+    That means that you can:
+
+     | Write                 | Render          | Use                            |
+     |-----------------------|-----------------|--------------------------------|
+     | basic markdown slides | in the terminal | anywhere with markdown support |
+
+    > **NOTE** `l | j | right arrow` advance the slides
+    >
+    > **NOTE** `q` quits
+    """,
+    order=0,
+)
 class Presentation(object):
     """Defines a presentation"""
 
@@ -113,16 +131,12 @@ class Presentation(object):
                 self.ignore_ext_failure,
             )
 
-        # style override order:
-        # 1. theme settings
-        self.styles = lookatme.themes.ensure_defaults(self.theme_mod)
-        # 2. inline styles from the presentation
-        dict_deep_update(self.styles, self.meta.get("styles", {}))
-        # 3. CLI style overrides
-        if self.style_override is not None:
-            self.styles["style"] = self.style_override  # type: ignore
+        self.styles = lookatme.config.set_global_style_with_precedence(
+            self.theme_mod,
+            self.meta.get("styles", {}),
+            self.style_override,
+        )
 
-        lookatme.config.STYLE = self.styles
         self.initial_load_complete = True
 
     def warn_exts(self, exts):
diff --git a/lookatme/render/markdown_block.py b/lookatme/render/markdown_block.py
index a9784b6..f575cbc 100644
--- a/lookatme/render/markdown_block.py
+++ b/lookatme/render/markdown_block.py
@@ -5,6 +5,8 @@ representations
 
 
 import copy
+import pygments
+import pygments.styles
 import re
 import sys
 from typing import Dict, List, Tuple, Union
@@ -16,6 +18,7 @@ import lookatme.render.markdown_inline as markdown_inline
 import lookatme.render.pygments as pygments_render
 from lookatme.contrib import contrib_first
 from lookatme.render.context import Context
+from lookatme.tutorial import tutor
 from lookatme.widgets.fancy_box import FancyBox
 import lookatme.utils as utils
 
@@ -48,6 +51,23 @@ def render_all(ctx: Context):
         render(token, ctx)
 
 
+@tutor(
+    "markdown",
+    "paragraph",
+    r"""
+    Paragraphs in markdown are simply text with a full empty line between them:
+
+    <TUTOR:EXAMPLE>
+    paragraph 1
+
+    paragraph 2
+    </TUTOR:EXAMPLE>
+
+    ## Style
+
+    Paragraphs cannot be styled in lookatme.
+    """,
+)
 @contrib_first
 def render_paragraph_open(token, ctx: Context):
     """ """
@@ -135,6 +155,80 @@ def render_list_close(_, ctx: Context):
     ctx.container_pop()
 
 
+@tutor(
+    "markdown",
+    "ordered lists",
+    r"""
+    Ordered lists are lines of text prefixed by a `N. ` or `N)`, where `N` is
+    any number.
+
+    <TUTOR:EXAMPLE>
+    1. item
+    1. item
+        1. item
+            5. item
+            6. item
+        1. item
+    1. item
+    </TUTOR:EXAMPLE>
+
+    ## Style
+
+    Ordered lists can be styled with slide metadata. This is the default style:
+
+    <TUTOR:STYLE>numbering</TUTOR:STYLE>
+    """
+)
+@tutor(
+    "markdown",
+    "unordered lists",
+    r"""
+    Unordered lists are lines of text starting with either `*`, `+`, or `-`.
+
+    <TUTOR:EXAMPLE>
+    * item
+    * item
+        * item
+            * item
+            * item
+        * item
+    * item
+    </TUTOR:EXAMPLE>
+
+    ## Style
+
+    Unordered lists can be styled with slide metadata. This is the default style:
+
+    <TUTOR:STYLE>bullets</TUTOR:STYLE>
+    """
+)
+@tutor(
+    "markdown",
+    "lists",
+    r"""
+    Lists can either be ordered or unordered. You can nest lists by indenting
+    child lists by four spaces.
+
+    Other markdown elements can also be nested in lists.
+
+    <TUTOR:EXAMPLE>
+    1. item
+       > quote
+    1. item
+        * item
+            1. item
+               a paragraph
+
+               More text here blah blah blah
+            1. A new item
+        * item
+           ```python
+           print("hello")
+           ```
+    1. item
+    </TUTOR:EXAMPLE>
+    """
+)
 @contrib_first
 def render_list_item_open(_, ctx: Context):
     """ """
@@ -187,6 +281,27 @@ def render_list_item_close(_, ctx: Context):
     ctx.container_pop()
 
 
+@tutor(
+    "markdown",
+    "headings",
+    r"""
+    Headings are specified by prefixing text with `#` characters:
+
+    <TUTOR:EXAMPLE>
+    ## Heading Level 2
+    ### Heading Level 3
+    #### Heading Level 4
+    ##### Heading Level 5
+    </TUTOR:EXAMPLE>
+
+    ## Style
+
+    Headings can be styled with slide metadata. This is the default style:
+
+    <TUTOR:STYLE>headings</TUTOR:STYLE>
+    """,
+)
+@contrib_first
 @contrib_first
 def render_heading_open(token: Dict, ctx: Context):
     """ """
@@ -216,6 +331,33 @@ def render_heading_close(token: Dict, ctx: Context):
     ctx.ensure_new_block()
 
 
+@tutor(
+    "markdown",
+    "block quote",
+    r"""
+    Block quotes are lines of markdown prefixed with `> `. Block quotes can
+    contain text, other markdown, and can even be nested!
+
+    <TUTOR:EXAMPLE>
+    > Some quoted text
+    > > > > # Heading
+    > > > >
+    > > > *hello world*
+    > > >
+    > > ~~apples~~
+    > >
+    > space chips
+    </TUTOR:EXAMPLE>
+
+
+
+    ## Style
+
+    Block quotes can be styled with slide metadata. This is the default style:
+
+    <TUTOR:STYLE>quote</TUTOR:STYLE>
+    """
+)
 @contrib_first
 def render_blockquote_open(token: Dict, ctx: Context):
     """ """
@@ -259,6 +401,39 @@ def render_blockquote_close(token: Dict, ctx: Context):
     ctx.container_pop()
 
 
+@tutor(
+    "markdown",
+    "code blocks",
+    r"""
+    Multi-line code blocks are either surrounded by "fences" (three in a row of
+    either `\`` or `~`), or are lines of text indented at least four spaces.
+
+    Fenced codeblocks let you specify the language of the code:
+
+    <TUTOR:EXAMPLE>
+    ```python
+    def hello_world():
+        print("Hello, world!\n")
+    ```
+    </TUTOR:EXAMPLE>
+
+    ## Style
+
+    The syntax highlighting style used to highlight the code block can be
+    specified in the markdown metadata:
+
+    <TUTOR:STYLE>style</TUTOR:STYLE>
+
+    Valid values for the `style` field come directly from pygments. In the
+    version of pygments being used as you read this, the list of valid values is:
+
+    {pygments_values}
+
+    > **NOTE** This style name is confusing and will be renamed in lookatme v3.0+
+    """.format(
+        pygments_values=" ".join(pygments.styles.get_all_styles()),
+    )
+)
 @contrib_first
 def render_fence(token: Dict, ctx: Context):
     """Renders a code block using the Pygments library.
@@ -323,6 +498,32 @@ def _extract_nested_table_tokens(
     return (thead_token, tbody_token)
 
 
+@tutor(
+    "markdown",
+    "tables",
+    r"""
+    Rows in tables are defined by separating columns with `|` characters. The
+    header row is the first row defined and is separated by hypens (`---`).
+
+    Alignment within a column can be set by adding a colon, `:`, to the left,
+    right, or both ends of a header's separator.
+
+    <TUTOR:EXAMPLE>
+    | left align | centered | right align |
+    |------------|:--------:|------------:|
+    | 1          |     a    |           A |
+    | 11         |    aa    |          AA |
+    | 111        |    aaa   |         AAA |
+    | 1111       |   aaaaa  |        AAAA |
+    </TUTOR:EXAMPLE>
+
+    ## Style
+
+    Tables can be styled with slide metadata. This is the default style:
+
+    <TUTOR:STYLE>table</TUTOR:STYLE>
+    """,
+)
 @contrib_first
 def render_table_open(token: Dict, ctx: Context):
     """ """
diff --git a/lookatme/render/markdown_inline.py b/lookatme/render/markdown_inline.py
index 7a33833..d286e71 100644
--- a/lookatme/render/markdown_inline.py
+++ b/lookatme/render/markdown_inline.py
@@ -15,6 +15,7 @@ import lookatme.utils as utils
 from lookatme.contrib import contrib_first
 from lookatme.render.context import Context
 from lookatme.render.markdown_html import Tag
+from lookatme.tutorial import tutor
 from lookatme.widgets.clickable_text import LinkIndicatorSpec
 
 THIS_MOD = sys.modules[__name__]
@@ -52,6 +53,15 @@ def render_text(token, ctx: Context):
     ctx.inline_push((ctx.spec_text, token.get("content", "")))
 
 
+@tutor(
+    "markdown",
+    "emphasis",
+    r"""
+    <TUTOR:EXAMPLE>
+    The donut jumped *under* the crane.
+    </TUTOR:EXAMPLE>
+    """
+)
 @contrib_first
 def render_em_open(_, ctx: Context):
     ctx.spec_push(utils.spec_from_style("italics"))
@@ -62,6 +72,15 @@ def render_em_close(_, ctx: Context):
     ctx.spec_pop()
 
 
+@tutor(
+    "markdown",
+    "double emphasis",
+    r"""
+    <TUTOR:EXAMPLE>
+    They jumped **over** the wagon
+    </TUTOR:EXAMPLE>
+    """
+)
 @contrib_first
 def render_strong_open(_, ctx: Context):
     ctx.spec_push(utils.spec_from_style("bold"))
@@ -72,6 +91,15 @@ def render_strong_close(_, ctx: Context):
     ctx.spec_pop()
 
 
+@tutor(
+    "markdown",
+    "strikethrough",
+    r"""
+    <TUTOR:EXAMPLE>
+    I lost my ~~mind~~ keyboard and couldn't type anymore.
+    </TUTOR:EXAMPLE>
+    """
+)
 @contrib_first
 def render_s_open(_, ctx: Context):
     ctx.spec_push(utils.spec_from_style("strikethrough"))
@@ -82,6 +110,23 @@ def render_s_close(_, ctx: Context):
     ctx.spec_pop()
 
 
+@tutor(
+    "markdown",
+    "links",
+    r"""
+    Links are inline elements in markdown and have the form `[text](link)`
+
+    <TUTOR:EXAMPLE>
+    [lookatme on GitHub](https://github.com/d0c-s4vage/lookatme)
+    </TUTOR:EXAMPLE>
+
+    ## Style
+
+    Links can be styled with slide metadata. This is the default style:
+
+    <TUTOR:STYLE>link</TUTOR:STYLE>
+    """
+)
 @contrib_first
 def render_link_open(token, ctx: Context):
     attrs = dict(token["attrs"])
@@ -96,11 +141,54 @@ def render_link_close(_, ctx: Context):
     ctx.spec_pop()
 
 
+@tutor(
+    "markdown",
+    "images",
+    r"""
+    Vanilla lookatme renders images as links. Some extensions provide ways to
+    render images in the terminal.
+
+    Consider exploring:
+
+    * [lookatme.contrib.image_ueberzug](https://github.com/d0c-s4vage/lookatme.contrib.image_ueberzug)
+      * This works on Linux only, with X11, and must be separately installed
+
+    <TUTOR:EXAMPLE>
+    ![image alt](https://image/url)
+    </TUTOR:EXAMPLE>
+    """
+)
+@contrib_first
+def render_image(token, ctx: Context):
+    attrs = dict(token["attrs"])
+    attrs["href"] = attrs.get("src", "")
+
+    render_link_open({"type": "image_open", "attrs": list(attrs.items())}, ctx)
+    with ctx.use_tokens(token["children"]):
+        render_all(ctx)
+    render_link_close({}, ctx)
+
+
+@contrib_first
+def render_image_close(token, ctx: Context):
+    return render_link_close(token, ctx)
+
+
 @contrib_first
 def render_softbreak(_, ctx: Context):
     ctx.inline_push((ctx.spec_text, "\n"))
 
 
+@tutor(
+    "markdown",
+    "inline code",
+    r"""
+    <TUTOR:EXAMPLE>
+    The `OddOne` class accepts `Table` instances, converts them to raw pointers,
+    forces garbage collection to run.
+    </TUTOR:EXAMPLE>
+    """
+)
 @contrib_first
 def render_code_inline(token, ctx: Context):
     # TODO: add a style for the default programming language for inline text
diff --git a/lookatme/themes/__init__.py b/lookatme/themes/__init__.py
index f27140d..23dc851 100644
--- a/lookatme/themes/__init__.py
+++ b/lookatme/themes/__init__.py
@@ -3,12 +3,19 @@ Defines the built-in styles for lookatme
 """
 
 
+from typing import Any, Dict
+
 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"""
+def ensure_defaults(mod) -> Dict[str, Any]:
+    """Ensure that all required attributes exist within the provided module
+    """
     defaults = StyleSchema().dump(None)
     dict_deep_update(defaults, mod.theme)
+
+    if not isinstance(defaults, dict):
+        raise ValueError("Schemas didn't return a dict")
+
     return defaults
diff --git a/lookatme/tui.py b/lookatme/tui.py
index eeaedd7..3965d4b 100644
--- a/lookatme/tui.py
+++ b/lookatme/tui.py
@@ -16,6 +16,7 @@ import lookatme.parser
 import lookatme.render.markdown_block as markdown_block
 from lookatme.contrib import contrib_first, shutdown_contribs
 from lookatme.render.context import Context
+from lookatme.tutorial import tutor
 from lookatme.utils import spec_from_style
 from lookatme.widgets.clickable_text import ClickableText
 
@@ -156,6 +157,31 @@ class SlideRenderer(threading.Thread):
 
         return res
 
+    @tutor(
+        "general",
+        "markdown supported features",
+        r"""
+        Lookatme supports most markdown features.
+
+        |                         Supported | Not (yet) Supported |
+        |----------------------------------:|---------------------|
+        |                            Tables | Footnotes           |
+        |                          Headings | *Images             |
+        |                        Paragraphs | Inline HTML         |
+        |                      Block quotes |                     |
+        |                     Ordered lists |                     |
+        |                   Unordered lists |                     |
+        | Code blocks & syntax highlighting |                     |
+        |                 Inline code spans |                     |
+        |                   Double emphasis |                     |
+        |                   Single Emphasis |                     |
+        |                     Strikethrough |                     |
+        |                             Links |                     |
+
+        \*Images may be supported through extensions
+        """,
+        order=4,
+    )
     def _render_tokens(self, tokens):
         tmp_listbox = urwid.ListBox([])
         with self.ctx.use_tokens(tokens):
@@ -235,6 +261,49 @@ class MarkdownTui(urwid.Frame):
         spec = self.ctx.spec_text_with(spec)
         self.slide_num.set_text([(spec, slide_text)])
 
+    @tutor(
+        "general",
+        "title",
+        r"""
+        Notice the **title** up top *↑*  You can set it through
+
+        ## 1. Smart Slide Splitting
+
+        The first, lowest-level heading becomes the title, the next highest level
+        splits the slides
+
+        ```md
+        # My title
+
+        ## Slide 1
+
+        contents
+        ```
+
+        <!-- stop -->
+
+        ## 2. Metadata
+
+        Set the title explicitly through YAML metadata at the start of the slide:
+
+        ```md
+        ---
+        title: My title
+        ---
+
+        # Slide 1
+
+        Slide contents
+        ```
+
+        <!-- stop -->
+
+        > **NOTE** Metadata and styling will be covered later in this tutorial
+        >
+        > **NOTE** `h | k | delete | backspace | left arrow` reverse the slides
+        """,
+        order=1,
+    )
     def update_title(self):
         """Update the title"""
         title = self.pres.meta.get("title", "")
diff --git a/lookatme/tutorial.py b/lookatme/tutorial.py
new file mode 100644
index 0000000..2de48d1
--- /dev/null
+++ b/lookatme/tutorial.py
@@ -0,0 +1,302 @@
+"""
+Functions and sources for the markdown tutorial slides!
+"""
+
+import inspect
+import re
+from collections import OrderedDict
+from typing import Callable, List, Optional, Union
+
+import yaml
+
+import lookatme
+import lookatme.config as config
+import lookatme.utils as utils
+
+
+class Tutor:
+    """A class to handle/process tutorials for specific functionality
+
+    In addition to name, group, and slides content of the tutor, each Tutor
+    must also be associated with the implementation.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        group: str,
+        slides_md: str,
+        impl_fn: Callable,
+        order: int,
+        lazy_formatting: Optional[Callable] = None,
+    ):
+        """Create a new Tutor
+
+        Args:
+            lazy_formatting. Callable. Should return a dictionary that will be
+              unpacked into the kwargs of str.format()
+        """
+        self.name = name
+        self.group = group
+        self.slides_md = inspect.cleandoc(slides_md).strip()
+        self.impl_fn = impl_fn
+        self.order = order
+        self.lazy_formatting = lazy_formatting
+
+    def get_md(self, rendered_example=True) -> str:
+        """Get the tutor's markdown text after resolving any special markup
+        contained in it.
+            opts. Dict[str, Any]
+              slides. Current can include `{
+        """
+        slides_md = self.slides_md
+        if self.lazy_formatting is not None:
+            slides_md = slides_md.format(**self.lazy_formatting())
+
+        tag_handlers = {
+            "EXAMPLE": lambda contents: self._handle_show_and_render(contents, rendered_example),
+            "STYLE": self._handle_style_yaml,
+        }
+
+        res_md = []
+        last_idx = 0
+        regex = "<(?P<tag>TUTOR:(?P<type>[A-Z_]+))>(?P<inner>.*)</(?P=tag)>"
+        for match in re.finditer(regex, slides_md, re.MULTILINE | re.DOTALL):
+            res_md.append(slides_md[last_idx: match.start()])
+            match_groups = match.groupdict()
+            handler = tag_handlers.get(match_groups["type"], None)
+            if handler is None:
+                raise ValueError(
+                    "No handler defined for 'TUTOR:{}' tags".format(
+                        match_groups["type"]
+                    )
+                )
+            res_md.append(handler(match_groups["inner"]))
+            last_idx = match.end() + 1
+
+        res_md.append(slides_md[last_idx:])
+
+        return "\n\n".join([
+            self._get_heading(),
+            "".join(res_md),
+            self._get_source_note(),
+        ])
+
+    def _get_heading(self) -> str:
+        return "# {group}: {name}".format(
+            group=self.group.title(),
+            name=self.name.title(),
+        )
+
+    def _get_source_note(self) -> str:
+        link = self._get_source_link()
+        return "> This is implemented in {}".format(link)
+
+    def _get_source_link(self):
+        file_name = inspect.getsourcefile(inspect.unwrap(self.impl_fn))
+        if file_name is None:
+            return "??"
+
+        relpath = file_name.split("lookatme/", 1)[1]
+        _, lineno = inspect.getsourcelines(self.impl_fn)
+
+        version = "v" + lookatme.VERSION
+
+        return "[{module}.{fn_name}]({link})".format(
+            module=self.impl_fn.__module__,
+            fn_name=self.impl_fn.__qualname__,
+            link="https://github.com/d0c-s4vage/lookatme/blob/{version}/{path}#L{lineno}".format(
+                version=version,
+                path=relpath,
+                lineno=lineno,
+            )
+        )
+
+    def _handle_show_and_render(self, contents, rendered_example: bool = True) -> str:
+        contents = contents.strip()
+
+        markdown_example = "\n".join([
+            "~~~markdown",
+            contents,
+            "~~~",
+        ])
+        quoted_example = utils.prefix_text(markdown_example, "> ")
+
+        res = [
+            "***Markdown***:",
+            quoted_example,
+        ]
+
+        if rendered_example:
+            res += [
+                "***Rendered***:",
+                contents + "\n",
+            ]
+
+        return "\n\n".join(res)
+
+    def _handle_style_yaml(self, contents: str) -> str:
+        contents = contents.strip()
+        style = config.get_style()[contents]
+        style = {"styles": {contents: style}}
+        return "```yaml\n---\n{style_yaml}---\n```".format(
+            style_yaml=yaml.dump(style).encode().decode("unicode-escape"),
+        )
+
+
+GROUPED_TUTORIALS = OrderedDict()
+NAMED_TUTORIALS = OrderedDict()
+
+
+def get_tutorial_help() -> str:
+    res = []
+    res.append(inspect.cleandoc("""
+        Help for 'lookatme --tutorial'
+
+        Specific tutorials can be run with a comma-separated list of group or
+        tutorial names. Below are the groups and tutorial names currently defined.
+    """).strip())
+
+    for group_name, group_tutors in GROUPED_TUTORIALS.items():
+        res.append("")
+        res.append("  " + group_name)
+        for tutor_name in group_tutors.keys():
+            res.append("    " + tutor_name)
+
+    res.append("")
+    res.append(inspect.cleandoc("""
+        Substring matching is used to identify tutorials and groups. All matching
+        tutorials and groups are then run.
+
+        Examples:
+            lookatme --tutorial
+            lookatme --tutorial link,table
+            lookatme --tutorial general,list
+    """).strip())
+
+    return "\n".join(res)
+
+
+def print_tutorial_help():
+    print(get_tutorial_help())
+
+
+def tutor(
+    group: str,
+    name: str,
+    slides_md: str,
+    order: int = 99999,
+    lazy_formatting: Optional[Callable] = None
+):
+    """Define tutorial slides by using this as a decorator on a function!"""
+    def capture_fn(fn):
+        tutor = Tutor(name, group, slides_md, fn, order, lazy_formatting)
+        tutor_list = (
+            GROUPED_TUTORIALS
+            .setdefault(group, OrderedDict())
+            .setdefault(name, [])
+        )
+        tutor_list.append(tutor)
+        NAMED_TUTORIALS.setdefault(name, []).append(tutor)
+        return fn
+    return capture_fn
+
+
+def pretty_close_match(str1, str2):
+    str1 = str1.lower()
+    str2 = str2.lower()
+    if str1 in str2 or str2 in str1:
+        return True
+
+
+@tutor(
+    "general",
+    "tutorial",
+    r"""
+    Lookatme has a built-in tutorial feature that can be used for reference.
+
+    To launch lookatme's tutorial slides, run lookatme with the `--tutorial`
+    argument:
+
+    ```
+    lookatme --tutorial
+    ```
+
+    ## Specific Tutorials
+
+    Tutorials each have individual names and are organized into groups. You can
+    pass comma-separated values of strings that ~match group or tutorial names
+    to only run those tutorial slides:
+
+    ```bash
+    # only run the general slide group, and the list-related tutorials
+    lookatme --tutorial general,list
+
+    # only run the table tutorial slides
+    lookatme --tutorial table
+    ```
+
+    ## Seeing What's Available
+
+    If you pass the `help` value to `--tutorial`, lookatme me will list all
+    groups defined and all tutorial names within those groups. Currently this
+    is the output of `lookatme --tutorial help`. Notice that tutorial names
+    are nested under the group names:
+
+    ```
+    {lookatme_help}
+    ```
+    """,
+    order=99,  # last
+    lazy_formatting=lambda: {
+        "lookatme_help": get_tutorial_help()
+    }
+)
+def get_tutors(group_or_tutor: str) -> List[Tutor]:
+    for group_name, group_value in GROUPED_TUTORIALS.items():
+        if pretty_close_match(group_name, group_or_tutor):
+            return list(group_value.values())
+
+    res = []
+    for grouped_tutors in GROUPED_TUTORIALS.values():
+        for tutor_name, tutor_list in grouped_tutors.items():
+            if pretty_close_match(tutor_name, group_or_tutor):
+                res.append(tutor_list)
+
+    return res
+
+
+def _sort_tutors_by_order():
+    for group_name, tutors in list(GROUPED_TUTORIALS.items()):
+        del GROUPED_TUTORIALS[group_name]
+        tutor_list = list(tutors.items())
+        tutor_list = list(sorted(
+            tutor_list,
+            key=lambda x: min(tutor.order for tutor in x[1]),
+        ))
+        GROUPED_TUTORIALS[group_name] = OrderedDict(tutor_list)
+
+
+def get_tutorial_md(groups_or_tutors: List[str]) -> Union[None, str]:
+    _sort_tutors_by_order()
+
+    tutors = []
+    for group_or_tutor in groups_or_tutors:
+        tutors += get_tutors(group_or_tutor)
+
+    if len(tutors) == 0:
+        return None
+
+    res_slides = []
+    for tutor in tutors:
+        tutor_md = "\n\n".join(t.get_md() for t in tutor)
+        res_slides.append(tutor_md)
+
+    meta = inspect.cleandoc("""
+        ---
+        title: lookatme Tutorial
+        author: lookatme devs
+        ---
+    """).strip()
+
+    return meta + "\n" + "\n\n---\n\n".join(res_slides) + "\n\n---\n\n# End"
diff --git a/lookatme/utils.py b/lookatme/utils.py
index 6b40818..0224f7b 100644
--- a/lookatme/utils.py
+++ b/lookatme/utils.py
@@ -25,6 +25,10 @@ def get_meta(item):
     return meta
 
 
+def prefix_text(text: str, prefix: str, split: str = "\n") -> str:
+    return split.join(prefix + part for part in text.split(split))
+
+
 def row_text(rendered_row):
     """Return all text joined together from the rendered row"""
     return b"".join(x[-1] for x in rendered_row)
diff --git a/lookatme/widgets/table.py b/lookatme/widgets/table.py
index d57dd23..62d5644 100644
--- a/lookatme/widgets/table.py
+++ b/lookatme/widgets/table.py
@@ -318,7 +318,9 @@ class Table(urwid.Pile):
             # list of urwid.Piles
             for idx, cell in enumerate(row):
                 rend = cell.render((200,))
-                curr_col_width = max(len(rend_row.strip()) for rend_row in rend.text)
+
+                row_lens = [len(rend_row.strip()) for rend_row in rend.text]
+                curr_col_width = max([0] if not row_lens else row_lens)
                 column_maxes[idx] = max(column_maxes[idx], curr_col_width)
 
         return column_maxes


Test patch:
diff --git a/tests/test_parse.py b/tests/test_parse.py
index 408306b..bd91188 100644
--- a/tests/test_parse.py
+++ b/tests/test_parse.py
@@ -75,10 +75,35 @@ code block
 More text
     """
     parser = Parser()
-    slides = parser.parse_slides({}, input_data)
+    _, slides = parser.parse_slides({}, input_data)
     assert len(slides) == 2
 
 
+def test_parse_slides_with_progressive_stops_and_hrule_splits():
+    """Test that slide parsing works correctly
+    """
+    input_data = r"""
+# Heading
+
+<!-- stop -->
+
+p1
+
+<!-- stop -->
+
+p2
+
+---
+
+# Slide 2
+
+More text
+    """
+    parser = Parser()
+    _, slides = parser.parse_slides({}, input_data)
+    assert len(slides) == 4
+
+
 def test_parse_smart_slides_one_h1():
     """Test that slide smart splitting works correctly"""
     input_data = r"""
diff --git a/tests/test_tutorial.py b/tests/test_tutorial.py
new file mode 100644
index 0000000..169496d
--- /dev/null
+++ b/tests/test_tutorial.py
@@ -0,0 +1,70 @@
+"""
+Test tutorial functionality
+"""
+
+
+import inspect
+
+import lookatme.tutorial as tutorial
+
+
+def test_real_tutorials_exist():
+    assert "general" in tutorial.GROUPED_TUTORIALS
+    assert "markdown" in tutorial.GROUPED_TUTORIALS
+
+
+def test_tutorial_basic(mocker):
+    """Test that tutorials work correctly
+    """
+    mocker.patch("lookatme.tutorial.GROUPED_TUTORIALS", {})
+    mocker.patch("lookatme.tutorial.NAMED_TUTORIALS", {})
+
+    @tutorial.tutor("category", "name", "contents")
+    def some_function():
+        pass
+
+    assert "category" in tutorial.GROUPED_TUTORIALS
+    assert "name" in tutorial.GROUPED_TUTORIALS["category"]
+
+    category_md = tutorial.get_tutorial_md(["category"])
+    assert category_md is not None
+
+    name_md = tutorial.get_tutorial_md(["name"])
+    assert name_md is not None
+
+    assert category_md == name_md
+    assert "# Category: Name" in category_md
+    assert "contents" in category_md
+
+
+def test_tutor(mocker):
+    mocker.patch("lookatme.config.STYLE", {"test": {"test": "hello"}})
+    tutor = tutorial.Tutor(
+        "name",
+        "group",
+        "\n".join([
+            "<TUTOR:EXAMPLE>contents</TUTOR:EXAMPLE>",
+            "<TUTOR:STYLE>test</TUTOR:STYLE>",
+        ]),
+        impl_fn=lambda _: 10,
+        order=99999,
+    )
+
+    md_text = tutor.get_md()
+
+    md_example = "> ~~~markdown\n> contents\n> ~~~"
+    assert md_example in md_text
+
+    md_rendered = "\ncontents"
+    assert md_rendered in md_text
+
+    style_yaml = inspect.cleandoc("""
+        ```yaml
+        ---
+        styles:
+          test:
+            test: hello
+        ---
+        ```
+    """).strip()
+    assert style_yaml in md_text