diff --git a/mews/quickstart.py b/mews/quickstart.py
new file mode 100644
index 0000000..504ade4
--- /dev/null
+++ b/mews/quickstart.py
@@ -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 = """\
+
+
+
+
+ {% block title %}{{ SITENAME }}{% endblock %}
+{% include "mews/head.html" %}
+
+
+{% block content %}{% endblock %}
+
+ {{ SITENAME }}
+
+
+"""
+
+INDEX = """\
+{% extends "base.html" %}
+{% block content %}
+ {{ SITENAME }}
+{% if articles %}
+
+{% else %}
+ Nothing posted yet.
+{% endif %}
+{% endblock %}
+"""
+
+ARTICLE = """\
+{% extends "base.html" %}
+{% block title %}{{ article.title }}{% endblock %}
+{% block content %}
+ {{ article.title }}
+{{ article.content }}
+ All posts
+{% endblock %}
+"""
+
+PAGE = """\
+{% extends "base.html" %}
+{% block title %}{{ page.title }}{% endblock %}
+{% block content %}
+ {{ page.title }}
+{{ page.content }}
+ All posts
+{% 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 .
+"""
+
+
+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())
diff --git a/pyproject.toml b/pyproject.toml
index 3c043ee..a254f7a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,6 +16,7 @@ dependencies = [
[project.scripts]
mewsd = "mews.cli:main"
mewslint = "mews.lint:main"
+mews-quickstart = "mews.quickstart:main"
[dependency-groups]
dev = [
@@ -92,9 +93,16 @@ known-first-party = ["mews"]
# These print their results: they are the command line interface.
"mews/cli.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]
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]
requires = ["uv_build>=0.8"]
diff --git a/tests/test_quickstart.py b/tests/test_quickstart.py
new file mode 100644
index 0000000..bebd20b
--- /dev/null
+++ b/tests/test_quickstart.py
@@ -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 '' in index