-- [Κ] render/html.lua
-- ~ lexi hale <lexi@hale.su>
-- π― EUPL v1.2
-- ? renders cortav to beautiful, highly customizable
-- webpages full of css trickery to make them look
-- good both on a screen and when printed.
-- > cortav -m render:format html
local ct = require 'cortav'
local ss = require 'sirsem'
-- install rendering function for html
function ct.render.html(doc, opts, setup)
local doctitle = opts['title']
local f = string.format
local getSafeID = ct.tool.namespace()
local footnotes = {}
local footnotecount = 0
local cdata = function(...) return ... end
if opts.epub then
opts.xhtml = true
end
if opts.xhtml then
cdata = function(s)
return '<![CDATA[' .. s .. ']]>'
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 };
ruby = { color = 0xcdd6ff };
}
local stylesets = {
list = [[
@counter-style enclosed {
system: extends decimal;
prefix: "(";
suffix: ") ";
}
ul, ol {
padding: 0 1em;
}
li {
padding: 0.1em 0;
}
]];
list_ordered = [[]];
list_unordered = [[]];
footnote = [[
@media screen {
a[href].fnref {
text-decoration-style: dashed;
color: @tone(0.7 45);
text-decoration-color: @tone/0.4(0.7 45);
}
a[href]:hover.fnref {
color: @tone(0.9 45);
text-decoration-color: @tone/0.7(0.7 45);
}
}
aside.footnote {
font-family: 90%;
grid-template-columns: 1em 1fr min-content;
grid-template-rows: 1fr min-content;
position: fixed;
padding: 1em;
background: @tone(0.03);
margin:auto;
}
@media screen {
aside.footnote {
display: grid;
left: 10em;
right: 10em;
max-width: calc(@width + 2em);
max-height: 30vw;
bottom: 1em;
border: 1px solid black;
transform: translateY(200%);
transition: 0.4s;
z-index: 100;
}
aside.footnote:target {
transform: translateY(0%);
}
#cover {
position: fixed;
top: 0;
left: 0;
height: 100vh; width: 100vw;
background: linear-gradient(to top,
@tone/0.8(-0.07),
@tone/0.4(-0.07));
opacity: 0%;
transition: 1s;
pointer-events: none;
backdrop-filter: blur(0px);
}
aside.footnote:target ~ #cover {
opacity: 100%;
pointer-events: all;
backdrop-filter: blur(5px);
}
}
@media screen and (max-width: calc(@width + 20em)) {
aside.footnote {
left: 1em;
right: 1em;
}
}
@media print {
aside.footnote {
display: grid;
position: relative;
}
aside.footnote:first-of-type {
border-top: 1px solid black;
}
}
aside.footnote > a[href="#0"]{
grid-row: 2/3;
grid-column: 3/4;
display: block;
text-align: center;
padding: 0 0.3em;
text-decoration: none;
background: @tone(0.2);
color: @tone(1);
border: 1px solid black;
margin-top: 0.6em;
font-size: 150%;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-user-drag: none;
user-drag: none;
}
aside.footnote > a[href="#0"]:hover {
background: @tone(0.3);
color: @tone(2);
}
aside.footnote > a[href="#0"]:active {
background: @tone(0.05);
color: @tone(0.4);
}
@media print {
aside.footnote > a[href="#0"]{
display:none;
}
}
aside.footnote > div.number {
text-align:right;
grid-row: 1/2;
grid-column: 1/2;
}
aside.footnote > .text {
grid-row: 1/2;
grid-column: 2/4;
padding-left: 1em;
overflow-y: auto;
margin-top: 0;
}
aside.footnote > .text > :first-child {
margin-top: 0;
}
]];
docmeta = [[
.render-warn {
border: 1px solid @tone(0.1 20);
background: @tone(0.4 20);
padding: 1em;
margin: 5em 1em;
}
]];
embed = [[
embed, .embed {
width: 100%;
height: fit-content;
max-height: 80vh;
overflow: scroll;
}
embed {height: 20em;}
]];
header = [[
body { padding: 0 2.5em !important }
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;
text-align: justify;
}
section {
margin: 1.2em 0;
}
section:first-child { margin-top: 0; }
]];
subtitle = [[
.subtitle {
color: @tone(0.3 20);
font-size: 1.2em;
font-style: italic;
margin-left: 1em;
}
]];
accent = [[
@media screen {
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); }
}
@media print {
a[href] {
text-decoration: none;
color: black;
font-weight: bold;
}
h1,h2,h3,h4,h5,h6 {
border-bottom: 1px black;
}
}
]];
aside = [[
section > aside {
text-align: justify;
margin: 0 1.5em;
padding: 0.5em 0.8em;
background: @tone(0.05);
font-size: 90%;
border-left: 5px solid @tone(0.2 15);
border-right: 5px solid @tone(0.2 15);
}
section > aside p {
margin: 0;
margin-top: 0.6em;
}
section > aside p:first-child {
margin: 0;
}
section aside + aside {
margin-top: 0.5em;
}
]];
quoteCaption = [[
blockquote > div.caption {
text-align: right;
font-style: italic;
&::before {
content: "β ";
}
}
]];
code = [[
code {
display: inline-block;
background: @tone(-1);
color: @tone(0.7);
font-family: monospace;
font-size: 90%;
padding: 2px 5px;
user-select: all;
}
]];
var = [[
var {
font-style: italic;
font-family: monospace;
color: @tone(0.7);
font-size: 90%;
}
code var {
color: @tone(0.4);
}
]];
math = [[
span.equation {
display: inline-block;
background: @tone(0.08 5);
color: @tone(1.5 70);
padding: 0.1em 0.3em;
border: 1px inset @tone(0.3 5);
}
span.equation var {
color: @tone(1 40);
}
span.equation :is(code,var,strong) {
font-family: initial;
}
span.equation strong {
color: @tone(1 90);
padding: 0 0.4em;
}
span.equation code {
color: @tone(0.9 10);
background: none;
padding: 0;
}
]];
abbr = [[
abbr[title] { cursor: help; }
]];
editors_markup = [[]];
block_code_listing = [[
figure.listing {
font-family: monospace;
font-size: 85%;
background: @tone(0.05 20);
color: @tone(1 20);
padding: 0;
margin: 0.3em 0;
counter-reset: line-number;
position: relative;
border: 1px solid @tone(1 20);
}
:not(figure.listing) + figure.listing {
margin-top: 1em;
}
figure.listing + :not(figure.listing) {
margin-top: 1em;
}
figure.listing>div {
white-space: pre-wrap;
tab-size: 3;
-moz-tab-size: 3;
counter-increment: line-number;
text-indent: -2.3em;
margin-left: 2.3em;
}
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 20);
border-right: 1px solid @fg;
content: counter(line-number);
margin-right: 0.3em;
}
figure.listing>hr::before {
color: transparent;
padding-top: 0;
padding-bottom: 0;
}
figure.listing>div::before {
color: @tone(1 20);
}
figure.listing>div:last-child::before {
padding-bottom: 0.5em;
}
figure.listing>figcaption:first-child {
border: none;
border-bottom: 1px solid @tone(1 20);
}
figure.listing>figcaption::after {
display: block;
float: right;
font-weight: normal;
font-style: italic;
font-size: 70%;
padding-top: 0.3em;
}
figure.listing>figcaption {
font-family: sans-serif;
font-size: 120%;
padding: 0.2em 0.4em;
border: none;
color: @tone(2 20);
}
figure.listing > hr {
border: none;
margin: 0;
height: 0.7em;
counter-increment: line-number;
}
]];
root = [[
body {
font-size: 16pt;
page-break-before: always;
}
h1 {
page-break-before: always;
}
h1,h2,h3,h4,h5,h6 {
page-break-after: avoid;
}
]];
linkBlock = [[
a[href].link {
position: relative;
display: block;
padding: .5em;
padding-right: 1.5em;
border: 1px solid @tone(0.2 30);
background: @tone(0.05 30);
font-size: 1.1em;
margin: 0 1em;
text-decoration: none;
color: @tone(0.8 30);
}
a[href].link + a[href].link {
margin-top: -1px;
}
a[href].link:hover {
border-color: @tone(0.3 30);
background: @tone(0.2 30);
color: @tone(0.95 30);
}
a[href].link:hover + a[href].link {
margin-top: 0;
border-top: none;
}
a[href].link::after {
display: block;
position: absolute;
right: .5em;
content: "β";
top: 50%;
margin-left: 1em;
font-size: 1.8em;
transform: translateY(-50%);
color: @tone(0.3 30);
}
a[href].link:hover::after {
color: @tone(0.7 30);
}
]];
}
local stylesNeeded = {
flags = {};
order = {};
}
local function addStyle(sty)
-- convenience function, also just in case i end up having
-- to change the goddamn implementation again
if not stylesNeeded.flags[sty] then
stylesNeeded.flags[sty] = true
table.insert(stylesNeeded.order, sty)
return true
end
return false
end
addStyle 'root'
local render_state_handle = {
doc = doc;
opts = opts;
style_rules = styles; -- use stylesneeded if at all possible
style_add = addStyle;
stylesets = stylesets;
stylesets_active = stylesNeeded;
obj_htmlid = getSafeID;
-- remaining fields added later
}
-- this is kind of gross but the context object belongs to the parser,
-- not the renderer, so that's not a suitable place for this information
doc.stage = {
kind = 'render';
format = 'html';
html_render_state = render_state_handle;
}
setup(doc.stage)
local renderJob = doc:job('render_html', nil, render_state_handle)
doc.stage.job = renderJob;
local runhook = function(h, ...)
return renderJob:hook(h, render_state_handle, ...)
end
local function htmlentities(v)
return v:gsub('[<>&"]', function(x)
return string.format('&#%02u;', string.byte(x))
end)
end
local function htmlURI(uri)
local family = uri:canfetch()
if family == 'file' then
if uri.namespace == 'localhost' then
-- emit an actual file url
return 'file://' .. uri:construct('path','frag')
elseif uri.namespace == nil then
-- this is gonna be tricky. first we establish the location
-- of the CWD/asset base relative to the output file (if any;
-- assume equivalent otherwise) then express the difference
-- as a directory prefix.
-- jk tho for now we just emit the path+frag sadlol TODO
if uri.path == nil and uri.frag then
-- file:#sec links to #sec within the current document
return uri:part 'frag'
else
return uri:construct('path','frag')
end
else
b.origin:fail('file: URI namespace must be empty or βlocalhostβ for HTML links; others are not meaningful (offending URI: β%sβ)', uri.raw)
end
elseif family == 'http' then
local sc = 'http'
if uri.class[1] == 'https' or uri.class[2] == 'tls' then
sc = 'https'
end
if uri.namespace == nil and uri.auth == nil and uri.svc == nil then
-- omit the scheme so we can use a relative path
return uri:construct('path','query','frag')
else
uri.class = {sc}
return tostring(uri)
end
else return tostring(uri) end
end
local function idLink(id,b)
local dest_o, _, dest_s = b.origin:ref(id)
if dest_o == nil then
-- link is to the section itself
return '#' .. getSafeID(dest_s)
else
if type(dest_o) == 'table' then
return '#' .. getSafeID(dest_o)
else -- URI in reference
return htmlURI(ss.uri(dest_o))
end
end
end
local tagproc do
local html_open = function(t,attrs)
if attrs then
return t .. 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))
else return t end
end
local elt = function(t,attrs)
if opts.xhtml then
return f('<%s />', html_open(t,attrs))
end
return f('<%s>', html_open(t,attrs))
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)
if t then
return f('<%s>%s</%s>', html_open(t,attrs), body, t)
else
return tostring(body)
end
end;
catenate = table.concat;
};
}
end
local function getBaseRenderers(procs, span_renderers)
local tag, elt, catenate = procs.tag, procs.elt, procs.catenate
local htmlDoc = function(title, head, body)
local attrs
local header = [[<!doctype html>]]
if opts['epub'] then
-- so cursed
attrs = {
xmlns = "http://www.w3.org/1999/xhtml";
['xmlns:epub'] = "http://www.idpf.org/2007/ops";
}
header = [[<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE html>]]
end
return header .. tag('html',attrs,
tag('head', nil,
(opts.epub and '' or elt('meta',{charset = 'utf-8'})) ..
(title and tag('title', nil, title) or '') ..
(head or '')) ..
tag('body', nil, body or ''))
end
local function htmlSpan(spans, block, sec)
local text = {}
for k,v in pairs(spans) do
if type(v) == 'string' then
v=htmlentities(v)
for fn, ext in renderJob:each('hook','render_html_sanitize') do
v = fn(renderJob:delegate(ext), v)
end
table.insert(text,v)
else
table.insert(text, (span_renderers[v.kind](v, block, sec)))
end
end
return table.concat(text)
end
return {htmlDoc=htmlDoc, htmlSpan=htmlSpan}
end
local spanparse = function(...)
local s = ct.parse_span(...)
doc.docjob:hook('meddle_span', s)
return s
end
local cssRulesFor = {}
function getCSSImageForResource(r)
return '' -- TODO
end
local function getSpanRenderers(procs)
local tag, elt, catenate = procs.tag, procs.elt, procs.catenate
local span_renderers = {}
local plainrdr = getBaseRenderers(tagproc.toTXT, span_renderers)
local htmlSpan = getBaseRenderers(procs, span_renderers).htmlSpan
function span_renderers.format(sp,...)
local tags = {
strong = 'strong';
emph = 'em';
strike = 'del';
insert = 'ins';
literal = 'code';
variable = 'var';
super = 'sup';
sub = 'sub';
underline = 'u';
}
if sp.style == 'literal' and not opts['fossil-uv'] then
addStyle 'code'
elseif sp.style == 'strike' or sp.style == 'insert' then
addStyle 'editors_markup'
elseif sp.style == 'variable' then
addStyle 'var'
end
return tag(tags[sp.style],nil,htmlSpan(sp.spans,...))
end
function span_renderers.codepoint(t,b,s)
-- is this a UTF8 output?
return utf8.char(t.code)
-- else
-- return string.format("&#%u;", code)
end
function span_renderers.deref(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
addStyle 'abbr'
return tag('abbr',{title=r},next(t.spans) and htmlSpan(t.spans,b,s) or name)
end
if r.kind == 'resource' then
local rid = getSafeID(r, 'res-')
if r.class == 'image' then
if not cssRulesFor[r] then
local css = prepcss(string.format([[
section p > .%s {
background: %s;
}
]], rid, getCSSImageForResource(r)))
stylesets[r] = css
cssRulesFor[r] = css
addStyle(r)
end
return tag('div',{class=rid},catenate{''})
elseif r.class == 'video' then
local vid = {}
return tag('video',nil,vid)
elseif r.class == 'font' then
b.origin:fail('fonts cannot be instantiated, use %font directive instead')
end
else
b.origin:fail('%s is not an object that can be embedded', t.ref)
end
end
function span_renderers.var(v,b,s)
local r, raw = ct.expand_var(v)
if raw then return r else
return htmlSpan(r , b, s)
end
end
function span_renderers.raw(v,b,s)
return htmlSpan(v.spans, b, s)
end
function span_renderers.link(sp,b,s)
local href = idLink(sp.ref,b)
local lsp = ct.linkspans(sp)
return tag('a',{href=href}, lsp and htmlSpan(lsp,b,s) or href)
end
span_renderers['line-break'] = function(sp,b,s)
return elt('br')
end
function span_renderers.macro(m,b,s)
-- local macroname = plainrdr.htmlSpan(
-- ct.parse_span(m.macro, b.origin), b,s)
local r = b.origin:ref(m.macro)
if type(r) ~= 'string' then
b.origin:fail('%s is an object, not a reference', r.id)
end
local mctx = b.origin:clone()
mctx.invocation = m
local ir = ct.parse_span(r, mctx)
-- even though this happens at render time, it really shouldn't;
-- we pretend this is happening as part of the document job
local j = b.origin.doc.docjob
for fn, ext, state in j:each('hook', 'doc_macro_expand_span') do
local r = fn(j:delegate(ext), ir, b)
if r then ir = r end
end
return htmlSpan(ir, b, s)
end
function span_renderers.math(m,b,s)
addStyle 'math'
local spans = {}
local function fmt(sp, target)
for i,v in ipairs(sp) do
if type(v) == 'string' then
local x = ct.tool.mathfmt(b.origin, v)
for _,v in ipairs(x) do
table.insert(target, v)
end
elseif type(v) == 'table' then
if v.spans then
local tbl = ss.delegate(v)
tbl.spans = {}
fmt(v.spans, tbl.spans)
table.insert(target, tbl)
else
table.insert(target, v)
end
end
end
end
fmt(m.spans,spans)
return tag('span',{class='equation'},htmlSpan(spans, b, s))
end;
function span_renderers.directive(d,b,s)
if d.ext == 'html' then
elseif b.origin.doc:allow_ext(d.ext) then
elseif d.crit then
b.origin:fail('critical extension %s unavailable', d.ext)
elseif d.failthru then
return htmlSpan(d.spans, b, s)
end
end
function span_renderers.footnote(f,b,s)
local linkattr = {}
if opts.epub then
linkattr['epub:type'] = 'noteref'
else
addStyle 'footnote'
linkattr.class = 'fnref'
end
local source, sid, ssec = b.origin:ref(f.ref)
local cnc = getSafeID(ssec) .. ' ' .. sid
local fn
if footnotes[cnc] then
fn = footnotes[cnc]
else
footnotecount = footnotecount + 1
fn = {num = footnotecount, origin = b.origin, fnid=cnc, source = source}
fn.id = getSafeID(fn)
footnotes[cnc] = fn
end
linkattr.href = '#'..fn.id
return tag('a', linkattr, htmlSpan(f.spans) ..
tag('sup',nil, fn.num))
end
return span_renderers
end
local astproc
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;
['horiz-rule'] = function(b,s)
return elt'hr'
end;
paragraph = function(b,s)
addStyle 'paragraph'
return tag('p', nil, sr.htmlSpan(b.spans, b, s), b)
end;
subtitle = function(b,s)
addStyle 'subtitle'
return tag('div', {class='subtitle'}, 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
addStyle 'header'
end
-- use correct styling in subdocuments
local visDepth = b.captions.depth + (b.origin.docDepth or 0)
local h = math.min(6,math.max(1,visDepth))
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;
link = function(b,s)
addStyle 'linkBlock'
local href
if b.uri then
href = htmlURI(b.uri)
elseif b.ref then
href = idLink(b.ref, b)
end
local sp = sr.htmlSpan(b.spans, b, s)
return tag('a',{class='link', href=href},
catenate{tag('div', {}, sp)})
end;
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)
addStyle 'block_code_listing'
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;
aside = function(b,s)
local bn = {}
addStyle 'aside'
if #b.lines == 1 then
bn[1] = sr.htmlSpan(b.lines[1], b, s)
else
for _,v in pairs(b.lines) do
table.insert(bn, tag('p', {}, sr.htmlSpan(v, b, s)))
end
end
return tag('aside', {}, bn)
end;
['break'] = function() -- HACK
-- lists need to be rewritten to work like asides
return '';
end;
}
local function renderSubdoc(doc)
local ir = {}
for i, sec in ipairs(doc.secorder) do
local secnodes = {}
for i, bl in ipairs(sec.blocks) do
if block_renderers[bl.kind] then
table.insert(secnodes, block_renderers[bl.kind](bl,sec))
end
end
if next(secnodes) then
if doc.secorder[2] then --#secs>1?
-- only wrap in a section if >1 section
table.insert(ir, tag('section',
{id = getSafeID(sec)},
secnodes))
else
ir = secnodes
end
end
end
return ir
end
local function flatten(t)
if t == nil then
return ''
elseif type(t) == 'string' then
return t
elseif type(t) == 'table' then
if t[1] then
return catenate(ss.map(flatten, t))
elseif t.tag then
return tag(t.tag, t.attrs or {}, flatten(t.nodes), t.src)
elseif t.elt then
return elt(t.elt, t.attrs or {}, t.src)
end
end
end
function block_renderers.embed(b,s)
local obj
if b.rsrc
then obj = b.rsrc
else obj = b.origin:ref(b.ref)
end
local function uriForSource(s)
if s.mode == 'link' or s.mode == 'auto' then
return htmlURI(s.uri)
elseif s.mode == 'embed' then
local mime = s.mime:clone()
mime.opts = {}
return string.format('data:%s;base64,%s', mime, ss.str.b64e(s.raw))
end
end
local function P(p) -- get prop
if b.props and b.props[p] then
return b.props[p]
end
return obj.props[p]
end
local cap = b.cap or P'desc' or P'detail'
local capIR = '';
if b.label_node then
local ln = b.label_node
capIR = sr.htmlSpan(ln.spans, ln, s)
elseif cap then
-- the block here should really be the relevant
-- ref definition if an override caption isn't
-- specified, but oh well
capIR = sr.htmlSpan(spanparse(
cap, b.origin
), b, s)
end
--figure out how to embed the given object
local embedActs = {
{ss.mime'image/*', function(s,ctr)
if s == nil then
return {tag = "picture", nodes = {}}
else
local uri = uriForSource(s)
local fbimg, idx
if next(ctr.nodes) == nil then
idx = 1
fbimg = {
elt = 'img'; --fallback
attrs = {
alt = P'desc' or P'detail' or '';
title = P'detail';
src = uri;
width = P'width';
height = P'height';
};
}
else idx = #ctr.nodes end
table.insert(ctr.nodes, idx, {
elt = 'source'; --fallback
attrs = { srcset = uri; };
})
if fbimg then
table.insert(ctr.nodes,fbimg)
else
-- fallback <img> should be lowest-prio image
ctr.nodes[#ctr.nodes].attrs.src = uri;
end
end
end};
{ss.mime'text/x.cortav', function(s,ctr)
if s == nil then
return {}
elseif next(ctr) == nil then
if (s.mode == 'embed' or s.mode == 'auto') and s.doc then
addStyle 'embed'
ctr.tag = 'div'; -- kinda hacky, maybe fix
ctr.attrs = {class='embed'}
ctr.nodes = renderSubdoc(s.doc)
elseif s.mode == 'link' then
-- yeah this is not gonna work my dude
addStyle 'embed'
ctr.elt = 'embed';
ctr.attrs = {
type = 'text/x.cortav';
src = htmlURI(s.uri);
}
end
end
end};
{ss.mime'text/html', function(s,ctr)
if s == nil then
return {}
elseif next(ctr) == nil then
if (s.mode == 'embed' or s.mode == 'auto') and s.raw then
addStyle 'embed'
ctr.tag = 'div'
ctr.attrs = {class='embed'}
ctr.nodes = s.raw
elseif s.mode == 'link' then
addStyle 'embed'
ctr.elt = 'embed';
ctr.attrs = {
type = 'text/html';
src = htmlURI(s.uri);
}
end
end
end};
{ss.mime'text/*', function(s,ctr)
if s == nil then
return {}
elseif next(ctr) == nil then
local mime = s.mime:clone()
mime.opts={}
if (s.mode == 'embed' or s.mode == 'auto') and s.raw then
addStyle 'embed'
ctr.tag = 'pre';
ctr.attrs = {class='embed'}
ctr.nodes = htmlentities(s.raw);
elseif s.mode == 'link' then
addStyle 'embed'
ctr.elt = 'embed';
ctr.attrs = {
type = tostring(mime);
src = htmlURI(s.uri);
}
end
end
end};
}
local rtype
local fallback
for n, src in ipairs(obj.srcs) do
if fallback == nil and (src.mode == 'link' or src.mode == 'auto') then
fallback = src
end
for i, ea in ipairs(embedActs) do
if ea[1] < src.mime then -- fits!
rtype = ea
goto compatFound
end
end
end
-- nothing found; install fallback link
if fallback then
local lnk = htmlURI(fallback.uri)
return tag('a', {href=lnk}, catenate {
tag('div',{class=xref}, catenate {
'β '; capIR;
string.format(" [%s]", tostring(fallback.mime));
})})
else
addStyle 'docmeta'
return tag('div',{class="render-warn"},
'could not embed object type ' .. tostring(obj.srcs.mime))
end
::compatFound::
local top = rtype[2]() -- create container
for n, src in ipairs(obj.srcs) do
if rtype[1] < src.mime then
rtype[2](src, top)
end
end
local ft = flatten(top)
if b.mode == 'inline' then
-- TODO insert caption
return ft
else
local prop = {}
if b.mode == 'open' then
prop.open = true
end
return tag('details', prop, catenate {
tag('summary', {}, capIR);
ft;
})
end
end
function block_renderers.macro(b,s)
local all = renderSubdoc(b.doc)
local cat = catenate(ss.map(flatten,all))
return tag(nil, {}, cat)
end
function block_renderers.quote(b,s)
local ir = renderSubdoc(b.doc)
if b.label_node then
addStyle 'quoteCaption'
table.insert(ir, tag('div', {class='caption'},
sr.htmlSpan(b.label_node.spans, b.label_node, s)))
end
return tag('blockquote', b.id and {id=getSafeID(b)} or {}, catenate(ss.map(flatten,ir)))
end
return block_renderers
end
local function getRenderers(procs)
local span_renderers = getSpanRenderers(procs)
local r = getBaseRenderers(procs,span_renderers)
r.block_renderers = getBlockRenderers(procs, r)
return r
end
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 dr = astproc.toHTML -- default renderers
local plainr = astproc.toTXT
render_state_handle.ir = ir;
local function renderBlocks(blocks, irs)
for i, block in ipairs(blocks) do
local rd
if astproc.toIR.block_renderers[block.kind] then
rd = astproc.toIR.block_renderers[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
addStyle 'headingAnchors'
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
if rd.src and rd.src.origin.lang then
if not rd.attrs then rd.attrs = {} end
rd.attrs.lang = rd.src.origin.lang
end
table.insert(irs.nodes, rd)
runhook('ir_section_node_insert', rd, irs, sec)
end
end
end
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 = 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)
renderBlocks(sec.blocks, irs)
end
elseif sec.kind == 'quote' then
elseif sec.kind == 'listing' then
elseif sec.kind == 'embed' then
end
if irs then table.insert(ir, irs) end
end
do local fnsorted = {}
for _, fn in pairs(footnotes) do
fnsorted[fn.num] = fn
end
for _, fn in ipairs(fnsorted) do
local tag = tagproc.toIR.tag
local body
if type(fn.source) == 'table' then
if fn.source.kind == 'resource' then
local fake_embed = {
kind = 'embed';
rsrc = fn.source;
origin = fn.origin;
mode = 'inline';
}
local rendered = astproc.toIR.block_renderers.embed(
fake_embed, fn.origin.sec
)
if not rendered then
fn.origin:fail('unacceptable resource mime type β%sβ for footnote target β%sβ', fn.source.mime, fn.source.id or '(anonymous)')
end
body = rendered
else
fn.origin:fail('footnote span links to block β%sβ of unacceptable kind β%sβ', fn.source.kind)
end
else
body = {tag='div',nodes={}}
local ftir = {}
for l in fn.source:gmatch('([^\n]*)') do
ct.parse_line(l, fn.origin, ftir)
end
renderBlocks(ftir,body)
end
local fattr = {id=fn.id}
if opts.epub then
---UUUUUUGHHH
local npfx = string.format('(%u) ', fn.num)
if next(body.nodes) then
local n = body.nodes[1]
repeat
if n.nodes[1] then
if type(n.nodes[1]) == 'string' then
n.nodes[1] = npfx .. n.nodes[1]
break
end
n = n.nodes[1]
else
n.nodes[1] = {tag='p',attrs={},nodes={npfx}}
break
end
until false
else
if body.tag == 'div' then
body.nodes[1] = {tag='p',attrs={},nodes={npfx}}
elseif body.tag == 'pre' then
body.nodes[1] = npfx .. body.nodes[1]
else
body = {tag='div', nodes = {npfx, body}}
end
end
fattr['epub:type'] = 'footnote'
else
fattr.class = 'footnote'
end
body.attrs = body.attrs or {}
body.attrs.class = 'text'
local note = tag('aside', fattr, opts.epub and body.nodes or {
tag('div',{class='number'}, tostring(fn.num)),
body,
-- tag('div',{class='text'}, body.nodes),
tag('a',{href='#0'},'‫')
})
table.insert(ir, note)
end
end
if next(footnotes) and not opts.epub then
table.insert(ir, tagproc.toIR.tag('div',{id='cover'},''))
end
-- restructure passes
runhook('ir_restructure_pre', ir)
-- flay empty containers
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 type(v) ~= 'string' and v.nodes and v.tag == nil then
table.remove(sec.nodes,i)
for j=1,#v.nodes do
table.insert(sec.nodes, i+j - 1, v.nodes[j])
end
end
i=i+1 end
end
end
---- 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
addStyle 'list'
local ltag
if v.src.ordered
then ltag = 'ol' addStyle 'list_ordered'
else ltag = 'ul' addStyle 'list_unordered'
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
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 .. tagproc.toHTML.tag(v.tag, v.attrs, collect_nodes(v.nodes))
elseif v.text then
ts = ts .. tagproc.toHTML.tag(v.tag,v.attrs,v.text)
else
ts = ts .. tagproc.toHTML.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 == 'width' then
return opts['width'] or '100vw'
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('@(%b[]):(%b[])', function(v,d) return opts[v:sub(2,-2)] or v:sub(2,-2) 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 not opts.epub and (opts.accent or (not opts['dark-on-light']) and (not opts['fossil-uv'])) then
addStyle 'accent'
end
for _,k in pairs(stylesNeeded.order) 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 .. 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 .. tagproc.toHTML.tag('style',{type='text/css'},cdata(table.concat(styles)))
end
table.insert(head, styletag)
end
if opts['fossil-uv'] then
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
end