Skip to content

Commit e9f8bdb

Browse files
ppiegazedocsy
andauthored
docsy: add the subpage-cards shortcode and the gate that backs it (#293)
Cards for a page's immediate subpages, generated from each child's front-matter description and icon, so a card and the page it points at cannot drift apart. Emits the same .grid-container and .link-card markup the hand-written form does, so card.css is unchanged and this needs no CSS of its own. It is a shortcode rather than automatic because placement carries meaning. All 25 section pages that had cards put them below the top, median 54% down, three past 87%, after intro prose and deliberately. An implicit generator has to pick a position and would move the cards on every one. Another 47 section pages have none and would silently acquire cards nobody reviewed. And the 70 generated API-reference landings must have none at all: 66 already carry a Directory table listing the same children, so a grid there is a second copy of it. That last case is the argument in one line. Descriptor yes, machine-facing listing yes, human-facing cards no, is not a state an implicit generator can express. A marker says it by being absent. The markdown twin stays implicit. list.md appends its Subpages block with the same descriptions, and position carries no meaning for an agent reading a twin. check_subpage_cards.py closes the one thing implicit generation genuinely bought: a new section page cannot ship with no way forward for a reader. A page with children and no marker fails, unless it sets subpage_cards: false. Scoped to authored content, since api-reference is generated and has no cards by design. The 47 pages that predate the gate sit in a baseline and shrink. Hand-written link-card blocks are reported as a note rather than a failure. 15 of the 25 cannot migrate mechanically: 6 order their cards editorially rather than by weight, 4 hold deliberate cross-links to siblings, 2 use shorter card titles than page titles, 2 card only some children, and one groups 12 cards into five named sections. Verified: exit 1 on a new cardless section, 0 with the shortcode, 0 with the opt-out, 0 on the current tree. Signed-off-by: Peeter Piegaze <1153481+ppiegaze@users.noreply.github.com> Co-authored-by: docsy <docsy@union.ai>
1 parent 2082d9e commit e9f8bdb

4 files changed

Lines changed: 263 additions & 1 deletion

File tree

Makefile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ PORT ?= 9000
99
BUILD := $(shell date +%s)
1010
UV := uv run --project unionai-docs-infra
1111

12-
.PHONY: index-search index-search-settings index-search-synonyms refresh-search-popularity check-search-labels all base dist variant dev serve usage update-examples sync-examples llm-docs check-api-docs update-api-docs regen-api-docs-all check-helm-docs update-helm-docs generate-helm-docs update-redirects dry-run-redirects deploy-redirects check-deleted-pages check-generated-links check-asset-refs check-version-menu-parity check-pin-window-parity check-links check-generated-content check-icon-names update-icon-names clean clean-generated
12+
.PHONY: index-search index-search-settings index-search-synonyms refresh-search-popularity check-search-labels all base dist variant dev serve usage update-examples sync-examples llm-docs check-api-docs update-api-docs regen-api-docs-all check-helm-docs update-helm-docs generate-helm-docs update-redirects dry-run-redirects deploy-redirects check-deleted-pages check-generated-links check-asset-refs check-version-menu-parity check-pin-window-parity check-links check-generated-content check-icon-names check-subpage-cards update-icon-names clean clean-generated
1313
all: usage
1414

1515
usage:
@@ -235,6 +235,14 @@ check-generated-content:
235235
# The two are mutually unintelligible and both fail SILENTLY (an empty slot, or
236236
# a literal `:name:`), and no other check can see it -- a missing icon is not a
237237
# broken link. DOC-1444.
238+
# A section page with children must say where its subpage cards go, or opt out.
239+
# The cards are placed by an explicit shortcode rather than injected (DOC-1509),
240+
# so this is what stops a new section page shipping with no way forward for a
241+
# reader. The baseline holds the pages that predate the gate; it shrinks.
242+
check-subpage-cards:
243+
@$(UV) unionai-docs-infra/tools/check_subpage_cards.py \
244+
--baseline unionai-docs-infra/tools/check_subpage_cards_baseline.txt
245+
238246
check-icon-names:
239247
@$(UV) unionai-docs-infra/tools/check_icon_names.py
240248

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{{- /* Cards for this page's immediate subpages, one per child.
2+
3+
Replaces a hand-written grid of {{< link-card >}} calls with the same
4+
markup generated from each child's own front matter, so a card and the
5+
page it points at cannot drift apart. Emits `.grid-container` and
6+
`.link-card` exactly as the hand-written form does; the styling in
7+
card.css is unchanged and this needs no CSS of its own.
8+
9+
WHY THIS IS A SHORTCODE AND NOT AUTOMATIC (DOC-1509)
10+
11+
Because placement carries meaning. All 25 section pages that had cards
12+
put them BELOW the top -- median 54% down, three past 87% -- after intro
13+
prose, deliberately. An implicit generator has to choose a position and
14+
would move the cards on every one of them. Another 47 section pages have
15+
no cards, and would silently acquire cards nobody reviewed. And the 70
16+
generated API-reference landings must NOT have cards at all: 66 already
17+
carry a `## Directory` table listing the same children, so a grid there
18+
is a second copy of it.
19+
20+
That last case is the whole argument. "Descriptor yes, machine-facing
21+
listing yes, human-facing cards no" is not a state an implicit generator
22+
can express. A marker says it by being absent.
23+
24+
The markdown twin is a separate matter and stays implicit: `list.md`
25+
appends `## Subpages` with the same descriptions, and position carries
26+
no meaning for an agent reading a twin.
27+
28+
PARAMETERS
29+
weight (bool, default true) order by front-matter weight, else by title
30+
cols (int, optional) fixed column count, as with {{< grid >}}
31+
*/ -}}
32+
33+
{{- $page := .Page -}}
34+
35+
{{- if not $page.Pages -}}
36+
{{- errorf "subpage-cards: %q has no subpages. The shortcode belongs on a section page; on a leaf it renders nothing, which reads as a styling bug rather than a mistake." $page.File.Path -}}
37+
{{- end -}}
38+
39+
{{- $children := $page.Pages -}}
40+
{{- if ne (.Get "weight") "false" -}}
41+
{{- $children = $children.ByWeight -}}
42+
{{- else -}}
43+
{{- $children = $children.ByTitle -}}
44+
{{- end -}}
45+
46+
{{- $cols := .Get "cols" -}}
47+
<div class="grid-container"{{ with $cols }} style="grid-template-columns: repeat({{ . }}, 1fr)"{{ end }}>
48+
{{- range $children -}}
49+
{{- if (partial "page-allowed.html" .).allowed -}}
50+
<a class="link-card" href="{{ .RelPermalink }}">
51+
{{- with .Params.icon -}}
52+
<sl-icon class="link-card-icon" name="{{ . }}" aria-hidden="true"></sl-icon>
53+
{{- end -}}
54+
<span class="link-card-title">{{ .Title }}</span>
55+
{{- with .Params.description -}}
56+
<span class="link-card-body">{{ $page.RenderString . }}</span>
57+
{{- end -}}
58+
</a>
59+
{{- end -}}
60+
{{- end -}}
61+
</div>

tools/check_subpage_cards.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#!/usr/bin/env python3
2+
"""Fail when a section page with children has no subpage cards.
3+
4+
WHY A CHECK RATHER THAN AUTOMATIC GENERATION (DOC-1509)
5+
6+
Cards are placed by an explicit `{{< subpage-cards >}}` marker, not injected,
7+
because placement carries meaning: every section page that had cards put them
8+
below its intro prose, and the generated API reference must have none at all
9+
(its `## Directory` table already lists the same children).
10+
11+
The one thing implicit generation genuinely bought was that a new section page
12+
could not ship with no navigation because its author did not know the
13+
convention. This check buys that back without taking the placement decision away
14+
from the author. Convention plus a gate -- the same shape as
15+
check_deleted_pages.py and check_generated_content.py, both of which exist
16+
because a convention on its own did not hold.
17+
18+
SCOPE
19+
20+
Authored content only. `content/api-reference/` is generated and deliberately
21+
has no cards; running this over it would fail on 70 pages by design.
22+
23+
A page opts out with `subpage_cards: false` in its front matter. Use it for a
24+
section whose landing page is a document in its own right rather than a
25+
signpost -- and say why in a comment, because the next person will wonder.
26+
27+
Usage:
28+
check_subpage_cards.py [content-dir] [--exclude-dir PATH ...]
29+
"""
30+
31+
import argparse
32+
import re
33+
import sys
34+
from pathlib import Path
35+
36+
MARKER = re.compile(r"\{\{[<%]\s*subpage-cards\b")
37+
LINK_CARD = re.compile(r"\{\{[<%]\s*link-card\b")
38+
OPT_OUT = re.compile(r"^subpage_cards:\s*false\s*$", re.MULTILINE)
39+
40+
DEFAULT_EXCLUDES = ("api-reference", "__docs_builder__")
41+
42+
43+
def frontmatter(text: str) -> str:
44+
if not text.startswith("---"):
45+
return ""
46+
end = text.find("\n---", 3)
47+
return text[4:end] if end != -1 else ""
48+
49+
50+
def main() -> int:
51+
ap = argparse.ArgumentParser(description=__doc__,
52+
formatter_class=argparse.RawDescriptionHelpFormatter)
53+
ap.add_argument("content", nargs="?", default="content", type=Path)
54+
ap.add_argument("--exclude-dir", action="append", default=[])
55+
ap.add_argument("--baseline", type=Path,
56+
help="file of paths that predate this gate, one per line")
57+
args = ap.parse_args()
58+
59+
if not args.content.is_dir():
60+
print(f"check-subpage-cards: no such directory: {args.content}", file=sys.stderr)
61+
return 2
62+
63+
excludes = set(DEFAULT_EXCLUDES) | set(args.exclude_dir)
64+
65+
baseline = set()
66+
if args.baseline and args.baseline.is_file():
67+
for line in args.baseline.read_text(encoding="utf-8").splitlines():
68+
line = line.split("#", 1)[0].strip()
69+
if line:
70+
baseline.add(line)
71+
72+
missing, opted_out, legacy, grandfathered, ok = [], [], [], [], 0
73+
for index in sorted(args.content.rglob("_index.md")):
74+
rel = index.relative_to(args.content)
75+
if rel.parts and rel.parts[0] in excludes:
76+
continue
77+
d = index.parent
78+
has_child = any(p.is_dir() for p in d.iterdir()) or \
79+
any(p.name != "_index.md" for p in d.glob("*.md"))
80+
if not has_child:
81+
continue
82+
83+
text = index.read_text(encoding="utf-8")
84+
if OPT_OUT.search(frontmatter(text)):
85+
opted_out.append(str(rel))
86+
elif MARKER.search(text):
87+
ok += 1
88+
elif LINK_CARD.search(text):
89+
legacy.append(str(rel))
90+
elif str(rel) in baseline:
91+
grandfathered.append(str(rel))
92+
else:
93+
missing.append(str(rel))
94+
95+
total = ok + len(opted_out) + len(legacy) + len(grandfathered) + len(missing)
96+
print(f"check-subpage-cards: {total} section page(s) with children "
97+
f"({ok} with cards, {len(opted_out)} opted out"
98+
+ (f", {len(legacy)} still hand-written" if legacy else "")
99+
+ (f", {len(grandfathered)} in the baseline" if grandfathered else "") + ")")
100+
101+
if legacy:
102+
print("")
103+
print(f"NOTE: {len(legacy)} page(s) still use hand-written link-card blocks.")
104+
print(" Not a failure -- they render. Migrate to {{< subpage-cards >}}")
105+
print(" so a card and the page it points at cannot drift apart.")
106+
for p in legacy:
107+
print(f" {p}")
108+
109+
if not missing:
110+
print("check-subpage-cards: OK")
111+
return 0
112+
113+
print("")
114+
print(f"FATAL: {len(missing)} section page(s) with children have no subpage cards.")
115+
print(" A reader landing there gets no way forward except the sidebar.")
116+
print("")
117+
print(" Add {{< subpage-cards >}} where the cards belong -- usually after")
118+
print(" the intro prose, not at the top. If this page genuinely should not")
119+
print(" have them, set `subpage_cards: false` in its front matter and say why.")
120+
print("")
121+
for p in missing:
122+
print(f" {p}")
123+
print("")
124+
return 1
125+
126+
127+
if __name__ == "__main__":
128+
sys.exit(main())
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Section pages with children that have no subpage cards, as of 2026-08-30.
2+
#
3+
# A RATCHET, not a permission slip. The gate's job is to stop a NEW section page
4+
# shipping with no way forward for a reader; these predate it. DOC-1509 is
5+
# explicit that they must not be auto-carded -- deciding whether a landing page
6+
# wants cards, and what belongs above them, is an editorial call per page.
7+
#
8+
# Delete a line when the page gets `{{< subpage-cards >}}`, or when it gets
9+
# `subpage_cards: false` because it is a document rather than a signpost.
10+
# Never add a line to make a build pass: a new entry means the gate caught the
11+
# thing it exists to catch.
12+
#
13+
# 47 entries. By area: deployment 18, user-guide 13, security 5, integrations 4,
14+
# oss-deployment 3, community 2, tutorials 1, plus the site root.
15+
16+
# The site root. Its landing page is the variant-switching intro, so whether it
17+
# wants cards at all is a design question, not an oversight.
18+
_index.md
19+
20+
community/_index.md
21+
community/contributing-docs/_index.md
22+
deployment/_index.md
23+
deployment/byoc/_index.md
24+
deployment/byoc/enabling-aws-resources/_index.md
25+
deployment/byoc/enabling-azure-resources/_index.md
26+
deployment/byoc/enabling-gcp-resources/_index.md
27+
deployment/byoc/single-sign-on-setup/_index.md
28+
deployment/selfmanaged/_index.md
29+
deployment/selfmanaged/architecture/_index.md
30+
deployment/selfmanaged/configuration/_index.md
31+
deployment/selfmanaged/helm-chart-reference/_index.md
32+
deployment/selfmanaged/selfmanaged-aws/_index.md
33+
deployment/selfmanaged/selfmanaged-azure/_index.md
34+
deployment/selfmanaged/selfmanaged-coreweave/_index.md
35+
deployment/selfmanaged/selfmanaged-crusoe/_index.md
36+
deployment/selfmanaged/selfmanaged-gcp/_index.md
37+
deployment/selfmanaged/selfmanaged-generic/_index.md
38+
deployment/selfmanaged/selfmanaged-nebius/_index.md
39+
deployment/selfmanaged/selfmanaged-oci/_index.md
40+
integrations/_index.md
41+
integrations/agents/_index.md
42+
integrations/opentelemetry/_index.md
43+
integrations/wandb/_index.md
44+
oss-deployment/_index.md
45+
oss-deployment/kind-deployment/_index.md
46+
oss-deployment/plugin-setup/_index.md
47+
security/_index.md
48+
security/architecture/_index.md
49+
security/compliance/_index.md
50+
security/data-protection/_index.md
51+
security/identity-and-access/_index.md
52+
tutorials/data-processing/micro-batching/_index.md
53+
user-guide/advanced-project/_index.md
54+
user-guide/agents/build-agent/_index.md
55+
user-guide/agents/build-mcp/_index.md
56+
user-guide/agents/sandboxing/_index.md
57+
user-guide/apps/build-apps/_index.md
58+
user-guide/apps/configure-apps/_index.md
59+
user-guide/apps/native-app-integrations/_index.md
60+
user-guide/apps/serve-and-deploy-apps/_index.md
61+
user-guide/migration/flyte-2/_index.md
62+
user-guide/run-scaling/_index.md
63+
user-guide/tasks/task-configuration/_index.md
64+
user-guide/tasks/task-deployment/_index.md
65+
user-guide/tasks/task-programming/_index.md

0 commit comments

Comments
 (0)