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