MarkdownMesh

is a not-for-profit project whose goals include:
  1. Introducing people to the Markdown language and various ways to use it
  2. Creating tools for Markdown that are simple, carefully-scoped, secure, extensible, and blazingly fast
  3. Being a little bit of extra wind in people's sails as they do good stuff

Command-line tool

                      mm live-preview md/ 

The live-preview action starts live preview mode for the docset in the specified dir.

--allow-external-connections

By default, live preview mode only accepts network connections from the same computer.

If you want to see the preview page from another computer on your local network (e.g. a phone or tablet connected to the same Wi-Fi), you'll need to specify --allow-external-connections, which will accept connections from any computer. In networking software terms, the server will listen on 0.0.0.0 instead of 127.0.0.1.

This will accept connections from any computer that can communicate with yours on the network. But with most network routers / internet connections, you're behind a NAT and other computers probably cannot talk to yours unless they're on the same LAN.


$ mm live-preview md/ --tls … --allow-external-connections


Network:    --allow-external-connections

TLS:        …

Auth:
    - Authentication mode:  token-in-URL
    - Authorization scope:  all pages in this docset


→ https://192.168.1.293:8000/preview?token=9f5be4b…

--tls

TODO write this up

You may want to define a command-line alias such as this:

alias lp='mm live-preview --tls … --allow-external-connections'

zip -0 -r - md/     | mm zip2zip  > www-html.zip

This action is a function from a .zip archive to a .zip archive:

  • Input: a .zip whose file tree contains a directory of docset source (.md + other files)
  • Output: a .zip of rendered HTML (+ assets), suitable for deploying to a static web server

To emit a .zip of a directory foo in the current working directory:

zip -0 -r - foo

To emit a .zip of a specific commit abcdef123 of a git repository:

git archive --format=zip -0 abcdef123

--docset-root

In most contexts, you don't need this option.

Unless you've got some sort of multiple-projects-in-one-folder-with-relative-links-between-the-project-dirs scenario going on, you probably don't have to read about this.

Here's the logic for finding the root of the docset within the zip:

  • If there are zero .MarkdownMesh.json files in the input zip, then docset root defaults to root of the zip.
  • If there is exactly one, then docset root defaults to the dir which contains the config JSON file.
  • If there are multiple, then zip2zip will error unless you specify the --docset-root.

For example, suppose you have this file layout:

some-monorepo/
    foo/
    site/
        .MarkdownMesh.json
        ...
    src/
        ...
    bar/
    site/
        .MarkdownMesh.json
        ...
    src/
        ...

...and that you want to pass the entire monorepo as a zip file to zip2zip.

