cortav  cortav.lua at [87fed4ec34]

File cortav.lua artifact a950584594 part of check-in 87fed4ec34


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

local ct = { render = {} }

local function hexdump(s)
	local hexlines, charlines = {},{}
	for i=1,#s do
		local line = math.floor((i-1)/16) + 1
		hexlines[line] = (hexlines[line] or '') .. string.format("%02x ",string.byte(s, i))
		charlines[line] = (charlines[line] or '') .. ' ' .. string.gsub(string.sub(s, i, i), '[^%g ]', '\x1b[;35m·\x1b[36;1m') .. ' '
	end
	local str = ''
	for i=1,#hexlines do
		str = str .. '\x1b[1;36m' .. charlines[i] .. '\x1b[m\n' .. hexlines[i] .. '\n'
	end
	return str
end

local function dump(o, state, path, depth)
	state = state or {tbls = {}}
	depth = depth or 0
	local pfx = string.rep('   ', depth)
	if type(o) == "table" then
		local str = ''
		for k,p in pairs(o) do
			local done = false
			local exp
			if type(p) == 'table' then
				if state.tbls[p] then
					exp = '<' .. state.tbls[p] ..'>'
					done = true
				else
					state.tbls[p] = path and string.format('%s.%s', path, k) or k
				end
			end
			if not done then
				local function dodump() return dump(
					p, state,
					path and string.format("%s.%s", path, k) or k,
					depth + 1
				) end
				-- boy this is ugly
				if type(p) ~= 'table' or
					getmetatable(p) == nil or
					getmetatable(p).__tostring == nil then
					exp = dodump()
				end
				if type(p) == 'table' then
					exp = string.format('{\n%s%s}', exp, pfx)
					local meta = getmetatable(p)
					if meta then
						if meta.__tostring then
							exp = tostring(p)
						end
						if meta.__name then
							exp = meta.__name .. ' ' .. exp
						end
					end
				end
			end
			str = str .. pfx .. string.format("%s = %s\n", k, exp)
		end
		return str
	elseif type(o) == "string" then
		return string.format('“%s”', o)
	else
		return tostring(o)
	end
end

local function
lerp(t, a, b)
	return (1-t)*a + (t*b)
end

local function
startswith(str, pfx)
	return string.sub(str, 1, #pfx) == pfx
end

local function declare(c)
	local cls = setmetatable({
		__name = c.ident;
	}, {
		__name = 'class';
		__tostring = function() return c.ident or '(class)' end;
	})

	cls.__call = c.call
	cls.__index = function(self, k)
		if c.default and c.default[k] then
			return c.default[k]
		end
		if k == 'clone' then
			return function(self)
				local new = cls.mk()
				for k,v in pairs(self) do
					new[k] = v
				end
				if c.clonesetup then
					c.clonesetup(new, self)
				end
				return new
			end
		elseif k == 'to' then
			return function(self, to, ...)
				if to == 'string' then return tostring(self)
				elseif to == 'number' then return tonumber(self)
				elseif to == 'int' then return math.floor(tonumber(self))
				elseif c.cast and c.cast[to] then
					return c.cast[to](self, ...)
				elseif type(to) == 'table' and getmetatable(to) and getmetatable(to).cvt and getmetatable(to).cvt[cls] then
				else error((c.ident or 'class') .. ' is not convertible to ' .. (type(to) == 'string' and to or tostring(to))) end
			end
		end
		if c.fns then return c.fns[k] end
	end

	if c.cast then
		if c.cast.string then
			cls.__tostring = c.cast.string
		end
		if c.cast.number then
			cls.__tonumber = c.cast.number
		end
	end

	cls.mk = function(...)
		local val = setmetatable(c.mk and c.mk(...) or {}, cls)
		if c.init then
			for k,v in pairs(c.init) do
				val[k] = v
			end
		end
		if c.construct then
			c.construct(val, ...)
		end
		return val
	end
	getmetatable(cls).__call = function(_, ...) return cls.mk(...) end
	cls.is = function(o) return getmetatable(o) == cls end
	return cls
end
ct.exn = declare {
	ident = 'exn';
	mk = function(kind, ...)
		return {
			vars = {...};
			kind = kind;
		}
	end;
	cast = {
		string = function(me)
			return me.kind.report(table.unpack(me.vars))
		end;
	};
	fns = {
		throw = function(me) error(me) end;
	}
}
ct.exnkind = declare {
	ident = 'exn-kind';
	mk = function(desc, report)
		return {
			desc = desc;
			report = report or function(msg,...)
				return string.format(msg,...)
			end;
		}
	end;
	call = function(me, ...)
		return ct.exn(me, ...)
	end;
}

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

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;
}

