Index: cli.lua ================================================================== --- cli.lua +++ cli.lua @@ -9,11 +9,11 @@ local function main(input, output, log, mode, suggestions, vars) local doc = ct.parse(input.stream, input.src, mode) input.stream:close() if mode['parse:show-tree'] then - log:write(dump(doc)) + log:write(ss.dump(doc)) end -- the document has now had a chance to give its say; if it hasn't specified -- any modes of its own, we now merge in the 'weak modes' (suggestions) for k,v in pairs(suggestions) do @@ -161,11 +161,11 @@ end local nt = {} for j = i+1, i+nargs do table.insert(nt, arg[j]) end - print('onsw') + onswitch[longopt](table.unpack(nt)) elseif nargs == 1 then onswitch[longopt](arg[i+1]) else onswitch[longopt]() end @@ -202,12 +202,12 @@ end return main(input, outp, log, mode, suggestions, 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 ss.exn.is(e) then str = e.kind.desc end Index: cortav.ct ================================================================== --- cortav.ct +++ cortav.ct @@ -86,12 +86,12 @@ most blocks contain a sequence of spans. these spans are produced by interpreting a stream of [*styled-text] following the control sequence. styled-text is a sequence of codepoints potentially interspersed with escapes. an escape is formed by an open square bracket [$\[] followed by a [*span control sequence], and arguments for that sequence like more styled-text. escapes can be nested. * strong \[*[!styled-text]\]: causes its text to stand out from the narrative, generally rendered as bold or a brighter color. * emphatic \[![!styled-text]\]: indicates that its text should be spoken with emphasis, generally rendered as italics * literal \[$[!styled-text]\]: indicates that its text is a reference to a literal sequence of characters, variable name, or other discrete token. generally rendered in monospace -* strikeout \[$[~styled-text]\]: indicates that its text should be struck through or otherwise indicated for deletion -* insertion \[$[+styled-text]\]: indicates that its text should be indicated as a new addition to the text body. +* strikeout \[~[!styled-text]\]: indicates that its text should be struck through or otherwise indicated for deletion +* insertion \[+[!styled-text]\]: indicates that its text should be indicated as a new addition to the text body. ** consider using a macro definition [$\edit: [~[#1]][+[#2]]] to save typing if you are doing editing work * link \[>[!ref] [!styled-text]\]: produces a hyperlink or cross-reference denoted by [$ref], which may be either a URL specified with a reference or the name of an object like an image or section elsewhere in the document. the unicode characters [$→] and [$🔗] can also be used instead of [$>] to denote a link. * footnote \[^[!ref] [!styled-text]\]: annotates the text with a defined footnote * raw \[\\[!raw-text]\]: causes all characters within to be interpreted literally, without expansion. the only special characters are square brackets, which must have a matching closing bracket * raw literal \[$\\[!raw-text]\]: shorthand for [\[$[\…]]] @@ -201,10 +201,18 @@ extensions are mainly interacted with through directives. all extension directives must be prefixed with the name of the extension. ### toc sections that have a title will be included in the table of contents. the table of contents is by default inserted at the break between the first level-1 section and the section immediately following it. you may instead place the directive [$toc] where you wish the TOC to be inserted, or suppress it entirely with [$inhibits toc]. note that some renderers may not display the TOC as part of the document itself. +toc provides the directives: + +* [$%[*toc]]: insert a table of contents in the specified position. this can be used more than once, but doing so may have confusing, incorrect, or nonsensical results under some renderers, and some may just ignore the directive entirely +* [$%[*toc] mark [!styled-text]]: inserts a TOC entry with the label [!styled-text] pointing to the current location. this can be used to e.g. mark noteworthy images, instances of long quotes or literal blocks, or functions inside an expanded code block. +* [$%[*toc] name [!id styled-text]]: like [$%[*toc] mark] but allows an additional [!id] parameter which specifies the ID the renderer will assign to an anchor element. this is not meaningful for all renderers and when it is, it is up to the renderer to decide what it means. +** the [*html] render backend interprets [!id] as the [$id] element for the anchor tag +** the [*groff] render backend ignores [!id] + ### smart-quotes a cortav renderer may automatically translate punctuation marks to other punctuation marks depending on their context. ### hilite code can be highlighted according to the formal language it is written in. @@ -380,10 +388,24 @@ $ cortav readme.ct -ommmmy readme.html render:format html html:width 40em html:accent 80 html:hue-spread 35 html:dark-on-light ~~~ ## further directions +### additional backends +it is eventually intended to support to following backends, if reasonably practicable. +* [*html]: emit HTML and CSS code to typeset the document. [!in progress] +* [*svg]: emit SVG, taking advantage of its precise layout features to produce a nicely formatted and paginated document. pagination can be accomplished through emitting multiple files or by assigning one layer to each page. [!long term] +* [*groff]: the most important output backend, rivalling [*html]. will allow the document to be typeset in a wide variety of formats, including PDF and manpage. [!short term] +* [*gemtext]: essentially a downrezzing of cortav to make it readable to Gemini clients + +some formats may eventually warrant their own renderer, but are not a priority: +* [*text]: cortav source files are already plain text, but a certain amount of layout could be done using ascii art. +* [*ansi]: emit sequences of ANSI escape codes to lay out a document in a terminal-friendly way +* [*tex]: TeX is an unholy abomination and i neither like nor use it, but lots of people do and if cortav ever catches on, a TeX backend should probably be written eventually. + +PDF is not on either list because it's a nightmarish mess of a format and groff, which is installed on most linux systems already, can easily generate PDFs + ### LCH support right now, the use of color in the HTML renderer is very unsatisfactory. the accent mechanism operates on the basis of the CSS HSL function, which is not perceptually uniform; different hues will present different mixes of brightness and some (yellows?) may be ugly or unreadable. the ideal solution would be to simply switch to using LCH based colors. unfortunately, only Safari actually supports the LCH color function right now, and it's unlikely (unless Lea Verou and her husband manage to work a miracle) that Colors Level 4 is going to be implemented very widely any time soon. Index: cortav.lua ================================================================== --- cortav.lua +++ cortav.lua @@ -108,10 +108,11 @@ 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) + return block end; ref = function(self,id) if not id:find'%.' then local rid = self.sec.refs[id] if self.sec.refs[id] then @@ -151,10 +152,18 @@ local o = ct.sec(id, depth) 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(...) end @@ -195,18 +204,29 @@ else 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 local function fmtfn(str) @@ -223,10 +243,161 @@ 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 + +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'] local f = string.format @@ -421,40 +592,33 @@ border: none; 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'}) .. (title and tag('title', nil, title) or '') .. @@ -514,11 +678,11 @@ 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) + 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) end @@ -549,41 +713,18 @@ htmlDoc = htmlDoc; } 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; directive = function(b,s) @@ -607,11 +748,10 @@ 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 @@ -635,59 +775,118 @@ 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; + 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, ' ') table.insert(rd.nodes, { @@ -695,10 +894,11 @@ attrs = {href = '#' .. irs.attrs.id, class='anchor'}; 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 elseif sec.kind == 'listing' then @@ -706,10 +906,11 @@ end 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 if sec.tag == 'section' then @@ -775,23 +976,24 @@ end end end end + runhook('ir_restructure_post', ir) -- 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)) + 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 local body = collect_nodes(ir) @@ -875,21 +1077,21 @@ 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']}) + 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) end @@ -1048,14 +1250,15 @@ return spans 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) if l:sub(1,1) == '.' then l = l:sub(2) end @@ -1063,11 +1266,11 @@ kind = "paragraph"; 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) end @@ -1087,15 +1290,18 @@ } 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 if mode == 'uses' then @@ -1111,16 +1317,10 @@ 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 @@ -1129,11 +1329,11 @@ c.expand_next = 0 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 buf.str = buf.str:gsub('%s+$','') @@ -1179,16 +1379,21 @@ 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) + 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 = { {seq = '.', fn = insert_paragraph}; @@ -1197,10 +1402,29 @@ {seq = '#', fn = insert_section}; {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*(.-)$' local ordered = stars:sub(#stars) == ':' @@ -1210,15 +1434,16 @@ depth = depth; 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 for w,pos in cmdline:gmatch '([^%s]+)()' do @@ -1231,23 +1456,37 @@ end 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) local n = str:sub(1,start-1) .. str:sub(stop+1) @@ -1262,22 +1501,25 @@ 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 = { + 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 if startswith(s, '—') then @@ -1296,11 +1538,11 @@ } 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 ctx.doc = ct.doc.mk() @@ -1310,48 +1552,70 @@ mode = mode; } 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 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 Index: ext/toc.lua ================================================================== --- ext/toc.lua +++ ext/toc.lua @@ -1,12 +1,184 @@ local ct = require 'cortav' local ss = require 'sirsem' + +local css_toc = [[ + +]] + +local css_toc_fixed = [[ + @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; + } + } + } +]] ct.ext.install { id = 'toc'; desc = 'provides a table of contents for HTML renderer plus generic fallback'; version = ss.version {0,1; 'devel'}; contributors = {{name='lexi hale', handle='velartrill', mail='lexi@hale.su', homepage='https://hale.su'}}; - directive = function(words) + default = true; -- on unless inhibited + hook = { + doc_init = function(job) + print('initing doc:toc',job.doc) + job.state.toc_custom_position = false + end; + + render_html_init = function(job, render) + render.stylesets.toc = css_toc + render.stylesets.tocFixed = css_toc_fixed + end; + + render_html_ir_assemble = function(job, render, ir) + -- the custom position state is part of the document job, + -- but rendering is a separate job, so we need to get the + -- state of this extension in the parent job, which is + -- done with the job:unwind(depth) call. unwind is a method + -- of the delegate we access the job through which gives us + -- direct access to the job state of this extension; unwind + -- climbs the jobtree and constructs a similar delegate for + -- the nth parent. note that this will only work if the + -- current extension hasn't been excluded by predicate from + -- the nth parent! + if not job:unwind(1).state.toc_custom_position then + -- TODO insert %toc end of first section + end + end; + }; + directives = { + mark = function (job, ctx, words) + local _, _, text = words(2) + ctx:insert {kind = 'anchor', _toc_label = ct.parse_span(text,ctx)} + end; + name = function (job, ctx, words) + local _, _, id, text = words(3) + ctx:insert {kind = 'anchor', id=id, _toc_label = ct.parse_span(text,ctx)} + end; + [true] = function (job, ctx, words) + local _, op, val = words(2) + if op == nil then + local toc = {kind='toc'} + ctx:insert(toc) + -- same deal here -- directives are processed as part of + -- the parse job, which is forked off the document job, + -- so we need to climb the jobstack + job:unwind(1).state.toc_custom_position = true + job:hook('ext_toc_position', ctx, toc) + else + ctx:fail 'bad %toc directive' + end + end; + }; + render = { + toc = { + html = function(job, renderer, block, section) + -- “tagproc” contains the functions that determine what kind + -- of data our abstract tags will be transformed into. this + -- is needed to that plain text, HTML, and HTML IR can be + -- produced from the same functions just by varying the + -- proc set. + -- + -- “astproc” contains the functions that determine what form + -- our span arrays (and blocks, but not relevant here) will + -- be transformed into, and is analogous to “tagproc” + local tag = renderer.tagproc.tag; + local elt = renderer.tagproc.elt; + local catenate = renderer.tagproc.catenate; + local sr = renderer.astproc.span_renderers; + local getSafeID = renderer.state.obj_htmlid; + + -- toplevel HTML IR + local lst = {tag = 'ol', attrs={class='toc'}, nodes={}} + + -- "renderer.state" contains the stateglob of the renderer + -- itself, not to be confused with the "state" parameter + -- which contains this extension's share of the job state + -- we use it to activate the stylesets we injected earlier + renderer.state.stylesets_active.toc = true + if renderer.state.opts['width'] then + renderer.state.stylesets_active.tocFixed = true + end + + -- assemble a tree of links from the document section + -- structure. this is tricky, because we need a tree, but + -- all we have is a flat list with depth values attached to + -- each node. + local stack = {lst} + local top = function() return stack[#stack] end + -- job.doc is the document the render job is bound to, and + -- its secorder field is a list of all the doc's sections in + -- the order they occur ("doc.sections" is a hashmap from name + -- to section object) + local all = job.doc.secorder + + for i, sec in ipairs(all) do + if sec.heading_node then -- does this section have a label? + local ent = tag('li',nil, + catenate{tag('a', {href='#'..getSafeID(sec)}, + sr.htmlSpan(sec.heading_node.spans, sec.heading_node, sec))}) + 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 + + -- now we need to assemble a list of items within the + -- section worthy of an entry on their own. currently + -- this is only anchors created with %toc mark|name + local innerlinks = {} + local noteworthy = { anchor = true } + for j, block in pairs(sec.blocks) do + if noteworthy[block.kind] then + local label = ss.coalesce(block._toc_label, block.label, block.spans) + if label then + table.insert(innerlinks, { + id = renderer.state.obj_htmlid(block); + label = label; + block = block; + }) + end + end + end + + if next(innerlinks) then + local n = {tag = 'ol', attrs = {}, nodes = {}} + for i, l in ipairs(innerlinks) do + local nn = { + tag = 'a'; + attrs = {href = '#' .. l.id}; + nodes = {sr.htmlSpan(l.label, l.block, sec)}; + } + table.insert(n.nodes, {tag = 'li', attrs = {}, nodes={nn}}) + end + table.insert(ent.nodes, n) + end + print(ss.dump(ent)) + end + end + return lst + end; - end; + [true] = function() end; -- fallback // convert to different node types + }; + }; } Index: sirsem.lua ================================================================== --- sirsem.lua +++ sirsem.lua @@ -158,11 +158,11 @@ 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( + local function dodump() return ss.dump( p, state, path and string.format("%s.%s", path, k) or k, depth + 1 ) end -- boy this is ugly @@ -366,5 +366,25 @@ else return nil end end +function ss.walk(o, key, ...) + if o[key] then + if select('#', ...) == 0 then + return o[key] + else + return ss.walk(o[key], ...) + end + end + return nil +end + +function ss.coalesce(x, ...) + if x ~= nil then + return x + elseif select('#', ...) == 0 then + return nil + else + return ss.coalesce(...) + end +end