Possible reasons for this include:

  • pages within foo/site/ want to use assets that live outside of foo/
  • pages within foo/site/ want to import fragments of code (to CodeBlock elements) from bar/src/
  • pages within foo/site/ want to import fragments of Markdown (as parts of the doc) from bar/site/
  • pages within foo/site/ want to link to a sections of a pages within bar/site/ using a relative Markdown link like [](../bar/site/….md#…) that gets converted to a URL based in part on the url_root in bar/site/'s .MarkdownMesh config JSON.

In this scenario, you'll need to specify --docset-root foo/site/

echo $'## Foo\n...' | mm ast-json > ast.json
{"type": "Document", "children": [
    {"type": "Section", "level": 2, "children": [
        {"type": "Heading", "level": 2, "children": [
            {"type": "Text", "value": "Foo", "children": []}
        ]},
        {"type": "Paragraph", "children": [
            {"type": "Text", "value": "...", "children": []}
        ]}
    ]}
]}
echo $'## Foo\n...' | mm ast      > ast.txt
Document
╷
╰╴Section                { level: 2 }
  ╷
  ├╴Heading ── Text      { level: 2 }, "Foo"
  │
  ╰╴Paragraph ── Text    {}, "..."

Other actions

echo '[Foo](#foo)' | mm html-fragment

<p><a href="#foo">Foo</a></p>

echo '[Foo](#foo)' | mm html-page

<!DOCTYPE html>
<html>
...the details of this HTML depend on your config...

<p><a href="#foo">Foo</a></p>

...
</html>

Sandbox model (WASM sandbox + explicit authz for reqs / subprocs beyond that)

This tool consists of a WASM blob plus a simple wrapper.

Plugins only run within that WASM sandbox. They can request external things, but those requests are mediated by the wrapper, outside of the WASM VM, and they require explicit and carefully-scoped authorizations.

Examples:

  • "Plugin foo may make HTTP requests, but:
    • only to the host localhost:8000
    • only GET the pathname /asdf
    • only send querystring params whose keys are in { id }
  • "Plugin bar may run subprocesses, but:
    • only ssh
    • only with the exact args ["user@vm", "-T", "pngquant 32 -"]
    • only send it the input any *.png file from the docset source .zip
    • only obtain the process's stdout
    • only obtain stdout if the process succeeds
  • Plugin asdf may run a blob of web stuff inside a sandboxed / offline headless web browser instance and obtain the result

Text editor extension

Initially, this is for the text editor family known as Visual Studio Code™ / VSCodium / Code - OSS.

This optional extension helps mm live-preview to :

This extension also lets plugins propagate changes in the other direction, e.g. making by edits to your CodeBlock elements' code based on interaction with the preview.

Live preview mode

Live preview mode is like the preview pane you can see when editing Markdown in most IDEs, but it can be accessed from any web browser, including from a web browser on an external device.

Markdown

Markdown is a markup language for documents.

Markdown files (extension: .md) consist of plain text, with certain symbols indicating various things beyond plain text (list items, links, footnotes, images, etc).

Markdown syntax is similar to the formatting symbols in most workplace chat systems
(- list item, `code`, _italic/em_, etc), but more extensive.

Select any of these syntax tree elements for an example and more details:

## Part 5

Lorem ipsum

### Foo

More text
Document
╷
╰╴Section
  ╷
  ├╴Heading ── Text
  │
  ├╴Paragraph ── Text
  │
  ╰╴Section
    ╷
    ├╴Heading ── Text
    │
    ╰╴Paragraph ── Text
<article>

<section id="part-5">
<h2>Part 5</h2>
<p>Lorem ipsum</p>
<h3 id="foo">Foo</h3>
<p>More text</p>
</section>

</article>

JSON Schema

type DocumentElement = {
    type: "Document";
    children: Element[];
    extras?: Extras;
}
## Part 5

Lorem ipsum

### Foo

More text
Document
╷
╰╴Section
  ╷
  │ { level: 2 }
  │
  ├╴Heading ── Text
  │
  ├╴Paragraph ── Text
  │
  ╰╴Section
    ╷
    │ { level: 3 }
    │
    ├╴Heading ── Text
    │
    ╰╴Paragraph ── Text
<article>

<section id="part-5">
<h2>Part 5</h2>
<p>Lorem ipsum</p>
<h3 id="foo">Foo</h3>
<p>More text</p>
</section>

</article>
Note: how you configure the plugins affects whether any <section> elements appear in the HTML, and for which Heading level(s) they appear.

JSON Schema

type SectionElement = {
    type: "Section";
    children: Element[];
    extras?: Extras;
    level: number;
}
## Part 5

Lorem ipsum

### Foo

More text
Document
╷
╰╴Section
  ╷
  ├╴Heading ── Text
  │
  │   { level: 2 }
  │
  ├╴Paragraph ── Text
  │
  ╰╴Section
    ╷
    ├╴Heading ── Text
    │
    │   { level: 3 }
    │
    ╰╴Paragraph ── Text
<article>

<section id="part-5">
<h2>Part 5</h2>
<p>Lorem ipsum</p>
<h3 id="foo">Foo</h3>
<p>More text</p>
</section>

</article>
Note: how you configure the plugins affects whether any <section> elements appear in the HTML, and for which Heading level(s) they appear.

JSON Schema

type HeadingElement = {
    type: "Heading";
    children: Element[];
    extras?: Extras;
    level: number;
}
## Part 5

Lorem ipsum

### Foo

More text
Document
╷
╰╴Section
  ╷
  ├╴Heading ── Text
  │
  ├╴Paragraph ── Text
  │
  ╰╴Section
    ╷
    ├╴Heading ── Text
    │
    ╰╴Paragraph ── Text
<article>

<section id="part-5">
<h2>Part 5</h2>
<p>Lorem ipsum</p>
<h3 id="foo">Foo</h3>
<p>More text</p>
</section>

</article>

JSON Schema

type ParagraphElement = {
    type: "Paragraph";
    children: Element[];
    extras?: Extras;
}
Some text

<div>...</div>

<!-- ... -->
╷
├╴Paragraph
│ ╷
│ ╰╴Text
│
│     { value: "Some text" }
│
├╴HTML
│
╰╴Comment
<p>Some text</p>

<div>...</div>

JSON Schema

type TextNode = {
    type: "Text";
    children: Element[]; // empty
    extras?: Extras;
    value: string;
}
Some text

<div>...</div>

<!-- ... -->
╷
├╴Paragraph
│
├╴HTML
│
│     { value: "<div>...</div>" }
│
╰╴Comment
<p>Some text</p>

<div>...</div>

JSON Schema

type HTMLNode = {
    type: "HTML";
    children: Element[]; // empty
    extras?: Extras;
    value: string;
}
Some text

<div>...</div>

<!-- ... -->
╷
├╴Paragraph
│
├╴HTML
│
╰╴Comment

     { value: " ... " }

<p>Some text</p>

<div>...</div>

<!-- ... -->
Note: by default, comments do not get included in the HTML. You can override that in your config.

JSON Schema

type CommentNode = {
    type: "Comment";
    children: Element[]; // empty
    extras?: Extras;
    value: string;
}
X _Y_ Z

X **Y** Z

X ~~Y~~ Z
╷
├╴Paragraph
│ ╷
│ ├╴Text
│ ├╴Em ── Text
│ ╰╴Text
│
├╴Paragraph
│ ╷
│ ├╴Text
│ ├╴EmBold ── Text
│ ╰╴Text
│
╰╴Paragraph
  ╷
  ├╴Text
  ├╴Deletion ── Text
  ╰╴Text
<p>X <em>Y</em> Z</p>

<p>X <strong>Y</strong> Z</p>

<p>X <del>Y</del> Z</p>

JSON Schema

type EmElement = {
    type: "Em";
    children: Element[];
    extras?: Extras;
}
X _Y_ Z

X **Y** Z

X ~~Y~~ Z
╷
├╴Paragraph
│ ╷
│ ├╴Text
│ ├╴Em ── Text
│ ╰╴Text
│
├╴Paragraph
│ ╷
│ ├╴Text
│ ├╴EmBold ── Text
│ ╰╴Text
│
╰╴Paragraph
  ╷
  ├╴Text
  ├╴Deletion ── Text
  ╰╴Text
<p>X <em>Y</em> Z</p>

<p>X <strong>Y</strong> Z</p>

<p>X <del>Y</del> Z</p>

JSON Schema

type EmBoldElement = {
    type: "EmBold";
    children: Element[];
    extras?: Extras;
}
X _Y_ Z

X **Y** Z

X ~~Y~~ Z
╷
├╴Paragraph
│ ╷
│ ├╴Text
│ ├╴Em ── Text
│ ╰╴Text
│
├╴Paragraph
│ ╷
│ ├╴Text
│ ├╴EmBold ── Text
│ ╰╴Text
│
╰╴Paragraph
  ╷
  ├╴Text
  ├╴Deletion ── Text
  ╰╴Text
<p>X <em>Y</em> Z</p>

<p>X <strong>Y</strong> Z</p>

<p>X <del>Y</del> Z</p>

JSON Schema

type DeletionElement = {
    type: "Deletion";
    children: Element[];
    extras?: Extras;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type LinkRefDefElement = {
    type: "LinkRefDef";
    children: Element[];
    extras?: Extras;

    url: string;
    title: string | null;

    ref_id: string;
    ref_label: string | null;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type ImageElement = {
    type: "Image";
    children: Element[];
    extras?: Extras;

    url: string;
    title: string | null;
    alt: string | null;

    ref_id: string | null;
    ref_label: string | null;

    // Extension:
    info_string: string;
    lang: string | null;
    meta: string | null;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type CodeBlockElement = {
    type: "CodeBlock";
    children: Element[];
    extras?: Extras;

    value: string;

    info_string: string;
    lang: string | null;
    meta: string | null;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type CodeInlineElement = {
    type: "CodeInline";
    children: Element[];
    extras?: Extras;

    value: string;

    info_string: string;
    lang: string | null;
    meta: string | null;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type ListElement = {
    type: "List";
    children: Element[];
    extras?: Extras;

    ordered: boolean;
    start: number | null;
    spread: boolean;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type ListItemElement = {
    type: "ListItem";
    children: Element[];
    extras?: Extras;

    spread: boolean;
    checked: boolean | null;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type BlockquoteElement = {
    type: "Blockquote";
    children: Element[];
    extras?: Extras;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type ThematicBreakElement = {
    type: "ThematicBreak";
    children: Element[];
    extras?: Extras;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type FootnoteDefElement = {
    type: "FootnoteDef";
    children: Element[];
    extras?: Extras;

    ref_id: string;
    ref_label: string | null;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type FootnoteRefElement = {
    type: "FootnoteRef";
    children: Element[];
    extras?: Extras;

    ref_id: string;
    ref_label: string | null;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type TableElement = {
    type: "Table";
    children: Element[];
    extras?: Extras;

    align: (TextAlign | null)[];
}

type TextAlign = (
    "left" | "center" | "right"
)


(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type TableHeadElement = {
    type: "TableHead";
    children: Element[];
    extras?: Extras;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)
Note:

HTML supports having multiple <tbody>s per <table> (e.g. for expressing groups of rows).

GfM-Markdown tables don't provide a way to express that, but Plugins can create them in the AST based on whatever (e.g. based on blank rows between non-blank rows).

JSON Schema

type TableBodyElement = {
    type: "TableBody";
    children: Element[];
    extras?: Extras;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type TableRowElement = {
    type: "TableRow";
    children: Element[];
    extras?: Extras;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type TableCellElement = {
    type: "TableCell";
    children: Element[];
    extras?: Extras;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type ImportElement = {
    type: "Import";
    children: Element[];
    extras?: Extras;

    url: string;
    title: string | null;
    alt: string | null;

    ref_id: string | null;
    ref_label: string | null;

    info_string: string;
    lang: string | null;
    meta: string | null;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type TabNavigatorElement = {
    type: "TabNavigator";
    children: Element[];
    extras?: Extras;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type TabElement = {
    type: "Tab";
    children: Element[];
    extras?: Extras;

    title: string;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type SmallElement = {
    type: "Small";
    children: Element[];
    extras?: Extras;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type LayoutRowElement = {
    type: "LayoutRow";
    children: Element[];
    extras?: Extras;

    num_columns: number | null;
}
(TODO: write this up)
(TODO: write this up)
(TODO: write this up)

JSON Schema

type LayoutColumnElement = {
    type: "LayoutColumn";
    children: Element[];
    extras?: Extras;

    size: number | null;
}

Creating plugins

Plugin API Overview

Stages / hooks

doc_on_ast_draft
doc_on_ast_locked
doc_on_html_draft
doc_on_html_locked

docset_on_html_drafts
docset_on_assets_ready
docset_on_htmls_locked

Docset

.config
.filebytes_with(…)
.docs_with(…)
.doc_with(…)
.add_file(…)
.asset_via_subproc(…)
.asset_via_http(…)
.asset_via_browser(…)

AssetRef

.as_image(…)
AssetRefs get returned immediately, while the asset renders in the background. When rendered, any Image elements created will automatically have their attributes updated as relevant (including src, srcset, integrity).

Doc

(this a subclass of MarkdownElement)

doc.url_full
doc.url_pathname
doc.path_in_docset

doc.html_tree
doc.html # read-only

doc.docset

doc.add_css(…)
doc.add_js(…)
doc.add_head_tag(…)

MarkdownElement

.isa(T.CodeBlock)

.find_all(…)
.find_first(…) # or None
.find_last(…)  # or None
.find_sole(…)
# ^ Error iff not onee

.find_images(…)
.find_code_blocks(…)
…

.parent
.children
.siblings_after
.siblings_before
.preceding_sibling_or_none

.delete()
.replace_with(nodes)
.set_html_attr(k, v)

.props
# (for the current plugin)

                

When running in live preview mode, if you modify your plugin's source code file and then save that file, the file system event will be detected and mm live-preview can refresh its preview pages, with those docs being rendered using your updated plugin code.

You will be able to use either Zig or Python to create a plugin.

With Zig, your plugin gets compiled and ends up as WASM.

With Python, the plugin doesn't need to be compiled. It gets run within a compiled-to-WASM Python interpreter with a reduced Python standard library. (re: sandboxing model)

Plugins ✨

List of plugins

Plugin Example Result

e.g.

import-code !![](../src/code.py#foo) Inline a copy of the function definition foo from code.py as a CodeBlock with { lang: "py" }
fe-components <SomeWidget … /> HTML / CSS, rendered at build time from that component/template (.html / .tsx / .svelte).
Later: also support client-side Preact, React, or Svelte
infd-set-brackets The `{red,green,blue}`:sb LEDs

    ⎧ red,   ⎫
The ⎨ green, ⎬ LEDs
    ⎩ blue   ⎭
(HTML/CSS T.B.D., but will support screen reading and plaintext copy-pasting as "The red, green, and blue LEDs")

...for language extensions (beyond CommonMark)

md-math $…$ or $`…`$ for inline
$$…$$ for block
CodeInline or CodeBlock with { lang: "math" }
HTML/CSS, rendered at build time by KaTeX
md-footnotes [^Foo] / [^Foo]: ... FootnoteRef / FootnoteDef elements, HTML
md-tables Table, TableHead, TableBodys, TableRows, TableCells
md-tabnavigator TabNavigator / Tab : e.g. the Markdown section of this page has one Tab for each element. There are many ways to style a TabNavigator, and most do not require any JavaScript.
md-layout LayoutRow / LayoutColumn <div>s + CSS
md-small <small>…</small> Small <small>
md-codeinline-info-string `{}`:json
`{}`:"foo bar"
CodeInline with { info_string: "json" }
CodeInline with { info_string: "foo bar" }
md-import ✨ !![](…)
!![](…):…
!![](…):"… …"
This plugin's job is to turn that syntax into an Import element. To have that element result in any actual importing, use the ...for importing plugins (in the next plugin group)

...for importing

import-markdown !![](../foo.md#bar):firstP Inline a copy of the first paragraph of section Bar from foo.md
import-code !![](../src/code.py#foo) Inline a copy of the function definition foo from code.py as a CodeBlock with { lang: "py" }
import-usda-from-usdz !![](../x.usdz#/a/b):usda CodeBlock with { lang: "usda" }
(of the OpenUSD prim at path /a/b in x.usdz)

...for the world wide web

www-sitemap-xml /sitemap.xml file for search engines, optionally including the SHA-256 of each page.
www-validate-external-links Like www-validate-internal-links, but for external links:
  • Option: only check via existing .warc files
  • Option: always perform new HTTP requests
  • Option: perform HTTP requests only for URLs that are stale or not present in the .warc files

...for frontend stuff (CSS/JS/assets/etc)

fe-page-template
fe-assets
  • hash-based URL pathnames
  • SRI for all resources, not just scripts
fe-css-from-style-elements If you include a <style> element in your .md, e.g. at the end of the file, this plugin will include it in the main CSS for the page (which could be inline or as an asset, depending on config).

...for images

img-iiif ![](./….png#IIIF/…/…/…/….jpg) Using some external IIIF tool/server, render image clips using IIIF Image API syntax (for region, size, rotation, quality, and format).
img-thumbnails ![](foo.png):t Create a thumbnail (with custom profile "t"), using img-iiif
Have the <img> link to the full image

You define profiles in your config for this plugin.

...for code

code-highlighting
code-ast ```py :ast-side-by-side … Show a CodeBlock's code side-by-side with its AST, optionally with some subsets highlighted.
At first, this will only support { py, md, ts, zig }

...for diagrams

diagrams-mermaid
Mermaid in a CodeBlock
with { lang: "mermaid" }
diagrams-excalidraw
Excalidraw JSON in a CodeBlock
with { lang: "excalidraw" }
diagrams-jsoncanvas
JSON Canvas in a CodeBlock
with { lang: "jsoncanvas" }
diagrams-graphviz
Graphviz in a CodeBlock
with { lang: "graphviz" }

...for music

music-lilypond
LilyPond in a CodeBlock
with { lang: "ly" }

...for information design

infd-set-brackets The `{red,green,blue}`:sb LEDs

    ⎧ red,   ⎫
The ⎨ green, ⎬ LEDs
    ⎩ blue   ⎭
(HTML/CSS T.B.D., but will support screen reading and plaintext copy-pasting as "The red, green, and blue LEDs")
infd-style-links-by-category Link elements e.g. include an icon before some link types:
  • external link
  • internal link
  • same-page fragment link (#)
  • Wikimedia link
  • 📦 code repository link
  • 📄 link to a .pdf, etc
  • 🧩 #plugin-* on the same page
(you can configure it with patterns that only make sense within the context of your docset.)

Project status

Not yet published.

Project itinerary

  1. Not yet published
  2. Draft WASM blob (+ wrapper code) available at no cost
  3. FLOSS, v1.0

Acknowledgements

The command-line tool builds upon parts of the following FLOSS works:


This page
does not use
any JavaScript.