markdown

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 9, 2026 License: MIT Imports: 10 Imported by: 0

README

Arandu

Arandu Markdown

An Arandu package that renders a Markdown body for a page: HTML that is safe to write unescaped, the headings a table of contents lists, the plain text search and generative engines read, and the reading time.

It does not parse Markdown. hesape/str.Markdown is the collection's renderer — CommonMark with GitHub tables, task lists, strikethrough and autolinks — and it passes raw HTML through untouched, leaving the sanitizing to its caller. This package is that caller: it reads the output back with the HTML5 tokenizer and writes it again through a closed allowlist.

Install

go get github.com/hyz-is/arandu-markdown

Wire it

An Arandu application registers a module explicitly. There is no service provider, no container and no discovery, so these are the lines to paste into bootstrap/app.go and there are no others.

The import, with the other module imports:

import (
	markdown "github.com/hyz-is/arandu-markdown"
)

The construction, in Build, before k.Register:

	markdownModule, err := markdown.New(markdown.Config{
		// The ids the layout already uses, so no heading takes one.
		Reserved: []string{"content", "comments"},
		// The origins img-src allows besides 'self' -- the CDN of the
		// media library, if there is one.
		ImageOrigins: imageOrigins,
	})
	if err != nil {
		return App{}, err
	}

And the registration, inside the k.Register(...) call already there:

		markdownModule,

New returns an error rather than starting half-wired: a heading level out of range, a reserved id no heading could take, or an image origin that is not a bare https:// origin is refused where it is written.

Registering it adds no route, no table and no background loop. It serves no route on purpose: a body reaches a page through the controller that authorized the entry it belongs to, and a route here would render text for anybody who sent some.

Render

The module value is shared: hand it to whatever builds the page.

doc := markdownModule.Render(post.Body)

doc.HTML()   // template.HTML, written unescaped by the view: {!! .Body.HTML() !!}
doc.Headings // []Heading{Level, ID, Text}, for the table of contents
doc.Text     // what the body says, one line per block, for search and llms-full.txt
doc.Words    // len(strings.Fields(doc.Text))
doc.Minutes  // reading time at Config.WordsPerMinute, 0 for an empty body

Render never fails and is a pure function of the source and the configuration, so the result may be cached by the source.

What comes out

Markdown HTML
paragraphs, hard breaks, --- p, br, hr
# to ###### h1–h6, never above Config.TopHeading, with an id at the top level
**strong**, *em*, ~~del~~, `code` strong, em, del, code
fenced and indented code pre and code class="language-…"
lists, task lists, quotes, tables ul, ol start, li, disabled input type="checkbox", blockquote, table with align
links a href title — http(s) with no userinfo, mailto: with an address, same-site paths, in-page anchors
images img src alt title loading="lazy" decoding="async" — same-site paths and Config.ImageOrigins
an image alone in its paragraph figure, with the image's title as its figcaption

Everything else is shown as the text it was. A pasted <iframe> appears on the page as <iframe>, a refused link keeps its words and a refused image its description, so an author reading the preview sees what did not make it instead of wondering where it went.

Configuration

Field Default What it does
TopHeading 2 The highest level a heading is drawn at. A higher one is lowered to it, never shifted, so # and ## both become sections. The contents list this level and the one below it
Reserved none Ids the page already uses. A heading that would take one gets -2
ImageOrigins none Bare https:// origins an image may load from besides the site itself — the same list as the content security policy's img-src
WordsPerMinute 200 The reading speed Minutes is measured at

Cost

One linear pass over what str.Markdown returns. str.Markdown is linear on prose and quadratic on a long run of link openers that never close, so a field that takes a body from people the application does not trust caps its length, as its form validation does anyway.

Layout

module.go   registration, Document and Heading, and the one entry point
config.go   what the application passes in
render.go   the allowlist and the rewrite

Tests

go test -race ./...

FuzzRenderWritesOnlyTheAllowlist reads every output back with the same tokenizer and fails on any element, attribute or destination the allowlist does not write. Its seeds run on every go test; go test -fuzz searches past them.

arandu.mod.toml declares no capability, and tests/Unit/audit_test.go compares that with what the code calls.

Licence

MIT. See LICENSE.md. Copyright HYZIS.

Documentation

Overview

Package markdown is an Arandu module that turns a Markdown body into what a page draws from it: HTML that is safe to write unescaped, the headings a table of contents lists, the plain text search and generative engines read, and the reading time.

It does not parse Markdown. hesape/str.Markdown is the collection's one renderer -- CommonMark with the GitHub tables, task lists, strikethrough and autolinks -- and a second parser here would be a second way to spell the same document. What str.Markdown leaves to its caller is the reason this package exists: raw HTML passes through it untouched, so rendering a body somebody typed renders whatever HTML they typed.

This package reads that output back as HTML and writes it again through a closed list of elements and attributes. Everything on the list is what the renderer itself emits; everything else is shown as the text it was, so an author who pasted an iframe sees the tag on the page instead of wondering where it went. Links keep http(s), mailto, same-site paths and in-page anchors. Images keep same-site paths and the origins the application names, which is what its content security policy lets a page load.

The files are laid out by role:

module.go  -> registration and the one entry point, Render
config.go  -> what the application passes in
render.go  -> the allowlist and the rewrite