local function map(fn, lst)
	local new = {}
	for k,v in pairs(lst) do
		table.insert(new, fn(v,k))
	end
	return new
end
local function reduce(fn, acc, lst)
	for i,v in ipairs(lst) do
		acc = fn(acc, v, i)
	end
	return acc
end
local function fmtfn(str)
	return function(...)
		return string.format(str, ...)
	end
end

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 = {
		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,h2,h3,h4,h5,h6 {
				color: @tone(2);
				border-bottom: 1px solid @tone(0.7);
			}
		]];
		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;
			}
		]];
	}

	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={}, nodes={}}
			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)
				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
					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 = 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 reduce(function(a,b) return a..b end, '', 
				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 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 = lerp(tonumber(l), tbg, tfg)
				return tone(l, tonumber(sat), tonumber(sep), tonumber(alpha))
			end
		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

local function eachcode(str, ascode)
	local pos = {
		code = 1;
		byte = 1;
	}
	return function()
		if pos.byte > #str then return nil end
		local thischar = utf8.codepoint(str, pos.byte)
		local lastpos = {
			code = pos.code;
			byte = pos.byte;
			next = pos;
		}
		if not ascode then
			thischar = utf8.char(thischar)
			pos.byte = pos.byte + #thischar
		else
			pos.byte = pos.byte + #utf8.char(thischar)
		end
		pos.code = pos.code + 1
		return thischar, lastpos
	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 'literal'};
		{seq = '!', parse = formatter 'emph'};
		{seq = '*', parse = formatter 'strong'};
		{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 = 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 depth = 0
		if not startswith(s, start) then return nil end
		for c,p in eachcode(s) do
			if c == '\\' then
				p.next.byte = p.next.byte + #utf8.char(utf8.codepoint(s, p.next.byte))
				p.next.code = p.next.code + 1
			elseif c == start then
				depth = depth + 1
			elseif c == stop then
				depth = depth - 1
				if depth == 0 then
					return s:sub(1+#start, p.byte - #stop), p.byte -- FIXME
				elseif depth < 0 then
					ctx:fail('out of place %s', stop)
				end
			end
		end

		ctx:fail('[%s] expected before end of line', stop)
	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 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

local default_mode = {
	['render:format'] = 'html';
	['html:gen-styles'] = true;
}

local function filter(list, fn)
	local new = {}
	for i, v in ipairs(list) do
		if fn(v,i) then table.insert(new, v) end
	end
	return new
end

local function kmap(fn, list)
	local new = {}
	for k, v in pairs(list) do
		local nk,nv = fn(k,v)
		new[nk or k] = nv or v
	end
	return new
end
local function kfilter(list, fn)
	local new = {}
	for k, v in pairs(list) do
		if fn(k,v) then new[k] = v end
	end
	return new
end

local function main(input, output, log, mode, vars)
	local doc = ct.parse(input.stream, input.src, mode)
	input.stream:close()
	if mode['parse:show-tree'] then
		log:write(dump(doc))
	end

	if not mode['render:format'] then
		error 'what output format should i translate the input to?'
	end
	if mode['render:format'] == 'none' then return 0 end
	if not ct.render[mode['render:format']] then
		ct.exns.unimpl('output format “%s” unsupported', mode['render:format']):throw()
	end
	
	local render_opts = kmap(function(k,v)
		return k:sub(2+#mode['render:format'])
	end, kfilter(mode, function(m)
		return startswith(m, mode['render:format']..':')
	end))

	doc.vars = vars
	
	-- 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 = mode['render:format'];
		mode = mode;
	}

	output:write(ct.render[mode['render:format']](doc, render_opts))
	return 0
end

local inp,outp,log = io.stdin, io.stdout, io.stderr

local function entry_cli()
	local mode, vars, input = default_mode, {}, {
		stream = inp;
		src = {
			file = '(stdin)';
		}
	}

	local optnparams = function(o)
		local param_opts = {
			out = 1;
			log = 1;
			define = 2; -- key value
			['mode-set'] = 1;
			['mode-clear'] = 1;
			mode = 2;
		}
		return param_opts[o] or 0
	end

	local optmap = {
		o = 'out';
		l = 'log';
		d = 'define';
		V = 'version';
		h = 'help';
		y = 'mode-set', n = 'mode-clear';
		m = 'mode';
	}

	local checkmodekey = function(key)
		if not key:match '[^:]+:.+' then
			ct.exns.cli('invalid mode key %s', key):throw()
		end
		return key
	end
	local onswitch = {
		out = function(file)
			local nf = io.open(file,'wb')
			if nf then outp:close() outp = nf else
				ct.exns.io('could not open output file for writing', 'open',file):throw()
			end
		end;
		log = function(file)
			local nf = io.open(file,'wb')
			if nf then log:close() log = nf else
				ct.exns.io('could not open log file for writing', 'open',file):throw()
			end
		end;
		define = function(key,value)
			if startswith(key, 'cortav.') or startswith(key, 'env.') then
				ct.exns.cli 'cannot define variable in restricted namespace':throw()
			end
			vars[key] = value
		end;
		mode = function(key,value) mode[checkmodekey(key)] = value end;
		['mode-set'] = function(key) mode[checkmodekey(key)] = true end;
		['mode-clear'] = function(key) mode[checkmodekey(key)] = false end;
	}

	local args = {}
	local keepParsing = true
	do local i = 1 while i <= #arg do local v = arg[i]
		local execLongOpt = function(longopt)
			if not onswitch[longopt] then
				ct.exns.cli('switch --%s unrecognized', longopt):throw()
			end
			local nargs = optnparams(longopt)

			if nargs > 1 then
				if i + nargs > #arg then
					ct.exns.cli('not enough arguments for switch --%s (%u expected)', longopt, nargs):throw()
				end
				local nt = {}
				for j = i+1, i+nargs do
					table.insert(nt, arg[j])
				end
				onswitch[longopt](table.unpack(nt))
			elseif nargs == 1 then
				onswitch[longopt](arg[i+1])
			end
			i = i + nargs
		end
		if v == '--' then
			keepParsing = false
		else
			local longopt = v:match '^%-%-(.+)$'
			if keepParsing and longopt then
				execLongOpt(longopt)
			else
				if keepParsing and v:sub(1,1) == '-' then
					for c,p in eachcode(v:sub(2)) do
						if optmap[c] then
							execLongOpt(optmap[c])
						else
							ct.exns.cli('switch -%i unrecognized', c):throw()
						end
					end
				else
					table.insert(args, v)
				end
			end

		end
	i = i + 1 end end

	if args[1] and args[1] ~= '' then
		local file = io.open(arg[1], "rb")
		if not file then error('unable to load file ' .. args[1]) end
		input.stream = file
		input.src.file = args[1]
	end

	return main(input, outp, log, mode, vars)
end

local ok, e = pcall(entry_cli)
-- local ok, e = true, entry_cli()
if not ok then
	local str = 'translation failure'
	if ct.exn.is(e) then
		str = e.kind.desc
	end
	local color = false
	if log:seek() == nil then
		-- this is not a very reliable heuristic for detecting
		-- attachment to a tty but it's better than nothing
		if os.getenv('COLORTERM') then
			color = true
		else
			local term = os.getenv('TERM')
			if term:find 'color' then color = true end
		end
	end
	if color then
		str = string.format('\27[1;31m%s\27[m', str)
	end
	log:write(string.format('%s: %s\n', str, e))
	os.exit(1)
end
os.exit(e)