cortav  cortav.lua at [d5e8521d30]

File cortav.lua artifact 70cf5fbd0d part of check-in d5e8521d30


-- [ʞ] cortav.lua
--  ~ lexi hale <lexi@hale.su>
--  © AGPLv3
--  ? reference implementation of the cortav document language

local ss = require 'sirsem'
-- aliases for commonly used sirsem funcs
local startswith = ss.str.begins
local eachcode = ss.str.enc.utf8.each
local dump = ss.dump
local declare = ss.declare

-- make this module available to require() when linked into a lua bytecode program with luac
local ct = ss.namespace 'cortav'
ct.render = {}

ct.exns = {
	tx = ss.exnkind('translation error', function(msg,...)
		return string.format("(%s:%u) "..msg, ...)
	end);
	io = ss.exnkind('IO error', function(msg, ...)
		return string.format("<%s %s> "..msg, ...)
	end);
	cli = ss.exnkind 'command line parse error';
	mode = ss.exnkind('bad mode', function(msg, ...)
		return string.format("mode “%s” "..msg, ...)
	end);
	unimpl = ss.exnkind 'feature not implemented';
	ext = ss.exnkind 'extension error';
}

ct.ctx = declare {
	mk = function(src) return {src = src} end;
	ident = 'context';
	cast = {
		string = function(me)
			return string.format("%s:%s [%u]", me.src.file, me.line, me.generation or 0)
		end;
	};
	clonesetup = function(new, old)
		for k,v in pairs(old) do new[k] = v end
		if old.generation then
			new.generation = old.generation + 1
		else
			new.generation = 1
		end
	end;
	fns = {
		fail = function(self, msg, ...)
			ct.exns.tx(msg, self.src.file, self.line or 0, ...):throw()
		end;
		insert = function(self, block)
			block.origin = self:clone()
			table.insert(self.sec.blocks,block)
		end;
		ref = function(self,id)
			if not id:find'%.' then
				local rid = self.sec.refs[id]
				if self.sec.refs[id] then
					return self.sec.refs[id]
				else self:fail("no such ref %s in current section", id or '') end
			else
				local sec, ref = string.match(id, "(.-)%.(.+)")
				local s = self.doc.sections[sec]
				if s then
					if s.refs[ref] then
						return s.refs[ref]
					else self:fail("no such ref %s in section %s", ref, sec) end
				else self:fail("no such section %s", sec) end
			end
		end
	};
}

ct.sec = declare {
	ident = 'section';
	mk = function() return {
		blocks = {};
		refs = {};
		depth = 0;
		kind = 'ordinary';
	} end;
	construct = function(self, id, depth)
		self.id = id
		self.depth = depth
	end;
}

ct.doc = declare {
	ident = 'doc';
	fns = {
		mksec = function(self, id, depth)
			local o = ct.sec(id, depth)
			if id then self.sections[id] = o end
			table.insert(self.secorder, o)
			return o
		end;
		context_var = function(self, var, ctx, test)
			local fail = function(...)
				if test then return false end
				ctx:fail(...)
			end
			if startswith(var, 'cortav.') then
				local v = var:sub(8)
				if v == 'page' then
					if ctx.page then return tostring(ctx.page)
						else return '(unpaged)' end
				elseif v == 'renderer' then
					if not self.stage then
						return fail 'document is not being rendererd'
					end
					return self.stage.format
				elseif v == 'datetime' then
					return os.date()
				elseif v == 'time' then
					return os.date '%H:%M:%S'
				elseif v == 'date' then
					return os.date '%A %d %B %Y'
				elseif v == 'id' then
					return 'cortav.lua (reference implementation)'
				elseif v == 'file' then
					return self.src.file
				else
					return fail('unimplemented predefined variable %s', var)
				end
			elseif startswith(var, 'env.') then
				local v = var:sub(5)
				local val = os.getenv(v)
				if not val then
					return fail('undefined environment variable %s', v)
				end
			elseif self.stage.kind == 'render' and startswith(var, self.stage.format..'.') then
				-- TODO query the renderer somehow
				return fail('renderer %s does not implement variable %s', self.stage.format, var)
			elseif self.vars[var] then
				return self.vars[var]
			else
				if test then return false end
				return '' -- is this desirable behavior?
			end
		end;
	};
	mk = function() return {
		sections = {};
		secorder = {};
		embed = {};
		meta = {};
		vars = {};
	} end;
}

-- FP helper functions

local function fmtfn(str)
	return function(...)
		return string.format(str, ...)
	end
end

ct.ext = { loaded = {} }
function ct.ext.install(ext)
	if not ext.id then
		ct.exns.ext 'extension missing “id” field':throw()
	end
	if ct.ext.loaded[ext.id] then
		ct.exns.ext('there is already an extension with ID “%s” loaded', ext.id):throw()
	end
	ct.ext.loaded[ext.id] = ext
end