An application builds it once in bootstrap/app.go and registers it there. There is no service provider, no container and no discovery.

Index

Constants

View Source
const (
	// DefaultTopHeading is the level the highest heading of a body is drawn at
	// when Config leaves TopHeading at zero. A page has one h1, its own title,
	// so a body starts one level below it.
	DefaultTopHeading = 2
	// DefaultWordsPerMinute is the reading speed Document.Minutes is measured
	// at when Config leaves WordsPerMinute at zero.
	DefaultWordsPerMinute = 200
	// MaxWordsPerMinute is the ceiling WordsPerMinute is refused above. A
	// speed nobody reads at is a reading time that says every body takes a
	// minute.
	MaxWordsPerMinute = 1000
)

The defaults for the optional settings. They are constants rather than literals inside Config.withDefaults, so the value a reader finds here is the value the package uses.

Variables

This section is empty.

Functions

This section is empty.

Types

type Config

type Config struct {
	// TopHeading is the level the highest heading of a body is drawn at, from
	// 1 to 6. Zero means DefaultTopHeading.
	//
	// A heading above it is lowered to it rather than shifted: a body written
	// with # and ## for its sections comes out with both at this level, and
	// the ### under them one below. Shifting every level by one would turn the
	// ## an author meant as a section into a subsection whenever somebody else
	// in the same collection used #.
	TopHeading int

	// Reserved are the element ids the page around the body already uses --
	// the skip link's target, the comment form, the share bar. A heading never
	// takes one, so an anchor the layout owns keeps pointing where it did.
	Reserved []string

	// ImageOrigins are the origins an image in a body may load from besides the
	// page's own: bare https origins, such as the CDN a media library serves
	// from. Empty means same-site paths only.
	//
	// It is the list the application's content security policy names in
	// img-src, and it is asked for here rather than read from there because a
	// renderer that emitted an image the policy refuses would draw a broken
	// picture instead of its description.
	ImageOrigins []string

	// WordsPerMinute is the reading speed Document.Minutes is measured at.
	// Zero means DefaultWordsPerMinute.
	WordsPerMinute int
}

Config is what the application passes when it builds the renderer.

A typed struct rather than a map: a misspelled key in a map is a setting that silently keeps its default, and the failure shows up as a page nobody asked for rather than as an error. Here a field that does not exist does not compile.

func (Config) Validate

func (c Config) Validate() error

Validate reports what the configuration cannot be used with.

It is called by New, so an application with a setting that cannot work fails where it is wired rather than on the first body that needed it.

type Document

type Document struct {

	// Headings are the top two levels of the body's own headings, in order,
	// for a table of contents. A heading inside a quote or a list is part of
	// that block, not a section of the page, and is not listed.
	Headings []Heading
	// Text is what the body says, without markup: one line per block, for a
	// search index, a description, llms-full.txt and the word count. It is
	// never HTML, and a view that prints it escapes it like any other string.
	Text string
	// Words is the number of words in Text.
	Words int
	// Minutes is the reading time at Config.WordsPerMinute, rounded up: at
	// least one for a body with any words, and zero for one without.
	Minutes int
	// contains filtered or unexported fields
}

Document is a rendered body.

func (Document) HTML

func (d Document) HTML() template.HTML

HTML is the body as markup. Every element and attribute in it is one the allowlist wrote, and every character of text in it was escaped, so a view writes it unescaped: {!! .Body.HTML() !!} in Kyse.

It is a method and not a field because that is the shape a view's raw output is entitled to. `aru doctor` accepts {!! !!} around a call -- markup something produced by escaping -- and warns on a value, which is what a field reads as; the renderer is the thing that escaped this one.

type Heading

type Heading struct {
	// Level is the level the heading was drawn at, after Config.TopHeading.
	Level int
	// ID is the element id the heading carries, unique in the body and never
	// one of Config.Reserved, so "#" + ID is its address.
	ID string
	// Text is the heading without markup.
	Text string
}

Heading is one entry of a table of contents.

type Module

type Module struct {
	// contains filtered or unexported fields
}

Module is what the application registers, and what renders a body.

It holds nothing that changes after New, so one value is safe to share between every request that renders.

func New

func New(cfg Config) (*Module, error)

New returns the module, or the reason it cannot be built.

It returns an error rather than panicking or carrying on, because everything it refuses is a wiring mistake, and a wiring mistake found at boot costs one restart. The same mistake found later is a page drawn with a broken picture.

func (*Module) Name

func (m *Module) Name() string

Name is the module identifier: a lowercase slug, stable, no spaces.

func (*Module) Render

func (m *Module) Render(src string) Document

Render renders a Markdown body.

It never fails: a body is text somebody wrote, and every part of it that is not on the allowlist is still text. What comes back is the same for the same source and configuration, so a caller may cache it by the source.

The cost is str.Markdown's plus one linear pass. str.Markdown is linear on ordinary prose and quadratic on a run of link openers that never close, so a field that takes a body from people the application does not trust caps its length, as a form field does anyway.

func (*Module) Routes

func (m *Module) Routes(*fhttp.Router)

Routes registers nothing.

A body reaches a page through the application's own controller, which already holds the Grant that decided whether the reader may see the entry it belongs to. A route here would render text for anybody who sent some, with no entry and no Grant behind it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL