max / docengine
git clone https://makenot.work/git/max/docengine.git
git clone git@ssh.makenot.work:max/docengine.git
| Name | Size | |
|---|---|---|
| .cargo/ | ||
| benches/ | ||
| docs/ | ||
| fuzz/ | ||
| scripts/ | ||
| src/ | ||
| tests/ | ||
| .gitignore | 439 B | |
| Cargo.toml | 2.6 KB | |
| LICENSE | 1.0 KB | |
| README.md | 8.1 KB | |
| rust-toolchain.toml | 523 B |
README
DocEngine
Configurable markdown-to-HTML rendering library with sanitization presets. Built on pulldown-cmark (GFM) and ammonia.
Used by MNW (site docs, blog posts, user-generated content), Multithreaded (forum posts), and the desktop apps (descriptions, notes).
Internal: not published to crates.io. Consumed by path in-tree, and by git dependency from anything built in a container, where a cross-repo path dependency is not reachable.
Presets
Five rendering presets, each with different security/feature tradeoffs:
| Preset | Use case | Tables | Images | Raw HTML | Dangerous scheme filter | Sanitization |
|---|---|---|---|---|---|---|
| Permissive | Docs, blog posts (trusted) | Y | Y | Y | N | Default ammonia |
| Standard | App text fields (descriptions) | Y | N | Y | N | Default ammonia |
| Strict | User-generated content (forums) | N | N | N | Y | nofollow on links |
| Chat | Chat messages | N | N | N | Y | nofollow on links, inline tags only |
| Sanitize-only | External HTML (RSS feeds) | – | – | – | – | Default ammonia, no markdown parsing |
Plain text
The presets all answer “how do I show this markdown”. render_plain answers
“what does this markdown say”, for the places that cannot take markup at all: a
list row’s preview line, a notification body, a search snippet, an alt
attribute. Block structure survives as newlines; emphasis, code spans and link
URLs do not. It produces text, not safe HTML, so whatever puts that text in a
document still escapes it.
use docengine::{render_chat, render_permissive, render_plain, render_standard, render_strict, sanitize_html};
// Convenience functions
let html = render_permissive("# Hello\n\n**Bold** text");
let html = render_standard("A description with [link](https://example.com)");
let html = render_strict("User post with @mentions and `code`");
let html = render_chat("A chat message with a [link](https://example.com)");
let html = sanitize_html("<p>Pre-rendered</p><script>stripped</script>");
// Markdown to text, for a preview line rather than a document
let text = render_plain("See **the [docs](https://example.com)**."); // "See the docs."
// Builder pattern for custom configurations
use docengine::{Renderer, SanitizePreset};
let html = Renderer::permissive()
.with_strip_images(true) // override: strip images even in permissive
.with_footnotes(false)
.render("# Custom config");
// Render with metadata (word count, reading time)
let result = Renderer::standard().render_with_meta("Some article text...");
println!("{} words, ~{} min read", result.word_count, result.reading_time_minutes);
Feature Flags
All optional features are off by default. Enable what you need:
| Flag | Dependencies | Provides |
|---|---|---|
doc-loader | regex | DocLoader – load a directory of .md files into an in-memory page store |
directives | regex-lite | post_process_directives – [!NOTE]/[!TIP]/[!TABS] blockquote alerts and code tabs |
frontmatter | toml | parse_frontmatter – extract TOML frontmatter delimited by +++ |
mentions | regex-lite | extract_mentions, resolve_mentions – @username parsing and linking |
quotes | regex-lite, uuid | post_process_quotes – replace [quote:POST_ID:HASH] markers with author attribution |
media-urls | regex-lite | rewrite_media_paths, img_to_video – CDN path rewriting and video tag conversion |
full | all of the above | Enable everything |
# In Cargo.toml
docengine = { path = "../../Libraries/docengine" } # From MNW/server/ or Apps/
docengine = { git = "https://makenot.work/git/max/docengine" } # From a container build
The path form needs the ~/Code layout on disk, which a clone does not
reproduce. Anything building in a container takes the git form.
Core API
Types
Renderer– configurable markdown renderer with builder patternRenderResult– rendered HTML plusword_countandreading_time_minutesSanitizePreset–Permissive,Standard,Strict,Minimal,ChatTocEntry– heading level, text, and anchor for table of contents
Functions
| Function | Description |
|---|---|
render_permissive(md) | Render with full GFM features |
render_standard(md) | Render without images |
render_strict(md) | Render with all restrictions (UGC-safe) |
render_chat(md) | Render a chat message: strict, and inline only |
sanitize_html(html) | Clean pre-rendered HTML without markdown parsing |
word_count(text) | Count words in raw text |
reading_time_minutes(wc) | Estimate reading time (200 wpm) |
extract_title(md) | Pull the first # Heading from markdown |
strip_first_heading(md) | Remove the first # Heading (for template-rendered titles) |
extract_toc(md) | Build a Vec<TocEntry> from all headings |
render_toc_html(entries) | Render TOC entries as a <nav class="toc"> HTML list |
Feature-gated
| Function / Type | Feature | Description |
|---|---|---|
DocLoader::load(path, config) | doc-loader | Load .md files from disk, render to HTML, build searchable index |
DocPage, DocIndexEntry | doc-loader | Page and index entry types |
post_process_directives(html) | directives | Convert [!NOTE]/[!TIP]/etc. blockquotes to alert divs, [!TABS] to tabbed code blocks |
parse_frontmatter(input) | frontmatter | Parse +++-delimited TOML frontmatter |
Frontmatter | frontmatter | Struct with title, date, tags, section, draft, extra |
extract_mentions(md) | mentions | Find unique @username mentions (skips code blocks) |
resolve_mentions(md, valid, template) | mentions | Replace @user with [@user](/path/to/user) for known usernames |
post_process_quotes(html, authors) | quotes | Replace [quote:UUID:HASH] with clickable attribution |
rewrite_media_paths(md, base, user) | media-urls | Rewrite relative image paths to absolute CDN URLs |
img_to_video(html) | media-urls | Convert <img> tags pointing to video files into <video> elements |
Value substitution
{{ dotted.path | filter(args) }} substitution is not a docengine feature. It lives in
subst (the generic engine) and mnw-assumptions (the MNW business-model layer on top),
both in the MNW tree. The seam is DocLoaderConfig::pre_process: MNW’s server builds an
Assumptions at boot and hands over its substitute. Any other pre-render text transform
plugs into the same hook.
Consumers
| Project | Features used | Preset |
|---|---|---|
| MNW | doc-loader, directives, frontmatter, media-urls | Permissive (docs/blog), Standard (descriptions) |
| Multithreaded | mentions, quotes | Strict (forum posts) |
| GoingsOn | core only | Standard (notes, descriptions) |
| Balanced Breakfast | core only | Sanitize-only (RSS feed content) |
| livechat | core only | Chat (messages). Not mentions: highlighting is client-side, so @name stays plain text |
Security
All presets sanitize output through ammonia. The strict preset additionally:
- Strips all raw HTML and images at the parser level (before ammonia)
- Replaces
javascript:,data:,vbscript:URLs with# - Adds
rel="noopener noreferrer nofollow"to all links
The chat preset is strict plus an inline-only tag allowlist, and it carries one
guarantee the others do not state: rendering a message makes no outbound request.
A link becomes an anchor and stays one. There is no preview fetch, no URL
resolution, no metadata lookup, and no HTTP client anywhere in this crate’s
dependency tree – chat_render_path_cannot_fetch asserts the last of those
against the lockfile, so a fetcher cannot be added without a test failing first.
Zero unsafe code.
License
MIT. See LICENSE.
Permissive on purpose. The products carry a different license: MNW and Multithreaded are PolyForm Noncommercial, while the libraries meant for reuse are MIT. That split is what lets a GPL consumer link this crate at all, since PolyForm’s noncommercial term is an added restriction GPL section 7 forbids.