cortav  toc.lua at [e51980e07a]

File ext/toc.lua artifact 35da212f52 part of check-in e51980e07a


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;
				}
	}
]]

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,
				-- 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.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 = 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
					end
				end
				return lst
			end;

			[true] = function() end; -- fallback // convert to different node types
		};
	};
}