cortav  Diff

Differences From Artifact [f20a833e35]:

To Artifact [1d4d9e0a4b]:


     1      1   -- [ʞ] cortav.lua
     2      2   --  ~ lexi hale <lexi@hale.su>
     3      3   --  © AGPLv3
     4         ---  ? renderer
            4  +--  ? reference implementation of the cortav document language
     5      5   
     6      6   local ct = { render = {} }
     7      7   
     8      8   local function hexdump(s)
     9      9   	local hexlines, charlines = {},{}
    10     10   	for i=1,#s do
    11     11   		local line = math.floor((i-1)/16) + 1
................................................................................
    66     66   		return str
    67     67   	elseif type(o) == "string" then
    68     68   		return string.format('“%s”', o)
    69     69   	else
    70     70   		return tostring(o)
    71     71   	end
    72     72   end
           73  +
           74  +local function
           75  +lerp(t, a, b)
           76  +	return (1-t)*a + (t*b)
           77  +end
           78  +
           79  +local function
           80  +startswith(str, pfx)
           81  +	return string.sub(str, 1, #pfx) == pfx
           82  +end
    73     83   
    74     84   local function declare(c)
    75     85   	local cls = setmetatable({
    76     86   		__name = c.ident;
    77     87   	}, {
    78     88   		__name = 'class';
    79     89   		__tostring = function() return c.ident or '(class)' end;
................................................................................
   173    183   	io = ct.exnkind('IO error', function(msg, ...)
   174    184   		return string.format("<%s %s> "..msg, ...)
   175    185   	end);
   176    186   	cli = ct.exnkind 'command line parse error';
   177    187   	mode = ct.exnkind('bad mode', function(msg, ...)
   178    188   		return string.format("mode “%s” "..msg, ...)
   179    189   	end);
          190  +	unimpl = ct.exnkind 'feature not implemented';
   180    191   }
   181    192   
   182    193   ct.ctx = declare {
   183    194   	mk = function(src) return {src = src} end;
   184    195   	ident = 'context';
   185    196   	cast = {
   186    197   		string = function(me)
................................................................................
   195    206   			new.generation = 1
   196    207   		end
   197    208   	end;
   198    209   	fns = {
   199    210   		fail = function(self, msg, ...)
   200    211   			ct.exns.tx(msg, self.src.file, self.line or 0, ...):throw()
   201    212   		end;
          213  +		insert = function(self, block)
          214  +			block.origin = self:clone()
          215  +			table.insert(self.sec.blocks,block)
          216  +		end;
   202    217   		ref = function(self,id)
   203    218   			if not id:find'%.' then
   204    219   				local rid = self.sec.refs[id]
   205    220   				if self.sec.refs[id] then
   206    221   					return self.sec.refs[id]
   207    222   				else self:fail("no such ref %s in current section", id or '') end
   208    223   			else
................................................................................
   237    252   	fns = {
   238    253   		mksec = function(self, id, depth)
   239    254   			local o = ct.sec(id, depth)
   240    255   			if id then self.sections[id] = o end
   241    256   			table.insert(self.secorder, o)
   242    257   			return o
   243    258   		end;
          259  +		context_var = function(self, var, ctx, test)
          260  +			local fail = function(...)
          261  +				if test then return false end
          262  +				ctx:fail(...)
          263  +			end
          264  +			if startswith(var, 'cortav.') then
          265  +				local v = var:sub(8)
          266  +				if v == 'page' then
          267  +					if ctx.page then return tostring(ctx.page)
          268  +						else return '(unpaged)' end
          269  +				elseif v == 'renderer' then
          270  +					if not self.stage then
          271  +						return fail 'document is not being rendererd'
          272  +					end
          273  +					return self.stage.format
          274  +				elseif v == 'datetime' then
          275  +					return os.date()
          276  +				elseif v == 'time' then
          277  +					return os.date '%H:%M:%S'
          278  +				elseif v == 'date' then
          279  +					return os.date '%A %d %B %Y'
          280  +				elseif v == 'id' then
          281  +					return 'cortav.lua (reference implementation)'
          282  +				elseif v == 'file' then
          283  +					return self.src.file
          284  +				else
          285  +					return fail('unimplemented predefined variable %s', var)
          286  +				end
          287  +			elseif startswith(var, 'env.') then
          288  +				local v = var:sub(5)
          289  +				local val = os.getenv(v)
          290  +				if not val then
          291  +					return fail('undefined environment variable %s', v)
          292  +				end
          293  +			elseif self.stage.kind == 'render' and startswith(var, self.stage.format..'.') then
          294  +				-- TODO query the renderer somehow
          295  +				return fail('renderer %s does not implement variable %s', self.stage.format, var)
          296  +			elseif self.vars[var] then
          297  +				return self.vars[var]
          298  +			else
          299  +				if test then return false end
          300  +				return '' -- is this desirable behavior?
          301  +			end
          302  +		end;
   244    303   	};
   245    304   	mk = function() return {
   246    305   		sections = {};
   247    306   		secorder = {};
   248    307   		embed = {};
   249    308   		meta = {};
          309  +		vars = {};
   250    310   	} end;
   251    311   }
   252    312   
   253    313   local function map(fn, lst)
   254    314   	local new = {}
   255    315   	for k,v in pairs(lst) do
   256    316   		table.insert(new, fn(v,k))
................................................................................
   266    326   local function fmtfn(str)
   267    327   	return function(...)
   268    328   		return string.format(str, ...)
   269    329   	end
   270    330   end
   271    331   
   272    332   function ct.render.html(doc, opts)
          333  +	local doctitle = opts['title']
   273    334   	local f = string.format
   274    335   	local ids = {}
   275    336   	local canonicalID = {}
   276    337   	local function getSafeID(obj)
   277    338   		if canonicalID[obj] then
   278    339   			return canonicalID[obj]
   279    340   		elseif obj.id and ids[obj.id] then
................................................................................
   307    368   		lisp = { color = 0x77ff88 };
   308    369   		fortran = { color = 0xff779a };
   309    370   		python = { color = 0xffd277 };
   310    371   		python = { color = 0xcdd6ff };
   311    372   	}
   312    373   
   313    374   	local stylesets = {
          375  +		accent = [[
          376  +			body { background: @bg; color: @fg }
          377  +			a[href] {
          378  +				color: @tone(0.7 30);
          379  +				text-decoration-color: @tone/0.4(0.7 30);
          380  +			}
          381  +			a[href]:hover {
          382  +				color: @tone(0.9 30);
          383  +				text-decoration-color: @tone/0.7(0.7 30);
          384  +			}
          385  +			h1,h2,h3,h4,h5,h6 {
          386  +				color: @tone(2);
          387  +				border-bottom: 1px solid @tone(0.7);
          388  +			}
          389  +		]];
   314    390   		code = [[
   315    391   			code {
   316         -				background: #000;
   317         -				color: #fff;
          392  +				background: @fg;
          393  +				color: @bg;
   318    394   				font-family: monospace;
   319    395   				font-size: 90%;
   320    396   				padding: 3px 5px;
   321    397   			}
   322    398   		]];
   323    399   		abbr = [[
   324    400   			abbr[title] { cursor: help; }
   325    401   		]];
   326    402   		editors_markup = [[]];
   327    403   		block_code_listing = [[
   328    404   			section > figure.listing {
   329    405   				font-family: monospace;
   330         -				background: #000;
   331         -				color: #fff;
          406  +				background: @tone(0.05);
          407  +				color: @fg;
   332    408   				padding: 0;
   333    409   				margin: 0.3em 0;
   334    410   				counter-reset: line-number;
   335    411   				position: relative;
          412  +				border: 1px solid @fg;
   336    413   			}
   337    414   			section > figure.listing>div {
   338    415   				white-space: pre-wrap;
   339    416   				counter-increment: line-number;
   340    417   				text-indent: -2.3em;
   341    418   				margin-left: 2.3em;
   342    419   			}
   343    420   			section > figure.listing>:is(div,hr)::before {
   344    421   				width: 1.0em;
   345    422   				padding: 0.2em 0.4em;
   346    423   				text-align: right;
   347    424   				display: inline-block;
   348         -				background-color: #333;
   349         -				border-right: 1px solid #fff;
          425  +				background-color: @tone(0.2);
          426  +				border-right: 1px solid @fg;
   350    427   				content: counter(line-number);
   351    428   				margin-right: 0.3em;
   352    429   			}
   353    430   			section > figure.listing>hr::before {
   354         -				color: #333;
          431  +				color: transparent;
   355    432   				padding-top: 0;
   356    433   				padding-bottom: 0;
   357    434   			}
   358    435   			section > figure.listing>div::before {
   359         -				color: #fff;
          436  +				color: @fg;
   360    437   			}
   361    438   			section > figure.listing>div:last-child::before {
   362    439   				padding-bottom: 0.5em;
   363    440   			}
   364    441   			section > figure.listing>figcaption:first-child {
   365    442   				border: none;
   366         -				border-bottom: 1px solid #fff;
          443  +				border-bottom: 1px solid @fg;
   367    444   			}
   368    445   			section > figure.listing>figcaption::after {
   369    446   				display: block;
   370    447   				float: right;
   371    448   				font-weight: normal;
   372    449   				font-style: italic;
   373    450   				font-size: 70%;
   374    451   				padding-top: 0.3em;
   375    452   			}
   376    453   			section > figure.listing>figcaption {
   377    454   				font-family: sans-serif;
   378         -				font-weight: bold;
   379         -				font-size: 130%;
          455  +				font-size: 120%;
   380    456   				padding: 0.2em 0.4em;
   381    457   				border: none;
          458  +				color: @tone(2);
   382    459   			}
   383    460   			section > figure.listing > hr {
   384    461   				border: none;
   385    462   				margin: 0;
   386    463   				height: 0.7em;
   387    464   				counter-increment: line-number;
   388    465   			}
................................................................................
   413    490   				else
   414    491   					table.insert(text, span_renderers[v.kind](v, block, sec))
   415    492   				end
   416    493   			end
   417    494   			return table.concat(text)
   418    495   		end
   419    496   
   420         -		function span_renderers.format(sp)
          497  +		function span_renderers.format(sp,...)
   421    498   			local tags = { strong = 'strong', emph = 'em', strike = 'del', insert = 'ins', literal = 'code' }
   422    499   			if sp.style == 'literal' and not opts['fossil-uv'] then
   423    500   				stylesNeeded.code = true
   424    501   			end
   425    502   			if sp.style == 'del' or sp.style == 'ins' then
   426    503   				stylesNeeded.editors_markup = true
   427    504   			end
   428         -			return tag(tags[sp.style],nil,htmlSpan(sp.spans))
          505  +			return tag(tags[sp.style],nil,htmlSpan(sp.spans,...))
   429    506   		end
   430    507   
   431         -		function span_renderers.term(t,b)
          508  +		function span_renderers.term(t,b,s)
   432    509   			local r = b.origin:ref(t.ref)
   433    510   			local name = t.ref
   434    511   			if name:find'%.' then name = name:match '^[^.]*%.(.+)$' end
   435    512   			if type(r) ~= 'string' then
   436    513   				b.origin:fail('%s is an object, not a reference', t.ref)
   437    514   			end
   438    515   			stylesNeeded.abbr = true
   439         -			return tag('abbr',{title=r},next(t.spans) and htmlSpan(t.spans) or name)
          516  +			return tag('abbr',{title=r},next(t.spans) and htmlSpan(t.spans,b,s) or name)
   440    517   		end
   441    518   
   442         -		function span_renderers.link(sp,b)
          519  +		function span_renderers.macro(m,b,s)
          520  +			local r = b.origin:ref(m.macro)
          521  +			if type(r) ~= 'string' then
          522  +				b.origin:fail('%s is an object, not a reference', t.ref)
          523  +			end
          524  +			local mctx = b.origin:clone()
          525  +			mctx.invocation = m
          526  +			return htmlSpan(ct.parse_span(r, mctx),b,s)
          527  +		end
          528  +
          529  +		function span_renderers.var(v,b,s)
          530  +			local val
          531  +			if v.pos then
          532  +				if not v.origin.invocation then
          533  +					v.origin:fail 'positional arguments can only be used in a macro invocation'
          534  +				elseif not v.origin.invocation.args[v.pos] then
          535  +					v.origin:fail('macro invocation %s missing positional argument #%u', v.origin.invocation.macro, v.pos)
          536  +				end
          537  +				val = v.origin.invocation.args[v.pos]
          538  +			else
          539  +				val = v.origin.doc:context_var(v.var, v.origin)
          540  +			end
          541  +			if v.raw then
          542  +				return val
          543  +			else
          544  +				return htmlSpan(ct.parse_span(val, v.origin), b, s)
          545  +			end
          546  +		end
          547  +
          548  +		function span_renderers.link(sp,b,s)
   443    549   			local href
   444    550   			if b.origin.doc.sections[sp.ref] then
   445    551   				href = '#' .. sp.ref
   446    552   			else
   447    553   				if sp.addr then href = sp.addr else
   448    554   					local r = b.origin:ref(sp.ref)
   449    555   					if type(r) == 'table' then
   450    556   						href = '#' .. getSafeID(r)
   451    557   					else href = r end
   452    558   				end
   453    559   			end
   454         -			return tag('a',{href=href},next(sp.spans) and htmlSpan(sp.spans) or href)
          560  +			return tag('a',{href=href},next(sp.spans) and htmlSpan(sp.spans,b,s) or href)
   455    561   		end
   456    562   		return {
   457    563   			span_renderers = span_renderers;
   458    564   			htmlSpan = htmlSpan;
   459    565   			htmlDoc = htmlDoc;
   460    566   		}
   461    567   	end
................................................................................
   486    592   			end
   487    593   			return lst
   488    594   		end
   489    595   
   490    596   		local block_renderers = {
   491    597   			paragraph = function(b,s)
   492    598   				return tag('p', nil, sr.htmlSpan(b.spans, b, s), b)
          599  +			end;
          600  +			directive = function(b,s)
          601  +				-- deal with renderer directives
          602  +				local _, cmd, args = b.words(2)
          603  +				if cmd == 'page-title' then
          604  +					if not opts.title then doctitle = args end
          605  +				elseif b.critical then
          606  +					b.origin:fail('critical HTML renderer directive “%s” not supported', cmd)
          607  +				end
   493    608   			end;
   494    609   			label = function(b,s)
   495    610   				if ct.sec.is(b.captions) then
   496    611   					local h = math.min(6,math.max(1,b.captions.depth))
   497    612   					return tag(f('h%u',h), nil, sr.htmlSpan(b.spans, b, s), b)
   498    613   				else
   499    614   					-- handle other uses of labels here
................................................................................
   523    638   					else
   524    639   						return elt('hr')
   525    640   					end
   526    641   				end, b.lines)
   527    642   				if b.title then
   528    643   					table.insert(nodes,1,tag('figcaption',nil,sr.htmlSpan(b.title)))
   529    644   				end
   530         -				langsused[b.lang] = true
          645  +				if b.lang then langsused[b.lang] = true end
   531    646   				return tag('figure', {class='listing', lang=b.lang, id=b.id and getSafeID(b)}, catenate(nodes))
   532    647   			end;
   533    648   			['break'] = function() --[[nop]] end;
   534    649   		}
   535    650   		return block_renderers;
   536    651   	end
   537    652   
................................................................................
   554    669   					end
   555    670   				end, attrs)) or '')
   556    671   	end
   557    672   	local tag = function(t,attrs,body)
   558    673   		return f('%s%s</%s>', elt(t,attrs), body, t)
   559    674   	end
   560    675   
   561         -	local doctitle
   562    676   	local ir = {}
   563    677   	local toc
   564    678   	local dr = getRenderers(tag,elt,table.concat) -- default renderers
   565    679   	local plainr = getRenderers(function(t,a,v) return v  end,
   566    680   	                            function(t,a)   return '' end, table.concat)
   567    681   	local irBlockRdrs = getBlockRenderers(
   568    682   		function(t,a,v,o) return {tag = t, attrs = a, nodes = type(v) == 'string' and {v} or v, src = o} end,
................................................................................
   682    796   	for k in pairs(langsused) do
   683    797   		local spec = langpairs[k] or {color=0xaaaaaa}
   684    798   		stylesets.block_code_listing = stylesets.block_code_listing .. string.format(
   685    799   			[[section > figure.listing[lang="%s"]>figcaption::after
   686    800   				{ content: '%s'; color: #%06x }]],
   687    801   			k, spec.name or k, spec.color)
   688    802   	end
          803  +
          804  +	local prepcss = function(css)
          805  +		local tone = function(fac, sat, sep, alpha)
          806  +			local hsl = function(h,s,l,a)
          807  +				local v = string.format('%s, %u%%, %u%%', h,s,l)
          808  +				if a then
          809  +					return string.format('hsla(%s, %s)', v,a)
          810  +				else
          811  +					return string.format('hsl(%s)', v)
          812  +				end
          813  +			end
          814  +			sat = sat or 1
          815  +			fac = math.max(math.min(fac, 1), 0)
          816  +			sat = math.max(math.min(sat, 1), 0)
          817  +			if opts.accent then
          818  +				local hue = 'var(--accent)'
          819  +				local hsep = tonumber(opts['hue-spread'])
          820  +				if hsep and sep and sep ~= 0 then
          821  +					hue = string.format('calc(%s - %s)', hue, sep * hsep)
          822  +				end
          823  +				return hsl(hue, math.floor(100*sat), math.floor(100*fac), alpha)
          824  +			else
          825  +				local g = math.floor(0xFF * fac)
          826  +				return string.format('#' .. string.rep('%02x',alpha and 4 or 3), g,g,g,alpha and math.floor(0xFF*alpha))
          827  +			end
          828  +		end
          829  +		local replace = function(var,alpha,param)
          830  +			local tonespan = opts.accent and .1 or 0
          831  +			local tbg = opts['dark-on-light'] and 1.0 - tonespan or tonespan
          832  +			local tfg = opts['dark-on-light'] and tonespan or 1.0 - tonespan
          833  +			if var == 'bg' then
          834  +				return tone(tbg,nil,nil,tonumber(alpha))
          835  +			elseif var == 'fg' then
          836  +				return tone(tfg,nil,nil,tonumber(alpha))
          837  +			elseif var == 'tone' then
          838  +				local l, sep, sat
          839  +				for i=1,3 do -- 🙄
          840  +					l,sep,sat = param:match('^%('..string.rep('([^%s]*)%s*',i)..'%)$')
          841  +					if l then break end
          842  +				end
          843  +				l = lerp(tonumber(l), tbg, tfg)
          844  +				return tone(l, tonumber(sat), tonumber(sep), tonumber(alpha))
          845  +			end
          846  +		end
          847  +		css = css:gsub('@(%w+)/([0-9.]+)(%b())', replace)
          848  +		css = css:gsub('@(%w+)(%b())', function(a,b) return replace(a,nil,b) end)
          849  +		css = css:gsub('@(%w+)/([0-9.]+)', replace)
          850  +		css = css:gsub('@(%w+)', function(a,b) return replace(a,nil,b) end)
          851  +		return (css:gsub('%s+',' '))
          852  +	end
   689    853   
   690    854   	local styles = {}
          855  +	if opts.width then
          856  +		table.insert(styles, string.format([[body {padding:0 1em;margin:auto;max-width:%s}]], opts.width))
          857  +	end
          858  +	if opts.accent then
          859  +		table.insert(styles, string.format(':root {--accent:%s}', opts.accent))
          860  +	end
          861  +	if opts.accent or (not opts['dark-on-light']) then
          862  +		stylesNeeded.accent = true
          863  +	end
          864  +
          865  +
   691    866   	for k in pairs(stylesNeeded) do
   692         -		table.insert(styles, (stylesets[k]:gsub('%s+',' ')))
          867  +		if not stylesets[k] then ct.exns.unimpl('styleset %s not implemented (!)',  k):throw() end
          868  +		table.insert(styles, prepcss(stylesets[k]))
   693    869   	end
   694    870   
   695    871   	local head = {}
   696    872   	local styletag = ''
   697    873   	if opts['link-css'] then
   698    874   		local css = opts['link-css']
   699    875   		if type(css) ~= 'string' then ct.exns.mode('must be a string', 'html:link-css'):throw() end
................................................................................
   711    887   	elseif opts.snippet then
   712    888   		return styletag .. body
   713    889   	else
   714    890   		return dr.htmlDoc(doctitle, next(head) and table.concat(head), body)
   715    891   	end
   716    892   end
   717    893   
   718         -local function
   719         -startswith(str, pfx)
   720         -	return string.sub(str, 1, #pfx) == pfx
   721         -end
   722         -
   723    894   local function eachcode(str, ascode)
   724    895   	local pos = {
   725    896   		code = 1;
   726    897   		byte = 1;
   727    898   	}
   728    899   	return function()
   729    900   		if pos.byte > #str then return nil end
................................................................................
   740    911   			pos.byte = pos.byte + #utf8.char(thischar)
   741    912   		end
   742    913   		pos.code = pos.code + 1
   743    914   		return thischar, lastpos
   744    915   	end
   745    916   end
   746    917   
   747         -local function formatter(sty)
   748         -	return function(s,c)
   749         -		return {
   750         -			kind = 'format';
   751         -			style = sty;
   752         -			spans = ct.parse_span(s, c);
   753         -			origin = c:clone();
   754         -		}
   755         -	end
   756         -end
   757         -ct.spanctls = {
   758         -	{seq = '$', parse = formatter 'literal'};
   759         -	{seq = '!', parse = formatter 'emph'};
   760         -	{seq = '*', parse = formatter 'strong'};
   761         -	{seq = '\\', parse = function(s, c) -- raw
   762         -		return s
   763         -	end};
   764         -	{seq = '$\\', parse = function(s, c) -- raw
   765         -		return {
   766         -			kind = 'format';
   767         -			style = 'literal';
   768         -			spans = {s};
   769         -			origin = c:clone();
   770         -		}
   771         -	end};
   772         -	{seq = '&', parse = function(s, c)
   773         -		local r, t = s:match '^([^%s]+)%s*(.-)$'
   774         -		return {
   775         -			kind = 'term';
   776         -			spans = (t and t ~= "") and ct.parse_span(t, c) or {};
   777         -			ref = r; 
   778         -			origin = c:clone();
   779         -		}
   780         -	end};
   781         -	{seq = '^', parse = function(s, c)
   782         -		local fn, t = s:match '^([^%s]+)%s*(.-)$'
   783         -		return {
   784         -			kind = 'footnote';
   785         -			spans = (t and t~='') and ct.parse_span(t, c) or {};
   786         -			ref = fn;
   787         -			origin = c:clone();
   788         -		}
   789         -	end};
   790         -	{seq = '>', parse = function(s, c)
          918  +do -- define span control sequences
          919  +	local function formatter(sty)
          920  +		return function(s,c)
          921  +			return {
          922  +				kind = 'format';
          923  +				style = sty;
          924  +				spans = ct.parse_span(s, c);
          925  +				origin = c:clone();
          926  +			}
          927  +		end
          928  +	end
          929  +	local function insert_link(s, c)
   791    930   		local to, t = s:match '^([^%s]+)%s*(.-)$'
   792    931   		if not to then c:fail('invalid link syntax >%s', s) end
   793    932   		if t == "" then t = nil end
   794    933   		return {
   795    934   			kind = 'link';
   796    935   			spans = (t and t~='') and ct.parse_span(t, c) or {};
   797    936   			ref = to;
   798    937   			origin = c:clone();
   799    938   		}
   800         -	end};
   801         -}
          939  +	end
          940  +	local function insert_var_ref(raw)
          941  +		return function(s, c)
          942  +			local pos = tonumber(s)
          943  +			return {
          944  +				kind = 'var';
          945  +				pos = pos;
          946  +				raw = raw;
          947  +				var = not pos and s or nil;
          948  +				origin = c:clone();
          949  +			}
          950  +		end
          951  +	end
          952  +	ct.spanctls = {
          953  +		{seq = '$', parse = formatter 'literal'};
          954  +		{seq = '!', parse = formatter 'emph'};
          955  +		{seq = '*', parse = formatter 'strong'};
          956  +		{seq = '\\', parse = function(s, c) -- raw
          957  +			return s
          958  +		end};
          959  +		{seq = '$\\', parse = function(s, c) -- raw
          960  +			return {
          961  +				kind = 'format';
          962  +				style = 'literal';
          963  +				spans = {s};
          964  +				origin = c:clone();
          965  +			}
          966  +		end};
          967  +		{seq = '&', parse = function(s, c)
          968  +			local r, t = s:match '^([^%s]+)%s*(.-)$'
          969  +			return {
          970  +				kind = 'term';
          971  +				spans = (t and t ~= "") and ct.parse_span(t, c) or {};
          972  +				ref = r; 
          973  +				origin = c:clone();
          974  +			}
          975  +		end};
          976  +		{seq = '^', parse = function(s, c)
          977  +			local fn, t = s:match '^([^%s]+)%s*(.-)$'
          978  +			return {
          979  +				kind = 'footnote';
          980  +				spans = (t and t~='') and ct.parse_span(t, c) or {};
          981  +				ref = fn;
          982  +				origin = c:clone();
          983  +			}
          984  +		end};
          985  +		{seq = '>', parse = insert_link};
          986  +		{seq = '→', parse = insert_link};
          987  +		{seq = '🔗', parse = insert_link};
          988  +		{seq = '##', parse = insert_var_ref(true)};
          989  +		{seq = '#', parse = insert_var_ref(false)};
          990  +	}
          991  +end
   802    992   
   803    993   function ct.parse_span(str,ctx)
   804    994   	local function delimited(start, stop, s)
   805    995   		local depth = 0
   806    996   		if not startswith(s, start) then return nil end
   807    997   		for c,p in eachcode(s) do
   808    998   			if c == '\\' then
................................................................................
   816   1006   					return s:sub(1+#start, p.byte - #stop), p.byte -- FIXME
   817   1007   				elseif depth < 0 then
   818   1008   					ctx:fail('out of place %s', stop)
   819   1009   				end
   820   1010   			end
   821   1011   		end
   822   1012   
   823         -		ctx:fail('%s expected before end of line', stop)
         1013  +		ctx:fail('[%s] expected before end of line', stop)
   824   1014   	end
   825   1015   	local buf = ""
   826   1016   	local spans = {}
   827   1017   	local function flush()
   828   1018   		if buf ~= "" then
   829   1019   			table.insert(spans, buf)
   830   1020   			buf = ""
................................................................................
   833   1023   	local skip = false
   834   1024   	for c,p in eachcode(str) do
   835   1025   		if skip == true then
   836   1026   			skip = false
   837   1027   			buf = buf .. c
   838   1028   		elseif c == '\\' then
   839   1029   			skip = true
         1030  +		elseif c == '{' then
         1031  +			flush()
         1032  +			local substr, following = delimited('{','}',str:sub(p.byte))
         1033  +			local splitstart, splitstop = substr:find'%s+'
         1034  +			local id, argstr
         1035  +			if splitstart then
         1036  +				id, argstr = substr:sub(1,splitstart-1), substr:sub(splitstop+1)
         1037  +			else
         1038  +				id, argstr = substr, ''
         1039  +			end
         1040  +			local o = {
         1041  +				kind = 'macro';
         1042  +				macro = id;
         1043  +				args = {};
         1044  +				origin = ctx:clone();
         1045  +			}
         1046  +
         1047  +			do local start = 1
         1048  +				local i = 1
         1049  +				while i <= #argstr do
         1050  +					while i<=#argstr and (argstr:sub(i,i) ~= '|' or argstr:sub(i-1,i) == '\\|') do
         1051  +						i = i + 1
         1052  +					end
         1053  +					local arg = argstr:sub(start, i == #argstr and i or i-1)
         1054  +					start = i+1
         1055  +					table.insert(o.args, arg)
         1056  +					i = i + 1
         1057  +				end
         1058  +			end
         1059  +
         1060  +			p.next.byte = p.next.byte + following - 1
         1061  +			table.insert(spans,o)
   840   1062   		elseif c == '[' then
   841   1063   			flush()
   842   1064   			local substr, following = delimited('[',']',str:sub(p.byte))
   843   1065   			p.next.byte = following + p.byte
   844   1066   			local found = false
   845   1067   			for _,i in pairs(ct.spanctls) do
   846   1068   				if startswith(substr, i.seq) then
................................................................................
   942   1164   			c.expand_next = 0
   943   1165   		end
   944   1166   	end;
   945   1167   }
   946   1168   
   947   1169   local function insert_table_row(l,c)
   948   1170   	local row = {}
   949         -	for kind, a1, text, a2 in l:gmatch '([+|])(:?)%s*([^:+|]*)%s*(:?)' do
   950         -		local header = kind == '+'
   951         -		local align
   952         -		if     a1 == ':' and a2 ~= ':' then
   953         -			align = 'left'
   954         -		elseif a1 == ':' and a2 == ':' then
   955         -			align = 'center'
   956         -		elseif a1 ~= ':' and a2 == ':' then
   957         -			align = 'right'
   958         -		end
   959         -		text = text:match '^%s*(.-)%s*$'
   960         -		table.insert(row, {
   961         -			spans = ct.parse_span(text, c);
   962         -			align = align;
   963         -			header = header;
   964         -		})
         1171  +	local buf
         1172  +	local flush = function()
         1173  +		if buf then table.insert(row, buf) end
         1174  +		buf = { str = '' }
         1175  +	end
         1176  +	for c,p in eachcode(l) do
         1177  +		if c == '|' or c == '+' and (p.code == 1 or l:sub(p.byte-1,p.byte-1)~='\\') then
         1178  +			flush()
         1179  +			buf.header = c == '+'
         1180  +		elseif c == ':' then
         1181  +			local lst = l:sub(p.byte-#c,p.byte-#c)
         1182  +			local nxt = l:sub(p.next.byte,p.next.byte)
         1183  +			if lst == '|' or lst == '+' and l:sub(p.byte-2,p.byte-2) ~= '\\' then
         1184  +				buf.align = 'left'
         1185  +			elseif nxt == '|' or nxt == '|' then
         1186  +				if buf.align == 'left' then
         1187  +					buf.align = 'center'
         1188  +				else
         1189  +					buf.align = 'right'
         1190  +				end
         1191  +			else
         1192  +				buf.str = buf.str .. c
         1193  +			end
         1194  +		elseif c:match '%s' then
         1195  +			if buf.str ~= '' then buf.str = buf.str .. c end
         1196  +		elseif c == '\\' then
         1197  +			local nxt = l:sub(p.next.byte,p.next.byte)
         1198  +			if nxt == '|' or nxt == '+' or nxt == ':' then
         1199  +				buf.str = buf.str .. nxt
         1200  +				p.next.byte = p.next.byte + #nxt
         1201  +				p.next.code = p.next.code + 1
         1202  +			else
         1203  +				buf.str = buf.str .. c
         1204  +			end
         1205  +		else
         1206  +			buf.str = buf.str .. c
         1207  +		end
         1208  +	end
         1209  +	if buf.str ~= '' then flush() end 
         1210  +	for _,v in pairs(row) do
         1211  +		v.spans = ct.parse_span(v.str, c)
   965   1212   	end
   966   1213   	if #c.sec.blocks > 1 and c.sec.blocks[#c.sec.blocks].kind == 'table' then
   967   1214   		local tbl = c.sec.blocks[#c.sec.blocks]
   968   1215   		table.insert(tbl.rows, row)
   969   1216   	else
   970   1217   		table.insert(c.sec.blocks, {
   971   1218   			kind = 'table';
................................................................................
   974   1221   		})
   975   1222   	end
   976   1223   end
   977   1224   
   978   1225   ct.ctlseqs = {
   979   1226   	{seq = '.', fn = insert_paragraph};
   980   1227   	{seq = '¶', fn = insert_paragraph};
         1228  +	{seq = '❡', fn = insert_paragraph};
   981   1229   	{seq = '#', fn = insert_section};
   982   1230   	{seq = '§', fn = insert_section};
   983   1231   	{seq = '+', fn = insert_table_row};
   984   1232   	{seq = '|', fn = insert_table_row};
   985   1233   	{seq = '│', fn = insert_table_row};
   986   1234   	{pred = function(s,c) return s:match'^[*:]' end, fn = blockwrap(function(l,c) -- list
   987   1235   		local stars = l:match '^([*:]+)'
................................................................................
  1005   1253   		local words = function(i)
  1006   1254   			local wds = {}
  1007   1255   			if i == 0 then return cmdline end
  1008   1256   			for w,pos in cmdline:gmatch '([^%s]+)()' do
  1009   1257   				table.insert(wds, w)
  1010   1258   				i = i - 1
  1011   1259   				if i == 0 then
  1012         -					return table.unpack(wds), cmdline:sub(pos)
         1260  +					table.insert(wds,cmdline:sub(pos))
         1261  +					return table.unpack(wds)
  1013   1262   				end
  1014   1263   			end
  1015   1264   		end
  1016   1265   
  1017   1266   		local cmd, rest = words(1)
  1018   1267   		if ct.directives[cmd] then
  1019   1268   			ct.directives[cmd](words,c)
         1269  +		elseif cmd == c.doc.stage.mode['render:format'] then
         1270  +			-- this is a directive for the renderer; insert it into the tree as is
         1271  +			c:insert {
         1272  +				kind = 'directive';
         1273  +				critical = crit == '!';
         1274  +				words = words;
         1275  +			}
  1020   1276   		elseif crit == '!' then
  1021   1277   			c:fail('critical directive %s not supported',cmd)
  1022   1278   		end
  1023   1279   	end;};
  1024   1280   	{seq = '~~~', fn = blockwrap(function(l,c)
  1025   1281   		local extract = function(ptn, str)
  1026   1282   			local start, stop = str:find(ptn)
................................................................................
  1030   1286   			return ex, n
  1031   1287   		end
  1032   1288   		local lang, id, title
  1033   1289   		if l:match '^~~~%s*$' then -- no args
  1034   1290   		elseif l:match '^~~~.*~~~%s*$' then -- CT style
  1035   1291   			local s = l:match '^~~~%s*(.-)%s*~~~%s*$'
  1036   1292   			lang, s = extract('%b[]', s)
  1037         -			lang = lang:sub(2,-2)
         1293  +			if lang then lang = lang:sub(2,-2) end
  1038   1294   			id, title = extract('#[^%s]+', s)
  1039   1295   			if id then id = id:sub(2) end
  1040   1296   		elseif l:match '^~~~' then -- MD shorthand style
  1041   1297   			lang = l:match '^~~~%s*(.-)%s*$'
  1042   1298   		end
  1043   1299   		c.mode = {
  1044   1300   			kind = 'code';
................................................................................
  1067   1323   		end
  1068   1324   	end; fn = blockwrap(function()
  1069   1325   		return { kind = 'horiz-rule' }
  1070   1326   	end)};
  1071   1327   	{fn = insert_paragraph};
  1072   1328   }
  1073   1329   
  1074         -function ct.parse(file, src)
         1330  +function ct.parse(file, src, mode)
  1075   1331   	local function
  1076   1332   	is_whitespace(cp)
  1077   1333   		return cp == 0x20
  1078   1334   	end
  1079   1335   
  1080   1336   	local ctx = ct.ctx.mk(src)
  1081   1337   	ctx.line = 0
  1082   1338   	ctx.doc = ct.doc.mk()
         1339  +	ctx.doc.src = src
         1340  +	ctx.doc.stage = {
         1341  +		kind = 'parse';
         1342  +		mode = mode;
         1343  +	}
  1083   1344   	ctx.sec = ctx.doc:mksec() -- toplevel section
  1084   1345   	ctx.sec.origin = ctx:clone()
  1085   1346   
  1086   1347   	for full_line in file:lines() do ctx.line = ctx.line + 1
  1087   1348   		local l
  1088   1349   		for p, c in utf8.codes(full_line) do
  1089   1350   			if not is_whitespace(c) then
................................................................................
  1153   1414   	for k, v in pairs(list) do
  1154   1415   		if fn(k,v) then new[k] = v end
  1155   1416   	end
  1156   1417   	return new
  1157   1418   end
  1158   1419   
  1159   1420   local function main(input, output, log, mode, vars)
  1160         -	local doc = ct.parse(input.stream, input.src)
         1421  +	local doc = ct.parse(input.stream, input.src, mode)
  1161   1422   	input.stream:close()
  1162   1423   	if mode['parse:show-tree'] then
  1163   1424   		log:write(dump(doc))
  1164   1425   	end
  1165   1426   
  1166   1427   	if not mode['render:format'] then
  1167   1428   		error 'what output format should i translate the input to?'
  1168   1429   	end
         1430  +	if mode['render:format'] == 'none' then return 0 end
  1169   1431   	if not ct.render[mode['render:format']] then
  1170         -		error(string.format('output format “%s” unsupported', mode['render:format']))
         1432  +		ct.exns.unimpl('output format “%s” unsupported', mode['render:format']):throw()
  1171   1433   	end
  1172   1434   	
  1173   1435   	local render_opts = kmap(function(k,v)
  1174   1436   		return k:sub(2+#mode['render:format'])
  1175   1437   	end, kfilter(mode, function(m)
  1176   1438   		return startswith(m, mode['render:format']..':')
  1177   1439   	end))
         1440  +
         1441  +	doc.vars = vars
         1442  +	
         1443  +	-- this is kind of gross but the context object belongs to the parser,
         1444  +	-- not the renderer, so that's not a suitable place for this information
         1445  +	doc.stage = {
         1446  +		kind = 'render';
         1447  +		format = mode['render:format'];
         1448  +		mode = mode;
         1449  +	}
  1178   1450   
  1179   1451   	output:write(ct.render[mode['render:format']](doc, render_opts))
         1452  +	return 0
  1180   1453   end
  1181   1454   
  1182   1455   local inp,outp,log = io.stdin, io.stdout, io.stderr
  1183   1456   
  1184   1457   local function entry_cli()
  1185   1458   	local mode, vars, input = default_mode, {}, {
  1186   1459   		stream = inp;
................................................................................
  1227   1500   		log = function(file)
  1228   1501   			local nf = io.open(file,'wb')
  1229   1502   			if nf then log:close() log = nf else
  1230   1503   				ct.exns.io('could not open log file for writing', 'open',file):throw()
  1231   1504   			end
  1232   1505   		end;
  1233   1506   		define = function(key,value)
  1234         -			-- set context key
         1507  +			if startswith(key, 'cortav.') or startswith(key, 'env.') then
         1508  +				ct.exns.cli 'cannot define variable in restricted namespace':throw()
         1509  +			end
         1510  +			vars[key] = value
  1235   1511   		end;
  1236   1512   		mode = function(key,value) mode[checkmodekey(key)] = value end;
  1237   1513   		['mode-set'] = function(key) mode[checkmodekey(key)] = true end;
  1238   1514   		['mode-clear'] = function(key) mode[checkmodekey(key)] = false end;
  1239   1515   	}
  1240   1516   
  1241   1517   	local args = {}
................................................................................
  1287   1563   	if args[1] and args[1] ~= '' then
  1288   1564   		local file = io.open(arg[1], "rb")
  1289   1565   		if not file then error('unable to load file ' .. args[1]) end
  1290   1566   		input.stream = file
  1291   1567   		input.src.file = args[1]
  1292   1568   	end
  1293   1569   
  1294         -	main(input, outp, log, mode, vars)
         1570  +	return main(input, outp, log, mode, vars)
  1295   1571   end
  1296   1572   
  1297         --- local ok, e = pcall(entry_cli)
  1298         -local ok, e = true, entry_cli()
         1573  +local ok, e = pcall(entry_cli)
         1574  +-- local ok, e = true, entry_cli()
  1299   1575   if not ok then
  1300   1576   	local str = 'translation failure'
         1577  +	if ct.exn.is(e) then
         1578  +		str = e.kind.desc
         1579  +	end
  1301   1580   	local color = false
  1302   1581   	if log:seek() == nil then
  1303   1582   		-- this is not a very reliable heuristic for detecting
  1304   1583   		-- attachment to a tty but it's better than nothing
  1305   1584   		if os.getenv('COLORTERM') then
  1306   1585   			color = true
  1307   1586   		else
................................................................................
  1308   1587   			local term = os.getenv('TERM')
  1309   1588   			if term:find 'color' then color = true end
  1310   1589   		end
  1311   1590   	end
  1312   1591   	if color then
  1313   1592   		str = string.format('\27[1;31m%s\27[m', str)
  1314   1593   	end
  1315         -	log:write(string.format('[%s] %s\n\t%s\n', os.date(), str, e))
         1594  +	log:write(string.format('%s: %s\n', str, e))
  1316   1595   	os.exit(1)
  1317   1596   end
         1597  +os.exit(e)