Help us shape the future of Go! Take the 2026 Go Developer Survey

markdown

package module
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Oct 25, 2026 License: MIT Imports: 11 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, an image origin that is not a bare https:// origin, a LinkRel naming a link type outside its list, or an Images mode that is not one of the two 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; rel from Config.LinkRel on an http(s) link
images img src alt title loading="lazy" decoding="async" — same-site paths and Config.ImageOrigins; none at all under Config.Images: markdown.ImagesNone
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
LinkRel none The rel every link to an http(s) address carries, out of external, nofollow, noopener, noreferrer, sponsored and ugc — "noopener nofollow ugc" for a body visitors wrote. Same-site paths, # anchors and mailto: never carry it: there is no other site for it to describe, and a nofollow on the site's own pages tells a crawler to skip them. Empty writes no rel
Images ImagesAllowedOrigins ImagesNone draws no image at all: each one, same-site paths included, is written as its description, as text. For a body nobody vetted, where a picture is a request the reader's browser makes to an address the author chose. New refuses it together with ImageOrigins, which would then allow nothing

Cost

One linear pass over what str.Markdown returns, and str.Markdown is linear in the length of the source on hostile input too: on a run of openers that never close, and on quotes and lists nested to the hundred levels it allows, whose lines it holds once rather than copying them at every level. Linear is not cheap on every shape, since a line under a hundred nested lists is read again at each of them, so a field that takes a body from people the application does not trust still 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

	// LinkRel is the rel attribute every link to an http or https address
	// carries: space-separated link types out of external, nofollow, noopener,
	// noreferrer, sponsored and ugc, such as "noopener nofollow ugc" for a body
	// visitors wrote. Each is written once, in the order given. Empty writes no
	// rel at all, which is the output a configuration without it always had.
	//
	// A same-site path, an in-page anchor and a mailto: address never carry
	// it: each leads to a page the site serves itself or to no page at all, so
	// there is no other site for the relation to describe, and a nofollow on
	// the site's own pages would tell a crawler to skip them.
	LinkRel string

	// Images is what an image in a body becomes. Empty means
	// ImagesAllowedOrigins.
	Images ImageMode
}

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 ImageMode added in v0.3.0

type ImageMode string

ImageMode is what an image in a body becomes.

const (
	// ImagesAllowedOrigins draws an image whose address is a same-site path or
	// on an origin Config.ImageOrigins names, and writes the description of
	// any other as text. It is what the empty ImageMode means.
	ImagesAllowedOrigins ImageMode = "allowed-origins"
	// ImagesNone never draws an image: every one is written as its
	// description, as text, wherever its address points. It is for a body
	// nobody vetted, where a picture is a request the reader's browser makes
	// to an address the author chose, same-site paths included.
	ImagesNone ImageMode = "none"
)

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, and str.Markdown is linear in the length of the source on hostile input too: on a run of openers that never close, and on quotes and lists nested to the hundred levels it allows, whose lines it holds once rather than copying them at every level. Linear is not cheap on every shape -- a line under a hundred nested lists is read again at each of them -- so a field that takes a body from people the application does not trust still 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