@@ -109,8 +109,9 @@ end; insert = function(self, block) block.origin = self:clone() table.insert(self.sec.blocks,block) + return block end; ref = function(self,id) if not id:find'%.' then local rid = self.sec.refs[id] @@ -152,8 +153,16 @@ if id then self.sections[id] = o end table.insert(self.secorder, o) return o end; + allow_ext = function(self,name) + if not ct.ext.loaded[name] then return false end + if self.ext.inhibit[name] then return false end + if self.ext.need[name] or self.ext.use[name] then + return true + end + return ct.ext.loaded[name].default + end; context_var = function(self, var, ctx, test) local fail = function(...) if test then return false end ctx:fail(...) @@ -196,16 +205,27 @@ if test then return false end return '' -- is this desirable behavior? end end; + job = function(self, name, pred, ...) -- convenience func + return self.docjob:fork(name, pred, ...) + end }; mk = function() return { sections = {}; secorder = {}; embed = {}; meta = {}; vars = {}; + ext = { + inhibit = {}; + need = {}; + use = {}; + }; } end; + construct = function(me) + me.docjob = ct.ext.job('doc', me, nil) + end; } -- FP helper functions @@ -224,8 +244,159 @@ ct.exns.ext('there is already an extension with ID “%s” loaded', ext.id):throw() end ct.ext.loaded[ext.id] = ext end + +function ct.ext.bind(doc) + local fns = {} + function fns.each(...) + local cext + local args = {...} + return function() + while true do + cext = next(ct.ext.loaded, cext) + if cext == nil then return nil end + if doc == nil or doc:allow_ext(cext.id) then + local v = ss.walk(ct.ext.loaded[cext.id], table.unpack(args)) + if v ~= nil then + return v, cext + end + end + end + end + end + + function fns.hook(h, ...) + -- this is the raw hook invocation function, used when hooks won't need + -- private state to hold onto between invocation. if private state is + -- necessary, construct a job instead + local ret = {} -- for hooks that compile lists of responses from extensions + for hook in fns.each('hook', h) do table.insert(ret,(hook(...))) end + return ret + end + + return fns +end + +do local globalfns = ct.ext.bind() + -- use these functions when document restrictions don't matter + ct.ext.each, ct.ext.hook = globalfns.each, globalfns.hook +end + +ct.ext.job = declare { + ident = 'ext-job'; + init = { + states = {}; + }; + construct = function(me,name,doc,pred,...) + print('constructing job',name,'for',doc) + -- prepare contexts for relevant extensions + me.name = name + me.doc = doc -- for reqs + limiting + for _, ext in pairs(ct.ext.loaded) do + if pred == nil or pred(ext) then + me.states[ext] = {} + end + end + me:hook('init', ...) + end; + fns = { + fork = function(me, name, pred, ...) + -- generate a branch job linked to this job + local branch = getmetatable(me)(name, me.doc, pred, ...) + branch.parent = me + return branch + end; + delegate = function(me, ext) -- creates a delegate for state access + local submethods = { + unwind = function(self, n) + local function + climb(dlg, job, n) + if n == 0 then + return job:delegate(dlg.extension) + else + return climb(dlg, job.parent, n-1) + end + end + + return climb(self._delegate_state, self._delegate_state.target, n) + end; + } + local d = setmetatable({ + _delegate_state = { + target = (me._delegate_state and me._delegate_state.target) or me; + extension = ext; + }; + }, { + __name = 'job:delegate'; + __index = function(self, key) + local D = self._delegate_state + if key == 'state' then + return D.target.states[self._delegate_state.extension] + elseif submethods[key] then + return submethods[key] + end + return D.target[key] + end; + __newindex = function(self, key, value) + local D = self._delegate_state + if key == 'state' then + D.target.states[D.extension] = value + else + D.target[D.extension] = value + end + end; + }); + return d; + end; + each = function(me, ...) + local ek + local path = {...} + return function() + while true do + ek = next(me.states, ek) + if not ek then return nil end + if me.doc:allow_ext(ek.id) then + local v = ss.walk(ek, table.unpack(path)) + if v then + return v, ek, me.states[ek] + end + end + end + end + end; + proc = function(me, ...) + local p + local owner + local state + for func, ext, s in me:each(...) do + if p == nil then + p = func + owner = ext + state = s + else + ct.exn.ext('extensions %s and %s define conflicting procedures for %s', owner.id, ext.id, table.concat({...},'.')):throw() + end + end + if p == nil then return nil end + if type(p) ~= 'function' then return p end + return function(...) + return p(me:delegate(owner), ...) + end, owner, state + end; + hook = function(me, hook, ...) + -- used when extensions may need to persist state across + -- multiple functions or invocations + local ret = {} + local hook_id = me.name ..'_'.. hook + for hookfn, ext, state in me:each('hook', hook_id) do + print(' - running hook for ext',ext.id) + table.insert(ret, (hookfn(me:delegate(ext),...))) + end + return ret + end; + }; +} -- renderer engines function ct.render.html(doc, opts) local doctitle = opts['title'] @@ -422,38 +593,31 @@ margin: 0; height: 0.7em; counter-increment: line-number; } - ]]; - toc = [[ - - ]]; - tocFixed = [[ - @media (min-width: calc(@[width]:[100vw] + 20em)) { - ol.toc { - position: fixed; - padding-top: 1em; padding-bottom: 1em; - padding-right: 1em; - margin-top: 0; margin-bottom: 0; - right: 0; top: 0; bottom: 0; - max-width: calc(50vw - ((@[width]:[0]) / 2) - 3.5em); - overflow-y: auto; - } - @media (max-width: calc(@[width]:[100vw] + 30em)) { - ol.toc { - max-width: calc(100vw - ((@[width]:[0])) - 9.5em); - } - body { - margin-left: 5em; - } - } - } ]]; } local stylesNeeded = {} - local function getSpanRenderers(tag,elt) + local render_state_handle = { + doc = doc; + opts = opts; + style_rules = styles; -- use stylesneeded if at all possible + stylesets = stylesets; + stylesets_active = stylesNeeded; + obj_htmlid = getSafeID; + -- remaining fields added later + } + + local renderJob = doc:job('render_html', nil, render_state_handle) + + local runhook = function(h, ...) + return renderJob:hook(h, render_state_handle, ...) + end + + local function getSpanRenderers(procs) + local tag, elt, catenate = procs.tag, procs.elt, procs.catenate local htmlDoc = function(title, head, body) return [[]] .. tag('html',nil, tag('head', nil, elt('meta',{charset = 'utf-8'}) .. @@ -515,9 +679,9 @@ 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) + v.origin.invocation.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) @@ -550,39 +714,16 @@ } end - local function getBlockRenderers(tag,elt,sr,catenate) - local function insert_toc(b,s) - local lst = {tag = 'ol', attrs={class='toc'}, nodes={}} - stylesNeeded.toc = true - if opts['width'] then - stylesNeeded.tocFixed = true - end - 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 function getBlockRenderers(procs, sr) + local tag, elt, catenate = procs.tag, procs.elt, procs.catenate + local null = function() return catenate{} end local block_renderers = { + anchor = function(b,s) + return tag('a',{id = getSafeID(b)},null()) + end; paragraph = function(b,s) stylesNeeded.paragraph = true; return tag('p', nil, sr.htmlSpan(b.spans, b, s), b) end; @@ -608,9 +749,8 @@ 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 = {} @@ -636,57 +776,116 @@ 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; + aside = function(b,s) + local bn = {} + for _,v in pairs(b.lines) do + table.insert(bn, tag('p', {}, sr.htmlSpan(v, b, s))) + end + return tag('aside', {}, bn) + 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) + local function getRenderers(procs) + local r = getSpanRenderers(procs) + r.block_renderers = getBlockRenderers(procs, r) 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', elt(t,attrs), body, t) - end - + local tagproc do + 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 + + tagproc = { + toTXT = { + tag = function(t,a,v) return v end; + elt = function(t,a) return '' end; + catenate = table.concat; + }; + toIR = { + tag = function(t,a,v,o) return { + tag = t, attrs = a; + nodes = type(v) == 'string' and {v} or v, src = o + } end; + + elt = function(t,a,o) return { + tag = t, attrs = a, src = o + } end; + + catenate = function(...) return ... end; + }; + toHTML = { + elt = elt; + tag = function(t,attrs,body) + return f('%s%s', elt(t,attrs), body, t) + end; + catenate = table.concat; + }; + } + end + + local astproc = { + toHTML = getRenderers(tagproc.toHTML); + toTXT = getRenderers(tagproc.toTXT); + toIR = { }; + } + astproc.toIR.span_renderers = ss.clone(astproc.toHTML); + astproc.toIR.block_renderers = getBlockRenderers(tagproc.toIR,astproc.toHTML); + -- note we use HTML here instead of IR span renderers, because as things + -- currently stand we don't need that level of resolution. if we ever + -- get to the point where we want to be able to twiddle spans around + -- we'll need to introduce an IR span renderer + + render_state_handle.astproc = astproc; + render_state_handle.tagproc = tagproc; + + -- bind to legacy names + -- yikes this needs to be cleaned up so badly 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) + local dr = astproc.toHTML -- default renderers + local plainr = astproc.toTXT + local irBlockRdrs = astproc.toIR.block_renderers; + render_state_handle.ir = ir; + + runhook('ir_assemble', ir) 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) + doctitle = astproc.toTXT.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={}} + runhook('ir_section_build', irs, sec) + for i, block in ipairs(sec.blocks) do - local rd = irBlockRdrs[block.kind](block,sec) + local rd + if irBlockRdrs[block.kind] then + rd = irBlockRdrs[block.kind](block,sec) + else + local rdr = renderJob:proc('render',block.kind,'html') + if rdr then + rd = rdr({ + state = render_state_handle; + tagproc = tagproc.toIR; + astproc = astproc.toIR; + }, block, sec) + end + end if rd then if opts['heading-anchors'] and block == sec.heading_node then stylesNeeded.headingAnchors = true table.insert(rd.nodes, ' ') @@ -696,8 +895,9 @@ nodes = {type(opts['heading-anchors'])=='string' and opts['heading-anchors'] or '§'}; }) end table.insert(irs.nodes, rd) + runhook('ir_section_node_insert', rd, irs, sec) end end end elseif sec.kind == 'blockquote' then @@ -707,8 +907,9 @@ if irs then table.insert(ir, irs) end end -- restructure passes + runhook('ir_restructure_pre', ir) ---- list insertion pass local lists = {} for _, sec in pairs(ir) do @@ -776,8 +977,9 @@ end end end + runhook('ir_restructure_post', ir) -- collection pass local function collect_nodes(t) local ts = '' @@ -784,13 +986,13 @@ 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)) + ts = ts .. tagproc.toHTML.tag(v.tag, v.attrs, collect_nodes(v.nodes)) elseif v.text then - ts = ts .. tag(v.tag,v.attrs,v.text) + ts = ts .. tagproc.toHTML.tag(v.tag,v.attrs,v.text) else - ts = ts .. elt(v.tag,v.attrs) + ts = ts .. tagproc.toHTML.elt(v.tag,v.attrs) end end return ts end @@ -876,19 +1078,19 @@ 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']}) + styletag = styletag .. tagproc.toHTML.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)) + styletag = styletag .. tagproc.toHTML.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) + return tagproc.toHTML.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) @@ -1049,12 +1251,13 @@ end local function blockwrap(fn) - return function(l,c) - local block = fn(l,c) + return function(l,c,j) + local block = fn(l,c,j) block.origin = c:clone(); table.insert(c.sec.blocks, block); + j:hook('block_insert', c, block, l) end end local insert_paragraph = blockwrap(function(l,c) @@ -1064,9 +1267,9 @@ spans = ct.parse_span(l, c); } end) -local insert_section = function(l,c) +local insert_section = function(l,c,j) 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) @@ -1088,13 +1291,16 @@ table.insert(s.blocks, heading) s.heading_node = heading end c.sec = s + + j:hook('section_attach', c, s) end -local dsetmeta = function(w,c) +local dsetmeta = function(w,c,j) local key, val = w(1) c.doc.meta[key] = val + j:hook('metadata_set', key, val) end local dextctl = function(w,c) local mode, exts = w(1) for e in exts:gmatch '([^%s]+)' do @@ -1112,14 +1318,8 @@ 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) @@ -1130,9 +1330,9 @@ end end; } -local function insert_table_row(l,c) +local function insert_table_row(l,c,j) local row = {} local buf local flush = function() if buf then @@ -1180,14 +1380,19 @@ 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) + j:hook('block_table_attach', c, tbl, row, l) + j:hook('block_table_row_insert', c, tbl, row, l) else - table.insert(c.sec.blocks, { + local tbl = { kind = 'table'; rows = {row}; origin = c:clone(); - }) + } + table.insert(c.sec.blocks, tbl) + j:hook('block_table_insert', c, tbl, l) + j:hook('block_table_row_insert', c, tbl, tbl.rows[1], l) end end ct.ctlseqs = { @@ -1198,8 +1403,27 @@ {seq = '§', fn = insert_section}; {seq = '+', fn = insert_table_row}; {seq = '|', fn = insert_table_row}; {seq = '│', fn = insert_table_row}; + {seq = '!', fn = function(l,c,j) + local last = c.sec.blocks[#c.sec.blocks] + local txt = l:match '^%s*!%s*(.-)$' + if (not last) or last.kind ~= 'aside' then + local aside = { + kind = 'aside'; + lines = { ct.parse_span(txt, c) } + } + c:insert(aside) + j:hook('block_aside_insert', c, aside, l) + j:hook('block_aside_line_insert', c, aside, aside.lines[1], l) + j:hook('block_insert', c, aside, l) + else + local sp = ct.parse_span(txt, c) + table.insert(last.lines, sp) + j:hook('block_aside_attach', c, last, sp, l) + j:hook('block_aside_line_insert', c, last, sp, l) + end + end}; {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*(.-)$' @@ -1211,13 +1435,14 @@ ordered = ordered; spans = ct.parse_span(txt, c); } end)}; - {seq = '\t', fn = function(l,c) + {seq = '\t', fn = function(l,c,j) local ref, val = l:match '\t+([^:]+):%s*(.*)$' c.sec.refs[ref] = val + j:hook('section_ref_attach', c, ref, val, l) end}; - {seq = '%', fn = function(l,c) -- directive + {seq = '%', fn = function(l,c,j) -- directive local crit, cmdline = l:match '^%%([!%%]?)%s*(.*)$' local words = function(i) local wds = {} if i == 0 then return cmdline end @@ -1232,21 +1457,35 @@ end local cmd, rest = words(1) if ct.directives[cmd] then - ct.directives[cmd](words,c) + ct.directives[cmd](words,c,j) 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 { + local dir = { kind = 'directive'; critical = crit == '!'; words = words; } + c:insert(dir) + j:hook('block_directive_render', j, c, dir) + elseif c.doc:allow_ext(cmd) then -- extension directives begin with their id + local ext = ct.ext.loaded[cmd] + if ext.directives then + local _, topcmd = words(2) + if ext.directives[topcmd] then + ext.directives[topcmd](j:delegate(ext), c, words) + elseif ext.directives[true] then -- catch-all + ext.directives[true](j:delegate(ext), c, words) + elseif crit == '!' then + c:fail('extension %s does not support critical directive %s', cmd, topcmd) + end + end elseif crit == '!' then c:fail('critical directive %s not supported',cmd) end end;}; - {seq = '~~~', fn = blockwrap(function(l,c) + {seq = '~~~', fn = blockwrap(function(l,c,j) 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) @@ -1263,20 +1502,23 @@ if id then id = id:sub(2) end elseif l:match '^~~~' then -- MD shorthand style lang = l:match '^~~~%s*(.-)%s*$' end - c.mode = { + local mode = { kind = 'code'; listing = { kind = 'listing'; lang = lang, id = id, title = title and ct.parse_span(title,c); lines = {}; } } + j:hook('mode_switch', c, mode) + c.mode = mode if id then if c.sec.refs[id] then c:fail('duplicate ID %s', id) end c.sec.refs[id] = c.mode.listing end + j:hook('block_insert', c, mode.listing, l) return c.mode.listing; end)}; {pred = function(s,c) if s:match '^[%-_][*_%-%s]+' then return true end @@ -1297,9 +1539,9 @@ function ct.parse(file, src, mode) local function is_whitespace(cp) - return cp == 0x20 + return cp == 0x20 or cp == 0xe390 end local ctx = ct.ctx.mk(src) ctx.line = 0 @@ -1311,8 +1553,11 @@ } ctx.sec = ctx.doc:mksec() -- toplevel section ctx.sec.origin = ctx:clone() + -- create states for extension hooks + local job = ctx.doc:job('parse',nil,ctx) + 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 @@ -1319,39 +1564,58 @@ l = full_line:sub(p) break end end + job:hook('line_read',ctx,l) + if ctx.mode then if ctx.mode.kind == 'code' then if l and l:match '^~~~%s*$' then + job:hook('block_listing_end',ctx,ctx.mode.listing) + job:hook('mode_switch', c, nil) ctx.mode = nil else -- TODO handle formatted code - table.insert(ctx.mode.listing.lines, {l}) + local newline = {l} + table.insert(ctx.mode.listing.lines, newline) + job:hook('block_listing_newline',ctx,ctx.mode.listing,newline) 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 + local function tryseqs(seqs, ...) + for _, i in pairs(seqs) do + if ((not i.seq ) or startswith(l, i.seq)) and + ((not i.pred) or i.pred (l, ctx )) then + i.fn(l, ctx, job, ...) + return true + end end + return false end - if not found then - ctx:fail 'incomprehensible input line' + + if not tryseqs(ct.ctlseqs) then + local found = false + + for eb, ext, state in job:each('blocks') do + if tryseqs(eb, state) then found = true break end + end + + if not found then + ctx:fail 'incomprehensible input line' + end end else if next(ctx.sec.blocks) and ctx.sec.blocks[#ctx.sec.blocks].kind ~= 'break' then - table.insert(ctx.sec.blocks, {kind='break'}) + local brk = {kind='break'} + job:hook('block_break', ctx, brk, l) + table.insert(ctx.sec.blocks, brk) end end end + job:hook('line_end',ctx,l) end return ctx.doc end