-- [ʞ] 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)