-- docutils-rst-cleanup.lua -- SPDX-FileCopyrightText: 2025 toastal -- SPDX-License-Identifier: MPL-2.0 -- -- This plugin cleans up the otherwise-really-good HTML5 markup from -- Docutils Plugin.require_version("4.9.0") function was_docutils_generated(elem) local content = HTML.get_attribute(elem, "content") return not Value.is_nil(Regex.match(content, "Docutils")) end local generators = HTML.select(page, "meta[name=\"generator\"][content]") if not Table.for_any(was_docutils_generated, generators) then Plugin.exit("This was not a Docutils-generated file, so no need to run transformations.") end -- Nickel is used to assert the types of this kinda complicated code metadata = config["metadata"] if not Value.is_table(metadata) or Table.is_empty(metadata) then Log.warning("No options metadata to to select on!") end content = HTML.select_one(page, "body > main[id]") if not Value.is_html(content) then Log.error(format("File ā€œ%sā€ lacks content!", page_file)) end -- Do some basic wrapping for the container article = HTML.create_element("article") HTML.add_class(article, "Stack") HTML.add_class(article, "Article") HTML.set_attribute(article, "id", "Article") HTML.wrap(content, article) content_id = "Content" HTML.set_tag_name(content, "section") HTML.add_class(content, "Article-body") HTML.add_class(content, "Content") HTML.set_attribute(content, "id", content_id) HTML.set_attribute(content, "itemprop", "articleBody") -- Adapt content peers = Table.get_key_default(soupault_config["custom_options"], "peers", {}) function find_best_peer(needle) local idx = 1 -- Try by name while peers[idx] do local peer = peers[idx] local name = peer["name"] if name == needle then return peer end idx = idx + 1 end -- Try by aliases idx = 1 while peers[idx] do local peer = peers[idx] local aliases = peer["aliases"] if Value.is_list(aliases) then local jdx = 1 while aliases[jdx] do local alias = aliases[jdx] if alias == needle then return peer end jdx = jdx + 1 end end idx = idx + 1 end return nil end -- Would *love* to see a element in HTML function fix_callouts(elem) local hash = Digest.md5(HTML.inner_html(elem)) Log.debug(format("Docutils callout fix for: %s", hash)) local hashed_id = "co_" .. hash HTML.set_tag_name(elem, "div") HTML.set_attribute(elem, "role", "note") HTML.set_attribute(elem, "aria-labelledby", hashed_id) HTML.remove_class(elem, "admonition") local old_classes = HTML.get_classes(elem) HTML.delete_attribute(elem, "class") local idx = 1 HTML.add_class(elem, "Callout") while old_classes[idx] do local class_ = old_classes[idx] HTML.add_class(elem, "Callout--" .. class_) idx = idx + 1 end HTML.set_attribute(elem, "type", old_classes[1]) local title = HTML.select_one(elem, ".admonition-title") HTML.remove_class(title, "admonition-title") HTML.set_tag_name(title, "strong") HTML.set_attribute(title, "id", hashed_id) HTML.add_class(title, "Callout-title") -- Useful until upstream gets a nice rendering options for role=note --local hr_before = HTML.create_element("hr") --HTML.add_class(hr_before, "srOnly") --HTML.set_attribute(hr_before, "role", "none") --HTML.prepend_child(elem, hr_before) --local hr_after = HTML.create_element("hr") --HTML.add_class(hr_after, "srOnly") --HTML.set_attribute(hr_after, "role", "none") --HTML.append_child(elem, hr_after) end function fix_blockquotes(elem) local quoteblock = HTML.create_element("div") HTML.add_class(quoteblock, "Quoteblock") HTML.wrap(quoteblock, elem) end function is_content_parent(elem) local parent = HTML.parent(elem) if Value.is_html(parent) then return HTML.get_attribute(parent, "id", content_id) else return false end end function group_headlines(article_body) Log.debug("Group Docutils headlines") local title = HTML.select_one(article_body, "h1.title") if Value.is_html(title) then HTML.set_attribute(title, "itemprop", "headline") end local subtitle = HTML.select_one(article_body, "h1.title + p.subtitle") if Value.is_html(subtitle) then HTML.set_attribute(subtitle, "itemprop", "alternativeHeadline") end if is_content_parent(title) then local headline = HTML.create_element("header") HTML.add_class(headline, "Article-headline") HTML.add_class(headline, "Headline") HTML.append_child(headline, title) if Value.is_html(subtitle) then HTML.append_child(headline, subtitle) end HTML.prepend_child(article, headline) return headline end end Table.iter_values(HTML.delete, HTML.select(page, "title")) Table.iter_values(fix_blockquotes, HTML.select(content, "blockquote")) Table.iter_values(fix_callouts, HTML.select(content, ".admonition")) headline = group_headlines(content) -- Relative to other tools, Docutils does a great job with metadata putting it -- in a
at the top of the document, but we want to do cleanup meta_dl = HTML.create_element("dl") docinfo = HTML.select_one(content, "dl.docinfo") word_count = nil function memo_word_count() if word_count == nil then word_count = JSON.from_string(Sys.get_program_output("wc -w", HTML.inner_text(content))) end if not Value.is_int(word_count) or word_count < 0 then Plugin.fail("Improper word count: %s", Value.repr(word_count)) end return word_count end function make_creative_work_value(itemprop) local dd = HTML.create_element("dd", val) HTML.add_class(dd, "CreativeWorkMetadata-value") if Value.is_string(itemprop) then HTML.set_attribute(dd, "itemprop", itemprop) end return dd end function put_metadata(field, props) Log.debug(format("Putting metadata for %s %s", field, JSON.to_string(props))) local values = {} if props["type"] == "docinfo" then local dds = HTML.select(docinfo, "dd." .. field) local idx = 1 while dds[idx] do local value = HTML.strip_tags(dds[idx]) HTML.delete(dds[idx]) if value ~= "" then if Value.is_string(props["separator"]) then values = Regex.split(value, props["separator"]) else values[1] = value end end idx = idx + 1 end -- Delete spare elements Table.iter_values(HTML.delete, HTML.select(docinfo, "." .. field)) elseif props["type"] == "docmeta" then -- TODO local metas = HTML.select(page, "head meta[name=\"" .. field .. "\"]") local idx = 1 while metas[idx] do local value = HTML.get_attribute(metas[idx], "content") if value ~= "" then if Value.is_string(props["separator"]) then values = Regex.split(value, props["separator"]) else values[1] = value end end end elseif props["type"] == "derived" then if field == "word_count" then values[1] = format("%d", memo_word_count()) elseif field == "time_burden" then local wc = memo_word_count() reading_time_slow = floor(wc / 200) reading_time_fast = floor(wc / 300) local time_msg = nil if reading_time_fast <= 1 then time_msg = "< 1 minute" elseif reading_time_fast == 1 then time_msg = "about 1 minute" elseif reading_time_slow == reading_time_fast then time_msg = reading_time_slow .. " minutes" else time_msg = reading_time_fast .. "–" .. reading_time_slow .. " minutes" end values[1] = time_msg end else Log.error(format("Illegal metadata type: %s", props["type"])) end Log.debug(format("Metadata values for %s: %s", field, JSON.to_string(values))) if Table.is_empty(values) then -- No values, no append return end local special = props["special"] local container = HTML.create_element("div") HTML.add_class(container, "CreativeWorkMetadata") HTML.add_class(container, "CreativeWorkMetadata--" .. props["modifier"]) local dt = HTML.create_element("dt") HTML.append_child(dt, HTML.parse(props["title"])) HTML.add_class(dt, "CreativeWorkMetadata-key") HTML.append_child(container, dt) local idx = 1 while values[idx] do local val = values[idx] local dd = make_creative_work_value(props["itemprop"]) -- Special element handling if special == nil then HTML.append_child(dd, HTML.create_text(val)) elseif special == "date" then local just_date = Date.reformat(val, {"%F", "%Y-%m-%d"}, "%Y-%m-%d") local iso_date = Date.reformat(val, {"%F", "%Y-%m-%d"}, "%F") local time = HTML.create_element("time", just_date) HTML.set_attribute(time, "datetime", iso_date) HTML.append_child(dd, time) elseif special == "peer" then HTML.set_attribute(dd, "itemscope", "") HTML.set_attribute(dd, "itemtype", "https://schema.org/Person") local span = HTML.create_element("span", val) HTML.set_attribute(span, "itemprop", "name") local peer = find_best_peer(val) if Value.is_table(peer) and Value.is_string(peer["uri"]) then local a = HTML.create_element("a") HTML.set_attribute(a, "href", peer["uri"]) HTML.set_attribute(a, "itemprop", "url") if props["itemprop"] == "author" then HTML.set_attribute(a, "rel", "author") end HTML.append_child(a, span) HTML.append_child(dd, a) else HTML.append_child(dd, span) end elseif special == "keywords" then -- TODO: tag pages + wrap in --HTML.set_attribute(a, "rel", "tag") HTML.append_child(dd, HTML.create_text(val)) elseif special == "license" then -- TODO: automatically cross reference with SPDX --HTML.set_attribute(a, "rel", "tag") HTML.append_child(dd, HTML.create_text(val)) else Log.warning(format("Unhandled special type %s from %s", special, props["type"])) end HTML.append_child(container, dd) idx = idx + 1 end HTML.append_child(meta_dl, container) end function is_only_white_space_children(elem) return String.trim(HTML.inner_text(elem)) == "" end if HTML.is_element(docinfo) then HTML.add_class(meta_dl, "Article-metadata") Table.iter_ordered(put_metadata, metadata) HTML.insert_before(content, meta_dl) if HTML.is_empty(docinfo) or is_only_white_space_children(docinfo) then HTML.delete(docinfo) else HTML.insert_before(content, docinfo) end end -- Keep rest in sync with templates/main.html main = HTML.create_element("main") HTML.add_class(main, "Main") HTML.set_attribute(main, "id", "Main") HTML.wrap(article, main) doc_wrapper = HTML.create_element("div") HTML.add_class(doc_wrapper, "DocWrapper") HTML.set_attribute(doc_wrapper, "id", "DocWrapper") HTML.wrap(main, doc_wrapper)