feat: quickstart that scaffolds a Pelican site writing Mews pages
This commit is contained in:
parent
884300f3cf
commit
0d9cf1ed26
3 changed files with 352 additions and 0 deletions
272
mews/quickstart.py
Normal file
272
mews/quickstart.py
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
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 \\
|
||||||
|
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. It only touches files ending .xhtml,
|
||||||
|
# so everything has to be written out under that suffix.
|
||||||
|
PLUGINS = ["pelican.plugins.mews"]
|
||||||
|
|
||||||
|
ARTICLE_URL = ARTICLE_SAVE_AS = "{{slug}}.xhtml"
|
||||||
|
PAGE_URL = PAGE_SAVE_AS = "{{slug}}.xhtml"
|
||||||
|
INDEX_SAVE_AS = "index.xhtml"
|
||||||
|
|
||||||
|
# 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": {{}},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
# Mews pages carry no author styling, so there is nothing for these to show.
|
||||||
|
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.xhtml">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.xhtml">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/`. To work on it with a reload on save:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
uv run pelican --autoreload --listen content
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publish it
|
||||||
|
|
||||||
|
Copy `output/` to any web server. Two things are worth setting:
|
||||||
|
|
||||||
|
- Serve `.xhtml` files as `text/html`. Section 7.1 of the spec asks for that,
|
||||||
|
so a browser still shows a page carrying a small markup error.
|
||||||
|
- Make `index.xhtml` the directory index, since the plugin only processes
|
||||||
|
files under that suffix. In Caddy: `file_server {{ index index.xhtml }}`.
|
||||||
|
|
||||||
|
## 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} 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())
|
||||||
|
|
@ -16,6 +16,7 @@ dependencies = [
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
mewsd = "mews.cli:main"
|
mewsd = "mews.cli:main"
|
||||||
mewslint = "mews.lint:main"
|
mewslint = "mews.lint:main"
|
||||||
|
mews-quickstart = "mews.quickstart:main"
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|
@ -92,9 +93,16 @@ known-first-party = ["mews"]
|
||||||
# These print their results: they are the command line interface.
|
# These print their results: they are the command line interface.
|
||||||
"mews/cli.py" = ["T201"]
|
"mews/cli.py" = ["T201"]
|
||||||
"mews/lint.py" = ["T201"]
|
"mews/lint.py" = ["T201"]
|
||||||
|
# Also a command line tool, and it holds templates for files it writes, whose
|
||||||
|
# line lengths are the generated file's business rather than this module's.
|
||||||
|
"mews/quickstart.py" = ["T201", "E501"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
# The scaffold build downloads Pelican and takes a minute. Run it with
|
||||||
|
# `pytest -m slow`, or everything with `--runslow`.
|
||||||
|
addopts = "-m 'not slow'"
|
||||||
|
markers = ["slow: needs the network and about a minute"]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.8"]
|
requires = ["uv_build>=0.8"]
|
||||||
|
|
|
||||||
72
tests/test_quickstart.py
Normal file
72
tests/test_quickstart.py
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
"""The scaffold: it has to produce a project that builds conforming pages."""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mews.lint import validate_file
|
||||||
|
from mews.quickstart import main, scaffold
|
||||||
|
|
||||||
|
EXPECTED = [
|
||||||
|
"pyproject.toml",
|
||||||
|
"pelicanconf.py",
|
||||||
|
"content/first-post.md",
|
||||||
|
"theme/templates/base.html",
|
||||||
|
"theme/templates/index.html",
|
||||||
|
"theme/templates/article.html",
|
||||||
|
"theme/templates/page.html",
|
||||||
|
"README.md",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_it_writes_a_whole_project(tmp_path):
|
||||||
|
scaffold(tmp_path / "site", "Signal Bars", "Tobias")
|
||||||
|
for name in EXPECTED:
|
||||||
|
assert (tmp_path / "site" / name).is_file(), name
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_settings_are_valid_python(tmp_path):
|
||||||
|
scaffold(tmp_path / "site", "Signal Bars", "Tobias")
|
||||||
|
settings: dict = {}
|
||||||
|
exec(
|
||||||
|
compile((tmp_path / "site" / "pelicanconf.py").read_text(), "c", "exec"),
|
||||||
|
settings,
|
||||||
|
)
|
||||||
|
assert settings["SITENAME"] == "Signal Bars"
|
||||||
|
assert settings["PLUGINS"] == ["pelican.plugins.mews"]
|
||||||
|
# The plugin only touches this suffix, so everything has to be saved under it.
|
||||||
|
assert settings["ARTICLE_SAVE_AS"].endswith(".xhtml")
|
||||||
|
assert settings["INDEX_SAVE_AS"].endswith(".xhtml")
|
||||||
|
assert settings["MARKDOWN"]["output_format"] == "xhtml"
|
||||||
|
|
||||||
|
|
||||||
|
def test_it_refuses_a_directory_with_something_in_it(tmp_path, capsys):
|
||||||
|
(tmp_path / "site").mkdir()
|
||||||
|
(tmp_path / "site" / "keep.txt").write_text("mine")
|
||||||
|
assert main([str(tmp_path / "site"), "--no-install"]) == 1
|
||||||
|
assert (tmp_path / "site" / "keep.txt").read_text() == "mine"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(shutil.which("uv") is None, reason="uv is not installed")
|
||||||
|
@pytest.mark.slow
|
||||||
|
def test_the_scaffolded_site_builds_conforming_pages(tmp_path):
|
||||||
|
"""The whole point: pelican runs and what it writes passes the validator."""
|
||||||
|
site = tmp_path / "site"
|
||||||
|
assert main([str(site), "--title", "Signal Bars"]) == 0
|
||||||
|
built = subprocess.run(
|
||||||
|
[shutil.which("uv"), "run", "pelican", "content"],
|
||||||
|
cwd=site,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert built.returncode == 0, built.stderr
|
||||||
|
pages = sorted((site / "output").glob("*.xhtml"))
|
||||||
|
assert [p.name for p in pages] == ["first-post.xhtml", "index.xhtml"]
|
||||||
|
for page in pages:
|
||||||
|
report = validate_file(str(page))
|
||||||
|
assert report.conforms, "\n".join(str(f) for f in report.failures)
|
||||||
|
# Section 6.1: the index has to be subscribable.
|
||||||
|
index = (site / "output" / "index.xhtml").read_text()
|
||||||
|
assert '<li><a href="first-post.xhtml">' in index
|
||||||
Loading…
Add table
Add a link
Reference in a new issue