diff --git a/changelog/330.documentation.rst b/changelog/330.documentation.rst
new file mode 100644
index 00000000..2223a56c
--- /dev/null
+++ b/changelog/330.documentation.rst
@@ -0,0 +1,7 @@
+The reference quadrant's landing page is now a grid of seven cards, each with an
+icon drawn in the root page's vocabulary, in place of an introduction that listed
+its pages by hand (narrative spec §3.9). ``tests/test_docs_landing_pages.py``
+holds the cards to the page's hidden toctree as it holds the other sections'
+tables, and now refuses an entry that reaches outside its section with ``..``.
+The root page's cards share the renamed ``teph-card`` classes, and its dark
+*Tutorials* icon no longer rings its accent on the dark ground. (:user:`claude`)
diff --git a/docs/src/_static/cards/reference/api-dark.svg b/docs/src/_static/cards/reference/api-dark.svg
new file mode 100644
index 00000000..b9e0d6c2
--- /dev/null
+++ b/docs/src/_static/cards/reference/api-dark.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/docs/src/_static/cards/reference/api-light.svg b/docs/src/_static/cards/reference/api-light.svg
new file mode 100644
index 00000000..40915eff
--- /dev/null
+++ b/docs/src/_static/cards/reference/api-light.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/docs/src/_static/cards/reference/changelog-dark.svg b/docs/src/_static/cards/reference/changelog-dark.svg
new file mode 100644
index 00000000..3191f70d
--- /dev/null
+++ b/docs/src/_static/cards/reference/changelog-dark.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/docs/src/_static/cards/reference/changelog-light.svg b/docs/src/_static/cards/reference/changelog-light.svg
new file mode 100644
index 00000000..9783dfa0
--- /dev/null
+++ b/docs/src/_static/cards/reference/changelog-light.svg
@@ -0,0 +1,20 @@
+
+
diff --git a/docs/src/_static/cards/reference/cli-dark.svg b/docs/src/_static/cards/reference/cli-dark.svg
new file mode 100644
index 00000000..f7ef205a
--- /dev/null
+++ b/docs/src/_static/cards/reference/cli-dark.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/docs/src/_static/cards/reference/cli-light.svg b/docs/src/_static/cards/reference/cli-light.svg
new file mode 100644
index 00000000..57b3cf57
--- /dev/null
+++ b/docs/src/_static/cards/reference/cli-light.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/docs/src/_static/cards/reference/config-dark.svg b/docs/src/_static/cards/reference/config-dark.svg
new file mode 100644
index 00000000..e98fb84c
--- /dev/null
+++ b/docs/src/_static/cards/reference/config-dark.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/docs/src/_static/cards/reference/config-light.svg b/docs/src/_static/cards/reference/config-light.svg
new file mode 100644
index 00000000..7e8bad53
--- /dev/null
+++ b/docs/src/_static/cards/reference/config-light.svg
@@ -0,0 +1,18 @@
+
+
diff --git a/docs/src/_static/cards/reference/glossary-dark.svg b/docs/src/_static/cards/reference/glossary-dark.svg
new file mode 100644
index 00000000..c228675b
--- /dev/null
+++ b/docs/src/_static/cards/reference/glossary-dark.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/docs/src/_static/cards/reference/glossary-light.svg b/docs/src/_static/cards/reference/glossary-light.svg
new file mode 100644
index 00000000..3e7411ee
--- /dev/null
+++ b/docs/src/_static/cards/reference/glossary-light.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/docs/src/_static/cards/reference/references-dark.svg b/docs/src/_static/cards/reference/references-dark.svg
new file mode 100644
index 00000000..ba0354f4
--- /dev/null
+++ b/docs/src/_static/cards/reference/references-dark.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/docs/src/_static/cards/reference/references-light.svg b/docs/src/_static/cards/reference/references-light.svg
new file mode 100644
index 00000000..d18b4b7a
--- /dev/null
+++ b/docs/src/_static/cards/reference/references-light.svg
@@ -0,0 +1,16 @@
+
+
diff --git a/docs/src/_static/cards/reference/whatsnew-dark.svg b/docs/src/_static/cards/reference/whatsnew-dark.svg
new file mode 100644
index 00000000..e2437d5d
--- /dev/null
+++ b/docs/src/_static/cards/reference/whatsnew-dark.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/docs/src/_static/cards/reference/whatsnew-light.svg b/docs/src/_static/cards/reference/whatsnew-light.svg
new file mode 100644
index 00000000..9e85d15f
--- /dev/null
+++ b/docs/src/_static/cards/reference/whatsnew-light.svg
@@ -0,0 +1,15 @@
+
+
diff --git a/docs/src/_static/cards/tutorials-dark.svg b/docs/src/_static/cards/tutorials-dark.svg
index c52e6f3e..210c212f 100644
--- a/docs/src/_static/cards/tutorials-dark.svg
+++ b/docs/src/_static/cards/tutorials-dark.svg
@@ -12,5 +12,5 @@
-
+
diff --git a/docs/src/_static/tephpy.css b/docs/src/_static/tephpy.css
index a7ef4ae4..76b79f40 100644
--- a/docs/src/_static/tephpy.css
+++ b/docs/src/_static/tephpy.css
@@ -66,7 +66,8 @@
}
/*
- * The four Diátaxis quadrant cards on the landing page.
+ * The landing pages' cards: the root page's four Diátaxis quadrants, and the
+ * reference quadrant's seven (narrative spec §3.9).
*
* The icon is a pair of images rather than sphinx-design's `:img-top:`, which
* takes one path and so cannot carry a light and a dark drawing. The pair is
@@ -80,7 +81,7 @@
* a white background to every `.bd-content img` on top of that -- two rules of
* different reach, and the class is the opt-out named in both.
*/
-.teph-quadrant-icon {
+.teph-card-icon {
display: block;
margin: 0 0 0.6rem;
width: 56px;
@@ -88,7 +89,8 @@
/*
* Beside the text once there is room for it, above the text when there is not.
- * `.. grid:: 2` is two columns at *every* breakpoint -- the emitted row carries
+ * The root page's `.. grid:: 2` is two columns at *every* breakpoint -- the
+ * emitted row carries
* `sd-row-cols-xs-2` through `sd-row-cols-lg-2` -- so a phone gets two cards of
* about 150px, and an icon indented out of the flow left "Understanding-
* oriented background." wrapping one word per line. Measured in Chromium at a
@@ -96,14 +98,18 @@
* the narrow layout needs no override: an `only-light`/`only-dark` image is
* hidden with `display: none !important` and any `display` this sets would lose
* to it anyway, but nothing here has to know that.
+ *
+ * The reference quadrant's `.. grid:: 1 2 2 2` is one column below 576px, so its
+ * cards never meet that width. Above the icon, the root page's longest segment
+ * still splits at 360px (:issue:`328`).
*/
@media (min-width: 576px) {
- .teph-quadrant .sd-card-body {
+ .teph-card .sd-card-body {
padding-left: 84px;
position: relative;
}
- .teph-quadrant-icon {
+ .teph-card-icon {
left: 16px;
margin: 0;
position: absolute;
diff --git a/docs/src/developer/docs-style.rst b/docs/src/developer/docs-style.rst
index 9661a007..3a924b92 100644
--- a/docs/src/developer/docs-style.rst
+++ b/docs/src/developer/docs-style.rst
@@ -439,16 +439,17 @@ or adding the reason.
Landing Pages
-------------
-A section landing page — each of the four Diátaxis quadrants, and
-:doc:`getting started <../start/index>` — is navigation rather than prose, which is
-the rule above read forwards: it carries no reading-time banner because nobody reads
-it through.
-It carries, in order, an introduction, one two-column ``list-table``, and a hidden
-``toctree``.
-
-The introduction says what the quadrant is for, who it assumes the reader is, what
-it guarantees of every page in it, and where to go if this is the wrong quadrant.
-It says nothing about an individual page. A paragraph that summarises the quadrant
+A section landing page is navigation rather than prose, which is the rule above read
+forwards: it carries no reading-time banner because nobody reads it through. Six
+sections take one of two shapes, and both end in a hidden ``toctree``.
+
+**A table**, in :doc:`getting started <../start/index>`, the tutorials, how-to and
+explanation quadrants, and this developer guide. The page carries, in order, an
+introduction, one two-column ``list-table``, and the ``toctree``.
+
+The introduction says what the section is for, who it assumes the reader is, what
+it guarantees of every page in it, and where to go if this is the wrong section.
+It says nothing about an individual page. A paragraph that summarises the section
page by page is a list that has to track a directory, and the how-to page's grew
from six clauses to nine by hand before this rule existed.
@@ -458,23 +459,36 @@ it is deliberately not the page's opening line, which a hover already shows. Wri
``:widths: auto`` and no header row, which is the shape the API reference's own
summary tables already take.
-The rows and the toctree carry the same pages in the same order, and it is the
+**A grid of cards**, in the reference quadrant, whose pages are looked up by name
+rather than chosen between. The page carries, in order, a one-sentence
+introduction, a ``.. grid:: 1 2 2 2`` of cards, the guidance paragraphs, and the
+``toctree``. The API card comes first and spans the row with ``:columns: 12``. One
+column below 576px is not a matter of taste: two columns at that width split words
+mid-word, which narrative spec §3.9 records measuring.
+
+Each card takes its page's title — the API card excepted, because that page is
+titled by the package name — a light and a dark icon from
+``_static/cards/reference/``, and one sentence that tells it from the card beside
+it. A card raises no hover tooltip, so its sentence may say what the page opens
+with where that is clearest. Draw an icon in the root page's vocabulary; its dark
+file differs from the light one only in the navy, ``#8FB8E8`` for ``#1B3A6B``, and
+the knock-out halo, ``#14181e`` for ``#FFFFFF``.
+
+The index and the toctree carry the same pages in the same order, and it is the
order a reader needs rather than the alphabet. Hiding a toctree hides it from the
page body only: the sidebar, the breadcrumb and the previous and next footer all
read its order. ``tests/test_docs_landing_pages.py`` fails when the two disagree,
-in membership or in order; when the table omits a page the quadrant holds, which
-an ``:orphan:`` page would otherwise do silently, since the build's own
-toctree check never sees one; and when the toctree is not hidden, which would
-publish the same list twice.
-
-Glossary terms stay out of the cells. A table is a directive, and :ref:`the
-first-mention rule ` already passes over a directive's body, so a
-``:term:`` in a cell neither satisfies that rule nor breaks it. Write first
-mentions in the introduction, and let a cell take the plain word.
-
-The reference quadrant is outside this rule for now: its entries are reached by
-name rather than chosen between, and ``narrative spec §3.9`` records the question
-rather than answering it.
+in membership or in order; when an entry links outside its section; when the index
+omits a page the section holds, which an ``:orphan:`` page would otherwise do
+silently, since the build's own toctree check never sees one; and when the toctree
+is not hidden, which would publish the same list twice. It reads a row's first
+cell and a card's ``:link:``, and derives the API card's page from ``conf.py``,
+since that page exists only while a build runs.
+
+Glossary terms stay out of the cells and the cards. Both are directives, and
+:ref:`the first-mention rule ` already passes over a directive's
+body, so a ``:term:`` in either neither satisfies that rule nor breaks it. Write
+first mentions in the prose, and let a cell or a card take the plain word.
Topic Tags
----------
diff --git a/docs/src/developer/plans/2026-09-15-tephpy-reference-cards.md b/docs/src/developer/plans/2026-09-15-tephpy-reference-cards.md
index 9ded65e5..ebc13863 100644
--- a/docs/src/developer/plans/2026-09-15-tephpy-reference-cards.md
+++ b/docs/src/developer/plans/2026-09-15-tephpy-reference-cards.md
@@ -984,13 +984,22 @@ Expected: PASS.
- [ ] **Step 5: Prove each check by mutation**
+*Corrected 2026-09-15, during implementation:* the restores below originally used ``git checkout --``, which restores the page this task replaces.
+
Run each block from a clean tree, read the failure, and restore before the next.
+```bash
+# Step 3's page is not committed until Step 8, so `git checkout --` would restore
+# the page this task replaces. Restore from this copy instead.
+cp docs/src/reference/index.rst "${TMPDIR:-/tmp}/reference-index.rst"
+```
+
```bash
# (a) order: swap What's New and Changelog in the toctree only
sed -i -e 's|^ whatsnew/index$| SWAP|' -e 's|^ changelog$| whatsnew/index|' -e 's|^ SWAP$| changelog|' docs/src/reference/index.rst
pixi run -e test pytest tests/test_docs_landing_pages.py -q --no-cov -k reference
-git checkout -- docs/src/reference/index.rst
+cp "${TMPDIR:-/tmp}/reference-index.rst" docs/src/reference/index.rst
+diff "${TMPDIR:-/tmp}/reference-index.rst" docs/src/reference/index.rst && echo restored
```
Expected: `test_the_index_and_the_toctree_are_one_ordered_list[reference]` FAILS.
@@ -998,7 +1007,8 @@ Expected: `test_the_index_and_the_toctree_are_one_ordered_list[reference]` FAILS
# (b) reach outside the section
sed -i 's|^ :link: cli$| :link: ../howtos/units|' docs/src/reference/index.rst
pixi run -e test pytest tests/test_docs_landing_pages.py -q --no-cov -k reference
-git checkout -- docs/src/reference/index.rst
+cp "${TMPDIR:-/tmp}/reference-index.rst" docs/src/reference/index.rst
+diff "${TMPDIR:-/tmp}/reference-index.rst" docs/src/reference/index.rst && echo restored
```
Expected: three FAIL, all `[reference]`. `test_every_entry_links_to_a_page_in_its_own_section` fails with `reference's index links to ../howtos/units, outside the section` — the check this mutation is for. `test_the_index_and_the_toctree_are_one_ordered_list` fails because the card no longer matches the toctree's `cli`, and `test_the_index_lists_every_page_in_the_section` because `cli` is no longer listed.
@@ -1014,7 +1024,8 @@ Expected: `test_the_index_lists_every_page_in_the_section[reference]` FAILS, nam
# (d) the toctree shown
sed -i '/^ :hidden:$/d' docs/src/reference/index.rst
pixi run -e test pytest tests/test_docs_landing_pages.py -q --no-cov -k reference
-git checkout -- docs/src/reference/index.rst
+cp "${TMPDIR:-/tmp}/reference-index.rst" docs/src/reference/index.rst
+diff "${TMPDIR:-/tmp}/reference-index.rst" docs/src/reference/index.rst && echo restored
```
Expected: `test_the_toctree_is_hidden[reference]` FAILS.
@@ -1026,7 +1037,7 @@ mv /tmp/api-light.svg docs/src/_static/cards/reference/api-light.svg
```
Expected: the fail-on-warning build exits non-zero, naming `api-light.svg`. **If it exits 0, do not add a gate:** record in the pull request that a missing icon builds clean, per `narrative spec §3.9` (presentation is ungated).
-Finish with `git status --short` printing only this task's intended changes.
+Finish with the final `diff` printing `restored` and `git status --short` printing only this task's intended changes.
- [ ] **Step 6: Update the stylesheet's comments**
diff --git a/docs/src/index.rst b/docs/src/index.rst
index 94d63071..7a814000 100644
--- a/docs/src/index.rst
+++ b/docs/src/index.rst
@@ -26,14 +26,14 @@ Plot and analyse :term:`tephigrams `.
.. grid-item-card:: Tutorials
:link: tutorials/index
:link-type: doc
- :class-card: teph-quadrant sd-rounded-3
+ :class-card: teph-card sd-rounded-3
.. image:: _static/cards/tutorials-light.svg
- :class: only-light teph-quadrant-icon
+ :class: only-light teph-card-icon
:alt: a path stepping up across the tephigram lattice to a marked end
.. image:: _static/cards/tutorials-dark.svg
- :class: only-dark teph-quadrant-icon
+ :class: only-dark teph-card-icon
:alt: a path stepping up across the tephigram lattice to a marked end
Learning-oriented lessons.
@@ -41,14 +41,14 @@ Plot and analyse :term:`tephigrams `.
.. grid-item-card:: How-To Guides
:link: howtos/index
:link-type: doc
- :class-card: teph-quadrant sd-rounded-3
+ :class-card: teph-card sd-rounded-3
.. image:: _static/cards/howtos-light.svg
- :class: only-light teph-quadrant-icon
+ :class: only-light teph-card-icon
:alt: one isopleth of a family drawn at a heavier weight
.. image:: _static/cards/howtos-dark.svg
- :class: only-dark teph-quadrant-icon
+ :class: only-dark teph-card-icon
:alt: one isopleth of a family drawn at a heavier weight
Goal-oriented recipes.
@@ -56,14 +56,14 @@ Plot and analyse :term:`tephigrams `.
.. grid-item-card:: Explanation
:link: explanation/index
:link-type: doc
- :class-card: teph-quadrant sd-rounded-3
+ :class-card: teph-card sd-rounded-3
.. image:: _static/cards/explanation-light.svg
- :class: only-light teph-quadrant-icon
+ :class: only-light teph-card-icon
:alt: a pair of axes turning through 45 degrees
.. image:: _static/cards/explanation-dark.svg
- :class: only-dark teph-quadrant-icon
+ :class: only-dark teph-card-icon
:alt: a pair of axes turning through 45 degrees
Understanding-oriented background.
@@ -71,14 +71,14 @@ Plot and analyse :term:`tephigrams `.
.. grid-item-card:: Reference
:link: reference/index
:link-type: doc
- :class-card: teph-quadrant sd-rounded-3
+ :class-card: teph-card sd-rounded-3
.. image:: _static/cards/reference-light.svg
- :class: only-light teph-quadrant-icon
+ :class: only-light teph-card-icon
:alt: an index of entries, one of them marked
.. image:: _static/cards/reference-dark.svg
- :class: only-dark teph-quadrant-icon
+ :class: only-dark teph-card-icon
:alt: an index of entries, one of them marked
Information-oriented API and glossary.
diff --git a/docs/src/reference/index.rst b/docs/src/reference/index.rst
index c314018d..e9ff6c92 100644
--- a/docs/src/reference/index.rst
+++ b/docs/src/reference/index.rst
@@ -1,12 +1,127 @@
Reference
=========
+.. The cards are the visible index and the toctree below is the navigation; the two
+ are one list, held by tests/test_docs_landing_pages.py (narrative spec §3.9).
+
+ Each icon is drawn in the root page's vocabulary, as that page's comment sets it
+ out: API, a module tree with one entry found; Command Line, a shell prompt at its
+ cursor; Configuration Options, two options on their scales with one set;
+ Glossary, a line and the label that names it; References, a printed page with its
+ source marked; What's New, a spark; Changelog, a run of entries with the newest
+ marked. Light and dark differ only in the navy and the knock-out halo, and live in
+ _static/cards/reference/.
+
The factual material, for looking things up rather than reading through.
-The API documentation is generated from the source, so it describes the package
-you have installed. Beside it sit the command line, every configuration option and
-its default, a glossary, the published sources this documentation cites, what's new
-in each release, and the changelog.
+.. grid:: 1 2 2 2
+ :gutter: 2
+
+ .. grid-item-card:: API
+ :link: generated/api/tephpy/index
+ :link-type: doc
+ :class-card: teph-card sd-rounded-3
+ :columns: 12
+
+ .. image:: ../_static/cards/reference/api-light.svg
+ :class: only-light teph-card-icon
+ :alt: a module tree, one entry found
+
+ .. image:: ../_static/cards/reference/api-dark.svg
+ :class: only-dark teph-card-icon
+ :alt: a module tree, one entry found
+
+ Generated from the source, so it describes the version you have installed.
+
+ .. grid-item-card:: Command Line
+ :link: cli
+ :link-type: doc
+ :class-card: teph-card sd-rounded-3
+
+ .. image:: ../_static/cards/reference/cli-light.svg
+ :class: only-light teph-card-icon
+ :alt: a shell prompt, its cursor marked
+
+ .. image:: ../_static/cards/reference/cli-dark.svg
+ :class: only-dark teph-card-icon
+ :alt: a shell prompt, its cursor marked
+
+ What to type at a shell.
+
+ .. grid-item-card:: Configuration Options
+ :link: config
+ :link-type: doc
+ :class-card: teph-card sd-rounded-3
+
+ .. image:: ../_static/cards/reference/config-light.svg
+ :class: only-light teph-card-icon
+ :alt: two options on their scales, one of them set
+
+ .. image:: ../_static/cards/reference/config-dark.svg
+ :class: only-dark teph-card-icon
+ :alt: two options on their scales, one of them set
+
+ What to set, in Python or in a file.
+
+ .. grid-item-card:: Glossary
+ :link: glossary
+ :link-type: doc
+ :class-card: teph-card sd-rounded-3
+
+ .. image:: ../_static/cards/reference/glossary-light.svg
+ :class: only-light teph-card-icon
+ :alt: a line, and the label that names it
+
+ .. image:: ../_static/cards/reference/glossary-dark.svg
+ :class: only-dark teph-card-icon
+ :alt: a line, and the label that names it
+
+ What a word means here, and where the API carries it.
+
+ .. grid-item-card:: References
+ :link: references
+ :link-type: doc
+ :class-card: teph-card sd-rounded-3
+
+ .. image:: ../_static/cards/reference/references-light.svg
+ :class: only-light teph-card-icon
+ :alt: a printed page, its source marked
+
+ .. image:: ../_static/cards/reference/references-dark.svg
+ :class: only-dark teph-card-icon
+ :alt: a printed page, its source marked
+
+ Where a convention or definition came from.
+
+ .. grid-item-card:: What's New
+ :link: whatsnew/index
+ :link-type: doc
+ :class-card: teph-card sd-rounded-3
+
+ .. image:: ../_static/cards/reference/whatsnew-light.svg
+ :class: only-light teph-card-icon
+ :alt: a spark, marking what is new
+
+ .. image:: ../_static/cards/reference/whatsnew-dark.svg
+ :class: only-dark teph-card-icon
+ :alt: a spark, marking what is new
+
+ A release, in the few things worth knowing.
+
+ .. grid-item-card:: Changelog
+ :link: changelog
+ :link-type: doc
+ :class-card: teph-card sd-rounded-3
+
+ .. image:: ../_static/cards/reference/changelog-light.svg
+ :class: only-light teph-card-icon
+ :alt: a run of entries, the newest marked
+
+ .. image:: ../_static/cards/reference/changelog-dark.svg
+ :class: only-dark teph-card-icon
+ :alt: a run of entries, the newest marked
+
+ A release, one entry per pull request.
If you are deciding what to catch, read :mod:`tephpy.exceptions`. What
``tephpy`` raises about your data — its units, its physical consistency, the
@@ -22,6 +137,7 @@ concept in one plain sentence and then says how it appears in the package — th
data it involves, its units, and the API that carries it.
.. toctree::
+ :hidden:
:maxdepth: 1
generated/api/tephpy/index
diff --git a/tests/test_docs_landing_pages.py b/tests/test_docs_landing_pages.py
index 41684865..c5ab1647 100644
--- a/tests/test_docs_landing_pages.py
+++ b/tests/test_docs_landing_pages.py
@@ -2,17 +2,21 @@
#
# This file is part of tephpy and is distributed under the 3-Clause BSD license.
# See the LICENSE file in the package root directory for licensing details.
-"""A landing page's table and its toctree are one list (narrative spec §3.9)."""
+"""A landing page's index and its toctree are one list (narrative spec §3.9)."""
from __future__ import annotations
-from pathlib import Path
+from functools import cache
+from pathlib import Path, PurePosixPath
import re
import pytest
+from tests.by_path import load_path
+
REPO = Path(__file__).parents[1]
DOCS = REPO / "docs" / "src"
+CONF = DOCS / "conf.py"
#: The sections whose landing page carries a table (narrative spec §3.9).
#:
@@ -25,12 +29,18 @@
#: appearance would put the developer guide inside two gates that deliberately exclude
#: it.
#:
-#: The reference quadrant is out, decided rather than deferred: its entries are reached
-#: by name rather than chosen between, its introduction already guides a reader to the
-#: two pages that need it, and it carries no prose list tracking a directory -- which is
-#: the defect narrative spec §3.9 exists to close.
+#: The reference quadrant takes cards instead (`CARD_SECTIONS`): its pages are
+#: looked up by name, and a card answers that with an icon where a row would offer a
+#: choice nobody makes (narrative spec §3.9).
TABLE_SECTIONS = ("start", "tutorials", "howtos", "explanation", "developer")
+#: The sections whose landing page carries a grid of cards (narrative spec §3.9).
+CARD_SECTIONS: tuple[str, ...] = ("reference",)
+
+#: The directive a card is written with. A card's own options are the ``:name:``
+#: lines directly under it, and the first line that is not one ends them.
+CARD = ".. grid-item-card::"
+
#: A ``:doc:`` role, with the explicit target that wins over the display text when
#: one is written -- the same two-part shape ``check_glossary_links.py`` reads a
#: ``:term:`` in.
@@ -109,22 +119,73 @@ def toctree_options(source: str) -> list[str]:
]
-def _entries(directory: Path) -> list[Path]:
+@cache
+def _autoapi() -> tuple[PurePosixPath, str]:
+ """Return where autoapi writes the API pages, and the package it documents.
+
+ Read by executing `conf.py`, as `tests/test_docs_whatsnew.py` reads
+ ``rst_epilog``: a text scan would pass on a value sitting in a comment. Cached,
+ because every section's discovery asks and the answer cannot change in a run.
+
+ Returns
+ -------
+ tuple of (PurePosixPath, str)
+ ``autoapi_root``, relative to the documentation source, and the name of the
+ package directory ``autoapi_dirs`` names.
+
+ """
+ conf = load_path("tephpy_docs_conf", CONF)
+ return PurePosixPath(conf.autoapi_root), PurePosixPath(conf.autoapi_dirs[0]).name
+
+
+def generated_pages(section: str) -> list[str]:
+ """Return the pages a section offers that exist only while a build runs.
+
+ autoapi writes the API reference under ``autoapi_root`` during a build and, with
+ ``autoapi_keep_files = False``, removes it afterwards; the directory is
+ git-ignored besides. Discovery cannot see a page that is not on disk, so the one
+ entry a section's index gives the API is derived instead (narrative spec §3.9).
+
+ Parameters
+ ----------
+ section : str
+ The section's directory name under the documentation source.
+
+ Returns
+ -------
+ list of str
+ ``["generated/api/tephpy/index"]`` for the section ``autoapi_root`` sits in,
+ and nothing for any other.
+
+ """
+ root, package = _autoapi()
+ if root.parts[0] != section:
+ return []
+ return [str(PurePosixPath(*root.parts[1:], package, "index"))]
+
+
+def _entries(directory: Path, skip: Path) -> list[Path]:
"""Return the documents one section offers, one path per destination.
A subdirectory carrying its own ``index.rst`` is a subsection: it
contributes that landing page and **nothing beneath it**, because from the
- parent's table it is a single destination however many documents sit inside
+ parent's index it is a single destination however many documents sit inside
it -- the specification collection is one row, not twenty. A subdirectory
without one is a plain grouping, and its documents belong to the parent.
Recursion stops at a landing page rather than pruning by name, so a
subsection nested two deep behaves the same as one nested one deep
(narrative spec §3.9).
+ ``skip`` is autoapi's output directory, passed over wherever a build left it:
+ its one page is counted by `generated_pages` instead, and a stale tree would
+ otherwise add entries no index names.
+
Parameters
----------
directory : Path
The section directory to read.
+ skip : Path
+ The directory autoapi writes into.
Returns
-------
@@ -134,41 +195,46 @@ def _entries(directory: Path) -> list[Path]:
"""
found: list[Path] = []
for path in directory.iterdir():
+ if path == skip:
+ continue
if path.is_dir():
landing_page = path / "index.rst"
if landing_page.is_file():
found.append(landing_page)
else:
- found.extend(_entries(path))
+ found.extend(_entries(path, skip))
elif path.suffix == ".rst" and path.name != "index.rst":
found.append(path)
return found
def pages(quadrant: str, docs: Path = DOCS) -> list[str]:
- """Return every page in a quadrant, as a landing table would name it.
+ """Return every page on disk in a section, as its landing index would name it.
- A `:doc:` target on a landing page is relative to the quadrant, so that is
+ A `:doc:` target on a landing page is relative to the section, so that is
what these are made relative to. The section's own ``index.rst`` is a landing
page rather than an entry in one and is left out; a subsection's is both, and
- counts as one entry of its parent.
+ counts as one entry of its parent. A page only a build writes is not on disk,
+ and `generated_pages` supplies it.
Parameters
----------
quadrant : str
- The quadrant's directory name under ``docs``.
+ The section's directory name under ``docs``.
docs : Path, optional
The documentation source root.
Returns
-------
list of str
- The quadrant's pages, sorted.
+ The section's pages, sorted.
"""
root = docs / quadrant
+ skip = docs / _autoapi()[0]
return sorted(
- path.relative_to(root).with_suffix("").as_posix() for path in _entries(root)
+ path.relative_to(root).with_suffix("").as_posix()
+ for path in _entries(root, skip)
)
@@ -202,6 +268,62 @@ def table_targets(source: str) -> list[str | None]:
return found
+def card_targets(source: str) -> list[str | None]:
+ """Return the documents a page's landing cards link to, in card order.
+
+ Only a card's own option block is read -- the ``:name: value`` lines directly
+ under ``.. grid-item-card::`` -- so an option of an image nested in the card, or
+ a field in its body, is not mistaken for the card's link.
+
+ Parameters
+ ----------
+ source : str
+ The reStructuredText source of one page.
+
+ Returns
+ -------
+ list of str or None
+ Each card's ``:link:``, or ``None`` for a card carrying none -- reported
+ rather than skipped, so a card that links nowhere fails the page instead of
+ shrinking the list silently.
+
+ """
+ lines = source.splitlines()
+ found: list[str | None] = []
+ for index, line in enumerate(lines):
+ if not line.strip().startswith(CARD):
+ continue
+ link = None
+ for option in lines[index + 1 :]:
+ stripped = option.strip()
+ if not stripped.startswith(":"):
+ break
+ name, _, value = stripped[1:].partition(":")
+ if name == "link":
+ link = value.strip()
+ found.append(link)
+ return found
+
+
+def index_targets(section: str, source: str) -> list[str | None]:
+ """Return a landing page's index, read in the shape its section takes.
+
+ Parameters
+ ----------
+ section : str
+ The section's directory name.
+ source : str
+ The reStructuredText source of its landing page.
+
+ Returns
+ -------
+ list of str or None
+ What `card_targets` reads for a card section, and `table_targets` otherwise.
+
+ """
+ return card_targets(source) if section in CARD_SECTIONS else table_targets(source)
+
+
def landing(quadrant: str, docs: Path = DOCS) -> str:
"""Return one quadrant's landing page source.
@@ -310,56 +432,131 @@ def test_a_subdirectory_without_a_landing_page_gives_up_its_documents(tmp_path):
assert pages("howtos", docs=tmp_path) == ["advanced/tuning"]
+def test_card_targets_reads_each_cards_link_in_order():
+ source = (
+ " .. grid-item-card:: API\n"
+ " :link: generated/api/tephpy/index\n"
+ " :link-type: doc\n"
+ " :columns: 12\n\n"
+ " Generated.\n\n"
+ " .. grid-item-card:: Command Line\n"
+ " :link-type: doc\n"
+ " :link: cli\n\n"
+ " What to type.\n"
+ )
+ assert card_targets(source) == ["generated/api/tephpy/index", "cli"]
+
+
+def test_card_targets_reports_a_card_that_links_nowhere():
+ """Reported rather than skipped, as a table row is."""
+ source = (
+ " .. grid-item-card:: Glossary\n"
+ " :class-card: teph-card sd-rounded-3\n\n"
+ " .. image:: glossary-light.svg\n"
+ " :class: only-light teph-card-icon\n"
+ )
+ assert card_targets(source) == [None]
+
+
+def test_card_targets_reads_only_the_cards_own_options():
+ """A ``:link:`` after the option block is body text, not the card's link."""
+ source = (
+ " .. grid-item-card:: Command Line\n"
+ " :link-type: doc\n\n"
+ " :link: cli\n"
+ )
+ assert card_targets(source) == [None]
+
+
+def test_the_api_page_is_derived_for_the_section_autoapi_writes_into():
+ """It exists only while a build runs, so discovery cannot find it."""
+ assert generated_pages("reference") == ["generated/api/tephpy/index"]
+ assert generated_pages("howtos") == []
+
+
+def test_discovery_passes_over_a_generated_tree_a_build_left_behind(tmp_path):
+ """A stale autoapi tree must not add pages the index is then asked to list."""
+ section = tmp_path / "reference"
+ (section / "generated" / "api" / "tephpy").mkdir(parents=True)
+ (section / "index.rst").touch()
+ (section / "cli.rst").touch()
+ (section / "generated" / "api" / "index.rst").touch()
+ (section / "generated" / "api" / "tephpy" / "index.rst").touch()
+ assert pages("reference", docs=tmp_path) == ["cli"]
+
+
def test_every_section_this_gate_governs_is_on_disk():
"""A gate that finds nothing passes by never having looked."""
- for quadrant in TABLE_SECTIONS:
- assert (DOCS / quadrant).is_dir(), f"{quadrant} is missing"
+ for section in TABLE_SECTIONS + CARD_SECTIONS:
+ assert (DOCS / section).is_dir(), f"{section} is missing"
+
+
+def test_a_section_takes_one_shape():
+ """Two constants naming one section would put two indexes on its page."""
+ assert not set(TABLE_SECTIONS) & set(CARD_SECTIONS)
-@pytest.mark.parametrize("quadrant", TABLE_SECTIONS)
-def test_the_table_and_the_toctree_are_one_ordered_list(quadrant):
+@pytest.mark.parametrize("section", TABLE_SECTIONS + CARD_SECTIONS)
+def test_a_landing_page_carries_one_index(section):
+ """A table page carries no cards, and a card page no table (narrative spec §3.9)."""
+ other = ".. list-table::" if section in CARD_SECTIONS else CARD
+ assert other not in landing(section)
+
+
+@pytest.mark.parametrize("section", TABLE_SECTIONS + CARD_SECTIONS)
+def test_the_index_and_the_toctree_are_one_ordered_list(section):
"""Narrative spec §3.9: the visible index and the navigation are one list.
Sequence and not set. The toctree is hidden, which hides it from the page body
and from nothing else: the sidebar, the breadcrumb and the previous/next footer
- all read its order, so a table ordered differently would disagree with the
+ all read its order, so an index ordered differently would disagree with the
navigation drawn around it.
"""
- source = landing(quadrant)
- assert table_targets(source) == toctree_entries(source)
+ source = landing(section)
+ assert index_targets(section, source) == toctree_entries(source)
+
+@pytest.mark.parametrize("section", TABLE_SECTIONS + CARD_SECTIONS)
+def test_every_entry_links_to_a_page_in_its_own_section(section):
+ """A target is a page of the section, or the one page a build generates there.
-@pytest.mark.parametrize("quadrant", TABLE_SECTIONS)
-def test_every_row_links_to_a_page_in_its_own_quadrant(quadrant):
- for target in table_targets(landing(quadrant)):
- assert target is not None, (
- f"{quadrant} has a row whose first cell links nowhere"
+ ``..`` is refused outright: ``DOCS / section / "../howtos/units.rst"`` names a
+ file that exists, so without it an entry pointing into another section would
+ pass as a page of this one.
+ """
+ generated = generated_pages(section)
+ for target in index_targets(section, landing(section)):
+ assert target is not None, f"{section} has an entry that links nowhere"
+ assert ".." not in PurePosixPath(target).parts, (
+ f"{section}'s index links to {target}, outside the section"
)
- assert (DOCS / quadrant / f"{target}.rst").is_file(), (
- f"{quadrant}'s table links to {target}, which is not a page in it"
+ assert target in generated or (DOCS / section / f"{target}.rst").is_file(), (
+ f"{section}'s index links to {target}, which is not a page in it"
)
-@pytest.mark.parametrize("quadrant", TABLE_SECTIONS)
-def test_the_table_lists_every_page_in_the_quadrant(quadrant):
- """The table is the quadrant's index, so it indexes the quadrant.
+@pytest.mark.parametrize("section", TABLE_SECTIONS + CARD_SECTIONS)
+def test_the_index_lists_every_page_in_the_section(section):
+ """The index is the section's index, so it indexes the section.
- The ordered comparison above holds the table and the toctree to each other and
+ The ordered comparison above holds the index and the toctree to each other and
would not notice a page missing from both, which is how a page goes unlisted:
one commit that adds a page and neither list. The fail-on-warning build catches
the ordinary case -- Sphinx reports a document in no toctree -- but not an
- `:orphan:` page, which builds clean and would sit in the quadrant unreachable
+ `:orphan:` page, which builds clean and would sit in the section unreachable
from its own landing page.
"""
- listed = sorted(target for target in table_targets(landing(quadrant)) if target)
- assert listed == pages(quadrant)
+ listed = sorted(
+ target for target in index_targets(section, landing(section)) if target
+ )
+ assert listed == sorted(pages(section) + generated_pages(section))
-@pytest.mark.parametrize("quadrant", TABLE_SECTIONS)
-def test_the_toctree_is_hidden(quadrant):
- """Narrative spec §3.9: the table is the visible index, and it is the only one.
+@pytest.mark.parametrize("section", TABLE_SECTIONS + CARD_SECTIONS)
+def test_the_toctree_is_hidden(section):
+ """Narrative spec §3.9: the index is the visible one, and it is the only one.
- Without this the page renders the same list twice, the table and the toctree
+ Without this the page renders the same list twice, the index and the toctree
under it, which is the duplication the shape exists to remove.
"""
- assert ":hidden:" in toctree_options(landing(quadrant))
+ assert ":hidden:" in toctree_options(landing(section))
diff --git a/tests/test_docs_snippets.py b/tests/test_docs_snippets.py
index 20a8b6f9..18043c4d 100644
--- a/tests/test_docs_snippets.py
+++ b/tests/test_docs_snippets.py
@@ -1170,12 +1170,11 @@ def test_a_page_naming_an_exception_points_at_the_hierarchy():
def test_the_reference_index_sends_a_caller_to_the_hierarchy():
- """The quadrant's own description otherwise omits the subject entirely.
+ """The quadrant's own landing page otherwise omits the subject entirely.
- The index enumerates what the reference holds — the command line, the
- configuration options, the glossary, the citations, the changelog — and
- named no exception at all, so a reader deciding what to catch had nothing
- to follow (:issue:`213`).
+ Its cards name what the reference holds, and none of them is the exception
+ hierarchy, so without this paragraph a reader deciding what to catch has
+ nothing to follow (:issue:`213`).
"""
index = (DOCS / "reference" / "index.rst").read_text(encoding="utf-8")
assert SIGNPOSTS in index, (