-- [ʞ] cortav.lua
-- ~ lexi hale <lexi@hale.su>
-- © AGPLv3
-- ? reference implementation of the cortav document language
local ss = require 'sirsem'
-- aliases for commonly used sirsem funcs
local startswith = ss.str.begins
local eachcode = ss.str.enc.utf8.each
local dump = ss.dump
local declare = ss.declare
-- make this module available to require() when linked into a lua bytecode program with luac
local ct = ss.namespace 'cortav'
ct.render = {}
ct.exns = {
tx = ss.exnkind('translation error', function(msg,...)
return string.format("(%s:%u) "..msg, ...)
end);
io = ss.exnkind('IO error', function(msg, ...)
return string.format("<%s %s> "..msg, ...)
end);
cli = ss.exnkind 'command line parse error';
mode = ss.exnkind('bad mode', function(msg, ...)
return string.format("mode “%s” "..msg, ...)
end);
unimpl = ss.exnkind 'feature not implemented';
ext = ss.exnkind 'extension error';
}
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;
}
-- FP helper functions
local function fmtfn(str)
return function(...)
return string.format(str, ...)
end
end
ct.ext = { loaded = {} }
function ct.ext.install(ext)
if not ext.id then
ct.exns.ext 'extension missing “id” field':throw()
end
if ct.ext.loaded[ext.id] then
ct.exns.ext('there is already an extension with ID “%s” loaded', ext.id):throw()
end
ct.ext.loaded[ext.id] = ext
end
-- renderer engines
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 = {
header = [[
h1,h2,h3,h4,h5,h6 { border-bottom: 1px solid @tone(0.7); }
h1 { font-size: 200%; border-bottom-style: double !important; border-bottom-width: 3px !important; margin: 0em -1em; }
h2 { font-size: 130%; margin: 0em -0.7em; }
h3 { font-size: 110%; margin: 0em -0.5em; }
h4 { font-size: 100%; font-weight: normal; margin: 0em -0.2em; }
h5 { font-size: 90%; font-weight: normal; }
h6 { font-size: 80%; font-weight: normal; }
h3, h4, h5, h6 { border-bottom-style: dotted !important; }
h1,h2,h3,h4,h5,h6 {
margin-top: 0;
margin-bottom: 0;
}
:is(h1,h2,h3,h4,h5,h6) + p {
margin-top: 0.4em;
}
]];
headingAnchors = [[
:is(h1,h2,h3,h4,h5,h6) > a[href].anchor {
text-decoration: none;
font-size: 1.2em;
padding: 0.3em;
opacity: 0%;
transition: 0.3s;
font-weight: 100;
}
:is(h1,h2,h3,h4,h5,h6):hover > a[href].anchor {
opacity: 50%;
}
:is(h1,h2,h3,h4,h5,h6) > a[href].anchor:hover {
opacity: 100%;
}
]] .. -- this is necessary to avoid the sections jumping around
-- when focus changes from one to another
[[ section {
border: 1px solid transparent;
}
section:target {
margin-left: -2em;
margin-right: -2em;
padding: 0 2em;
background: @tone(0.04);
border: 1px dotted @tone(0.3);
}
section:target > :is(h1,h2,h3,h4,h5,h6) {
}
]];
paragraph = [[
p {
margin: 0.7em 0;
}
section {
margin: 1.2em 0;
}
section:first-child { margin-top: 0; }
]];
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 { color: @tone(2); }
h2 { color: @tone(1.5); }
h3 { color: @tone(1.2); }
h4 { color: @tone(1); }
h5,h6 { color: @tone(0.8); }
]];
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)
stylesNeeded.paragraph = true;
return tag('p', nil, sr.htmlSpan(b.spans, b, s), b)
end;
directive = function(b,s)
-- deal with renderer directives
local _, cmd, args = b.words(2)
if cmd == 'page-title' then
if not opts.title then doctitle = args end
elseif b.critical then
b.origin:fail('critical HTML renderer directive “%s” not supported', cmd)
end
end;
label = function(b,s)
if ct.sec.is(b.captions) then
if not (opts['fossil-uv'] or opts.snippet) then
stylesNeeded.header = true
end
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 = ss.map(function(l)
if #l > 0 then
return tag('div',nil,sr.htmlSpan(l, b, s))
else
return elt('hr')
end
end, b.lines)
if b.title then
table.insert(nodes,1,tag('figcaption',nil,sr.htmlSpan(b.title)))
end
if b.lang then langsused[b.lang] = true end
return tag('figure', {class='listing', lang=b.lang, id=b.id and getSafeID(b)}, catenate(nodes))
end;
['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 ss.reduce(function(a,b) return a..b end, '',
ss.map(function(v,k)
if v == true
then return ' '..k
elseif v then return f(' %s="%s"', k, v)
end
end, attrs)) 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
if opts['heading-anchors'] and block == sec.heading_node then
stylesNeeded.headingAnchors = true
table.insert(rd.nodes, ' ')
table.insert(rd.nodes, {
tag = 'a';
attrs = {href = '#' .. irs.attrs.id, class='anchor'};
nodes = {type(opts['heading-anchors'])=='string' and opts['heading-anchors'] or '§'};
})
end
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 = ss.math.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
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 'emph'};
{seq = '*', parse = formatter 'strong'};
{seq = '~', parse = formatter 'strike'};
{seq = '+', parse = formatter 'inser'};
{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 = formatter 'literal'};
{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 r = { pcall(ss.str.delimit, nil, start, stop, s) }
if r[1] then return table.unpack(r, 2) end
ctx:fail(tostring(r[2]))
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
buf.str = buf.str:gsub('%s+$','')
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