refactor: split the repo into self-contained validator, directory, quickstart, spec and site folders

validator/ holds mewslint (lint, css, fetch, DTD/CSS data); directory/ holds
the mews.page service (app, db, check, pages, cli) and depends on validator/;
quickstart/ holds mews-quickstart and md2mews.py for third parties who want
to create Mews content without cloning the repo; spec/ consolidates doc/ and
dtd/ with the canonical mews-0.1.css; site/ becomes a Pelican project with
the forward-facing pages as Markdown, the check form kept as a raw HTML
snippet. Root pyproject.toml is now a virtual uv workspace over validator,
directory and quickstart, with cross-cutting tests that span more than one
of them staying at the repo root. Makefile and flake.nix updated to match.
This commit is contained in:
randogoth 2026-10-13 12:29:23 +03:00
parent 5fa8708bd1
commit 060d4e00fd
90 changed files with 1290 additions and 287 deletions

211
quickstart/md2mews.py Normal file
View file

@ -0,0 +1,211 @@
#!/usr/bin/env python
# /// script
# requires-python = ">=3.10"
# dependencies = ["mistune>=3.0"]
# ///
"""Convert a Markdown file into a Mews page (Mews Profile 0.1).
Parsing is CommonMark (mistune) plus pipe tables; uv installs mistune
from the script header on the first run. The converter maps the tree onto
what mews-0.1.dtd permits: ATX and setext headings, paragraphs, emphasis,
strong, code, links, images (with titles), hard breaks, fenced and
indented code blocks, block quotes, ordered and unordered lists (with
start), and pipe tables. Raw HTML is escaped as text and table alignment
markers are dropped, because Mews has neither. Tables are recognized only
at the top level, matching the DTD, which allows them nowhere else.
Run with: uv run md2mews.py page.md -o page.html --lang en
"""
import argparse
import html
import sys
from pathlib import Path
import mistune
VERSION = "0.1"
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")
MARKDOWN = mistune.create_markdown(renderer="ast", plugins=["table"])
def esc(text):
return html.escape(text)
def plain(nodes):
"""Text content of inline nodes, used for image alt text."""
parts = []
for node in nodes:
if node["type"] in ("text", "codespan", "inline_html"):
parts.append(node.get("raw", ""))
else:
parts.append(plain(node.get("children", [])))
return "".join(parts)
def inline_nodes(nodes):
out = []
for node in nodes:
kind, attrs = node["type"], node.get("attrs", {})
if kind == "text":
out.append(esc(node["raw"]))
elif kind == "codespan":
out.append(f"<code>{esc(node['raw'])}</code>")
elif kind == "emphasis":
out.append(f"<em>{inline_nodes(node['children'])}</em>")
elif kind == "strong":
out.append(f"<strong>{inline_nodes(node['children'])}</strong>")
elif kind == "link":
title = f' title="{esc(attrs["title"])}"' if attrs.get("title") else ""
out.append(f'<a href="{esc(attrs["url"])}"{title}>{inline_nodes(node["children"])}</a>')
elif kind == "image":
title = f' title="{esc(attrs["title"])}"' if attrs.get("title") else ""
alt = esc(plain(node["children"]))
out.append(f'<img src="{esc(attrs["url"])}" alt="{alt}"{title} />')
elif kind == "linebreak":
out.append("<br />")
elif kind == "softbreak":
out.append(" ")
elif kind == "inline_html":
out.append(esc(node["raw"]))
else:
sys.exit(f"error: unsupported inline markup: {kind}")
return "".join(out)
def render_block(node, level, no_table):
"""Render one block node into indented lines.
no_table is set inside block quotes and list items, where the Mews DTD
does not allow a table.
"""
pad = " " * level
kind, attrs = node["type"], node.get("attrs", {})
if kind in ("paragraph", "block_text"):
return [f"{pad}<p>{inline_nodes(node['children'])}</p>" if kind == "paragraph"
else f"{pad}{inline_nodes(node['children'])}"]
if kind == "heading":
tag = f"h{attrs['level']}"
return [f"{pad}<{tag}>{inline_nodes(node['children'])}</{tag}>"]
if kind == "block_quote":
return [f"{pad}<blockquote>", *render_blocks(node["children"], level + 1, True), f"{pad}</blockquote>"]
if kind == "block_code":
return [f"{pad}<pre>{esc(node['raw'].rstrip(chr(10)))}</pre>"]
if kind == "thematic_break":
return [f"{pad}<hr />"]
if kind == "block_html":
return [f"{pad}<p>{esc(node['raw'].strip())}</p>"]
if kind == "list":
tag = "ol" if attrs["ordered"] else "ul"
start = f' start="{attrs["start"]}"' if attrs.get("start") not in (None, 1) else ""
out = [f"{pad}<{tag}{start}>"]
for item in node["children"]:
out.extend(render_item(item, level + 1))
out.append(f"{pad}</{tag}>")
return out
if kind == "table":
if no_table:
sys.exit("error: a table inside a quote or list item cannot be a Mews page")
return render_table(node, pad)
sys.exit(f"error: unsupported Markdown block: {kind}")
def render_item(node, level):
pad = " " * level
children = node["children"]
if len(children) == 1 and children[0]["type"] == "block_text":
return [f"{pad}<li>{inline_nodes(children[0]['children'])}</li>"]
out = [f"{pad}<li>", *render_blocks(children, level + 1, True), f"{pad}</li>"]
return out
def render_blocks(nodes, level, no_table):
lines = []
for node in nodes:
if node["type"] == "blank_line":
continue
lines.extend(render_block(node, level, no_table))
return lines
def render_table(node, pad):
out = [f"{pad}<table>"]
for section in node["children"]:
rows = [section] if section["type"] == "table_head" else section["children"]
for row in rows:
cells = ""
for cell in row["children"]:
tag = "th" if cell["attrs"].get("head") else "td"
cells += f"<{tag}>{inline_nodes(cell['children'])}</{tag}>"
out.append(f"{pad} <tr>{cells}</tr>")
out.append(f"{pad}</table>")
return out
def page(title, body, language, direction, variant, description):
attrs = f'xml:lang="{language}" lang="{language}"'
if direction:
attrs += f' dir="{direction}"'
head = [
f" <title>{esc(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="{esc(description)}" />')
head.append(' <link rel="stylesheet" type="text/css" href="mews-0.1.css" />')
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 main():
parser = argparse.ArgumentParser(description="Convert a Markdown file into a Mews page.")
parser.add_argument("source", help="Markdown file to convert")
parser.add_argument("-o", "--output", help='output file, or "-" for stdout (default: source name with .html)')
parser.add_argument("--title", help="page title (default: first top-level heading)")
parser.add_argument("--description", help="meta description")
parser.add_argument("--lang", default="en", help="page language (default: en)")
parser.add_argument("--dir", choices=("ltr", "rtl"), help="text direction")
parser.add_argument("--variant", choices=VARIANTS, help="body variant class (SPEC.md 5.3)")
args = parser.parse_args()
source = Path(args.source)
blocks = MARKDOWN(source.read_text(encoding="utf-8-sig"))
title = args.title
if not title:
title = next(
(plain(block["children"]) for block in blocks if block["type"] == "heading" and block["attrs"]["level"] == 1),
None,
) or source.stem
result = page(title, render_blocks(blocks, 1, False), args.lang, args.dir, args.variant, args.description)
if args.output == "-":
sys.stdout.write(result)
else:
output = Path(args.output) if args.output else source.with_suffix(".html")
output.write_text(result, encoding="utf-8")
print(f"wrote {output} ({title})")
if __name__ == "__main__":
main()

19
quickstart/pyproject.toml Normal file
View file

@ -0,0 +1,19 @@
[project]
name = "mews-quickstart"
version = "0.1.0"
description = "Scaffold a Pelican site that writes Mews pages"
authors = [{name = "Mews Profile developers"}]
license = "Apache-2.0"
requires-python = ">=3.10"
dependencies = []
[project.scripts]
mews-quickstart = "quickstart:main"
[build-system]
requires = ["uv_build>=0.8"]
build-backend = "uv_build"
[tool.uv.build-backend]
module-root = ""
module-name = "quickstart"

View file

@ -0,0 +1,275 @@
r"""Scaffold a Pelican site that writes Mews pages.
Run it straight off the repository, with no checkout and nothing installed:
uvx --from git+https://code.randogoth.com/randogoth/mews.page.git#subdirectory=quickstart \\
mews-quickstart mysite
It writes a project, pins Pelican and the Mews plugin in its pyproject.toml,
installs them, and leaves a first post to edit.
"""
import argparse
from datetime import UTC, datetime
from pathlib import Path
import shutil
import subprocess
import sys
REPO = "git+https://code.randogoth.com/randogoth/mews.page.git"
PYPROJECT = """\
[project]
name = "{slug}"
version = "0.1.0"
description = "A Mews site"
requires-python = ">=3.10"
dependencies = [
"pelican>=4.9",
"markdown>=3.4",
"pelican-mews @ {repo}#subdirectory=pelican-mews",
]
"""
PELICANCONF = """\
AUTHOR = {author!r}
SITENAME = {title!r}
SITEURL = ""
PATH = "content"
TIMEZONE = "UTC"
DEFAULT_LANG = "en"
THEME = "theme"
# The plugin reads the allowed elements and attributes from the Mews DTD and
# brings every page inside that subset.
PLUGINS = ["pelican.plugins.mews"]
# This whole site is Mews, so its pages are plain .html: a server sends those
# as text/html, which section 7.1 asks for so a small markup error still shows
# as a page, and index.html is the directory index everywhere. The plugin
# defaults to .xhtml instead, for sites that are only partly Mews.
MEWS_SUFFIX = ".html"
ARTICLE_URL = ARTICLE_SAVE_AS = "{{slug}}.html"
PAGE_URL = PAGE_SAVE_AS = "{{slug}}.html"
# Markdown has to produce XML for the plugin to parse. Tables and definition
# lists are both in the Mews subset but need their extensions turned on.
MARKDOWN = {{
"output_format": "xhtml",
"extension_configs": {{
"markdown.extensions.tables": {{}},
"markdown.extensions.def_list": {{}},
}},
}}
# Every page written under MEWS_SUFFIX goes through the plugin, and Pelican
# falls back to its own HTML templates for these when the theme has none. They
# have nothing to show on a site like this, so they stay off; give them
# templates in theme/templates/ if you want them.
DIRECT_TEMPLATES = ["index"]
AUTHOR_SAVE_AS = CATEGORY_SAVE_AS = TAG_SAVE_AS = ""
AUTHORS_SAVE_AS = CATEGORIES_SAVE_AS = TAGS_SAVE_AS = ARCHIVES_SAVE_AS = ""
# Pelican writes several feeds by default and they are all broken until
# SITEURL is set. Section 6.2 makes an Atom feed optional and the dated links
# on the index are already a subscription, so they start off.
FEED_ALL_ATOM = FEED_ALL_RSS = None
CATEGORY_FEED_ATOM = CATEGORY_FEED_RSS = None
AUTHOR_FEED_ATOM = AUTHOR_FEED_RSS = None
TAG_FEED_ATOM = TAG_FEED_RSS = None
TRANSLATION_FEED_ATOM = TRANSLATION_FEED_RSS = None
# Raise this to stop a build that would strip something, rather than logging
# the removal and carrying on.
MEWS_STRICT = False
"""
BASE = """\
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE html PUBLIC "-//WAPFORUM//DTD XHTML Mobile 1.2//EN"
"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="{{ DEFAULT_LANG }}" lang="{{ DEFAULT_LANG }}">
<head>
<title>{% block title %}{{ SITENAME }}{% endblock %}</title>
{% include "mews/head.html" %}
</head>
<body>
{% block content %}{% endblock %}
<hr />
<address>{{ SITENAME }}</address>
</body>
</html>
"""
INDEX = """\
{% extends "base.html" %}
{% block content %}
<h1>{{ SITENAME }}</h1>
{% if articles %}
<ul>
{% for article in articles %}
<li><a href="{{ article.url }}">{{ article.date.strftime("%Y-%m-%d") }} {{ article.title }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>Nothing posted yet.</p>
{% endif %}
{% endblock %}
"""
ARTICLE = """\
{% extends "base.html" %}
{% block title %}{{ article.title }}{% endblock %}
{% block content %}
<h1>{{ article.title }}</h1>
{{ article.content }}
<p><a href="index.html">All posts</a></p>
{% endblock %}
"""
PAGE = """\
{% extends "base.html" %}
{% block title %}{{ page.title }}{% endblock %}
{% block content %}
<h1>{{ page.title }}</h1>
{{ page.content }}
<p><a href="index.html">All posts</a></p>
{% endblock %}
"""
POST = """\
Title: First post
Date: {date}
Slug: first-post
Write here. The plugin keeps whatever fits the Mews subset and strips the
rest, so you can use the ordinary Markdown you already know: *emphasis*,
`code`, [links](https://mews.page/spec/0.1), lists, quotes and tables.
- One
- Two
> Authors provide structure. Readers control presentation.
Delete this file once you have something of your own to say.
"""
README = """\
# {title}
A Mews site. The pages follow [Mews Profile 0.1](https://mews.page/spec/0.1).
## Build it
```shell
uv run pelican content
```
The finished pages land in `output/`, as plain `.html` that any web server
serves correctly with no configuration. To work on it with a reload on save:
```shell
uv run pelican --autoreload --listen content
```
## Publish it
Copy `output/` to any web server. Nothing to configure.
## Subscribe to it
The index lists each post as a dated link, which is all a Mews client needs
(section 6.1). If you also want an Atom feed for ordinary feed readers, set
`SITEURL` in `pelicanconf.py` and put back the `FEED_ALL_ATOM` default.
## Check it
```shell
uvx --from {repo}#subdirectory=validator mewslint https://your.site/
```
Or list your site in the directory at <https://mews.page/check>.
"""
def write(root: Path, files: dict[str, str]) -> None:
"""Write each file, creating the directories it needs."""
for name, text in files.items():
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
def scaffold(root: Path, title: str, author: str) -> None:
"""Write a complete Pelican project that produces Mews pages."""
slug = "".join(c if c.isalnum() else "-" for c in root.name.lower()).strip("-")
write(
root,
{
"pyproject.toml": PYPROJECT.format(slug=slug or "mews-site", repo=REPO),
"pelicanconf.py": PELICANCONF.format(title=title, author=author),
"theme/templates/base.html": BASE,
"theme/templates/index.html": INDEX,
"theme/templates/article.html": ARTICLE,
"theme/templates/page.html": PAGE,
"content/first-post.md": POST.format(
date=datetime.now(UTC).strftime("%Y-%m-%d %H:%M")
),
"README.md": README.format(title=title, repo=REPO),
},
)
def main(argv: list[str] | None = None) -> int:
"""Create the project, install its dependencies, and say what to run."""
parser = argparse.ArgumentParser(
prog="mews-quickstart",
description="Start a Pelican site that writes Mews pages.",
)
parser.add_argument(
"directory", nargs="?", default="mysite", help="where to put it"
)
parser.add_argument("--title", help="site name (default: the directory name)")
parser.add_argument("--author", default="", help="your name, for the metadata")
parser.add_argument(
"--no-install", action="store_true", help="write the files and stop"
)
args = parser.parse_args(argv)
root = Path(args.directory).resolve()
if root.exists() and any(root.iterdir()):
print(
f"{root} already has something in it. Pick an empty directory.",
file=sys.stderr,
)
return 1
title = args.title or root.name
scaffold(root, title, args.author)
print(f"Wrote {root}")
if not args.no_install:
uv = shutil.which("uv")
if uv is None:
print("uv isn't on the path, so nothing was installed.", file=sys.stderr)
else:
print("Installing Pelican and the Mews plugin...")
result = subprocess.run([uv, "sync"], cwd=root, check=False)
if result.returncode != 0:
print(
"The install didn't finish. Run `uv sync` yourself to see "
"what went wrong.",
file=sys.stderr,
)
return 1
print(
f"\nNext:\n"
f" cd {root.name}\n"
f" uv run pelican --autoreload --listen content\n\n"
f"Then open http://localhost:8000/ and edit content/first-post.md."
)
return 0
if __name__ == "__main__":
sys.exit(main())