2026-10-11 15:05:38 +03:00
|
|
|
"""Emitting the pages mews.page serves.
|
|
|
|
|
|
|
|
|
|
Every page the service produces is itself a Mews page, so this module writes the
|
|
|
|
|
same prologue, head and body shape as md2mews.py. The emitter is repeated here
|
|
|
|
|
rather than imported because md2mews.py is a standalone script that pulls in a
|
|
|
|
|
Markdown parser; tests/test_pages.py fails if the two drift apart.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
import fcntl
|
|
|
|
|
from html import escape
|
|
|
|
|
import os
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
import sqlite3
|
|
|
|
|
|
|
|
|
|
from mews import VERSION
|
|
|
|
|
from mews.lint import validate_bytes
|
|
|
|
|
|
|
|
|
|
DOCTYPE = (
|
|
|
|
|
'<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"\n'
|
|
|
|
|
' "http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">'
|
|
|
|
|
)
|
|
|
|
|
VARIANTS = ("mews-warm", "mews-cool", "mews-green", "mews-mono")
|
|
|
|
|
STYLESHEET = "mews-0.1.css"
|
|
|
|
|
SITE_VARIANT = "mews-cool"
|
|
|
|
|
|
|
|
|
|
# SPEC.md 4.3 caps a page at 256 KB and asks for 64 KB. The directory refuses to
|
|
|
|
|
# publish a page over the first and warns past the second.
|
|
|
|
|
SIZE_MUST = 256 * 1024
|
|
|
|
|
SIZE_SHOULD = 64 * 1024
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _text(value: str) -> str:
|
|
|
|
|
"""Escape text content, leaving apostrophes alone so prose reads as prose."""
|
|
|
|
|
return escape(value, quote=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _attr(value: str) -> str:
|
|
|
|
|
"""Escape a value going into an attribute."""
|
|
|
|
|
return escape(value, quote=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def page(
|
|
|
|
|
title: str,
|
|
|
|
|
body: list[str],
|
|
|
|
|
language: str = "en",
|
|
|
|
|
direction: str | None = None,
|
|
|
|
|
variant: str | None = None,
|
|
|
|
|
description: str | None = None,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Wrap body lines in a complete Mews page. Mirrors md2mews.page()."""
|
|
|
|
|
attrs = f'xml:lang="{language}" lang="{language}"'
|
|
|
|
|
if direction:
|
|
|
|
|
attrs += f' dir="{direction}"'
|
|
|
|
|
head = [
|
|
|
|
|
f" <title>{_text(title)}</title>",
|
|
|
|
|
f' <meta name="mews-profile" content="{VERSION}" />',
|
|
|
|
|
' <meta name="viewport" content="width=device-width" />',
|
|
|
|
|
]
|
|
|
|
|
if description:
|
|
|
|
|
head.append(f' <meta name="description" content="{_attr(description)}" />')
|
|
|
|
|
head.append(f' <link rel="stylesheet" type="text/css" href="{STYLESHEET}" />')
|
|
|
|
|
body_attrs = f' class="{variant}"' if variant else ""
|
|
|
|
|
return "\n".join(
|
|
|
|
|
[
|
|
|
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
|
|
|
DOCTYPE,
|
|
|
|
|
f'<html xmlns="http://www.w3.org/1999/xhtml" {attrs}>',
|
|
|
|
|
" <head>",
|
|
|
|
|
*head,
|
|
|
|
|
" </head>",
|
|
|
|
|
f" <body{body_attrs}>",
|
|
|
|
|
*body,
|
|
|
|
|
" </body>",
|
|
|
|
|
"</html>",
|
|
|
|
|
"",
|
|
|
|
|
]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def site_page(title: str, body: list[str], description: str | None = None) -> str:
|
|
|
|
|
"""Wrap body lines in a page with the mews.page look."""
|
|
|
|
|
return page(title, body, variant=SITE_VARIANT, description=description)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def message(title: str, paragraphs: list[str], links: list[tuple[str, str]]) -> str:
|
|
|
|
|
"""Build a short page that says one thing, with a way onwards."""
|
|
|
|
|
body = [f" <h1>{_text(title)}</h1>"]
|
|
|
|
|
body += [f" <p>{_text(line)}</p>" for line in paragraphs]
|
|
|
|
|
body.append(" <hr />")
|
|
|
|
|
body += [
|
|
|
|
|
f' <p><a href="{_attr(href)}">{_text(label)}</a></p>'
|
|
|
|
|
for href, label in links
|
|
|
|
|
]
|
|
|
|
|
return site_page(title, body, description=paragraphs[0] if paragraphs else None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def report(findings_report, *, listed: bool, command: str) -> str:
|
|
|
|
|
"""Build the page an author gets back after a check."""
|
|
|
|
|
conforms = findings_report.conforms
|
|
|
|
|
if listed:
|
|
|
|
|
title = "Your site is listed"
|
|
|
|
|
opening = (
|
|
|
|
|
"That page follows Mews Profile 0.1, so your site is in the "
|
|
|
|
|
"directory now. Thank you for adding it."
|
|
|
|
|
)
|
|
|
|
|
elif conforms:
|
|
|
|
|
title = "This page conforms"
|
2026-10-11 16:56:53 +03:00
|
|
|
opening = (
|
|
|
|
|
"That page follows Mews Profile 0.1. Nothing was stored, and the "
|
|
|
|
|
"site is not in the directory: use Check and list for that."
|
|
|
|
|
)
|
2026-10-11 15:05:38 +03:00
|
|
|
else:
|
|
|
|
|
title = "This page doesn't conform yet"
|
|
|
|
|
opening = (
|
|
|
|
|
"Here is what to fix. Each line names the section of the spec that "
|
|
|
|
|
"explains the rule, so you can go and read why."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
body = [f" <h1>{_text(title)}</h1>", f" <p>{_text(opening)}</p>"]
|
|
|
|
|
if findings_report.url:
|
|
|
|
|
body.append(
|
|
|
|
|
f' <p>Checked <a href="{_attr(findings_report.url)}">'
|
|
|
|
|
f"{_text(findings_report.url)}</a>.</p>"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
if findings_report.failures:
|
|
|
|
|
body.append(" <h2>To fix</h2>")
|
|
|
|
|
body.append(" <ul>")
|
|
|
|
|
body += [f" <li>{_text(str(f))}</li>" for f in findings_report.failures]
|
|
|
|
|
body.append(" </ul>")
|
|
|
|
|
if findings_report.warnings:
|
|
|
|
|
body.append(" <h2>Worth fixing</h2>")
|
|
|
|
|
body.append(" <ul>")
|
|
|
|
|
body += [f" <li>{_text(str(f))}</li>" for f in findings_report.warnings]
|
|
|
|
|
body.append(" </ul>")
|
|
|
|
|
for note in findings_report.notes:
|
|
|
|
|
body.append(f" <p>{_text(note)}</p>")
|
|
|
|
|
|
|
|
|
|
body.append(" <hr />")
|
|
|
|
|
body.append(
|
|
|
|
|
' <p>The rules are all in the <a href="/spec/0.1">spec</a>. Checking '
|
|
|
|
|
"on your own machine is quicker while you are still fixing things:</p>"
|
|
|
|
|
)
|
|
|
|
|
body.append(f" <pre>{_text(command)}</pre>")
|
|
|
|
|
body.append(
|
|
|
|
|
' <p><a href="/check" accesskey="1">Check another page</a> '
|
|
|
|
|
'· <a href="/directory" accesskey="2">The directory</a></p>'
|
|
|
|
|
)
|
|
|
|
|
return site_page(title, body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def directory(rows: list[sqlite3.Row]) -> str:
|
|
|
|
|
"""Build the directory page: every site whose page passes the checks."""
|
|
|
|
|
body = [" <h1>Mews directory</h1>"]
|
|
|
|
|
if rows:
|
|
|
|
|
count = f"{len(rows)} site" + ("" if len(rows) == 1 else "s")
|
|
|
|
|
body.append(
|
|
|
|
|
f" <p>{count} listed. Every one of these pages passed the checks "
|
|
|
|
|
'against <a href="/spec/0.1">Mews Profile 0.1</a>, and gets looked '
|
|
|
|
|
"at again every week.</p>"
|
|
|
|
|
)
|
|
|
|
|
body.append(" <ul>")
|
|
|
|
|
for row in rows:
|
|
|
|
|
label = _text(row["title"] or row["domain"])
|
|
|
|
|
line = f' <li><a href="{_attr(row["url"])}">{label}</a>'
|
|
|
|
|
line += f" — {_text(row['domain'])}"
|
|
|
|
|
if row["description"]:
|
|
|
|
|
line += f". {_text(row['description'])}"
|
|
|
|
|
body.append(line + "</li>")
|
|
|
|
|
body.append(" </ul>")
|
|
|
|
|
else:
|
|
|
|
|
body.append(" <p>Nothing here yet. Yours could be the first one in!</p>")
|
|
|
|
|
body.append(" <hr />")
|
|
|
|
|
body.append(
|
|
|
|
|
' <p><a href="/check" accesskey="1">Add your site</a> '
|
|
|
|
|
'· <a href="/" accesskey="0">Mews</a></p>'
|
|
|
|
|
)
|
|
|
|
|
body.append(
|
|
|
|
|
f" <address>Updated {datetime.now(UTC).strftime('%Y-%m-%d %H:%M')} "
|
|
|
|
|
"UTC</address>"
|
|
|
|
|
)
|
|
|
|
|
return site_page(
|
|
|
|
|
"Mews directory",
|
|
|
|
|
body,
|
|
|
|
|
description="Sites whose pages follow Mews Profile 0.1.",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def write_directory(target: str, rows: list[sqlite3.Row]) -> list[str]:
|
|
|
|
|
"""Write the directory page, replacing it only if it conforms.
|
|
|
|
|
|
|
|
|
|
The page is validated before it is published, because the one page that has
|
|
|
|
|
to be a good example is this one. Returns any warnings about it.
|
|
|
|
|
"""
|
|
|
|
|
destination = Path(target)
|
|
|
|
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
text = directory(rows).encode()
|
|
|
|
|
if len(text) > SIZE_MUST:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"The directory page would be over 256 KB, which section 4.3 "
|
|
|
|
|
"forbids. Split it before listing more sites."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
lock = destination.with_name(destination.name + ".lock")
|
|
|
|
|
with open(lock, "w") as handle:
|
|
|
|
|
fcntl.flock(handle, fcntl.LOCK_EX)
|
|
|
|
|
result = validate_bytes(text)
|
|
|
|
|
if not result.conforms:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
"The directory page doesn't conform: "
|
|
|
|
|
+ "; ".join(str(f) for f in result.failures)
|
|
|
|
|
)
|
|
|
|
|
temporary = destination.with_name("." + destination.name + ".tmp")
|
|
|
|
|
temporary.write_bytes(text)
|
2026-10-11 16:39:39 +03:00
|
|
|
# The web server reads this file straight off disk as another user, so
|
|
|
|
|
# it is made readable on purpose. The service runs with a private umask
|
|
|
|
|
# to keep the database to itself, and that would otherwise apply here.
|
|
|
|
|
temporary.chmod(0o644)
|
2026-10-11 15:05:38 +03:00
|
|
|
os.replace(temporary, destination)
|
|
|
|
|
|
|
|
|
|
warnings = [str(f) for f in result.warnings]
|
|
|
|
|
if len(text) > SIZE_SHOULD:
|
|
|
|
|
warnings.append(
|
|
|
|
|
f"The directory page is {len(text) // 1024} KB, over the 64 KB "
|
|
|
|
|
"section 4.3 asks for."
|
|
|
|
|
)
|
|
|
|
|
return warnings
|