local ct = require 'cortav'
local ss = require 'sirsem'
local css_toc = [[
@media screen and (max-width: calc(@[width]:[100vw] * 2)) {
ol.toc {
float: right;
background: @bg;
padding: 0 2em;
margin-right: -4em;
}
}
]]
local css_toc_fixed_lod = [[
@media (min-width: calc(@[width]:[100vw] * 2)) {
ol.toc {
background: linear-gradient(to right, transparent 25%, @tone(0.1 50)),
@tone/0.4(-0.1 50);
}
ol.toc > li > ol li {
background: linear-gradient(to right, transparent, rgba(0,0,0,0.4));
}
}
]]
local css_toc_fixed = [[
@media screen and (min-width: calc(@[width]:[100vw] * 2)) {
ol.toc {
position: fixed;
right: 0; top: 0; bottom: 0;
max-width: calc(50vw - ((@[width]:[0]) / 2) - 3.5em);
overflow-y: auto;
background: @tone/0.4(-0.1 50);
padding: 1em 1em;
padding-right: 0;
border-left: 1px solid @tone(-2 50);
margin: 0;
}
@media (max-width: calc(@[width]:[100vw] * 2.5)) {
ol.toc {
max-width: calc(100vw - ((@[width]:[0])) - 9.5em);
}
body {
margin-left: 5em;
}
}
ol.toc li {
padding: 0;
margin-left: 1em;
}
ol.toc a[href] {
display: block;
padding: 0.15em 0;
color: @tone(0.8 50);
background: linear-gradient(to right, transparent, @tone(0.3 50));
background-position-x: 10em;
background-repeat: no-repeat;
transition: 0.25s;
}
ol.toc a[href]:not(:hover) {
text-decoration-color: transparent;
}
@supports not (text-decoration-color: transparent) {
ol.toc a[href]:not(:hover) {
text-decoration: none;
}
}
ol.toc a[href]:hover {
color: @tone(1.3 50);
background-position-x: 0%;
}
ol.toc ol {
font-size: 95%;
width: 100%;
padding-left: 0;
}
ol.toc > li {
list-style: upper-roman;
}
ol.toc > li > a {
font-weight: bold;
}
ol.toc > ol > li {
list-style: decimal;
}
ol.toc > li > ol > li > ol > li {
list-style: enclosed;
}
ol.toc > li > ol > li > ol > li > ol > li {
list-style: lower-roman;
}
}
]]
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'}};
default = true; -- on unless inhibited
hook = {
doc_init = function(job)
job.state.toc_custom_position = false
end;
render_html_init = function(job, render)
render.stylesets.toc = css_toc
render.stylesets.tocFixed = css_toc_fixed
render.stylesets.tocFixedLOD = css_toc_fixed_lod
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;
-- if we want state that can persist into the render job,
-- 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.style_add'toc'
if renderer.state.opts.width then
renderer.state.style_add'tocFixed'
end
if not renderer.state.opts['dark-on-light'] then
renderer.state.style_add'tocFixedLOD'
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 = {}
local function blockHasSubdoc(b)
local subdocBlockKinds = {
quote = true;
embed = true;
macro = true;
}
return subdocBlockKinds[b.kind] and ct.doc.is(b.doc)
end
local function scandoc(doc, depth)
for i, sec in ipairs(doc.secorder) do
table.insert(all, {ref = sec, depth = sec.depth + depth})
for j, block in ipairs(sec.blocks) do
if blockHasSubdoc(block) then
scandoc(block.doc, depth + sec.depth-1)
end
end
end
end
scandoc(job.doc,0)
for i, secptr in ipairs(all) do
local sec = secptr.ref
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 secptr.depth > #stack then
local n = {tag = 'ol', attrs={}, nodes={ent}}
table.insert(top().nodes[#top().nodes].nodes, n)
table.insert(stack, n)
else
if secptr.depth < #stack then
for j=#stack,secptr.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(top().nodes, n)
table.insert(stack, n)
end
end
end
return lst
end;
[true] = function() end; -- fallback // convert to different node types
};
};
}