@@ -1,8 +1,8 @@ -- [ʞ] cortav.lua -- ~ lexi hale -- © AGPLv3 --- ? renderer +-- ? reference implementation of the cortav document language local ct = { render = {} } local function hexdump(s) @@ -69,8 +69,18 @@ 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; @@ -176,8 +186,9 @@ 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; @@ -198,8 +209,12 @@ 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 @@ -240,14 +255,59 @@ 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) @@ -269,8 +329,9 @@ end end function ct.render.html(doc, opts) + local doctitle = opts['title'] local f = string.format local ids = {} local canonicalID = {} local function getSafeID(obj) @@ -310,12 +371,27 @@ 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: #000; - color: #fff; + background: @fg; + color: @bg; font-family: monospace; font-size: 90%; padding: 3px 5px; } @@ -326,14 +402,15 @@ editors_markup = [[]]; block_code_listing = [[ section > figure.listing { font-family: monospace; - background: #000; - color: #fff; + 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; @@ -344,27 +421,27 @@ width: 1.0em; padding: 0.2em 0.4em; text-align: right; display: inline-block; - background-color: #333; - border-right: 1px solid #fff; + background-color: @tone(0.2); + border-right: 1px solid @fg; content: counter(line-number); margin-right: 0.3em; } section > figure.listing>hr::before { - color: #333; + color: transparent; padding-top: 0; padding-bottom: 0; } section > figure.listing>div::before { - color: #fff; + 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 #fff; + border-bottom: 1px solid @fg; } section > figure.listing>figcaption::after { display: block; float: right; @@ -374,12 +451,12 @@ padding-top: 0.3em; } section > figure.listing>figcaption { font-family: sans-serif; - font-weight: bold; - font-size: 130%; + font-size: 120%; padding: 0.2em 0.4em; border: none; + color: @tone(2); } section > figure.listing > hr { border: none; margin: 0; @@ -416,31 +493,60 @@ end return table.concat(text) end - function span_renderers.format(sp) + 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)) + return tag(tags[sp.style],nil,htmlSpan(sp.spans,...)) end - function span_renderers.term(t,b) + 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) or name) + return tag('abbr',{title=r},next(t.spans) and htmlSpan(t.spans,b,s) or name) end - function span_renderers.link(sp,b) + 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 @@ -450,9 +556,9 @@ href = '#' .. getSafeID(r) else href = r end end end - return tag('a',{href=href},next(sp.spans) and htmlSpan(sp.spans) or href) + return tag('a',{href=href},next(sp.spans) and htmlSpan(sp.spans,b,s) or href) end return { span_renderers = span_renderers; htmlSpan = htmlSpan; @@ -489,8 +595,17 @@ 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)) @@ -526,9 +641,9 @@ end, b.lines) if b.title then table.insert(nodes,1,tag('figcaption',nil,sr.htmlSpan(b.title))) end - langsused[b.lang] = true + 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; } @@ -557,9 +672,8 @@ local tag = function(t,attrs,body) return f('%s%s', elt(t,attrs), body, t) end - local doctitle local ir = {} local toc local dr = getRenderers(tag,elt,table.concat) -- default renderers local plainr = getRenderers(function(t,a,v) return v end, @@ -685,12 +799,74 @@ [[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']) then + stylesNeeded.accent = true + end + + for k in pairs(stylesNeeded) do - table.insert(styles, (stylesets[k]:gsub('%s+',' '))) + 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 = '' @@ -714,13 +890,8 @@ return dr.htmlDoc(doctitle, next(head) and table.concat(head), body) end end -local function -startswith(str, pfx) - return string.sub(str, 1, #pfx) == pfx -end - local function eachcode(str, ascode) local pos = { code = 1; byte = 1; @@ -743,52 +914,20 @@ return thischar, lastpos end end -local function formatter(sty) - return function(s,c) - return { - kind = 'format'; - style = sty; - spans = ct.parse_span(s, c); - 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 = function(s, c) +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 { @@ -796,10 +935,61 @@ spans = (t and t~='') and ct.parse_span(t, c) or {}; ref = to; origin = c:clone(); } - end}; -} + 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 @@ -819,9 +1009,9 @@ end end end - ctx:fail('%s expected before end of line', stop) + ctx:fail('[%s] expected before end of line', stop) end local buf = "" local spans = {} local function flush() @@ -836,8 +1026,40 @@ 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 @@ -945,24 +1167,49 @@ } local function insert_table_row(l,c) local row = {} - for kind, a1, text, a2 in l:gmatch '([+|])(:?)%s*([^:+|]*)%s*(:?)' do - local header = kind == '+' - local align - if a1 == ':' and a2 ~= ':' then - align = 'left' - elseif a1 == ':' and a2 == ':' then - align = 'center' - elseif a1 ~= ':' and a2 == ':' then - align = 'right' - end - text = text:match '^%s*(.-)%s*$' - table.insert(row, { - spans = ct.parse_span(text, c); - align = align; - header = header; - }) + 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) @@ -977,8 +1224,9 @@ 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}; @@ -1008,16 +1256,24 @@ for w,pos in cmdline:gmatch '([^%s]+)()' do table.insert(wds, w) i = i - 1 if i == 0 then - return table.unpack(wds), cmdline:sub(pos) + 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;}; @@ -1033,9 +1289,9 @@ 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) - lang = lang:sub(2,-2) + 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*$' @@ -1070,9 +1326,9 @@ end)}; {fn = insert_paragraph}; } -function ct.parse(file, src) +function ct.parse(file, src, mode) local function is_whitespace(cp) return cp == 0x20 end @@ -1079,8 +1335,13 @@ 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 @@ -1156,9 +1417,9 @@ return new end local function main(input, output, log, mode, vars) - local doc = ct.parse(input.stream, input.src) + local doc = ct.parse(input.stream, input.src, mode) input.stream:close() if mode['parse:show-tree'] then log:write(dump(doc)) end @@ -1165,19 +1426,31 @@ 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 - error(string.format('output format “%s” unsupported', mode['render:format'])) + 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 @@ -1230,9 +1503,12 @@ ct.exns.io('could not open log file for writing', 'open',file):throw() end end; define = function(key,value) - -- set context key + 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; @@ -1290,15 +1566,18 @@ input.stream = file input.src.file = args[1] end - main(input, outp, log, mode, vars) + return main(input, outp, log, mode, vars) end --- local ok, e = pcall(entry_cli) -local ok, e = true, entry_cli() +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 @@ -1311,7 +1590,8 @@ end if color then str = string.format('\27[1;31m%s\27[m', str) end - log:write(string.format('[%s] %s\n\t%s\n', os.date(), str, e)) + log:write(string.format('%s: %s\n', str, e)) os.exit(1) end +os.exit(e)