-- renderer engines
function ct.render.html(doc, opts)
	local doctitle = opts['title']
	local f = string.format
	local ids = {}
	local canonicalID = {}
	local function getSafeID(obj)
		if canonicalID[obj] then
			return canonicalID[obj]
		elseif obj.id and ids[obj.id] then
			local newid
			local i = 1
			repeat newid = obj.id .. string.format('-%x', i)
				i = i + 1 until not ids[newid]
			ids[newid] = obj
			canonicalID[obj] = newid
			return newid
		else
			local cid = obj.id
			if not cid then
				local i = 1
				repeat cid = string.format('x-%x', i)
					i = i + 1 until not ids[cid]
			end
			ids[cid] = obj
			canonicalID[obj] = cid
			return cid
		end
	end

	local langsused = {}
	local langpairs = {
		lua = { color = 0x9377ff };
		terra = { color = 0xff77c8 };
		c = { name = 'C', color = 0x77ffe8 };
		html = { color = 0xfff877 };
		scheme = { color = 0x77ff88 };
		lisp = { color = 0x77ff88 };
		fortran = { color = 0xff779a };
		python = { color = 0xffd277 };
		python = { color = 0xcdd6ff };
	}

	local stylesets = {
		header = [[
			h1,h2,h3,h4,h5,h6 { border-bottom: 1px solid @tone(0.7); }
			h1 { font-size: 200%; border-bottom-style: double !important; border-bottom-width: 3px !important; margin: 0em -1em; }
			h2 { font-size: 130%; margin: 0em -0.7em; }
			h3 { font-size: 110%; margin: 0em -0.5em; }
			h4 { font-size: 100%; font-weight: normal; margin: 0em -0.2em; }
			h5 { font-size: 90%; font-weight: normal; }
			h6 { font-size: 80%; font-weight: normal; }
			h3, h4, h5, h6 { border-bottom-style: dotted !important; }
			h1,h2,h3,h4,h5,h6 { 
				margin-top: 0;
				margin-bottom: 0;
			}
			:is(h1,h2,h3,h4,h5,h6) + p {
				margin-top: 0.4em;
			}

		]];
		headingAnchors = [[
			:is(h1,h2,h3,h4,h5,h6) > a[href].anchor {
				text-decoration: none;
				font-size: 1.2em;
				padding: 0.3em;
				opacity: 0%;
				transition: 0.3s;
				font-weight: 100;
			}
			:is(h1,h2,h3,h4,h5,h6):hover > a[href].anchor {
				opacity: 50%;
			}
			:is(h1,h2,h3,h4,h5,h6) > a[href].anchor:hover {
				opacity: 100%;
			}

			]] .. -- this is necessary to avoid the sections jumping around
			      -- when focus changes from one to another
			[[ section {
				border: 1px solid transparent;
			}

			section:target {
				margin-left: -2em;
				margin-right: -2em;
				padding: 0 2em;
				background: @tone(0.04);
				border: 1px dotted @tone(0.3);
			}

			section:target > :is(h1,h2,h3,h4,h5,h6) {

			}
		]];
		paragraph = [[
			p {
				margin: 0.7em 0;
			}
			section {
				margin: 1.2em 0;
			}
			section:first-child { margin-top: 0; }
		]];
		accent = [[
			body { background: @bg; color: @fg }
			a[href] {
				color: @tone(0.7 30);
				text-decoration-color: @tone/0.4(0.7 30);
			}
			a[href]:hover {
				color: @tone(0.9 30);
				text-decoration-color: @tone/0.7(0.7 30);
			}
			h1 { color: @tone(2); }
			h2 { color: @tone(1.5); }
			h3 { color: @tone(1.2); }
			h4 { color: @tone(1); }
			h5,h6 { color: @tone(0.8); }
		]];
		code = [[
			code {
				background: @fg;
				color: @bg;
				font-family: monospace;
				font-size: 90%;
				padding: 3px 5px;
			}
		]];
		abbr = [[
			abbr[title] { cursor: help; }
		]];
		editors_markup = [[]];
		block_code_listing = [[
			section > figure.listing {
				font-family: monospace;
				background: @tone(0.05);
				color: @fg;
				padding: 0;
				margin: 0.3em 0;
				counter-reset: line-number;
				position: relative;
				border: 1px solid @fg;
			}
			section > figure.listing>div {
				white-space: pre-wrap;
				counter-increment: line-number;
				text-indent: -2.3em;
				margin-left: 2.3em;
			}
			section > figure.listing>:is(div,hr)::before {
				width: 1.0em;
				padding: 0.2em 0.4em;
				text-align: right;
				display: inline-block;
				background-color: @tone(0.2);
				border-right: 1px solid @fg;
				content: counter(line-number);
				margin-right: 0.3em;
			}
			section > figure.listing>hr::before {
				color: transparent;
				padding-top: 0;
				padding-bottom: 0;
			}
			section > figure.listing>div::before {
				color: @fg;
			}
			section > figure.listing>div:last-child::before {
				padding-bottom: 0.5em;
			}
			section > figure.listing>figcaption:first-child {
				border: none;
				border-bottom: 1px solid @fg;
			}
			section > figure.listing>figcaption::after {
				display: block;
				float: right;
				font-weight: normal;
				font-style: italic;
				font-size: 70%;
				padding-top: 0.3em;
			}
			section > figure.listing>figcaption {
				font-family: sans-serif;
				font-size: 120%;
				padding: 0.2em 0.4em;
				border: none;
				color: @tone(2);
			}
			section > figure.listing > hr {
				border: none;
				margin: 0;
				height: 0.7em;
				counter-increment: line-number;
			}
		]];
		toc = [[

		]];
		tocFixed = [[
			@media (min-width: calc(@[width]:[100vw] + 20em)) {
				ol.toc {
					position: fixed;
					padding-top: 1em; padding-bottom: 1em;
					padding-right: 1em;
					margin-top: 0; margin-bottom: 0;
					right: 0; top: 0; bottom: 0;
					max-width: calc(50vw - ((@[width]:[0]) / 2) - 3.5em);
					overflow-y: auto;
				}
				@media (max-width: calc(@[width]:[100vw] + 30em)) {
					ol.toc {
						max-width: calc(100vw - ((@[width]:[0])) - 9.5em);
					}
					body {
						margin-left: 5em;
					}
				}
			}
		]];
	}

	local stylesNeeded = {}

	local function getSpanRenderers(tag,elt)
		local htmlDoc = function(title, head, body)
			return [[<!doctype html>]] .. tag('html',nil,
				tag('head', nil,
					elt('meta',{charset = 'utf-8'}) ..
					(title and tag('title', nil, title) or '') ..
					(head or '')) ..
				tag('body', nil, body or ''))
		end

		local span_renderers = {}
		local function htmlSpan(spans, block, sec)
			local text = {}
			for k,v in pairs(spans) do
				if type(v) == 'string' then
					table.insert(text,(v:gsub('[<>&"]',
						function(x)
							return string.format('&#%02u;', string.byte(x))
						end)))
				else
					table.insert(text, span_renderers[v.kind](v, block, sec))
				end
			end
			return table.concat(text)
		end

		function span_renderers.format(sp,...)
			local tags = { strong = 'strong', emph = 'em', strike = 'del', insert = 'ins', literal = 'code' }
			if sp.style == 'literal' and not opts['fossil-uv'] then
				stylesNeeded.code = true
			end
			if sp.style == 'del' or sp.style == 'ins' then
				stylesNeeded.editors_markup = true
			end
			return tag(tags[sp.style],nil,htmlSpan(sp.spans,...))
		end

		function span_renderers.term(t,b,s)
			local r = b.origin:ref(t.ref)
			local name = t.ref
			if name:find'%.' then name = name:match '^[^.]*%.(.+)$' end
			if type(r) ~= 'string' then
				b.origin:fail('%s is an object, not a reference', t.ref)
			end
			stylesNeeded.abbr = true
			return tag('abbr',{title=r},next(t.spans) and htmlSpan(t.spans,b,s) or name)
		end

		function span_renderers.macro(m,b,s)
			local r = b.origin:ref(m.macro)
			if type(r) ~= 'string' then
				b.origin:fail('%s is an object, not a reference', t.ref)
			end
			local mctx = b.origin:clone()
			mctx.invocation = m
			return htmlSpan(ct.parse_span(r, mctx),b,s)
		end

		function span_renderers.var(v,b,s)
			local val
			if v.pos then
				if not v.origin.invocation then
					v.origin:fail 'positional arguments can only be used in a macro invocation'
				elseif not v.origin.invocation.args[v.pos] then
					v.origin:fail('macro invocation %s missing positional argument #%u', v.origin.invocation.macro, v.pos)
				end
				val = v.origin.invocation.args[v.pos]
			else
				val = v.origin.doc:context_var(v.var, v.origin)
			end
			if v.raw then
				return val
			else
				return htmlSpan(ct.parse_span(val, v.origin), b, s)
			end
		end

		function span_renderers.link(sp,b,s)
			local href
			if b.origin.doc.sections[sp.ref] then
				href = '#' .. sp.ref
			else
				if sp.addr then href = sp.addr else
					local r = b.origin:ref(sp.ref)
					if type(r) == 'table' then
						href = '#' .. getSafeID(r)
					else href = r end
				end
			end
			return tag('a',{href=href},next(sp.spans) and htmlSpan(sp.spans,b,s) or href)
		end
		return {
			span_renderers = span_renderers;
			htmlSpan = htmlSpan;
			htmlDoc = htmlDoc;
		}
	end


	local function getBlockRenderers(tag,elt,sr,catenate)
		local function insert_toc(b,s)
			local lst = {tag = 'ol', attrs={class='toc'}, nodes={}}
			stylesNeeded.toc = true
			if opts['width'] then
				stylesNeeded.tocFixed = true
			end
			local stack = {lst}
			local top = function() return stack[#stack] end
			local all = s.origin.doc.secorder
			for i, sec in ipairs(all) do
				if sec.heading_node then
					local ent = tag('li',nil,
						 catenate{tag('a', {href='#'..getSafeID(sec)},
							sr.htmlSpan(sec.heading_node.spans))})
					if sec.depth > #stack then
						local n = {tag = 'ol', attrs={}, nodes={ent}}
						table.insert(top().nodes[#top().nodes].nodes, n)
						table.insert(stack, n)
					else
						if sec.depth < #stack then
							for j=#stack,sec.depth+1,-1 do stack[j] = nil end
						end
						table.insert(top().nodes, ent)
					end
				end
			end
			return lst
		end

		local block_renderers = {
			paragraph = function(b,s)
				stylesNeeded.paragraph = true;
				return tag('p', nil, sr.htmlSpan(b.spans, b, s), b)
			end;
			directive = function(b,s)
				-- deal with renderer directives
				local _, cmd, args = b.words(2)
				if cmd == 'page-title' then
					if not opts.title then doctitle = args end
				elseif b.critical then
					b.origin:fail('critical HTML renderer directive “%s” not supported', cmd)
				end
			end;
			label = function(b,s)
				if ct.sec.is(b.captions) then
					if not (opts['fossil-uv'] or opts.snippet) then
						stylesNeeded.header = true
					end
					local h = math.min(6,math.max(1,b.captions.depth))
					return tag(f('h%u',h), nil, sr.htmlSpan(b.spans, b, s), b)
				else
					-- handle other uses of labels here
				end
			end;
			['list-item'] = function(b,s)
				return tag('li', nil, sr.htmlSpan(b.spans, b, s), b)
			end;
			toc = insert_toc;
			table = function(b,s)
				local tb = {}
				for i, r in ipairs(b.rows) do
					local row = {}
					for i, c in ipairs(r) do
						table.insert(row, tag(c.header and 'th' or 'td',
						{align=c.align}, sr.htmlSpan(c.spans, b)))
					end
					table.insert(tb, tag('tr',nil,catenate(row)))
				end
				return tag('table',nil,catenate(tb))
			end;
			listing = function(b,s)
				stylesNeeded.block_code_listing = true
				local nodes = ss.map(function(l)
					if #l > 0 then
						return tag('div',nil,sr.htmlSpan(l, b, s))
					else
						return elt('hr')
					end
				end, b.lines)
				if b.title then
					table.insert(nodes,1,tag('figcaption',nil,sr.htmlSpan(b.title)))
				end
				if b.lang then langsused[b.lang] = true end
				return tag('figure', {class='listing', lang=b.lang, id=b.id and getSafeID(b)}, catenate(nodes))
			end;
			['break'] = function() --[[nop]] end;
		}
		return block_renderers;
	end

	local pspan = getSpanRenderers(function(t,a,v) return v  end,
	                               function(t,a)   return '' end)
	 
	local function getRenderers(tag,elt,catenate)
		local r = getSpanRenderers(tag,elt)
		r.block_renderers = getBlockRenderers(tag,elt,r,catenate)
		return r
	end

	local elt = function(t,attrs)
		return f('<%s%s>', t,
			attrs and ss.reduce(function(a,b) return a..b end, '', 
				ss.map(function(v,k)
					if v == true
						then          return ' '..k
						elseif v then return f(' %s="%s"', k, v)
					end
				end, attrs)) or '')
	end
	local tag = function(t,attrs,body)
		return f('%s%s</%s>', elt(t,attrs), body, t)
	end

	local ir = {}
	local toc
	local dr = getRenderers(tag,elt,table.concat) -- default renderers
	local plainr = getRenderers(function(t,a,v) return v  end,
	                            function(t,a)   return '' end, table.concat)
	local irBlockRdrs = getBlockRenderers(
		function(t,a,v,o) return {tag = t, attrs = a, nodes = type(v) == 'string' and {v} or v, src = o} end,
		function(t,a,o) return {tag = t, attrs = a, src = o} end,
		dr, function(...) return ... end)

	for i, sec in ipairs(doc.secorder) do
		if doctitle == nil and sec.depth == 1 and sec.heading_node then
			doctitle = plainr.htmlSpan(sec.heading_node.spans, sec.heading_node, sec)
		end
		local irs
		if sec.kind == 'ordinary' then
			if #(sec.blocks) > 0 then
				irs = {tag='section',attrs={id = getSafeID(sec)},nodes={}}

				for i, block in ipairs(sec.blocks) do
					local rd = irBlockRdrs[block.kind](block,sec)
					if rd then
						if opts['heading-anchors'] and block == sec.heading_node then
							stylesNeeded.headingAnchors = true
							table.insert(rd.nodes, ' ')
							table.insert(rd.nodes, {
								tag = 'a';
								attrs = {href = '#' .. irs.attrs.id, class='anchor'};
								nodes = {type(opts['heading-anchors'])=='string' and opts['heading-anchors'] or '&sect;'};
							})
						end
						table.insert(irs.nodes, rd)
					end
				end
			end
		elseif sec.kind == 'blockquote' then
		elseif sec.kind == 'listing' then
		elseif sec.kind == 'embed' then
		end
		if irs then table.insert(ir, irs) end
	end

	-- restructure passes
	
	---- list insertion pass
	local lists = {}
	for _, sec in pairs(ir) do
		if sec.tag == 'section' then
			local i = 1 while i <= #sec.nodes do local v = sec.nodes[i]
				if v.tag == 'li' then
					local ltag
					if v.src.ordered
						then ltag = 'ol'
						else ltag = 'ul'
					end
					local last = i>1 and sec.nodes[i-1]
					if last and last.embed == 'list' and not (
						last.ref[#last.ref].src.depth   == v.src.depth and
						last.ref[#last.ref].src.ordered ~= v.src.ordered
					) then
						-- add to existing list
						table.insert(last.ref, v)
						table.remove(sec.nodes, i) i = i - 1
					else
						-- wrap in list
						local newls = {v}
						sec.nodes[i] = {embed = 'list', ref = newls}
						table.insert(lists,newls)
					end
				end
			i = i + 1 end
		end
	end

	for _, sec in pairs(ir) do
		if sec.tag == 'section' then
			for i, elt in pairs(sec.nodes) do
				if elt.embed == 'list' then
					local function fail_nest()
						elt.ref[1].src.origin:fail('improper list nesting')
					end
					local struc = {attrs={}, nodes={}}
					if elt.ref[1].src.ordered then struc.tag = 'ol' else struc.tag = 'ul' end
					if elt.ref[1].src.depth ~= 1 then fail_nest() end

					local stack = {struc}
					local copyNodes = function(old,new)
						for i,v in ipairs(old) do new[#new + i] = v end
					end
					for i,e in ipairs(elt.ref) do
						if e.src.depth > #stack then
							if e.src.depth - #stack > 1 then fail_nest() end
							local newls = {attrs={}, nodes={e}}
							copyNodes(e.nodes,newls)
							if e.src.ordered then newls.tag = 'ol' else newls.tag='ul' end
							table.insert(stack[#stack].nodes[#stack[#stack].nodes].nodes, newls)
							table.insert(stack, newls)
						else
							if e.src.depth < #stack then
								-- pop entries off the stack
								for i=#stack, e.src.depth+1, -1 do stack[i] = nil end
							end
							table.insert(stack[#stack].nodes, e)
						end
					end

					sec.nodes[i] = struc
				end
			end
		end
	end
	

	-- collection pass
	local function collect_nodes(t)
		local ts = ''
		for i,v in ipairs(t) do
			if type(v) == 'string' then
				ts = ts .. v
			elseif v.nodes then
				ts = ts .. tag(v.tag, v.attrs, collect_nodes(v.nodes))
			elseif v.text then
				ts = ts .. tag(v.tag,v.attrs,v.text)
			else
				ts = ts .. elt(v.tag,v.attrs)
			end
		end
		return ts
	end
	local body = collect_nodes(ir)

	for k in pairs(langsused) do
		local spec = langpairs[k] or {color=0xaaaaaa}
		stylesets.block_code_listing = stylesets.block_code_listing .. string.format(
			[[section > figure.listing[lang="%s"]>figcaption::after
				{ content: '%s'; color: #%06x }]],
			k, spec.name or k, spec.color)
	end

	local prepcss = function(css)
		local tone = function(fac, sat, sep, alpha)
			local hsl = function(h,s,l,a)
				local v = string.format('%s, %u%%, %u%%', h,s,l)
				if a then
					return string.format('hsla(%s, %s)', v,a)
				else
					return string.format('hsl(%s)', v)
				end
			end
			sat = sat or 1
			fac = math.max(math.min(fac, 1), 0)
			sat = math.max(math.min(sat, 1), 0)
			if opts.accent then
				local hue = 'var(--accent)'
				local hsep = tonumber(opts['hue-spread'])
				if hsep and sep and sep ~= 0 then
					hue = string.format('calc(%s - %s)', hue, sep * hsep)
				end
				return hsl(hue, math.floor(100*sat), math.floor(100*fac), alpha)
			else
				local g = math.floor(0xFF * fac)
				return string.format('#' .. string.rep('%02x',alpha and 4 or 3), g,g,g,alpha and math.floor(0xFF*alpha))
			end
		end
		local replace = function(var,alpha,param)
			local tonespan = opts.accent and .1 or 0
			local tbg = opts['dark-on-light'] and 1.0 - tonespan or tonespan
			local tfg = opts['dark-on-light'] and tonespan or 1.0 - tonespan
			if var == 'bg' then
				return tone(tbg,nil,nil,tonumber(alpha))
			elseif var == 'fg' then
				return tone(tfg,nil,nil,tonumber(alpha))
			elseif var == 'tone' then
				local l, sep, sat
				for i=1,3 do -- 🙄
					l,sep,sat = param:match('^%('..string.rep('([^%s]*)%s*',i)..'%)$')
					if l then break end
				end
				l = ss.math.lerp(tonumber(l), tbg, tfg)
				return tone(l, tonumber(sat), tonumber(sep), tonumber(alpha))
			end
		end
		css = css:gsub('@(%b[]):(%b[])', function(v,d) return opts[v:sub(2,-2)] or v:sub(2,-2) end)
		css = css:gsub('@(%w+)/([0-9.]+)(%b())', replace)
		css = css:gsub('@(%w+)(%b())', function(a,b) return replace(a,nil,b) end)
		css = css:gsub('@(%w+)/([0-9.]+)', replace)
		css = css:gsub('@(%w+)', function(a,b) return replace(a,nil,b) end)
		return (css:gsub('%s+',' '))
	end

	local styles = {}
	if opts.width then
		table.insert(styles, string.format([[body {padding:0 1em;margin:auto;max-width:%s}]], opts.width))
	end
	if opts.accent then
		table.insert(styles, string.format(':root {--accent:%s}', opts.accent))
	end
	if opts.accent or (not opts['dark-on-light']) and (not opts['fossil-uv']) then
		stylesNeeded.accent = true
	end


	for k in pairs(stylesNeeded) do
		if not stylesets[k] then ct.exns.unimpl('styleset %s not implemented (!)',  k):throw() end
		table.insert(styles, prepcss(stylesets[k]))
	end

	local head = {}
	local styletag = ''
	if opts['link-css'] then
		local css = opts['link-css']
		if type(css) ~= 'string' then ct.exns.mode('must be a string', 'html:link-css'):throw() end
		styletag = styletag .. elt('link',{rel='stylesheet',type='text/css',href=opts['link-css']})
	end
	if next(styles) then
		if opts['gen-styles'] then
			styletag = styletag .. tag('style',{type='text/css'},table.concat(styles))
		end
		table.insert(head, styletag)
	end

	if opts['fossil-uv'] then
		return tag('div',{class='fossil-doc',['data-title']=doctitle},styletag .. body)
	elseif opts.snippet then
		return styletag .. body
	else
		return dr.htmlDoc(doctitle, next(head) and table.concat(head), body)
	end
end

do -- define span control sequences
	local function formatter(sty)
		return function(s,c)
			return {
				kind = 'format';
				style = sty;
				spans = ct.parse_span(s, c);
				origin = c:clone();
			}
		end
	end
	local function insert_link(s, c)
		local to, t = s:match '^([^%s]+)%s*(.-)$'
		if not to then c:fail('invalid link syntax >%s', s) end
		if t == "" then t = nil end
		return {
			kind = 'link';
			spans = (t and t~='') and ct.parse_span(t, c) or {};
			ref = to;
			origin = c:clone();
		}
	end
	local function insert_var_ref(raw)
		return function(s, c)
			local pos = tonumber(s)
			return {
				kind = 'var';
				pos = pos;
				raw = raw;
				var = not pos and s or nil;
				origin = c:clone();
			}
		end
	end
	ct.spanctls = {
		{seq = '!', parse = formatter 'emph'};
		{seq = '*', parse = formatter 'strong'};
		{seq = '~', parse = formatter 'strike'};
		{seq = '+', parse = formatter 'inser'};
		{seq = '\\', parse = function(s, c) -- raw
			return s
		end};
		{seq = '$\\', parse = function(s, c) -- raw
			return {
				kind = 'format';
				style = 'literal';
				spans = {s};
				origin = c:clone();
			}
		end};
		{seq = '$', parse = formatter 'literal'};
		{seq = '&', parse = function(s, c)
			local r, t = s:match '^([^%s]+)%s*(.-)$'
			return {
				kind = 'term';
				spans = (t and t ~= "") and ct.parse_span(t, c) or {};
				ref = r; 
				origin = c:clone();
			}
		end};
		{seq = '^', parse = function(s, c)
			local fn, t = s:match '^([^%s]+)%s*(.-)$'
			return {
				kind = 'footnote';
				spans = (t and t~='') and ct.parse_span(t, c) or {};
				ref = fn;
				origin = c:clone();
			}
		end};
		{seq = '>', parse = insert_link};
		{seq = '→', parse = insert_link};
		{seq = '🔗', parse = insert_link};
		{seq = '##', parse = insert_var_ref(true)};
		{seq = '#', parse = insert_var_ref(false)};
	}
end

function ct.parse_span(str,ctx)
	local function delimited(start, stop, s)
		local r = { pcall(ss.str.delimit, nil, start, stop, s) }
		if r[1] then return table.unpack(r, 2) end
		ctx:fail(tostring(r[2]))
	end
	local buf = ""
	local spans = {}
	local function flush()
		if buf ~= "" then
			table.insert(spans, buf)
			buf = ""
		end
	end
	local skip = false
	for c,p in eachcode(str) do
		if skip == true then
			skip = false
			buf = buf .. c
		elseif c == '\\' then
			skip = true
		elseif c == '{' then
			flush()
			local substr, following = delimited('{','}',str:sub(p.byte))
			local splitstart, splitstop = substr:find'%s+'
			local id, argstr
			if splitstart then
				id, argstr = substr:sub(1,splitstart-1), substr:sub(splitstop+1)
			else
				id, argstr = substr, ''
			end
			local o = {
				kind = 'macro';
				macro = id;
				args = {};
				origin = ctx:clone();
			}

			do local start = 1
				local i = 1
				while i <= #argstr do
					while i<=#argstr and (argstr:sub(i,i) ~= '|' or argstr:sub(i-1,i) == '\\|') do
						i = i + 1
					end
					local arg = argstr:sub(start, i == #argstr and i or i-1)
					start = i+1
					table.insert(o.args, arg)
					i = i + 1
				end
			end

			p.next.byte = p.next.byte + following - 1
			table.insert(spans,o)
		elseif c == '[' then
			flush()
			local substr, following = delimited('[',']',str:sub(p.byte))
			p.next.byte = following + p.byte
			local found = false
			for _,i in pairs(ct.spanctls) do
				if startswith(substr, i.seq) then
					found = true
					table.insert(spans, i.parse(substr:sub(1+#i.seq), ctx))
					break
				end
			end
			if not found then
				ctx:fail('no recognized control sequence in [%s]', substr)
			end
		else
			buf = buf .. c
		end
	end
	flush()
	return spans
end

local function
blockwrap(fn)
	return function(l,c)
		local block = fn(l,c)
		block.origin = c:clone();
		table.insert(c.sec.blocks, block);
	end
end

local insert_paragraph = blockwrap(function(l,c)
	if l:sub(1,1) == '.' then l = l:sub(2) end
	return {
		kind = "paragraph";
		spans = ct.parse_span(l, c);
	}
end)

local insert_section = function(l,c)
	local depth, id, t = l:match '^([#§]+)([^%s]*)%s*(.-)$'
	if id and id ~= "" then
		if c.doc.sections[id] then
			c:fail('duplicate section name “%s”', id)
		end
	else id = nil end

	local s = c.doc:mksec(id, utf8.len(depth))
	s.depth = utf8.len(depth)
	s.origin = c:clone()
	s.blocks={}

	if t and t ~= "" then
		local heading = {
			kind = "label";
			spans = ct.parse_span(t,c);
			origin = s.origin;
			captions = s;
		}
		table.insert(s.blocks, heading)
		s.heading_node = heading
	end
	c.sec = s
end

local dsetmeta = function(w,c)
	local key, val = w(1)
	c.doc.meta[key] = val
end
local dextctl = function(w,c)
	local mode, exts = w(1)
	for e in exts:gmatch '([^%s]+)' do
		if mode == 'uses' then
		elseif mode == 'needs' then
		elseif mode == 'inhibits' then
		end
	end
end
local dcond = function(w,c)
	local mode, cond, exp = w(2)
	c.hide_next = mode == 'unless'
end;
ct.directives = {
	author = dsetmeta;
	license = dsetmeta;
	keywords = dsetmeta;
	desc = dsetmeta;
	toc = function(w,c)
		local toc, op, val = w(2)
		if op == nil then
			table.insert(c.sec.blocks, {kind='toc'})
		end
	end;
	when = dcond;
	unless = dcond;
	expand = function(w,c)
		local _, m = w(1)
		if m ~= 'off' then
			c.expand_next = 1
		else
			c.expand_next = 0
		end
	end;
}

local function insert_table_row(l,c)
	local row = {}
	local buf
	local flush = function()
		if buf then
			buf.str = buf.str:gsub('%s+$','')
			table.insert(row, buf)
		end
		buf = { str = '' }
	end
	for c,p in eachcode(l) do
		if c == '|' or c == '+' and (p.code == 1 or l:sub(p.byte-1,p.byte-1)~='\\') then
			flush()
			buf.header = c == '+'
		elseif c == ':' then
			local lst = l:sub(p.byte-#c,p.byte-#c)
			local nxt = l:sub(p.next.byte,p.next.byte)
			if lst == '|' or lst == '+' and l:sub(p.byte-2,p.byte-2) ~= '\\' then
				buf.align = 'left'
			elseif nxt == '|' or nxt == '|' then
				if buf.align == 'left' then
					buf.align = 'center'
				else
					buf.align = 'right'
				end
			else
				buf.str = buf.str .. c
			end
		elseif c:match '%s' then
			if buf.str ~= '' then buf.str = buf.str .. c end
		elseif c == '\\' then
			local nxt = l:sub(p.next.byte,p.next.byte)
			if nxt == '|' or nxt == '+' or nxt == ':' then
				buf.str = buf.str .. nxt
				p.next.byte = p.next.byte + #nxt
				p.next.code = p.next.code + 1
			else
				buf.str = buf.str .. c
			end
		else
			buf.str = buf.str .. c
		end
	end
	if buf.str ~= '' then flush() end 
	for _,v in pairs(row) do
		v.spans = ct.parse_span(v.str, c)
	end
	if #c.sec.blocks > 1 and c.sec.blocks[#c.sec.blocks].kind == 'table' then
		local tbl = c.sec.blocks[#c.sec.blocks]
		table.insert(tbl.rows, row)
	else
		table.insert(c.sec.blocks, {
			kind = 'table';
			rows = {row};
			origin = c:clone();
		})
	end
end

ct.ctlseqs = {
	{seq = '.', fn = insert_paragraph};
	{seq = '¶', fn = insert_paragraph};
	{seq = '❡', fn = insert_paragraph};
	{seq = '#', fn = insert_section};
	{seq = '§', fn = insert_section};
	{seq = '+', fn = insert_table_row};
	{seq = '|', fn = insert_table_row};
	{seq = '│', fn = insert_table_row};
	{pred = function(s,c) return s:match'^[*:]' end, fn = blockwrap(function(l,c) -- list
		local stars = l:match '^([*:]+)'
		local depth = utf8.len(stars)
		local id, txt = l:sub(#stars+1):match '^(.-)%s*(.-)$'
		local ordered = stars:sub(#stars) == ':'
		if id == '' then id = nil end
		return {
			kind = 'list-item';
			depth = depth;
			ordered = ordered;
			spans = ct.parse_span(txt, c);
		}
	end)};
	{seq = '\t', fn = function(l,c)
		local ref, val = l:match '\t+([^:]+):%s*(.*)$'
		c.sec.refs[ref] = val
	end};
	{seq = '%', fn = function(l,c) -- directive
		local crit, cmdline = l:match '^%%([!%%]?)%s*(.*)$'
		local words = function(i)
			local wds = {}
			if i == 0 then return cmdline end
			for w,pos in cmdline:gmatch '([^%s]+)()' do
				table.insert(wds, w)
				i = i - 1
				if i == 0 then
					table.insert(wds,cmdline:sub(pos))
					return table.unpack(wds)
				end
			end
		end

		local cmd, rest = words(1)
		if ct.directives[cmd] then
			ct.directives[cmd](words,c)
		elseif cmd == c.doc.stage.mode['render:format'] then
			-- this is a directive for the renderer; insert it into the tree as is
			c:insert {
				kind = 'directive';
				critical = crit == '!';
				words = words;
			}
		elseif crit == '!' then
			c:fail('critical directive %s not supported',cmd)
		end
	end;};
	{seq = '~~~', fn = blockwrap(function(l,c)
		local extract = function(ptn, str)
			local start, stop = str:find(ptn)
			if not start then return nil, str end
			local ex = str:sub(start,stop)
			local n = str:sub(1,start-1) .. str:sub(stop+1)
			return ex, n
		end
		local lang, id, title
		if l:match '^~~~%s*$' then -- no args
		elseif l:match '^~~~.*~~~%s*$' then -- CT style
			local s = l:match '^~~~%s*(.-)%s*~~~%s*$'
			lang, s = extract('%b[]', s)
			if lang then lang = lang:sub(2,-2) end
			id, title = extract('#[^%s]+', s)
			if id then id = id:sub(2) end
		elseif l:match '^~~~' then -- MD shorthand style
			lang = l:match '^~~~%s*(.-)%s*$'
		end
		c.mode = {
			kind = 'code';
			listing = {
				kind = 'listing';
				lang = lang, id = id, title = title and ct.parse_span(title,c);
				lines = {};
			}
		}
		if id then
			if c.sec.refs[id] then c:fail('duplicate ID %s', id) end
			c.sec.refs[id] = c.mode.listing
		end
		return c.mode.listing;
	end)};
	{pred = function(s,c)
		if s:match '^[%-_][*_%-%s]+' then return true end
		if startswith(s, '—') then
			for c, p in eachcode(s) do
				if ({
					['—'] = true, ['-'] = true, [' '] = true;
					['*'] = true, ['_'] = true, ['\t'] = true;
				})[c] ~= true then return false end
			end
			return true
		end
	end; fn = blockwrap(function()
		return { kind = 'horiz-rule' }
	end)};
	{fn = insert_paragraph};
}

function ct.parse(file, src, mode)
	local function
	is_whitespace(cp)
		return cp == 0x20
	end

	local ctx = ct.ctx.mk(src)
	ctx.line = 0
	ctx.doc = ct.doc.mk()
	ctx.doc.src = src
	ctx.doc.stage = {
		kind = 'parse';
		mode = mode;
	}
	ctx.sec = ctx.doc:mksec() -- toplevel section
	ctx.sec.origin = ctx:clone()

	for full_line in file:lines() do ctx.line = ctx.line + 1
		local l
		for p, c in utf8.codes(full_line) do
			if not is_whitespace(c) then
				l = full_line:sub(p)
				break
			end
		end
		if ctx.mode then
			if ctx.mode.kind == 'code' then
				if l and l:match '^~~~%s*$' then
					ctx.mode = nil
				else
					-- TODO handle formatted code
					table.insert(ctx.mode.listing.lines, {l})
				end
			else
				ctx:fail('unimplemented syntax mode %s', ctx.mode.kind)
			end
		else
			if l then
				local found = false
				for _, i in pairs(ct.ctlseqs) do
					if  ((not i.seq ) or startswith(l, i.seq)) and
						((not i.pred) or i.pred    (l, ctx  )) then
						found = true
						i.fn(l, ctx)
						break
					end
				end
				if not found then
					ctx:fail 'incomprehensible input line'
				end
			else
				if next(ctx.sec.blocks) and ctx.sec.blocks[#ctx.sec.blocks].kind ~= 'break' then
					table.insert(ctx.sec.blocks, {kind='break'})
				end
			end
		end
	end

	return ctx.doc
end