You've already forked pgbackrest
mirror of
https://github.com/pgbackrest/pgbackrest.git
synced 2026-10-30 01:21:33 +03:00
A browser caches by url and, with no cache headers to say otherwise, judges freshness by how long a file has gone unchanged, so after a deploy a reader could see new pages with the old style until the cache let go. Carry a short hash of the rendered content in the links to the style and the script, which changes the url when the content changes and leaves it alone when it does not. Since the hash is of the rendered content, a change to nothing but a comment changes no links, and one build of the documentation still comes out the same as the next.
723 lines
34 KiB
Python
723 lines
34 KiB
Python
"""Html Renderer.
|
|
|
|
Renders a document as a page. The page is built as a tree of elements and written once at the end, so the code that decides what a
|
|
page holds never deals with markup, and where a linefeed goes is decided in one place.
|
|
|
|
Where those linefeeds go matters more than it looks. The pages are compared against the pages of the previous build to check that a
|
|
change to the tool changed nothing about the documentation, and output that is all on one line makes that comparison useless."""
|
|
|
|
####################################################################################################################################
|
|
import hashlib
|
|
import os
|
|
import re
|
|
import shutil
|
|
import struct
|
|
|
|
from common.error import ToolError
|
|
from common.log import *
|
|
from common.storage import file_read, file_write, path_create, path_list
|
|
from common.xml import xml_node_attribute, xml_node_child, xml_node_child_list, xml_node_content, xml_node_field, xml_node_text
|
|
from command.render.execute import CONFIG_MARK_ADD, CONFIG_MARK_REMOVE, CONFIG_MARK_SAME, CacheInvalidError, DocExecute
|
|
from command.render.manifest import RENDER_HTML
|
|
from command.render.render import SECTION_ANCHOR, SECTION_ANCHOR_NO_INHERIT, child_list
|
|
|
|
# How deep a page may be sectioned before the headings run out of styles
|
|
_SECTION_DEPTH_MAX = 3
|
|
|
|
# What a page says about itself that is the same on every page
|
|
_DOCTYPE = '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">'
|
|
_ANALYTICS = (
|
|
'<script async src="https://www.googletagmanager.com/gtag/js?id=G-VKCRNV73H1"></script>',
|
|
"<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}"
|
|
"gtag('js',new Date());gtag('config','G-VKCRNV73H1');</script>",
|
|
)
|
|
|
|
# What a block that scrolls sideways says so a reader who cannot use a mouse can still reach what has scrolled out of it
|
|
_SCROLL = 'tabindex="0"'
|
|
|
|
# What a style or a script says about itself, which the page carries for a browser rather than for a reader
|
|
_COMMENT_EXP = re.compile(r"/\*.*?\*/", re.DOTALL)
|
|
|
|
# Leading linefeeds and trailing whitespace of a code block, which are how it sits in the xml rather than part of it
|
|
_BLOCK_BEGIN_EXP = re.compile(r"^\n+")
|
|
_BLOCK_END_EXP = re.compile(r"\s+$")
|
|
|
|
# What a line of a configuration file is called, which is what decides how it looks and what marks it
|
|
_CONFIG_CLASS_MAP = {
|
|
CONFIG_MARK_ADD: "config-line-add",
|
|
CONFIG_MARK_REMOVE: "config-line-remove",
|
|
CONFIG_MARK_SAME: "config-line",
|
|
}
|
|
|
|
|
|
####################################################################################################################################
|
|
class HtmlElement:
|
|
"""One element of a page."""
|
|
|
|
def __init__(self, type, class_name=None, content=None, id=None, ref=None, extra=None, pre=False):
|
|
self.type = type
|
|
self.class_name = class_name
|
|
self.content = content
|
|
self.id = id
|
|
self.ref = ref
|
|
self.extra = extra
|
|
self.pre = pre # Is the content laid out already, so nothing here may lay it out again?
|
|
self.child_list = []
|
|
|
|
################################################################################################################################
|
|
def add(self, element):
|
|
"""Add an element."""
|
|
|
|
self.child_list.append(element)
|
|
|
|
return element
|
|
|
|
################################################################################################################################
|
|
def add_new(self, type, class_name=None, **kwargs):
|
|
"""Add a new element."""
|
|
|
|
return self.add(HtmlElement(type, class_name, **kwargs))
|
|
|
|
|
|
####################################################################################################################################
|
|
class HtmlBuilder:
|
|
"""Builds a page from the elements it holds."""
|
|
|
|
def __init__(self, name, title, favicon, card, card_size, description, style_version, script_version):
|
|
self.name = name
|
|
self.title = title
|
|
self.favicon = favicon
|
|
self.card = card
|
|
self.card_size = card_size
|
|
self.description = description
|
|
self.style_version = style_version
|
|
self.script_version = script_version
|
|
|
|
self.body = HtmlElement("body")
|
|
self._pre_prior = False
|
|
|
|
################################################################################################################################
|
|
@staticmethod
|
|
def _escape(string):
|
|
"""Escape what cannot appear in an attribute."""
|
|
|
|
return string.replace("&", "&").replace("<", "<")
|
|
|
|
################################################################################################################################
|
|
def _element_render(self, element):
|
|
"""Render an element and everything it holds."""
|
|
|
|
result = ""
|
|
|
|
# Put a linefeed before laid out content unless the element before it was also laid out, which makes the page diffable
|
|
if element.type == "pre":
|
|
if not self._pre_prior:
|
|
result += "\n"
|
|
|
|
self._pre_prior = True
|
|
else:
|
|
self._pre_prior = False
|
|
|
|
result += "<%s%s%s%s%s>" % (
|
|
element.type,
|
|
"" if element.class_name is None else ' class="%s"' % element.class_name,
|
|
"" if element.ref is None else ' href="%s"' % element.ref,
|
|
"" if element.id is None else ' id="%s"' % element.id,
|
|
"" if element.extra is None else " %s" % element.extra,
|
|
)
|
|
|
|
if element.content is not None:
|
|
if element.pre:
|
|
result += element.content.replace("&", "&")
|
|
else:
|
|
# Linefeeds around the content make the page diffable
|
|
result += "\n%s\n" % element.content.strip()
|
|
else:
|
|
for child in element.child_list:
|
|
result += self._element_render(child)
|
|
|
|
result += "</%s>" % element.type
|
|
|
|
return result + ("\n" if element.type == "pre" else "")
|
|
|
|
################################################################################################################################
|
|
def render(self, analytics=False):
|
|
"""Render the page."""
|
|
|
|
result = _DOCTYPE
|
|
result += '<html xmlns="http://www.w3.org/1999/xhtml">'
|
|
result += "<head>"
|
|
result += "\n<title>" + self._escape(self.title) + "\n"
|
|
result += "</title>"
|
|
result += '<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>\n'
|
|
|
|
# Lay the page out at the width of the device rather than at the width of a desktop scaled down to fit
|
|
result += '<meta name="viewport" content="width=device-width, initial-scale=1"></meta>\n'
|
|
|
|
result += '<meta property="og:site_name" content="%s"></meta>\n' % self._escape(self.name)
|
|
result += '<meta property="og:title" content="%s"></meta>\n' % self._escape(self.title)
|
|
result += '<meta property="og:type" content="website"></meta>\n'
|
|
|
|
if self.favicon is not None:
|
|
result += '<link rel="icon" href="%s" type="image/svg+xml"></link>\n' % self.favicon
|
|
|
|
# The card is what a link to the page looks like where it is posted. It is laid out at 1200x630 because that is the frame
|
|
# the sites that show a preview crop to, and a card that is not that shape loses its top and bottom to the crop.
|
|
if self.card is not None:
|
|
result += '<meta property="og:image" content="{[backrest-url-base]}/%s"></meta>\n' % self.card
|
|
result += '<meta property="og:image:type" content="image/png"></meta>\n'
|
|
result += '<meta property="og:image:width" content="%d"></meta>\n' % self.card_size[0]
|
|
result += '<meta property="og:image:height" content="%d"></meta>\n' % self.card_size[1]
|
|
result += '<meta property="og:image:alt" content="%s"></meta>\n' % self._escape(self.name)
|
|
|
|
# Without this the preview is a thumbnail beside the text rather than the card above it
|
|
result += '<meta name="twitter:card" content="summary_large_image"></meta>\n'
|
|
|
|
if self.description is not None:
|
|
result += '<meta name="description" content="%s"></meta>\n' % self._escape(self.description)
|
|
result += '<meta property="og:description" content="%s"></meta>\n' % self._escape(self.description)
|
|
|
|
# The style and the script are linked with a version of their content, since a browser caches by the whole url, so a
|
|
# cached copy is refetched when the content changes and kept when it does not
|
|
result += '<link rel="stylesheet" href="default.css?v=%s" type="text/css"></link>\n' % self.style_version
|
|
|
|
# Deferred because the script reads the page it marks, and nothing on the page waits for it
|
|
result += '<script src="default.js?v=%s" defer="defer"></script>\n' % self.script_version
|
|
|
|
if analytics:
|
|
for line in _ANALYTICS:
|
|
result += line + "\n"
|
|
|
|
result += "</head>"
|
|
result += self._element_render(self.body)
|
|
|
|
return result + "</html>"
|
|
|
|
|
|
####################################################################################################################################
|
|
class DocHtmlPage(DocExecute):
|
|
"""Renders one document as a page."""
|
|
|
|
def __init__(self, manifest, key, menu, exe, style_version, script_version):
|
|
super().__init__(RENDER_HTML, manifest, key, exe)
|
|
|
|
self.menu = menu
|
|
self.style_version = style_version
|
|
self.script_version = script_version
|
|
|
|
################################################################################################################################
|
|
def process(self):
|
|
"""Render the page."""
|
|
|
|
var_store = self.manifest.var_store
|
|
render = self.manifest.render_get(RENDER_HTML)
|
|
|
|
title = xml_node_attribute(self.root, "title", True)
|
|
subtitle = xml_node_attribute(self.root, "subtitle")
|
|
|
|
tagline = var_store.get("project-tagline")
|
|
|
|
card = None if var_store.test("card", "n") else var_store.get("project-card")
|
|
|
|
builder = HtmlBuilder(
|
|
var_store.replace_str("{[project]}" + ("" if tagline is None else " - " + tagline)),
|
|
var_store.replace_str(title + ("" if subtitle is None else " - " + subtitle)),
|
|
var_store.get("project-favicon"),
|
|
card,
|
|
None if card is None else _png_size(os.path.join(self.manifest.path_doc, "resource", card)),
|
|
var_store.replace_str(xml_node_field(self.root, "description", True).strip()),
|
|
self.style_version,
|
|
self.script_version,
|
|
)
|
|
|
|
header = builder.body.add_new("div", "page-header")
|
|
|
|
# The logo goes home, which is where a reader expects a logo to take them, unless they are already home, since a page
|
|
# never links to itself. It is drawn by the style, so the name it holds is only for a reader who cannot see it, and on
|
|
# every other page it is also what says where the link goes.
|
|
if self.key == "index":
|
|
logo = header.add_new("div", "page-header-logo")
|
|
else:
|
|
logo = header.add_new("a", "page-header-logo", ref="{[project-url-root]}")
|
|
|
|
logo.add_new("span", "page-header-logo-name", content="{[project]}")
|
|
|
|
header.add_new("div", "page-header-title", content=title)
|
|
|
|
if subtitle is not None:
|
|
header.add_new("div", "page-header-subtitle", content=subtitle)
|
|
|
|
if self.menu:
|
|
menu_body = builder.body.add_new("div", "page-menu").add_new("div", "menu-body")
|
|
|
|
# The menu is in the order the manifest lists the pages rather than in the order they were rendered
|
|
for key in render.order:
|
|
out = self.manifest.render_out_get(RENDER_HTML, key)
|
|
|
|
if out is not None and out.menu is not None:
|
|
menu = menu_body.add_new("div", "menu")
|
|
|
|
# The page a reader is on is marked rather than linked, so the menu says where they are without offering a
|
|
# link to where they already are
|
|
if key == self.key:
|
|
menu.add_new("span", "menu-current", content=out.menu)
|
|
else:
|
|
menu.add_new(
|
|
"a",
|
|
"menu-link",
|
|
content=out.menu,
|
|
ref="{[project-url-root]}" if key == "index" else "%s.html" % key,
|
|
)
|
|
|
|
toc_body = None
|
|
|
|
if self.toc:
|
|
# A page with contents puts them in a column beside the text, which the body says so the style does not have to work
|
|
# it out from what the page happens to hold
|
|
builder.body.class_name = "page-sidebar"
|
|
|
|
# The contents are held twice over: the outer element is as tall as the text beside it, and the inner one is what
|
|
# sticks, so it can never be carried past the text into the footer below
|
|
toc = builder.body.add_new("div", "page-toc").add_new("div", "page-toc-inner")
|
|
toc.add_new("div", "page-toc-header").add_new("div", "page-toc-title", content="Table of Contents")
|
|
toc_body = toc.add_new("div", "page-toc-body")
|
|
|
|
body = builder.body.add_new("div", "page-body")
|
|
|
|
# A section set aside goes first whatever the document says, since the text can only run beside what is already there. Which
|
|
# sections were written after it is kept as a class, so a page too narrow to put it down the side can put it back where the
|
|
# document had it rather than at the end. The document keeps its own order for every other way it is rendered.
|
|
aside_list = []
|
|
before_list = []
|
|
after_list = []
|
|
|
|
for section in xml_node_child_list(self.root, "section"):
|
|
if xml_node_attribute(section, "html") == "n":
|
|
continue
|
|
|
|
if xml_node_attribute(section, "aside") == "y":
|
|
aside_list.append(section)
|
|
elif len(aside_list) > 0:
|
|
after_list.append(section)
|
|
else:
|
|
before_list.append(section)
|
|
|
|
for section_no, section in enumerate(aside_list + before_list + after_list):
|
|
element, toc_element = self._section_process(section, None, str(section_no + 1), 1)
|
|
|
|
if section in after_list:
|
|
element.class_name += " section-after-aside"
|
|
|
|
body.add(element)
|
|
|
|
if toc_body is not None and toc_element is not None:
|
|
toc_body.add(toc_element)
|
|
|
|
builder.body.add_new("div", "page-footer", content="{[html-footer]}")
|
|
|
|
return builder.render(analytics=var_store.test("analytics", "y"))
|
|
|
|
################################################################################################################################
|
|
def _section_process(self, section, anchor, section_no, depth):
|
|
"""Render a section and everything it holds."""
|
|
|
|
if xml_node_attribute(section, "log") == "y":
|
|
log(INFO, " " * (depth + 1) + "process section: %s" % xml_node_attribute(section, "path"))
|
|
|
|
if depth > _SECTION_DEPTH_MAX:
|
|
raise ToolError("section depth of %d exceeds maximum" % depth)
|
|
|
|
# A section takes the anchor of the section holding it unless it says otherwise, so a link to it reads as a path
|
|
anchor = (
|
|
""
|
|
if xml_node_attribute(section, SECTION_ANCHOR) == SECTION_ANCHOR_NO_INHERIT
|
|
else ("" if anchor is None else anchor + "/")
|
|
) + xml_node_attribute(section, "id", True)
|
|
|
|
toc_element = HtmlElement("div", "section%d-toc" % depth)
|
|
|
|
# A section set aside is put down the side of the page rather than in the run of it, which the style does from the class
|
|
element = HtmlElement(
|
|
"div", "section%d%s" % (depth, " section-aside" if xml_node_attribute(section, "aside") == "y" else "")
|
|
)
|
|
|
|
element.add_new("a", id=anchor)
|
|
|
|
title = self.process_text(xml_node_child(section, "title", True))
|
|
|
|
# A section can leave its header off the page when the page already says what the header would say. The section keeps its
|
|
# title for the contents and its anchor for a link to point at.
|
|
if xml_node_attribute(section, "header") != "n":
|
|
element.add_new("div", "section%d-header" % depth).add_new("div", "section%d-title" % depth, content=title)
|
|
|
|
# The contents are not numbered. The numbering says where a section sits on the page, which the contents already show by
|
|
# the order and the indent of their entries, so beside the text it would only take room from the titles.
|
|
|
|
# A section can give the contents a shorter title than it gives the page, since a heading has the width of the text to say
|
|
# what a section is and the contents beside the text have a column
|
|
title_toc = xml_node_attribute(section, "toc-title")
|
|
|
|
toc_element.add_new("div", "section%d-toc-title" % depth).add_new(
|
|
"a", content=title if title_toc is None else title_toc, ref="#%s" % anchor
|
|
)
|
|
|
|
text = xml_node_text(section)
|
|
|
|
if text is not None:
|
|
element.add_new("div", "section-intro", content=self.process_text(text))
|
|
|
|
body = element.add_new("div", "section-body")
|
|
child_no = 1
|
|
|
|
for child in child_list(section):
|
|
log(DEBUG, " " * (depth + 2) + "process child %s" % child.tag)
|
|
|
|
# Something the document keeps for another way of rendering it and this one leaves out. A condition cannot say this
|
|
# because a condition is evaluated once for the document rather than once for each way it is rendered.
|
|
if xml_node_attribute(child, "html") == "n":
|
|
continue
|
|
|
|
if child.tag == "execute-list":
|
|
self._execute_list_process(section, child, body, depth)
|
|
elif child.tag == "code-block":
|
|
body.add_new("pre", "code-block", content=_code_block(child), pre=True, extra=_SCROLL)
|
|
elif child.tag == "table":
|
|
self._table_process(child, body)
|
|
elif child.tag == "p":
|
|
body.add_new("div", "section-body-text", content=self.process_text(child))
|
|
elif child.tag == "backrest-config":
|
|
config = self._config_process(section, child, depth + 3, backrest=True)
|
|
|
|
if config is not None:
|
|
body.add(config)
|
|
elif child.tag == "postgres-config":
|
|
config = self._config_process(section, child, depth + 3, backrest=False)
|
|
|
|
if config is not None:
|
|
body.add(config)
|
|
elif child.tag == "list":
|
|
list_element = body.add_new("ul", "list-unordered")
|
|
|
|
for item in child_list(child):
|
|
list_element.add_new("li", "list-unordered", content=self.process_text(xml_node_text(item)))
|
|
elif child.tag == "sponsor-list":
|
|
self._sponsor_process(child, body)
|
|
elif child.tag in ("subtitle", "subsubtitle"):
|
|
body.add_new("div", "section%d-%s" % (depth, child.tag), content=self.process_text(xml_node_text(child)))
|
|
elif child.tag == "section":
|
|
child_element, child_toc = self._section_process(child, anchor, "%s.%d" % (section_no, child_no), depth + 1)
|
|
|
|
body.add(child_element)
|
|
|
|
if child_toc is not None:
|
|
toc_element.add(child_toc)
|
|
|
|
child_no += 1
|
|
elif child.tag == "admonition":
|
|
type = xml_node_attribute(child, "type", True)
|
|
admonition = body.add_new("div", "admonition")
|
|
|
|
admonition.add_new("div", type, content="%s: " % type.upper())
|
|
admonition.add_new("div", "%s-text" % type, content=self.process_text(child))
|
|
else:
|
|
self.section_child_process(section, child, depth + 1)
|
|
|
|
return element, None if xml_node_attribute(section, "toc") == "n" else toc_element
|
|
|
|
################################################################################################################################
|
|
def _execute_list_process(self, section, node, body, depth):
|
|
"""Render the commands of a section and what they wrote."""
|
|
|
|
show = xml_node_attribute(node, "show") != "n"
|
|
host_name = self.manifest.var_store.replace_str(xml_node_attribute(node, "host", True))
|
|
execute_body = None
|
|
|
|
if show:
|
|
execute = body.add_new("div", "execute")
|
|
execute.add_new(
|
|
"div",
|
|
"execute-title",
|
|
content='<span class="host">%s</span> <b>⇒</b> %s'
|
|
% (host_name, self.process_text(xml_node_child(node, "title", True))),
|
|
)
|
|
execute_body = execute.add_new("div", "execute-body")
|
|
|
|
for command in xml_node_child_list(node, "execute"):
|
|
show_command = xml_node_attribute(command, "show") != "n"
|
|
expect_error = xml_node_attribute(command, "err-expect") is not None
|
|
|
|
cmd, output = self.execute(section, host_name, command, indent=depth + 3, show=show and show_command)
|
|
|
|
if not (show and show_command):
|
|
continue
|
|
|
|
# The command runs beside the button that copies it rather than under it, so the button covers nothing a reader is
|
|
# reading however wide the command is
|
|
command_element = execute_body.add_new("div", "execute-body-cmd")
|
|
|
|
command_element.add_new("pre", "execute-cmd", content=cmd.replace("\n", "\n "), pre=True, extra=_SCROLL)
|
|
_copy_add(command_element, "command")
|
|
|
|
highlight = self.manifest.var_store.replace_str(xml_node_field(command, "exe-highlight"))
|
|
found = False
|
|
|
|
if output is not None:
|
|
if xml_node_field(command, "exe-highlight-type") == "error":
|
|
expect_error = True
|
|
|
|
# What a command wrote is one block that scrolls, with the lines held in a block of their own inside it, so that a
|
|
# line that is marked is marked across the width of the widest line rather than across the width of the part of it
|
|
# that is in view, and so that scrolling to the end of a line does not leave the lines around it behind
|
|
line_list = execute_body.add_new("div", "execute-body-output", extra=_SCROLL).add_new("div", "execute-line-list")
|
|
|
|
# Runs of lines are grouped by whether they are highlighted, so a run is one element rather than one per line
|
|
previous = None
|
|
run = None
|
|
|
|
for line in output.split("\n"):
|
|
highlighted = highlight is not None and re.search(highlight, line) is not None
|
|
|
|
if previous is not None and highlighted != previous:
|
|
line_list.add_new("pre", _output_class(previous, expect_error), content=run, pre=True)
|
|
run = None
|
|
|
|
run = line if run is None else run + "\n" + line
|
|
previous = highlighted
|
|
found = found or highlighted
|
|
|
|
# Whatever is left is the last run, since output always holds at least one line
|
|
line_list.add_new("pre", _output_class(previous, expect_error), content=run, pre=True)
|
|
|
|
if self.exe and self.is_required(section) and highlight is not None and not found:
|
|
raise ToolError("unable to find a match for highlight: %s" % highlight)
|
|
|
|
################################################################################################################################
|
|
def _table_process(self, node, body):
|
|
"""Render a table."""
|
|
|
|
title = xml_node_child(node, "title")
|
|
table = body.add_new("table", "table")
|
|
column_list = []
|
|
|
|
if title is not None:
|
|
label = xml_node_attribute(title, "label")
|
|
text = self.process_text(title)
|
|
|
|
table.add_new("caption", "table-caption", content=text if label is None else "%s: %s" % (label, text))
|
|
|
|
header = xml_node_child(node, "table-header")
|
|
|
|
if header is not None:
|
|
column_list = xml_node_child_list(header, "table-column")
|
|
row = table.add_new("tr", "table-header-row")
|
|
|
|
for column in column_list:
|
|
align = xml_node_attribute(column, "align") or "left"
|
|
fill = " table-header-fill" if xml_node_attribute(column, "fill") == "y" else ""
|
|
|
|
row.add_new("th", "table-header-%s%s" % (align, fill), content=self.process_text(column))
|
|
|
|
for data_row in xml_node_child_list(xml_node_child(node, "table-data", True), "table-row"):
|
|
row = table.add_new("tr", "table-row")
|
|
|
|
for cell_idx, cell in enumerate(xml_node_child_list(data_row, "table-cell")):
|
|
align = xml_node_attribute(column_list[cell_idx], "align") or "left" if len(column_list) > 0 else "left"
|
|
|
|
row.add_new("td", "table-data-%s" % align, content=self.process_text(cell))
|
|
|
|
################################################################################################################################
|
|
def _sponsor_process(self, node, body):
|
|
"""Render the sponsors, each as a link with a logo for a light and a dark page."""
|
|
|
|
sponsor_list = body.add_new("div", "sponsor-list")
|
|
|
|
for sponsor in child_list(node):
|
|
link = sponsor_list.add_new("div", "sponsor").add_new("a", ref=xml_node_attribute(sponsor, "url", True))
|
|
width = xml_node_attribute(sponsor, "width", True)
|
|
image = xml_node_attribute(sponsor, "img", True)
|
|
image_dark = xml_node_attribute(sponsor, "img-dark") or image
|
|
name = xml_node_content(sponsor)
|
|
|
|
link.add_new(
|
|
"img",
|
|
"sponsor-img sponsor-img-light",
|
|
extra='src="sponsor/%s" alt="%s" width="%s"' % (image, name, width),
|
|
)
|
|
link.add_new(
|
|
"img",
|
|
"sponsor-img sponsor-img-dark",
|
|
extra='src="sponsor/%s" alt="%s" width="%s"' % (image_dark, name, width),
|
|
)
|
|
|
|
################################################################################################################################
|
|
def _config_process(self, section, node, depth, backrest):
|
|
"""Render a configuration change as the file it leaves behind."""
|
|
|
|
if backrest:
|
|
file, config, show = self.backrest_config(section, node, depth)
|
|
else:
|
|
file, config, show = self.postgres_config(section, node, depth)
|
|
|
|
if not show:
|
|
return None
|
|
|
|
host_name = self.manifest.var_store.replace_str(xml_node_attribute(node, "host", True))
|
|
element = HtmlElement("div", "config")
|
|
|
|
# The title carries the button that copies the file, since what is shown of the file is a change to it rather than the file
|
|
# itself. The button comes before what the title says so that the title wraps around it rather than under it.
|
|
title = element.add_new("div", "config-title")
|
|
|
|
_copy_add(title, "configuration")
|
|
|
|
title.add_new(
|
|
"span",
|
|
"config-title-text",
|
|
content='<span class="host">%s</span>:<span class="file">%s</span> <b>⇒</b> %s'
|
|
% (host_name, file, self.process_text(xml_node_child(node, "title", True))),
|
|
)
|
|
|
|
# The lines are held in a block of their own inside the block that scrolls, so that a line the change marks is marked across
|
|
# the width of the widest line rather than across the width of the part of it that is in view
|
|
output = element.add_new("div", "config-body").add_new("div", "config-body-output", extra=_SCROLL)
|
|
line_list = output.add_new("div", "config-line-list")
|
|
|
|
for mark, line in config:
|
|
line_list.add_new("pre", _CONFIG_CLASS_MAP[mark], content=line, pre=True)
|
|
|
|
return element
|
|
|
|
|
|
####################################################################################################################################
|
|
def _png_size(file):
|
|
"""Read what a png says it measures.
|
|
|
|
The card says its size in the page so a crawler can lay the preview out before it has the image, and the size is read from the
|
|
image rather than written down so the two cannot disagree."""
|
|
|
|
with open(file, "rb") as handle:
|
|
return struct.unpack(">II", handle.read(24)[16:24])
|
|
|
|
|
|
####################################################################################################################################
|
|
def _resource_render(text, line_comment=False):
|
|
"""A style or a script with what it says about itself taken out.
|
|
|
|
Where the lines fall is left alone, so one build of the documentation can still be compared with the next, which is the same
|
|
reason the pages themselves are not written on one line."""
|
|
|
|
result = []
|
|
|
|
for line in _COMMENT_EXP.sub("", text).split("\n"):
|
|
line = line.rstrip()
|
|
|
|
# A comment on a line of its own goes with the line. One after code is left, since telling code from a string that holds
|
|
# what looks like a comment takes a parser rather than a rule, and the scripts here do not write one.
|
|
if line_comment and line.lstrip().startswith("//"):
|
|
continue
|
|
|
|
# A line that held nothing but a comment is empty now, and a run of empty lines is left as one
|
|
if line == "" and (len(result) == 0 or result[-1] == ""):
|
|
continue
|
|
|
|
result.append(line)
|
|
|
|
return "\n".join(result).strip("\n") + "\n"
|
|
|
|
|
|
####################################################################################################################################
|
|
def _resource_version(text):
|
|
"""A short hash of a style or a script, which is the version the links to it carry.
|
|
|
|
It is the rendered content that is hashed, so a change to nothing but a comment, which the render takes out, does not change
|
|
the version either, and one build of the documentation still comes out the same as the next."""
|
|
|
|
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:8]
|
|
|
|
|
|
####################################################################################################################################
|
|
def _copy_add(element, what):
|
|
"""Add the button that hands a reader what a block is showing rather than what the documentation wrote around it.
|
|
|
|
What the button is drawn with is left to the style, which says it once for the documentation rather than once for every block
|
|
that carries one."""
|
|
|
|
element.add_new("button", "code-copy", extra='type="button" title="Copy the %s" aria-label="Copy the %s"' % (what, what))
|
|
|
|
|
|
####################################################################################################################################
|
|
def _output_class(highlighted, expect_error):
|
|
"""What a run of output is called, which is what decides how it looks."""
|
|
|
|
if not highlighted:
|
|
return "execute-line"
|
|
|
|
return "execute-line-highlight" + ("-error" if expect_error else "")
|
|
|
|
|
|
####################################################################################################################################
|
|
def _code_block(node):
|
|
"""The content of a code block with the indent it was written at in the xml taken off."""
|
|
|
|
value = _BLOCK_END_EXP.sub("", _BLOCK_BEGIN_EXP.sub("", xml_node_content(node)))
|
|
|
|
# The line with the least indent decides how much indent to take off every line, so what is left is what was written. A blank
|
|
# line has no indent, so a block with one in it is left as it is.
|
|
indent = min(len(line.rstrip()) - len(line.strip()) for line in value.split("\n"))
|
|
|
|
return "\n".join(line[indent:] if line.startswith(" " * indent) else line for line in value.split("\n"))
|
|
|
|
|
|
####################################################################################################################################
|
|
def html_render(manifest, path_doc, path_out, exe):
|
|
"""Render every page and everything that goes beside them."""
|
|
|
|
render = manifest.render_get(RENDER_HTML)
|
|
var_store = manifest.var_store
|
|
|
|
style = _resource_render(file_read(os.path.join(path_doc, "resource/html/default.css")))
|
|
script = _resource_render(file_read(os.path.join(path_doc, "resource/html/default.js")), line_comment=True)
|
|
|
|
file_write(os.path.join(path_out, "default.css"), style)
|
|
file_write(os.path.join(path_out, "default.js"), script)
|
|
|
|
style_version = _resource_version(style)
|
|
script_version = _resource_version(script)
|
|
shutil.copyfile(os.path.join(path_doc, "resource/slogo.svg"), os.path.join(path_out, "slogo.svg"))
|
|
|
|
favicon = var_store.get("project-favicon")
|
|
|
|
if favicon is not None:
|
|
shutil.copyfile(os.path.join(path_doc, "resource", favicon), os.path.join(path_out, favicon))
|
|
|
|
card = var_store.get("project-card")
|
|
|
|
if card is not None and not var_store.test("card", "n"):
|
|
shutil.copyfile(os.path.join(path_doc, "resource", card), os.path.join(path_out, card))
|
|
|
|
if not var_store.test("sponsor", "n"):
|
|
path_sponsor = os.path.join(path_doc, "resource/sponsor")
|
|
path_sponsor_out = os.path.join(path_out, "sponsor")
|
|
|
|
path_create(path_sponsor_out)
|
|
|
|
for name in path_list(path_sponsor):
|
|
shutil.copyfile(os.path.join(path_sponsor, name), os.path.join(path_sponsor_out, name))
|
|
|
|
for key in sorted(render.out_map):
|
|
log(INFO, " render out: %s" % key)
|
|
|
|
out = render.out_map[key]
|
|
|
|
def build():
|
|
return var_store.replace_str(DocHtmlPage(manifest, key, render.menu, exe, style_version, script_version).process())
|
|
|
|
try:
|
|
html = build()
|
|
except CacheInvalidError:
|
|
# The cache no longer describes the document, so throw it away and build the document for real
|
|
manifest.cache_reset(out.source)
|
|
html = build()
|
|
|
|
file_write(os.path.join(path_out, out.file if out.file is not None else "%s.html" % key), html)
|