diff --git a/TODO.md b/TODO.md index b3eca77..92d80f5 100644 --- a/TODO.md +++ b/TODO.md @@ -2,14 +2,6 @@ Open work and open decisions for this repository. -## Work - -- **MkDocs backend for the generator:** `src/scene_repo_template/skeleton/mkdocs/`, from the prototype on `cy/scene-docs-mkdocs` (worktree `../CodeAndDocsTemplate-mkdocs`, `api-docs/` uncommitted). It writes `docs/`, with its own docs extra (`docs_dependencies`), docs workflow and SCENE stylesheet from `scene-palette.json`; add it to `BACKENDS` in `cli.py` and to the end-to-end docs test. The logo and favicon are in `skeleton/jupyter-book/docs/images/` because `footer.md` links them relatively; move them to `common/` only if both backends can reach them there. -- **CI on this repository:** its workflows skip private repositories, like those of generated projects. If the new repository is private, delete the `if:` lines in `.github/workflows/` here; the skeleton's keep them. -- **Review and test pass:** `pytest` on Windows and Linux with `SCENE_TEMPLATE_E2E=1`, `pre-commit run --all-files --hook-stage manual`, a strict book build with link checking, `actionlint` and `zizmor` on `.github/` and the skeleton's `dot-github/`, the interactive prompts by hand, and every page checked in light and dark mode. - ## Decisions -- **New repository:** this work moves to a new SCENE repository; its name and visibility are open. After the move, update the repository links in `pyproject.toml` (`urls`), `docs/myst.yml` (`github`) and the `uvx --from` command in `docs/guides/using-the-template.md`. `uvx --from git+https://…` works without credentials only on a public repository. Do not mark it as a GitHub template repository: projects come from the generator. -- **Footer funder line:** "Simons Foundation" now; SCENE's own wording is "supported by a Simons Foundation International grant administered by the Simons Foundation". -- **MLAI example headers:** authors are given as initials (C. Benquet, T. Sainsbury); the permission line has no date. +- **New repository:** this work moves to a new SCENE repository; its name and visibility are open. After the move, update the repository links in `pyproject.toml` (`urls`), `docs/myst.yml` (`github`) and `docs/guides/using-the-template.md` (uv, pip and conda tabs). Installing from `git+https://…` works without credentials only on a public repository. Do not mark it as a GitHub template repository: projects come from the generator. diff --git a/docs/guides/development-guide.md b/docs/guides/development-guide.md index 0727eda..ac57d4f 100644 --- a/docs/guides/development-guide.md +++ b/docs/guides/development-guide.md @@ -1,4 +1,4 @@ -# Development Guide +# Collaborating on code Each project sets its own rules, such as who reviews what and how decisions are made. The guidelines below are a suggested common starting point. diff --git a/docs/guides/using-the-template.md b/docs/guides/using-the-template.md index a9242e2..fd30061 100644 --- a/docs/guides/using-the-template.md +++ b/docs/guides/using-the-template.md @@ -48,6 +48,7 @@ You will need to enter: - A one-line description - The author - The GitHub URL +- The documentation: Jupyter Book (guides and notebooks) or MkDocs (API reference from docstrings and notebooks) This will show a summary, and wait for confirmation. @@ -60,9 +61,20 @@ The project then contains: - `pyproject.toml`, the package folder and a first test example in `tests/`; - `.pre-commit-config.yaml` and three GitHub Actions workflows: lint, tests and docs; -- `docs/`, a Jupyter Book with SCENE's logo, colours and footer; +- `docs/`, a Jupyter Book, or an MkDocs site with `mkdocs.yml`; - `LICENSE` (Apache-2.0) and a short `README.md`. +### Which documentation backend? + +| | Jupyter Book | MkDocs | +| --- | --- | --- | +| Best for | Guides, tutorials and analyses shown as notebooks | A package with an API | +| Needed | Markdown and notebooks (`.ipynb`), shown with their saved outputs | Markdown for the documentation, plus docstrings on the code | +| Configuration | `docs/myst.yml` | `mkdocs.yml` | +| Preview | `jupyter book start` in `docs/` (needs [Node.js](https://nodejs.org)) | `mkdocs serve` | + +A project can only have one. To switch later, generate a project with the other backend and copy its `docs/`, `.github/workflows/docs.yml`, `mkdocs.yml` if any, and the `docs` extra of `pyproject.toml`. + ## 2. Set up your environment In the new project, with your preferred package manager: @@ -141,8 +153,8 @@ Commit `uv.lock` with the change. - Create the repository on GitHub at the URL you decided, then commit and push. - CI runs as soon as you push, if the repository is public and Actions is enabled (**Settings → Actions**): see *Continuous Integration* in the [Project standards](guidelines.md). -- To publish the docs, enable GitHub Pages once: see [Documentation with Jupyter Book](jupyter-book.md#publish). +- To publish the docs, enable GitHub Pages once: in **Settings → Pages**, set **Source** to **GitHub Actions**. The docs workflow then publishes on every push to `main`, for either backend. More on Jupyter Book: [Documentation with Jupyter Book](jupyter-book.md#publish). ```{important} -No docs needed? Delete `docs/` and `.github/workflows/docs.yml`. +No docs needed? Delete `docs/`, `.github/workflows/docs.yml`, and `mkdocs.yml` if present. ``` diff --git a/docs/index.md b/docs/index.md index 96b91f1..476fabe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ title: SCENE Code & Data Guidelines Shared practices for code and data in [SCENE](https://www.project-scene.org/), the Simons Collaboration on Ecological Neuroscience, and a template repository for quick project setup. -See the [SCENE-Collaboration](https://github.com/SCENE-Collaboration) GitHub organisation for more other repositories and resources. +See the [SCENE-Collaboration](https://github.com/SCENE-Collaboration) GitHub organisation for other repositories and resources. ::::{grid} 1 1 2 2 diff --git a/src/scene_repo_template/cli.py b/src/scene_repo_template/cli.py index 176b283..893acf8 100644 --- a/src/scene_repo_template/cli.py +++ b/src/scene_repo_template/cli.py @@ -27,6 +27,20 @@ "`.github/workflows/docs.yml` publishes it to GitHub Pages once Pages is enabled with GitHub Actions as its source." ), }, + "mkdocs": { + "label": "MkDocs: API reference from docstrings, and notebooks", + # MkDocs 2.0 drops the plugin system mkdocstrings relies on. + "docs_dependencies": ( + '"mkdocs>=1.6,<2", "mkdocs-api-autonav>=0.1", "mkdocs-autorefs>=1.2", "mkdocs-jupyter>=0.25", ' + '"mkdocs-material>=9.5", "mkdocstrings[python]>=0.27", "pymdown-extensions<12.2; python_version < \'3.11\'"' + ), + "docs_readme": ( + "Built from `docs/` and `mkdocs.yml` with [MkDocs](https://www.mkdocs.org) and Material, " + "the API reference generated from docstrings and notebooks shown as pages: " + 'after `pip install -e ".[docs]"` (or `uv sync --extra docs`), `mkdocs serve` serves it locally. ' + "`.github/workflows/docs.yml` publishes it to GitHub Pages once Pages is enabled with GitHub Actions as its source." + ), + }, } TEMPLATE_SUFFIX = ".tmpl" diff --git a/src/scene_repo_template/skeleton/mkdocs/docs/images/favicon.png b/src/scene_repo_template/skeleton/mkdocs/docs/images/favicon.png new file mode 100644 index 0000000..a0de517 Binary files /dev/null and b/src/scene_repo_template/skeleton/mkdocs/docs/images/favicon.png differ diff --git a/src/scene_repo_template/skeleton/mkdocs/docs/images/scene-logo.png b/src/scene_repo_template/skeleton/mkdocs/docs/images/scene-logo.png new file mode 100644 index 0000000..de8ba5d Binary files /dev/null and b/src/scene_repo_template/skeleton/mkdocs/docs/images/scene-logo.png differ diff --git a/src/scene_repo_template/skeleton/mkdocs/docs/index.md.tmpl b/src/scene_repo_template/skeleton/mkdocs/docs/index.md.tmpl new file mode 100644 index 0000000..03ea926 --- /dev/null +++ b/src/scene_repo_template/skeleton/mkdocs/docs/index.md.tmpl @@ -0,0 +1,5 @@ +# $title + +$description + +The API reference is generated from the docstrings of `$package`. Add pages, Markdown files or notebooks, next to this one and list them under `nav:` in `mkdocs.yml`. diff --git a/src/scene_repo_template/skeleton/mkdocs/docs/stylesheets/scene.css b/src/scene_repo_template/skeleton/mkdocs/docs/stylesheets/scene.css new file mode 100644 index 0000000..33e1176 --- /dev/null +++ b/src/scene_repo_template/skeleton/mkdocs/docs/stylesheets/scene.css @@ -0,0 +1,54 @@ +/* SCENE theme for MkDocs Material, in the colours of the SCENE logo. */ + +:root { + --md-primary-fg-color: #125069; /* petrol blue */ + --md-primary-fg-color--light: #12638e; /* ocean blue */ + --md-primary-fg-color--dark: #0d3a4c; + --md-accent-fg-color: #41cfdb; /* cyan */ + --md-typeset-a-color: #12638e; +} + +[data-md-color-scheme="slate"] { + --md-primary-fg-color: #1f2d36; /* ink */ + --md-typeset-a-color: #41cfdb; +} + +/* Brand stripe above the header, in the logo's colours. */ +.md-header::before { + content: ""; + display: block; + height: 4px; + background: linear-gradient(90deg, #41cfdb, #31c5a1, #80c848, #edb71f, #e98724, #d76c26); +} + +.md-typeset h2 { + padding-bottom: 0.25em; + border-bottom: 2px solid var(--md-accent-fg-color); +} + +.md-copyright { + width: 100%; + text-align: center; +} + +/* Footer line: the name with its initials in stripe order (golden yellow skipped), as in the SCENE Jupyter Book, + the logo, then the funder. Markup: `copyright` in mkdocs.yml. */ +.scene-logo { + display: inline-block; + width: 1.4em; + height: 1.4em; + margin: 0 0.6em; + vertical-align: middle; + /* A background image, not an : its URL is relative to this file, so it holds on every page. */ + background: url("../images/scene-logo.png") center / contain no-repeat; +} + +.scene-footer strong { + color: #fff; +} + +.scene-footer em b:nth-of-type(1) { color: #41cfdb; } +.scene-footer em b:nth-of-type(2) { color: #31c5a1; } +.scene-footer em b:nth-of-type(3) { color: #80c848; } +.scene-footer em b:nth-of-type(4) { color: #e98724; } +.scene-footer em b:nth-of-type(5) { color: #d76c26; } diff --git a/src/scene_repo_template/skeleton/mkdocs/dot-github/workflows/docs.yml b/src/scene_repo_template/skeleton/mkdocs/dot-github/workflows/docs.yml new file mode 100644 index 0000000..9a2fb4c --- /dev/null +++ b/src/scene_repo_template/skeleton/mkdocs/dot-github/workflows/docs.yml @@ -0,0 +1,46 @@ +name: Docs + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + build: + # CI runs on public repositories only: private ones draw on the org's Actions quota, which can be billed. Delete this line to opt in. + if: ${{ !github.event.repository.private }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2 + with: + python-version: "3.13" + - run: uv sync --extra docs + - run: uv run mkdocs build --strict + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 + with: + path: site + + deploy: + # Pull requests only check that the site builds. + if: ${{ github.event_name == 'push' && !github.event.repository.private }} + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + concurrency: + group: pages + cancel-in-progress: false + steps: + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - id: deployment + uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 diff --git a/src/scene_repo_template/skeleton/mkdocs/mkdocs.yml.tmpl b/src/scene_repo_template/skeleton/mkdocs/mkdocs.yml.tmpl new file mode 100644 index 0000000..c2daae5 --- /dev/null +++ b/src/scene_repo_template/skeleton/mkdocs/mkdocs.yml.tmpl @@ -0,0 +1,83 @@ +# MkDocs configuration: https://www.mkdocs.org, theme https://squidfunk.github.io/mkdocs-material/ +# Preview locally with `mkdocs serve`; build with `mkdocs build --strict`. Pages are in docs/. +site_name: "$title" +site_description: "$description" +repo_url: $github_url + +theme: + name: material + logo: images/scene-logo.png + favicon: images/favicon.png + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: custom + accent: custom + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: custom + accent: custom + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - content.code.copy + - navigation.indexes + - navigation.path + - navigation.sections + - navigation.top + - search.highlight + - search.suggest + - toc.follow + +extra_css: + - stylesheets/scene.css + +plugins: + - search + - autorefs + # One page per module of the package, under "API reference". + - api-autonav: + modules: ["$package"] + nav_section_title: "API reference" + exclude_private: true + - mkdocstrings: + default_handler: python + handlers: + python: + options: + show_root_heading: true + show_symbol_type_heading: true + separate_signature: true + show_signature_annotations: true + docstring_style: google + line_length: 120 + inventories: + - url: https://docs.python.org/3/objects.inv + domains: [py, std] + # Notebooks (.ipynb) are pages, shown with the outputs saved in them, not re-executed. + # Only notebooks: by default, .py files in docs/ would become pages too. + - mkdocs-jupyter: + include: ["*.ipynb"] + +markdown_extensions: + - attr_list + - admonition + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.superfences + +extra: + generator: false + +# The SCENE name with coloured initials, styled in docs/stylesheets/scene.css. +copyright: "Simons Collaboration on Ecological NEuroscienceSimons Foundation" + +nav: + - Home: index.md + - API reference diff --git a/tests/test_cli.py b/tests/test_cli.py index 7b2b874..7dc1941 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ import importlib import importlib.metadata +import json import os import shutil import subprocess @@ -37,27 +38,51 @@ def refused(tmp_path, *options, folder="my-project"): return exit_info.value.code == 2 -def test_a_project_gets_the_common_files_and_its_docs_backend(tmp_path): - expected = { - ".github/workflows/docs.yml", - ".github/workflows/lint.yml", - ".github/workflows/tests.yml", - ".gitignore", - ".pre-commit-config.yaml", - "LICENSE", - "README.md", - "docs/_static/footer.md", - "docs/_static/scene.css", - "docs/images/favicon.png", - "docs/images/scene-logo.png", - "docs/index.md", - "docs/myst.yml", - "my_project/__init__.py", - "my_project/version.py", - "pyproject.toml", - "tests/test_version.py", - } - assert expected <= files_of(generate(tmp_path)) +COMMON_FILES = { + ".github/workflows/docs.yml", + ".github/workflows/lint.yml", + ".github/workflows/tests.yml", + ".gitignore", + ".pre-commit-config.yaml", + "LICENSE", + "README.md", + "docs/images/favicon.png", + "docs/images/scene-logo.png", + "docs/index.md", + "my_project/__init__.py", + "my_project/version.py", + "pyproject.toml", + "tests/test_version.py", +} +BACKEND_FILES = { + "jupyter-book": {"docs/myst.yml", "docs/_static/footer.md", "docs/_static/scene.css"}, + "mkdocs": {"mkdocs.yml", "docs/stylesheets/scene.css"}, +} + + +def test_every_backend_is_tested(): + assert set(BACKEND_FILES) == set(cli.BACKENDS) + + +@pytest.mark.parametrize("backend", BACKEND_FILES) +def test_a_project_gets_the_common_files_and_only_its_own_docs_backend(tmp_path, backend): + project = files_of(generate(tmp_path, f"--docs={backend}")) + assert COMMON_FILES | BACKEND_FILES[backend] <= project + other_backends = set().union(*(files for name, files in BACKEND_FILES.items() if name != backend)) + assert project & other_backends == set() + + +def test_the_backends_ship_the_same_logo_and_favicon(): + for image in ("scene-logo.png", "favicon.png"): + copies = {(cli.SKELETON / backend / "docs" / "images" / image).read_bytes() for backend in cli.BACKENDS} + assert len(copies) == 1, image + + +def test_the_docs_extra_follows_the_backend(tmp_path): + pyproject = (generate(tmp_path, "--docs=mkdocs") / "pyproject.toml").read_text(encoding="utf-8") + assert '"mkdocstrings[python]>=0.27"' in pyproject + assert '"mkdocs-jupyter>=0.25"' in pyproject + assert "jupyter-book" not in pyproject def test_no_skeleton_naming_is_left_in_a_project(tmp_path): @@ -303,7 +328,21 @@ def test_a_generated_project_installs_lints_and_passes_its_tests(tmp_path): @E2E @pytest.mark.skipif(shutil.which("node") is None, reason="Jupyter Book needs Node.js") -def test_a_generated_project_builds_its_docs(tmp_path): - dest = generate(tmp_path) +def test_a_generated_jupyter_book_builds(tmp_path): + dest = generate(tmp_path, "--docs=jupyter-book") run(dest, "uv", "sync", "--extra", "docs") run(dest / "docs", "uv", "run", "jupyter", "book", "build", "--html", "--strict") + + +@E2E +def test_a_generated_mkdocs_site_builds_with_an_api_page_per_module_and_a_notebook(tmp_path): + dest = generate(tmp_path, "--docs=mkdocs") + notebook = {"cells": [{"cell_type": "markdown", "metadata": {}, "source": ["# Analysis"]}], "metadata": {}} + notebook |= {"nbformat": 4, "nbformat_minor": 5} + (dest / "docs" / "analysis.ipynb").write_text(json.dumps(notebook), encoding="utf-8") + mkdocs_yml = dest / "mkdocs.yml" + mkdocs_yml.write_text(mkdocs_yml.read_text(encoding="utf-8") + " - analysis.ipynb\n", encoding="utf-8") + run(dest, "uv", "sync", "--extra", "docs") + run(dest, "uv", "run", "mkdocs", "build", "--strict") + assert (dest / "site" / "reference" / "my_project" / "version" / "index.html").is_file() + assert (dest / "site" / "analysis" / "index.html").is_file()