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:
parent
5fa8708bd1
commit
060d4e00fd
90 changed files with 1290 additions and 287 deletions
211
quickstart/md2mews.py
Normal file
211
quickstart/md2mews.py
Normal 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
19
quickstart/pyproject.toml
Normal 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"
|
||||
275
quickstart/quickstart/__init__.py
Normal file
275
quickstart/quickstart/__init__.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue