cortav  html.lua at [cf42cc180c]

File render/html.lua artifact 2e06151bbc part of check-in cf42cc180c


-- [ʞ] render/html.lua
--  ~ lexi hale <lexi@hale.su>
--  πŸ„― AGPLv3
--  ? renders cortav to beautiful, highly customizable
--    webpages full of css trickery to make them look
--    good both on a screen and when printed.
--  > cortav -m render:format html

local ct = require 'cortav'
local ss = require 'sirsem'

-- install rendering function for html
function ct.render.html(doc, opts, setup)
	local doctitle = opts['title']
	local f = string.format
	local getSafeID = ct.tool.namespace()

	local footnotes = {}
	local footnotecount = 0

	local cdata = function(...) return ... end
	if opts.epub then
		opts.xhtml = true
	end

	if opts.xhtml then
		cdata = function(s)
			return '<![CDATA[' .. s .. ']]>'
		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 };
		ruby = { color = 0xcdd6ff };
	}

	local stylesets = {
		list = [[
			@counter-style enclosed {
				system: extends decimal;
				prefix: "(";
				suffix: ") ";
			}
			ul, ol {
				padding: 0 1em;
			}
			li {
				padding: 0.1em 0;
			}
		]];
		list_ordered = [[]];
		list_unordered = [[]];
		footnote = [[
			@media screen {
				a[href].fnref {
					text-decoration-style: dashed;
					color: @tone(0.7 45);
					text-decoration-color: @tone/0.4(0.7 45);
				}
				a[href]:hover.fnref {
					color: @tone(0.9 45);
					text-decoration-color: @tone/0.7(0.7 45);
				}
			}
			aside.footnote {
				font-family: 90%;
				grid-template-columns: 1em 1fr min-content;
				grid-template-rows: 1fr min-content;
				position: fixed;
				padding: 1em;
				background: @tone(0.03);
				margin:auto;
			}
			@media screen {
				aside.footnote {
					display: grid;
					left: 10em;
					right: 10em;
					max-width: calc(@width + 2em);
					max-height: 30vw;
					bottom: 1em;
					border: 1px solid black;
					transform: translateY(200%);
					transition: 0.4s;
					z-index: 100;
				}
				aside.footnote:target {
					transform: translateY(0%);
				}
				#cover {
					position: fixed;
					top: 0;
					left: 0;
					height: 100vh; width: 100vw;
					background: linear-gradient(to top,
						@tone/0.8(-0.07),
						@tone/0.4(-0.07));
					opacity: 0%;
					transition: 1s;
					pointer-events: none;
					backdrop-filter: blur(0px);
				}
				aside.footnote:target ~ #cover {
					opacity: 100%;
					pointer-events: all;
					backdrop-filter: blur(5px);
				}
			}
			@media screen and (max-width: calc(@width + 20em)) {
				aside.footnote {
					left: 1em;
					right: 1em;
				}
			}
			@media print {
				aside.footnote {
					display: grid;
					position: relative;
				}
				aside.footnote:first-of-type {
					border-top: 1px solid black;
				}
			}

			aside.footnote > a[href="#0"]{
				grid-row: 2/3;
				grid-column: 3/4;
				display: block;
				text-align: center;
				padding: 0 0.3em;
				text-decoration: none;
				background: @tone(0.2);
				color: @tone(1);
				border: 1px solid black;
				margin-top: 0.6em;
				font-size: 150%;
				-webkit-user-select: none;
				-ms-user-select: none;
				user-select: none;
				-webkit-user-drag: none;
				user-drag: none;
			}
			aside.footnote > a[href="#0"]:hover {
				background: @tone(0.3);
				color: @tone(2);
			}
			aside.footnote > a[href="#0"]:active {
				background: @tone(0.05);
				color: @tone(0.4);
			}
			@media print {
				aside.footnote > a[href="#0"]{
					display:none;
				}
			}
			aside.footnote > div.number {
				text-align:right;
				grid-row: 1/2;
				grid-column: 1/2;
			}
			aside.footnote > .text {
				grid-row: 1/2;
				grid-column: 2/4;
				padding-left: 1em;
				overflow-y: auto;
				margin-top: 0;
			}
			aside.footnote > .text > :first-child {
				margin-top: 0;
			}
		]];
		docmeta = [[
			.render-warn {
				border: 1px solid @tone(0.1 20);
				background: @tone(0.4 20);
				padding: 1em;
				margin: 5em 1em;
			}
		]];
		embed = [[
			embed, .embed {
				width: 100%;
				height: fit-content;
				max-height: 80vh;
				overflow: scroll;
			}
			embed {height: 20em;}
		]];
		header = [[
			body { padding: 0 2.5em !important }
			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;
				text-align: justify;
			}
			section {
				margin: 1.2em 0;
			}
			section:first-child { margin-top: 0; }
		]];
		accent = [[
			@media screen {
				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); }
			}
			@media print {
				a[href] {
					text-decoration: none;
					color: black;
					font-weight: bold;
				}
				h1,h2,h3,h4,h5,h6 {
					border-bottom: 1px black;
				}
			}
		]];
		aside = [[
			section > aside {
				text-align: justify;
				margin: 0 1.5em;
				padding: 0.5em 0.8em;
				background: @tone(0.05);
				font-size: 90%;
				border-left: 5px solid @tone(0.2 15);
				border-right: 5px solid @tone(0.2 15);
			}
			section > aside p {
				margin: 0;
				margin-top: 0.6em;
			}
			section > aside p:first-child {
				margin: 0;
			}
         section aside + aside {
				margin-top: 0.5em;
			}
      ]];
		code = [[
			code {
				display: inline-block;
				background: @tone(-1);
				color: @tone(0.7);
				font-family: monospace;
				font-size: 90%;
				padding: 2px 5px;
				user-select: all;
			}
		]];
		var = [[
			var {
				font-style: italic;
				font-family: monospace;
				color: @tone(0.7);
				font-size: 90%;
			}
			code var {
				color: @tone(0.4);
			}
		]];
		math = [[
			span.equation {
				display: inline-block;
				background: @tone(0.08 5);
				color: @tone(1.5 70);
				padding: 0.1em 0.3em;
				border: 1px inset @tone(0.3 5);
			}
			span.equation var {
				color: @tone(1 40);
			}
			span.equation :is(code,var,strong) {
				font-family: initial;
			}
			span.equation strong {
				color: @tone(1 90);
				padding: 0 0.4em;
			}
			span.equation code {
				color: @tone(0.9 10);
				background: none;
				padding: 0;
			}
		]];
		abbr = [[
			abbr[title] { cursor: help; }
		]];
		editors_markup = [[]];
		block_code_listing = [[
			figure.listing {
				font-family: monospace;
				font-size: 85%;
				background: @tone(0.05 20);
				color: @tone(1 20);
				padding: 0;
				margin: 0.3em 0;
				counter-reset: line-number;
				position: relative;
				border: 1px solid @tone(1 20);
			}
			:not(figure.listing) + figure.listing {
            margin-top: 1em;
         }
			figure.listing + :not(figure.listing) {
				margin-top: 1em;
         }
			figure.listing>div {
				white-space: pre-wrap;
				tab-size: 3;
				-moz-tab-size: 3;
				counter-increment: line-number;
				text-indent: -2.3em;
				margin-left: 2.3em;
			}
			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 20);
				border-right: 1px solid @fg;
				content: counter(line-number);
				margin-right: 0.3em;
			}
			figure.listing>hr::before {
				color: transparent;
				padding-top: 0;
				padding-bottom: 0;
			}
			figure.listing>div::before {
				color: @tone(1 20);
			}
			figure.listing>div:last-child::before {
				padding-bottom: 0.5em;
			}
			figure.listing>figcaption:first-child {
				border: none;
				border-bottom: 1px solid @tone(1 20);
			}
			figure.listing>figcaption::after {
				display: block;
				float: right;
				font-weight: normal;
				font-style: italic;
				font-size: 70%;
				padding-top: 0.3em;
			}
			figure.listing>figcaption {
				font-family: sans-serif;
				font-size: 120%;
				padding: 0.2em 0.4em;
				border: none;
				color: @tone(2 20);
			}
			figure.listing > hr {
				border: none;
				margin: 0;
				height: 0.7em;
				counter-increment: line-number;
			}
		]];
		root = [[
			body {
				font-size: 16pt;
				page-break-before: always;
			}
			h1 {
				page-break-before: always;
			}
			h1,h2,h3,h4,h5,h6 {
				page-break-after: avoid;
			}
		]];
		linkBlock = [[
			a[href].link {
				position: relative;
				display: block;
				padding: .5em;
				padding-right: 1.5em;
				border: 1px solid @tone(0.2 30);
				background: @tone(0.05 30);
				font-size: 1.1em;
				margin: 0 1em;
				text-decoration: none;
				color: @tone(0.8 30);
			}
			a[href].link + a[href].link {
				margin-top: -1px;
			}
			a[href].link:hover {
				border-color: @tone(0.3 30);
				background: @tone(0.2 30);
				color: @tone(0.95 30);
			}
			a[href].link:hover + a[href].link {
				margin-top: 0;
				border-top: none;
			}
			a[href].link::after {
				display: block;
				position: absolute;
				right: .5em;
				content: "β†’";
				top: 50%;
				margin-left: 1em;
				font-size: 1.8em;
				transform: translateY(-50%);
				color: @tone(0.3 30);
			}
			a[href].link:hover::after {
				color: @tone(0.7 30);
			}
		]];
	}

	local stylesNeeded = {
		flags = {};
		order = {};
	}
	local function addStyle(sty)
		-- convenience function, also just in case i end up having
		-- to change the goddamn implementation again
		if not stylesNeeded.flags[sty] then
			stylesNeeded.flags[sty] = true
			table.insert(stylesNeeded.order, sty)
			return true
		end
		return false
	end

	addStyle 'root'

	local render_state_handle = {
		doc = doc;
		opts = opts;
		style_rules = styles; -- use stylesneeded if at all possible
		style_add = addStyle;
		stylesets = stylesets;
		stylesets_active = stylesNeeded;
		obj_htmlid = getSafeID;
		-- remaining fields added later
	}

	-- this is kind of gross but the context object belongs to the parser,
	-- not the renderer, so that's not a suitable place for this information
	doc.stage = {
		kind = 'render';
		format = 'html';
		html_render_state = render_state_handle;
	}

	setup(doc.stage)

	local renderJob = doc:job('render_html', nil, render_state_handle)
	doc.stage.job = renderJob;

	local runhook = function(h, ...)
		return renderJob:hook(h, render_state_handle, ...)
	end

	local function htmlentities(v)
		return v:gsub('[<>&"]', function(x)
			return string.format('&#%02u;', string.byte(x))
		end)
	end

	local function htmlURI(uri)
		local family = uri:canfetch()
		if family == 'file' then
			if uri.namespace == 'localhost' then
				-- emit an actual file url
				return 'file://' .. uri:construct('path','frag')
			elseif uri.namespace == nil then
				-- this is gonna be tricky. first we establish the location
				-- of the CWD/asset base relative to the output file (if any;
				-- assume equivalent otherwise) then express the difference
				-- as a directory prefix.
				-- jk tho for now we just emit the path+frag sadlol TODO
				if uri.path == nil and uri.frag then
					-- file:#sec links to #sec within the current document
					return uri:part 'frag'
				else
					return uri:construct('path','frag')
				end
			else
				b.origin:fail('file: URI namespace must be empty or β€œlocalhost” for HTML links; others are not meaningful (offending URI: β€œ%s”)', uri.raw)
			end
		elseif family == 'http' then
			local sc = 'http'
			if uri.class[1] == 'https' or uri.class[2] == 'tls' then
				sc = 'https'
			end
			if uri.namespace == nil and uri.auth == nil and uri.svc == nil then
				-- omit the scheme so we can use a relative path
				return uri:construct('path','query','frag')
			else
				uri.class = {sc}
				return tostring(uri)
			end
		else return tostring(uri) end
	end

	local function idLink(id,b)
		local dest_o, _, dest_s = b.origin:ref(id)
		if dest_o == nil then
			-- link is to the section itself
			return '#' .. getSafeID(dest_s)
		else
			if type(dest_o) == 'table' then
				return '#' .. getSafeID(dest_o)
			else -- URI in reference
				return htmlURI(ss.uri(dest_o))
			end
		end
	end

	local tagproc do
		local html_open = function(t,attrs)
			if attrs then
				return t .. 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))
			else return t end
		end

		local elt = function(t,attrs)
			if opts.xhtml then
				return f('<%s />', html_open(t,attrs))
			end
			return f('<%s>', html_open(t,attrs))
		end

		tagproc = {
			toTXT = {
				tag = function(t,a,v) return v  end;
				elt = function(t,a)   return '' end;
				catenate = table.concat;
			};
			toIR = {
				tag = function(t,a,v,o) return {
					tag = t, attrs = a;
					nodes = type(v) == 'string' and {v} or v, src = o
				} end;

				elt = function(t,a,o) return {
					tag = t, attrs = a, src = o
				} end;

				catenate = function(...) return ... end;
			};
			toHTML = {
				elt = elt;
				tag = function(t,attrs,body)
					if t then
						return f('<%s>%s</%s>', html_open(t,attrs), body, t)
					else
						return tostring(body)
					end
				end;
				catenate = table.concat;
			};
		}
	end

	local function getBaseRenderers(procs, span_renderers)
		local tag, elt, catenate = procs.tag, procs.elt, procs.catenate
		local htmlDoc = function(title, head, body)
			local attrs
			local header = [[<!doctype html>]]
			if opts['epub'] then
				-- so cursed
				attrs = {
					xmlns = "http://www.w3.org/1999/xhtml";
					['xmlns:epub'] = "http://www.idpf.org/2007/ops";
				}
				header = [[<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE html>]]
			end
			return header .. tag('html',attrs,
				tag('head', nil,
					(opts.epub and '' or elt('meta',{charset = 'utf-8'})) ..
					(title and tag('title', nil, title) or '') ..
					(head or '')) ..
				tag('body', nil, body or ''))
		end

		local function htmlSpan(spans, block, sec)
			local text = {}
			for k,v in pairs(spans) do
				if type(v) == 'string' then
					v=htmlentities(v)
					for fn, ext in renderJob:each('hook','render_html_sanitize') do
						v = fn(renderJob:delegate(ext), v)
					end
					table.insert(text,v)
				else
					table.insert(text, (span_renderers[v.kind](v, block, sec)))
				end
			end
			return table.concat(text)
		end
		return {htmlDoc=htmlDoc, htmlSpan=htmlSpan}
	end

	local spanparse = function(...)
		local s = ct.parse_span(...)
		doc.docjob:hook('meddle_span', s)
		return s
	end

	local cssRulesFor = {}
	function getCSSImageForResource(r)
		return '' -- TODO
	end

	local function getSpanRenderers(procs)
		local tag, elt, catenate = procs.tag, procs.elt, procs.catenate
		local span_renderers = {}
		local plainrdr = getBaseRenderers(tagproc.toTXT, span_renderers)
		local htmlSpan = getBaseRenderers(procs, span_renderers).htmlSpan

		function span_renderers.format(sp,...)
			local tags = {
				strong = 'strong';
				emph = 'em';
				strike = 'del';
				insert = 'ins';
				literal = 'code';
				variable = 'var';
				super = 'sup';
				sub = 'sub';
				underline = 'u';
			}
			if sp.style == 'literal' and not opts['fossil-uv'] then
				addStyle 'code'
			elseif sp.style == 'strike' or sp.style == 'insert' then
				addStyle 'editors_markup'
			elseif sp.style == 'variable' then
				addStyle 'var'
			end
			return tag(tags[sp.style],nil,htmlSpan(sp.spans,...))
		end

		function span_renderers.codepoint(t,b,s)
			-- is this a UTF8 output?
			return utf8.char(t.code)
			-- else
			-- return string.format("&#%u;", code)
		end

		function span_renderers.deref(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
				addStyle 'abbr'
				return tag('abbr',{title=r},next(t.spans) and htmlSpan(t.spans,b,s) or name)
			end
			if r.kind == 'resource' then
				local rid = getSafeID(r, 'res-')
				if r.class == 'image' then
					if not cssRulesFor[r] then
						local css = prepcss(string.format([[
							section p > .%s {
								background: %s;
							}
						]], rid, getCSSImageForResource(r)))
						stylesets[r] = css
						cssRulesFor[r] = css
						addStyle(r)
					end
					return tag('div',{class=rid},catenate{''})
				elseif r.class == 'video' then
					local vid = {}
					return tag('video',nil,vid)
				elseif r.class == 'font' then
					b.origin:fail('fonts cannot be instantiated, use %font directive instead')
				end
			else
				b.origin:fail('%s is not an object that can be embedded', t.ref)
			end
		end

		function span_renderers.var(v,b,s)
			local r, raw = ct.expand_var(v)
			if raw then return r else
				return htmlSpan(r , b, s)
			end
		end

		function span_renderers.raw(v,b,s)
			return htmlSpan(v.spans, b, s)
		end

		function span_renderers.link(sp,b,s)
			local href = idLink(sp.ref,b)
			local lsp = ct.linkspans(sp)
			return tag('a',{href=href}, lsp and htmlSpan(lsp,b,s) or href)
		end

		span_renderers['line-break'] = function(sp,b,s)
			return elt('br')
		end

		function span_renderers.macro(m,b,s)
-- 			local macroname = plainrdr.htmlSpan(
-- 				ct.parse_span(m.macro, b.origin), b,s)
			local r = b.origin:ref(m.macro)
			if type(r) ~= 'string' then
				b.origin:fail('%s is an object, not a reference', r.id)
			end
			local mctx = b.origin:clone()
			mctx.invocation = m
			local ir = ct.parse_span(r, mctx)
			-- even though this happens at render time, it really shouldn't;
			-- we pretend this is happening as part of the document job
			local j = b.origin.doc.docjob
			for fn, ext, state in j:each('hook', 'doc_macro_expand_span') do
				local r = fn(j:delegate(ext), ir, b)
				if r then ir = r end
			end
			return htmlSpan(ir, b, s)
		end
		function span_renderers.math(m,b,s)
			addStyle 'math'
			local spans = {}
			local function fmt(sp, target)
				for i,v in ipairs(sp) do
					if type(v) == 'string' then
						local x = ct.tool.mathfmt(b.origin, v)
						for _,v in ipairs(x) do
							table.insert(target, v)
						end
					elseif type(v) == 'table' then
						if v.spans then
							local tbl = ss.delegate(v)
							tbl.spans = {}
							fmt(v.spans, tbl.spans)
							table.insert(target, tbl)
						else
							table.insert(target, v)
						end
					end
				end
			end
			fmt(m.spans,spans)

			return tag('span',{class='equation'},htmlSpan(spans, b, s))
		end;
		function span_renderers.directive(d,b,s)
			if d.ext == 'html' then
			elseif b.origin.doc:allow_ext(d.ext) then
			elseif d.crit then
				b.origin:fail('critical extension %s unavailable', d.ext)
			elseif d.failthru then
				return htmlSpan(d.spans, b, s)
			end
		end
		function span_renderers.footnote(f,b,s)
			local linkattr = {}
			if opts.epub then
				linkattr['epub:type'] = 'noteref'
			else
				addStyle 'footnote'
				linkattr.class = 'fnref'
			end
			local source, sid, ssec = b.origin:ref(f.ref)
			local cnc = getSafeID(ssec) .. ' ' .. sid
			local fn
			if footnotes[cnc] then
				fn = footnotes[cnc]
			else
				footnotecount = footnotecount + 1
				fn = {num = footnotecount, origin = b.origin, fnid=cnc, source = source}
				fn.id = getSafeID(fn)
				footnotes[cnc] = fn
			end
			linkattr.href = '#'..fn.id
			return tag('a', linkattr, htmlSpan(f.spans) ..
						tag('sup',nil, fn.num))
		end

		return span_renderers
	end

	local astproc

	local function getBlockRenderers(procs, sr)
		local tag, elt, catenate = procs.tag, procs.elt, procs.catenate
		local null = function() return catenate{} end

		local block_renderers = {
			anchor = function(b,s)
				return tag('a',{id = getSafeID(b)},null())
			end;
			['horiz-rule'] = function(b,s)
				return elt'hr'
			end;
			paragraph = function(b,s)
				addStyle 'paragraph'
				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
						addStyle 'header'
					end
					-- use correct styling in subdocuments
					local visDepth = b.captions.depth + (b.origin.docDepth or 0)
					local h = math.min(6,math.max(1,visDepth))
					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;
			link = function(b,s)
				addStyle 'linkBlock'
				local href
				if b.uri then
					href = htmlURI(b.uri)
				elseif b.ref then
					href = idLink(b.ref, b)
				end
				local sp = sr.htmlSpan(b.spans, b, s)
				return tag('a',{class='link', href=href},
					catenate{tag('div', {}, sp)})
			end;
			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)
				addStyle 'block_code_listing'
				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;
			aside = function(b,s)
				local bn = {}
				addStyle 'aside'
				if #b.lines == 1 then
					bn[1] = sr.htmlSpan(b.lines[1], b, s)
				else
					for _,v in pairs(b.lines) do
						table.insert(bn, tag('p', {}, sr.htmlSpan(v, b, s)))
					end
				end
				return tag('aside', {}, bn)
			end;
			['break'] = function() -- HACK
				-- lists need to be rewritten to work like asides
				return '';
			end;
		}

		local function renderSubdoc(doc)
			local ir = {}
			for i, sec in ipairs(doc.secorder) do
				local secnodes = {}
				for i, bl in ipairs(sec.blocks) do
					if block_renderers[bl.kind] then
						table.insert(secnodes, block_renderers[bl.kind](bl,sec))
					end
				end
				if next(secnodes) then
					if doc.secorder[2] then --#secs>1?
						-- only wrap in a section if >1 section
						table.insert(ir, tag('section',
							{id = getSafeID(sec)},
							secnodes))
					else
						ir = secnodes
					end
				end
			end
			return ir
		end

		local function flatten(t)
			if t == nil then
				return ''
			elseif type(t) == 'string' then
				return t
			elseif type(t) == 'table' then
				if t[1] then
					return catenate(ss.map(flatten, t))
				elseif t.tag then
					return tag(t.tag, t.attrs or {}, flatten(t.nodes), t.src)
				elseif t.elt then
					return elt(t.elt, t.attrs or {}, t.src)
				end
			end
		end

		function block_renderers.embed(b,s)
			local obj
			if b.rsrc
				then obj = b.rsrc
				else obj = b.origin:ref(b.ref)
			end
			local function uriForSource(s)
				if s.mode == 'link' or s.mode == 'auto' then
					return htmlURI(s.uri)
				elseif s.mode == 'embed' then
					local mime = s.mime:clone()
					mime.opts = {}
					return string.format('data:%s;base64,%s', mime, ss.str.b64e(s.raw))
				end
			end
			--figure out how to embed the given object
			local function P(p) -- get prop
				if b.props and b.props[p] then
					return b.props[p]
				end
				return obj.props[p]
			end
			local embedActs = {
				{ss.mime'image/*',       function(s,ctr)
					if s == nil then
						return {tag = "picture", nodes = {}}
					else
						local uri = uriForSource(s)
						local fbimg, idx
						if next(ctr.nodes) == nil then
							idx = 1
							fbimg = {
								elt = 'img'; --fallback
								attrs = {
									alt = P'desc' or P'detail' or '';
									title = P'detail';
									src = uri;
									width = P'width';
									height = P'height';
								};
							}
						else idx = #ctr.nodes end
						table.insert(ctr.nodes, idx, {
							elt = 'source'; --fallback
							attrs = { srcset = uri; };
						})
						if fbimg then
							table.insert(ctr.nodes,fbimg)
						else
							-- fallback <img> should be lowest-prio image
							ctr.nodes[#ctr.nodes].attrs.src = uri;
						end
					end
				end};
				{ss.mime'text/x.cortav', function(s,ctr)
					if s == nil then
						return {}
					elseif next(ctr) == nil then
						if (s.mode == 'embed' or s.mode == 'auto') and s.doc then
							addStyle 'embed'
							ctr.tag = 'div'; -- kinda hacky, maybe fix
							ctr.attrs = {class='embed'}
							ctr.nodes = renderSubdoc(s.doc)
						elseif s.mode == 'link' then
							-- yeah this is not gonna work my dude
							addStyle 'embed'
							ctr.elt = 'embed';
							ctr.attrs = {
								type = 'text/x.cortav';
								src = htmlURI(s.uri);
							}
						end
					end
				end};
				{ss.mime'text/html',     function(s,ctr)
					if s == nil then
						return {}
					elseif next(ctr) == nil then
						if (s.mode == 'embed' or s.mode == 'auto') and s.raw then
							addStyle 'embed'
							ctr.tag = 'div'
							ctr.attrs = {class='embed'}
							ctr.nodes = s.raw
						elseif s.mode == 'link' then
							addStyle 'embed'
							ctr.elt = 'embed';
							ctr.attrs = {
								type = 'text/html';
								src = htmlURI(s.uri);
							}
						end
					end
				end};
				{ss.mime'text/*',     function(s,ctr)
					if s == nil then
						return {}
					elseif next(ctr) == nil then
						local mime = s.mime:clone()
						mime.opts={}
						if (s.mode == 'embed' or s.mode == 'auto') and s.raw then
							addStyle 'embed'
							ctr.tag = 'pre';
							ctr.attrs = {class='embed'}
							ctr.nodes = htmlentities(s.raw);
						elseif s.mode == 'link' then
							addStyle 'embed'
							ctr.elt = 'embed';
							ctr.attrs = {
								type = tostring(mime);
								src = htmlURI(s.uri);
							}
						end
					end
				end};
			}

			local rtype
			local fallback
			for n, src in ipairs(obj.srcs) do
				if fallback == nil and (src.mode == 'link' or src.mode == 'auto') then
					fallback = src
				end

				for i, ea in ipairs(embedActs) do
					if ea[1] < src.mime then -- fits!
						rtype = ea
						goto compatFound
					end
				end
			end
			-- nothing found; install fallback link
				if fallback then
					local lnk = htmlURI(fallback.uri)
					return tag('a', {href=lnk},
								  tag('div',{class=xref},
										string.format("β†’ %s [%s]", b.cap or '', tostring(fallback.mime))))
				else
					addStyle 'docmeta'
					return tag('div',{class="render-warn"},
								  'could not embed object type ' .. tostring(obj.srcs.mime))
				end

			::compatFound::
			local top = rtype[2]() -- create container
			for n, src in ipairs(obj.srcs) do
				if rtype[1] < src.mime then
					rtype[2](src, top)
				end
			end
			local ft = flatten(top)
			local cap = b.cap or P'desc' or P'detail'
			if b.mode == 'inline' then
				-- TODO insert caption
				return ft
			else
				local prop = {}
				if b.mode == 'open' then
					prop.open = true
				end
				return tag('details', prop, catenate {
					tag('summary', {},
						 cap and (
							 -- the block here should really be the relevant
							 -- ref definition if an override caption isn't
							 -- specified, but oh well
							 sr.htmlSpan(spanparse(
								 cap, b.origin
							 ), b, s)
						) or '');
					ft;
				})
			end
		end

		function block_renderers.macro(b,s)
			local all = renderSubdoc(b.doc)
			local cat = catenate(ss.map(flatten,all))
			return tag(nil, {}, cat)
		end

		function block_renderers.quote(b,s)
			local ir = renderSubdoc(b.doc)
			return tag('blockquote', b.id and {id=getSafeID(b)} or {}, catenate(ss.map(flatten,ir)))
		end

		return block_renderers
	end

	local function getRenderers(procs)
		local span_renderers = getSpanRenderers(procs)
		local r = getBaseRenderers(procs,span_renderers)
		r.block_renderers = getBlockRenderers(procs, r)
		return r
	end

	astproc = {
		toHTML = getRenderers(tagproc.toHTML);
		toTXT  = getRenderers(tagproc.toTXT);
		toIR   = { };
	}
	astproc.toIR.span_renderers = ss.clone(astproc.toHTML);
	astproc.toIR.block_renderers = getBlockRenderers(tagproc.toIR,astproc.toHTML);
		-- note we use HTML here instead of IR span renderers, because as things
		-- currently stand we don't need that level of resolution. if we ever
		-- get to the point where we want to be able to twiddle spans around
		-- we'll need to introduce an IR span renderer

	render_state_handle.astproc = astproc;
	render_state_handle.tagproc = tagproc;

	-- bind to legacy names
	-- yikes this needs to be cleaned up so badly
	local ir = {}
	local dr = astproc.toHTML -- default renderers
	local plainr = astproc.toTXT

	render_state_handle.ir = ir;

	local function renderBlocks(blocks, irs)
		for i, block in ipairs(blocks) do
			local rd
			if astproc.toIR.block_renderers[block.kind] then
				rd = astproc.toIR.block_renderers[block.kind](block,sec)
			else
				local rdr = renderJob:proc('render',block.kind,'html')
				if rdr then
					rd = rdr({
						state = render_state_handle;
						tagproc = tagproc.toIR;
						astproc = astproc.toIR;
					}, block, sec)
				end
			end
			if rd then
				if opts['heading-anchors'] and block == sec.heading_node then
					addStyle 'headingAnchors'
					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
				if rd.src and rd.src.origin.lang then
					if not rd.attrs then rd.attrs = {} end
					rd.attrs.lang = rd.src.origin.lang
				end
				table.insert(irs.nodes, rd)
				runhook('ir_section_node_insert', rd, irs, sec)
			end
		end
	end

	runhook('ir_assemble', ir)
	for i, sec in ipairs(doc.secorder) do
		if doctitle == nil and sec.depth == 1 and sec.heading_node then
			doctitle = astproc.toTXT.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={}}
				runhook('ir_section_build', irs, sec)
				renderBlocks(sec.blocks, irs)
			end
		elseif sec.kind == 'quote' then
		elseif sec.kind == 'listing' then
		elseif sec.kind == 'embed' then
		end
		if irs then table.insert(ir, irs) end
	end

	do local fnsorted = {}
		for _, fn in pairs(footnotes) do
			fnsorted[fn.num] = fn
		end

		for _, fn in ipairs(fnsorted) do
			local tag = tagproc.toIR.tag
			local body
			if type(fn.source) == 'table' then
				if fn.source.kind == 'resource' then
					local fake_embed = {
						kind = 'embed';
						rsrc = fn.source;
						origin = fn.origin;
						mode = 'inline';
					}
					local rendered = astproc.toIR.block_renderers.embed(
						fake_embed, fn.origin.sec
					)
					if not rendered then
						fn.origin:fail('unacceptable resource mime type β€œ%s” for footnote target β€œ%s”', fn.source.mime, fn.source.id or '(anonymous)')
					end
					body = rendered
				else
					fn.origin:fail('footnote span links to block β€œ%s” of unacceptable kind β€œ%s”', fn.source.kind)
				end
			else
				body = {tag='div',nodes={}}
				local ftir = {}
				for l in fn.source:gmatch('([^\n]*)') do
					ct.parse_line(l, fn.origin, ftir)
				end
				renderBlocks(ftir,body)
			end
			local fattr = {id=fn.id}
			if opts.epub then
				---UUUUUUGHHH
				local npfx = string.format('(%u) ', fn.num)
				if next(body.nodes) then
					local n = body.nodes[1]
					repeat
						if n.nodes[1] then
							if type(n.nodes[1]) == 'string' then
								n.nodes[1] = npfx .. n.nodes[1]
								break
							end
							n = n.nodes[1]
						else
							n.nodes[1] = {tag='p',attrs={},nodes={npfx}}
							break
						end
					until false

				else
					if body.tag == 'div' then
						body.nodes[1] = {tag='p',attrs={},nodes={npfx}}
					elseif body.tag == 'pre' then
						body.nodes[1] = npfx .. body.nodes[1]
					else
						body = {tag='div', nodes = {npfx, body}}
					end
				end
				fattr['epub:type'] = 'footnote'
			else
				fattr.class = 'footnote'
			end
			body.attrs = body.attrs or {}
			body.attrs.class = 'text'
			local note = tag('aside', fattr, opts.epub and body.nodes or {
				tag('div',{class='number'}, tostring(fn.num)),
				body,
-- 				tag('div',{class='text'}, body.nodes),
				tag('a',{href='#0'},'‫')
			})
			table.insert(ir, note)
		end
	end
	if next(footnotes) and not opts.epub then
		table.insert(ir, tagproc.toIR.tag('div',{id='cover'},''))
	end

	-- restructure passes
	runhook('ir_restructure_pre', ir)

	-- flay empty containers
	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 type(v) ~= 'string' and v.nodes and v.tag == nil then
					table.remove(sec.nodes,i)
					for j=1,#v.nodes do
						table.insert(sec.nodes, i+j - 1, v.nodes[j])
					end
				end
			i=i+1 end
		end
	end

	---- 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
					addStyle 'list'
					local ltag
					if v.src.ordered
						then ltag = 'ol' addStyle 'list_ordered'
						else ltag = 'ul' addStyle 'list_unordered'
					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

	runhook('ir_restructure_post', ir)

	-- 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 .. tagproc.toHTML.tag(v.tag, v.attrs, collect_nodes(v.nodes))
			elseif v.text then
				ts = ts .. tagproc.toHTML.tag(v.tag,v.attrs,v.text)
			else
				ts = ts .. tagproc.toHTML.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 == 'width' then
				return opts['width'] or '100vw'
			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 not opts.epub and (opts.accent or (not opts['dark-on-light']) and (not opts['fossil-uv'])) then
		addStyle 'accent'
	end


	for _,k in pairs(stylesNeeded.order) 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 .. tagproc.toHTML.elt('link',{rel='stylesheet',type='text/css',href=opts['link-css']})
	end
	if next(styles) then
		if opts['gen-styles'] then
			styletag = styletag .. tagproc.toHTML.tag('style',{type='text/css'},cdata(table.concat(styles)))
		end
		table.insert(head, styletag)
	end

	if opts['fossil-uv'] then
		return tagproc.toHTML.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