diff --git a/docs/product/build.py b/docs/product/build.py
index 3f7b9fd..b49b3e9 100644
--- a/docs/product/build.py
+++ b/docs/product/build.py
@@ -101,6 +101,69 @@ def prepare_table(name: str, table: dict) -> None:
table["has_footnote"] = bool(table.get("footnote"))
+def load_config(path: Path) -> dict:
+ """Parse the content YAML, reporting syntax errors against the offending line."""
+ text = path.read_text(encoding="utf-8")
+ try:
+ return yaml.safe_load(text)
+ except yaml.YAMLError as error:
+ mark = getattr(error, "problem_mark", None)
+ detail = getattr(error, "problem", str(error))
+ if mark is None:
+ raise SystemExit(f"{path}: {detail}")
+ line = (
+ text.splitlines()[mark.line] if mark.line < len(text.splitlines()) else ""
+ )
+ raise SystemExit(
+ f"{path}:{mark.line + 1}: {detail}\n"
+ f" {line.strip()}\n"
+ ' hint: inside a "quoted" value, HTML quotes must be escaped as \\"'
+ )
+
+
+def walk_strings(node, path: str = ""):
+ """Yield (dotted-path, text) for every string in the loaded config."""
+ if isinstance(node, dict):
+ for key, value in node.items():
+ yield from walk_strings(value, f"{path}.{key}" if path else str(key))
+ elif isinstance(node, list):
+ for index, value in enumerate(node):
+ yield from walk_strings(value, f"{path}[{index}]")
+ elif isinstance(node, str):
+ yield path, node
+
+
+def validate_markup(content: dict) -> None:
+ """Catch the two easy ways to write broken HTML in the config.
+
+ YAML only processes \\" as an escape inside "quoted" values. In a >- folded
+ or plain value the backslash survives into the page and breaks the markup,
+ so a loaded string should never still contain one.
+ """
+ problems = []
+ for path, text in walk_strings(content):
+ if '\\"' in text:
+ problems.append(
+ f'{path}: contains a literal \\" — in a >- or plain value write '
+ "the quote on its own, with no backslash"
+ )
+ for tag in ("b", "a", "span"):
+ opened = len(re.findall(rf"<{tag}\b", text))
+ closed = len(re.findall(rf"{tag}>", text))
+ if opened != closed:
+ problems.append(
+ f"{path}: {opened} <{tag}> but {closed} {tag}> — unbalanced tag"
+ )
+ # An unterminated href swallows the rest of the tag, which still leaves
+ # and balanced, so check the attribute closes its own quotes.
+ if len(re.findall(r"href=", text)) != len(re.findall(r'href="[^"]*"', text)):
+ problems.append(
+ f'{path}: malformed link — href="..." is missing its closing quote'
+ )
+ if problems:
+ raise SystemExit("\n".join(f"{p}" for p in problems))
+
+
def as_data_uri_payload(path: Path) -> str:
if not path.is_file():
raise SystemExit(f"missing asset: {path}")
@@ -223,8 +286,8 @@ def main() -> None:
level=logging.INFO, format="%(levelname)-8s %(message)s", stream=sys.stdout
)
- with open(args.config, encoding="utf-8") as fh:
- content = yaml.safe_load(fh)
+ content = load_config(args.config)
+ validate_markup(content)
for key in TABLE_KEYS:
if key not in content:
diff --git a/docs/product/datasheet.html.mustache b/docs/product/datasheet.html.mustache
index dfc7309..7fb0552 100644
--- a/docs/product/datasheet.html.mustache
+++ b/docs/product/datasheet.html.mustache
@@ -12,6 +12,8 @@
:root {
--navy: {{theme.navy}};
--red: {{theme.red}};
+ --tag-bg: {{theme.tag_bg}};
+ --tag-fg: {{theme.tag_fg}};
--ink: {{theme.ink}};
--ink-mid: {{theme.ink_mid}};
--ink-faint: {{theme.ink_faint}};
@@ -88,8 +90,8 @@ body {
.bar__tag {
margin-left: auto;
- background: var(--red);
- color: #fff;
+ background: var(--tag-bg);
+ color: var(--tag-fg);
font-size: var(--t-fine);
font-weight: 700;
letter-spacing: 0.16em;
@@ -110,6 +112,10 @@ body {
min-height: 0;
}
+/* Page 2 opens straight onto a section heading, so it needs the top margin
+ that page 1 gets from the hero's own padding. */
+.body--specs { padding-top: 5.5mm; }
+
.label {
font-size: var(--t-micro);
font-weight: 600;
@@ -146,8 +152,7 @@ body {
display: grid;
grid-template-columns: 1.32fr 1fr;
gap: 7mm;
- padding: 4.6mm 0 4.2mm;
- border-bottom: 2px solid var(--rule-hard);
+ padding: 4.6mm 0 5.4mm;
}
.hero h1 {
@@ -167,8 +172,6 @@ body {
max-width: 62ch;
}
-.hero .rule { height: 2px; background: var(--red); width: 22mm; margin-top: 3.2mm; }
-
.hero figure { margin: 0; display: flex; flex-direction: column; }
.hero img { width: 100%; height: 44mm; object-fit: cover; display: block; }
@@ -187,17 +190,11 @@ body {
.features {
display: grid;
grid-template-columns: repeat(3, 1fr);
- border-bottom: 2px solid var(--rule-hard);
+ column-gap: 7mm;
+ row-gap: 4.4mm;
+ padding: 0 0 5.4mm;
}
-.feature {
- padding: 2.8mm 4mm 2.9mm 0;
- border-right: 1px solid var(--rule);
-}
-
-.feature:nth-child(3n) { border-right: 0; padding-right: 0; }
-.feature:nth-child(n + 4) { border-top: 1px solid var(--rule); }
-
.feature h3 {
margin: 1.6mm 0 1.4mm;
font-size: var(--t-body);
@@ -256,8 +253,8 @@ tbody tr:last-child td { border-bottom: 0; }
/* ---------- pricing / faq blocks ---------- */
-.block { padding: 3.2mm 0; border-bottom: 2px solid var(--rule-hard); }
-.block:last-of-type { border-bottom: 0; }
+.block { padding: 0 0 5.4mm; }
+.block:last-of-type { padding-bottom: 0; }
.faq {
display: grid;
@@ -366,7 +363,7 @@ a:focus-visible { outline: 2px solid var(--red); outline-offset: 2px; }
{{&hero.body}}
-