-- faq-microdata Soupault Plugin -- SPDX-FileCopyrightText: 2026 toastal -- SPDX-License-Identifier: MPL-2.0 -- -- Applies Schema.org FAQPage markup on /faqs/ only. Docutils renders the -- `class:: details` definition list as native
elements -- inside div.details — a 1:1 fit, so no .rst changes are needed: -- article#Article → mainEntity FAQPage -- div.details > details → mainEntity Question -- summary → name (text content, markup kept) -- summary ~ * (answer nodes) → moved into acceptedAnswer Answer/text Plugin.require_version("5.0.0") function url_path() local url = page_url url = Regex.replace(url, "^[a-z]+://[^/]+", "") if url == "" then url = "/" elseif not String.starts_with(url, "/") then url = "/" .. url end return url end function head_meta_content(name) local meta = HTML.select_one(page, format("head meta[name=\"%s\"]", name)) if HTML.is_element(meta) then return HTML.get_attribute(meta, "content") end return nil end function hidden_meta(itemprop, content, parent) local elem = HTML.create_element("meta") HTML.set_attribute(elem, "itemprop", itemprop) HTML.set_attribute(elem, "content", content) HTML.append_child(parent, elem) return elem end function author_stub(parent, name) local span = HTML.create_element("span") HTML.set_attribute(span, "itemprop", "author") HTML.set_attribute(span, "itemscope", "") HTML.set_attribute(span, "itemtype", "https://schema.org/Person") hidden_meta("name", name, span) HTML.append_child(parent, span) return span end seen_slugs = {} -- Slug for linkable FAQ ids (the question text as-is: only whitespace -- runs and #/% become hyphens). Ids land on the answer div INSIDE the -- closed
: browsers auto-open ancestor details on fragment -- navigation only when the target is a descendant — never for the -- details itself. function slugify_question(text) -- HTML ids forbid whitespace and #/% break fragments; everything -- else (case, Unicode, punctuation) stays verbatim. slug = Regex.replace_all(text, "[\\s#%]+", "-") slug = Regex.replace(slug, "^-", "") slug = Regex.replace(slug, "-$", "") if slug == "" then return nil end if not Value.is_nil(seen_slugs[slug]) then local n = 2 while not Value.is_nil(seen_slugs[slug .. "-" .. format("%d", n)]) do n = n + 1 end slug = slug .. "-" .. format("%d", n) end seen_slugs[slug] = 1 return slug end path = url_path() if path == "/faqs" or path == "/faqs/" then seen_slugs = {} main = HTML.select_one(page, "main#Main") if not HTML.is_element(main) then Plugin.fail(format("No main#Main found in “%s”, skipping FAQPage scope", page_file)) end article = HTML.select_one(page, "article#Article") if not HTML.is_element(article) then Plugin.fail(format("No article#Article found in “%s”, cannot apply FAQPage scope", page_file)) end HTML.set_attribute(article, "itemscope", "") HTML.set_attribute(article, "itemprop", "mainEntity") HTML.set_attribute(article, "itemtype", "https://schema.org/FAQPage") h1 = HTML.select_one(main, "h1[itemprop=\"headline\"]") if not HTML.is_element(h1) then h1 = HTML.select_one(main, "h1") end if HTML.is_element(h1) then h1_text = String.trim(HTML.strip_tags(h1)) headline = HTML.create_element("meta") HTML.set_attribute(headline, "itemprop", "headline") HTML.set_attribute(headline, "content", h1_text) HTML.prepend_child(main, headline) else Log.warning(format("No h1 found in “%s”, skipping WebPage.headline compensation", page_file)) end description = head_meta_content("description") if Value.is_string(description) and description ~= "" then hidden_meta("description", description, article) end keywords = head_meta_content("keywords") if Value.is_string(keywords) and keywords ~= "" then hidden_meta("keywords", keywords, article) end author = head_meta_content("author") if Value.is_string(author) and author ~= "" then author_stub(article, author) end questions = HTML.select(article, "div.details > details") if Table.is_empty(questions) then Log.warning(format("No FAQ questions found in “%s”", page_file)) end local idx = 1 while questions[idx] do local item = questions[idx] local summary = HTML.select_one(item, "summary") if not HTML.is_element(summary) then Log.warning(format("FAQ question without summary in “%s”, skipping it", page_file)) else HTML.set_attribute(item, "itemscope", "") HTML.set_attribute(item, "itemprop", "mainEntity") HTML.set_attribute(item, "itemtype", "https://schema.org/Question") HTML.set_attribute(summary, "itemprop", "name") local answer_nodes = HTML.select(item, "summary ~ *") if Table.is_empty(answer_nodes) then Log.warning(format("FAQ question without answer in “%s”, skipping acceptedAnswer", page_file)) else local answer = HTML.create_element("div") HTML.set_attribute(answer, "itemprop", "acceptedAnswer") HTML.set_attribute(answer, "itemscope", "") HTML.set_attribute(answer, "itemtype", "https://schema.org/Answer") local text = HTML.create_element("div") HTML.set_attribute(text, "itemprop", "text") question_slug = slugify_question(String.trim(HTML.strip_tags(summary))) if Value.is_nil(question_slug) then Log.warning(format("FAQ question with unusable text in “%s”, skipping its id", page_file)) else HTML.set_attribute(text, "id", question_slug) end local jdx = 1 while answer_nodes[jdx] do HTML.append_child(text, answer_nodes[jdx]) jdx = jdx + 1 end HTML.append_child(answer, text) HTML.append_child(item, answer) end end idx = idx + 1 end end