Skip to content

Commit 81bfb68

Browse files
committed
docs(design): migrate PIG design records to companion site
1 parent 8ed6392 commit 81bfb68

57 files changed

Lines changed: 2849 additions & 33 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ check:
3030
@GOWORK=off go mod verify
3131
@GOWORK=off $(HUGO) build --minify --cleanDestinationDir --printPathWarnings --printI18nWarnings --panicOnWarning
3232
python3 bin/check_markdown.py content public
33+
python3 bin/check_design.py
3334
python3 bin/check_internal_links.py public

README.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ content/
2828
_link_release.md # sidebar entry pointing at /release/ (manualLink, not a page)
2929
_div_cmd.md # sidebar group heading (sidebar_divider, not a page)
3030
cmd.md # /cmd/ weight 100
31-
repo.md ext.md build.md sty.md inventory.md pg.md pt.md pb.md pitr.md
31+
repo.md ext.md build.md sty.md inventory.md do.md pg.md pt.md pe.md pb.md pitr.md
3232
blog/ # /blog/ — all posts, newest first
3333
release/ # /release/ and /release/pig-X.Y.Z/
34+
design/ # /design/ and dated bilingual decision records
3435
authors/vonng/ # /authors/vonng/ — an author profile (the `authors` taxonomy term)
3536
data/home/metrics.yaml # landing page counters
3637
```
@@ -46,6 +47,10 @@ Release notes are one dated post per version under
4647
`content/blog/release/`. Adding a release means adding a
4748
`pig-X.Y.Z.md` / `.zh.md` pair with `weight` ascending from the newest.
4849

50+
Design records are dated bilingual posts under `content/blog/design/`. Their historical
51+
`date` is the evidence-backed decision date; `lastmod` records the latest editorial or status
52+
review. They explain why a contract exists and link back to the current reference page.
53+
4954
Each page ships as an English `.md` plus a Chinese `.zh.md`. Two sections
5055
stay out of the docs sidebar tree via `toc_root: true``docs/` and
5156
`blog/` — because the sidebar root menu already lists them.
@@ -57,9 +62,8 @@ Three taxonomies are declared in `hugo.yaml`: `categories`, `tags`, and OINK
5762

5863
- **categories** name the kind of page, and they are localized. Only
5964
documentation carries them, using the Diátaxis four — `Tutorial` / `Task` /
60-
`Concept` / `Reference` (`教程` / `任务` / `概念` / `参考`). Release notes
61-
carry no category: the section already says what they are, so a `Release`
62-
term on every post would classify nothing.
65+
`Concept` / `Reference` (`教程` / `任务` / `概念` / `参考`). Release notes and
66+
design records carry no category: their sections already identify the page kind.
6367
- **tags** name the pig subsystem a page is about, from one closed vocabulary
6468
shared by documentation and release notes, in English on both language
6569
trees: `repo`, `ext`, `postgres`, `patroni`, `pgbackrest`, `pitr`, `sty`,
@@ -76,6 +80,18 @@ Blog indexes publish as OINK's default row list. `params.ui.blog_index_toggle`
7680
puts a control in the index toolbar so a reader can cycle any of them through
7781
list, cards, and table.
7882

83+
## Design records
84+
85+
`/design/` is the canonical rationale and decision-history surface. Current syntax remains in
86+
the root documentation pages, delivery timing remains in `/release/`, and implementation evidence
87+
links to fixed source commits. A design record uses the same explicit heading anchors in English
88+
and Chinese: `decision`, `context`, `alternatives`, `contract`, `impact`, `verification`, and
89+
`status`.
90+
91+
Do not publish raw planning prompts, local absolute paths, temporary review transcripts, secrets,
92+
or unverified completion claims. `bin/check_design.py` enforces pairing, front matter, tag vocabulary,
93+
anchor parity, visible decision metadata, source evidence, and the main publication-safety rules.
94+
7995
Each release note is written in OINK 0.6's native release forms rather than by
8096
hand:
8197

@@ -105,17 +121,17 @@ that user questions filed against `pgsty/pig` stay separate from page comments.
105121

106122
## Theme boundary
107123

108-
OINK 0.6.0 owns the documentation and blog layouts, navigation shell, search, table
124+
The pinned OINK module owns the documentation and blog layouts, navigation shell, search, table
109125
of contents, blocks and shortcodes, styles, scripts, fonts, and third-party
110126
runtimes. The site imports the pinned OINK 0.6.0 release as a Hugo Module.
111127

112128
The landing page and the download page keep their bespoke visual design, but
113129
they render *inside* the theme shell: OINK supplies `<head>`, the navbar, the
114130
fat footer, the search box and command palette, and the script bundle. Neither
115131
page hand-rolls chrome any more. Documentation uses the new search metadata, sidebar icon policy,
116-
content primitives, and assistant page actions. Release rows stay text-only:
117-
no `images` cascade is set on the blog tree, so nothing resolves a featured
118-
image and `params.ui.featured_image` has nothing to render.
132+
content primitives, and assistant page actions. The blog root does not impose a shared
133+
featured image; Release and Design may set their own section-level image cascades without
134+
forcing one asset onto every blog subsection.
119135

120136
The local layout surface is intentionally small:
121137

bin/check_design.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#!/usr/bin/env python3
2+
"""Validate the bilingual PIG design-record contract."""
3+
4+
from __future__ import annotations
5+
6+
import pathlib
7+
import re
8+
import sys
9+
10+
11+
DESIGN_DIR = pathlib.Path("content/blog/design")
12+
ALLOWED_TAGS = {
13+
"repo",
14+
"ext",
15+
"postgres",
16+
"patroni",
17+
"pgbackrest",
18+
"pitr",
19+
"sty",
20+
"build",
21+
"inventory",
22+
"catalog",
23+
"cli",
24+
"install",
25+
}
26+
REQUIRED_KEYS = {
27+
"title",
28+
"linkTitle",
29+
"date",
30+
"lastmod",
31+
"description",
32+
"tags",
33+
"weight",
34+
"authors",
35+
"draft",
36+
}
37+
REQUIRED_ANCHORS = {
38+
"decision",
39+
"context",
40+
"alternatives",
41+
"contract",
42+
"impact",
43+
"verification",
44+
"status",
45+
}
46+
FORBIDDEN = {
47+
"local absolute path": re.compile(r"/Users/|/home/[A-Za-z0-9_.-]+/"),
48+
"retired local document reference": re.compile(r"docs/(?:spec|refactor)/"),
49+
"temporary review reference": re.compile(r"(?:^|[\s`/])tmp/"),
50+
"unfinished marker": re.compile(r"\b(?:TODO|TBD|DRAFT)\b"),
51+
"raw agent-review detail": re.compile(r"Claude Code|claude-(?:sonnet|opus|fable)", re.I),
52+
}
53+
ANCHOR_RE = re.compile(r"\{#([a-z0-9-]+)\}")
54+
55+
56+
def parse_page(path: pathlib.Path) -> tuple[dict[str, str], str, list[str]]:
57+
errors: list[str] = []
58+
text = path.read_text(encoding="utf-8")
59+
lines = text.splitlines()
60+
if not lines or lines[0] != "---":
61+
return {}, text, [f"{path}: missing YAML front matter"]
62+
try:
63+
end = lines.index("---", 1)
64+
except ValueError:
65+
return {}, text, [f"{path}: unclosed YAML front matter"]
66+
meta: dict[str, str] = {}
67+
for number, line in enumerate(lines[1:end], 2):
68+
if not line or line.startswith((" ", "\t", "#")):
69+
continue
70+
if ":" not in line:
71+
errors.append(f"{path}:{number}: unsupported front matter line")
72+
continue
73+
key, value = line.split(":", 1)
74+
meta[key.strip()] = value.strip()
75+
return meta, "\n".join(lines[end + 1 :]), errors
76+
77+
78+
def parse_list(value: str) -> list[str]:
79+
if not (value.startswith("[") and value.endswith("]")):
80+
return []
81+
return [item.strip().strip("\"'") for item in value[1:-1].split(",") if item.strip()]
82+
83+
84+
def validate_index(errors: list[str]) -> None:
85+
for name in ("_index.md", "_index.zh.md"):
86+
path = DESIGN_DIR / name
87+
if not path.is_file():
88+
errors.append(f"missing design index: {path}")
89+
continue
90+
meta, _, page_errors = parse_page(path)
91+
errors.extend(page_errors)
92+
for key in ("title", "linkTitle", "description", "weight", "module", "blog_index"):
93+
if not meta.get(key):
94+
errors.append(f"{path}: missing index front matter key {key}")
95+
96+
97+
def validate_pair(english: pathlib.Path, chinese: pathlib.Path, errors: list[str]) -> None:
98+
en_meta, en_body, en_errors = parse_page(english)
99+
zh_meta, zh_body, zh_errors = parse_page(chinese)
100+
errors.extend(en_errors)
101+
errors.extend(zh_errors)
102+
103+
for path, meta in ((english, en_meta), (chinese, zh_meta)):
104+
missing = sorted(REQUIRED_KEYS - meta.keys())
105+
if missing:
106+
errors.append(f"{path}: missing front matter keys: {', '.join(missing)}")
107+
if meta.get("authors") != "[Vonng]":
108+
errors.append(f"{path}: authors must be [Vonng]")
109+
if meta.get("draft") != "false":
110+
errors.append(f"{path}: draft must be false")
111+
tags = parse_list(meta.get("tags", ""))
112+
if not tags or len(tags) > 4:
113+
errors.append(f"{path}: tags must contain one to four entries")
114+
unknown = sorted(set(tags) - ALLOWED_TAGS)
115+
if unknown:
116+
errors.append(f"{path}: unknown tags: {', '.join(unknown)}")
117+
118+
for key in ("date", "lastmod", "weight", "authors", "draft"):
119+
if en_meta.get(key) != zh_meta.get(key):
120+
errors.append(f"{english} / {chinese}: {key} differs")
121+
122+
en_anchors = ANCHOR_RE.findall(en_body)
123+
zh_anchors = ANCHOR_RE.findall(zh_body)
124+
if set(en_anchors) != REQUIRED_ANCHORS:
125+
errors.append(f"{english}: design anchors differ from the required contract")
126+
if en_anchors != zh_anchors:
127+
errors.append(f"{english} / {chinese}: heading anchors or order differ")
128+
129+
for path, body, marker in (
130+
(english, en_body, "**Decision date:**"),
131+
(chinese, zh_body, "**决策日期:**"),
132+
):
133+
if marker not in body:
134+
errors.append(f"{path}: missing visible decision metadata")
135+
if "]( /" in body:
136+
errors.append(f"{path}: malformed internal link spacing")
137+
if "](/" not in body:
138+
errors.append(f"{path}: missing current-site reference")
139+
if "https://github.com/pgsty/pig/" not in body:
140+
errors.append(f"{path}: missing pinned source or release evidence")
141+
for label, pattern in FORBIDDEN.items():
142+
match = pattern.search(body)
143+
if match:
144+
errors.append(f"{path}: contains {label} near {match.group(0)!r}")
145+
146+
147+
def main() -> int:
148+
errors: list[str] = []
149+
if not DESIGN_DIR.is_dir():
150+
print(f"missing design directory: {DESIGN_DIR}", file=sys.stderr)
151+
return 1
152+
153+
validate_index(errors)
154+
english_pages = sorted(
155+
path
156+
for path in DESIGN_DIR.glob("*.md")
157+
if path.name != "_index.md" and not path.name.endswith(".zh.md")
158+
)
159+
if not english_pages:
160+
errors.append("design section has no English records")
161+
expected_chinese = {path.with_name(f"{path.stem}.zh.md") for path in english_pages}
162+
actual_chinese = {
163+
path for path in DESIGN_DIR.glob("*.zh.md") if path.name != "_index.zh.md"
164+
}
165+
for missing in sorted(expected_chinese - actual_chinese):
166+
errors.append(f"missing Chinese design record: {missing}")
167+
for orphan in sorted(actual_chinese - expected_chinese):
168+
errors.append(f"orphan Chinese design record: {orphan}")
169+
for english in english_pages:
170+
chinese = english.with_name(f"{english.stem}.zh.md")
171+
if chinese.is_file():
172+
validate_pair(english, chinese, errors)
173+
174+
if errors:
175+
print(f"design check failed: {len(errors)} issues", file=sys.stderr)
176+
for error in errors:
177+
print(f"- {error}", file=sys.stderr)
178+
return 1
179+
180+
print(f"design check passed: {len(english_pages)} bilingual records")
181+
return 0
182+
183+
184+
if __name__ == "__main__":
185+
raise SystemExit(main())

bin/check_internal_links.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,32 +31,43 @@
3131
"build",
3232
"sty",
3333
"inventory",
34+
"do",
3435
"pg",
3536
"pt",
37+
"pe",
3638
"pb",
3739
"pitr",
3840
)
3941
REQUIRED_ROUTES = {
4042
"/",
4143
"/docs/",
4244
"/blog/",
45+
"/design/",
4346
"/release/",
4447
"/zh/",
4548
"/zh/docs/",
4649
"/zh/blog/",
50+
"/zh/design/",
4751
"/zh/release/",
4852
*(f"/{slug}/" for slug in ROOT_DOC_SLUGS),
4953
*(f"/zh/{slug}/" for slug in ROOT_DOC_SLUGS),
5054
}
51-
FORBIDDEN_ROUTE_PREFIXES = ("/blog/release/", "/zh/blog/release/")
55+
FORBIDDEN_ROUTE_PREFIXES = (
56+
"/blog/release/",
57+
"/zh/blog/release/",
58+
"/blog/design/",
59+
"/zh/blog/design/",
60+
)
5261
REQUIRED_OUTPUTS = {
5362
"index.md",
5463
"llms.txt",
5564
"docs/index.md",
65+
"design/index.md",
5666
"release/index.md",
5767
"zh/index.md",
5868
"zh/llms.txt",
5969
"zh/docs/index.md",
70+
"zh/design/index.md",
6071
"zh/release/index.md",
6172
*(f"{slug}/index.md" for slug in ROOT_DOC_SLUGS),
6273
*(f"zh/{slug}/index.md" for slug in ROOT_DOC_SLUGS),

content/blog/_index.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: PIG Blog
33
url: /blog/
44
linkTitle: Blog
5-
description: Release notes and project news
5+
description: Release notes, design records, and project news
66
weight: 40
77
type: blog
88
sidebar_root_for: self
@@ -22,12 +22,12 @@ cascade:
2222
- print
2323
- markdown
2424
params:
25-
# No `images` cascade here on purpose: release rows keep OINK's clean
26-
# text-only presentation. The site card still comes from params.images.
25+
# The blog root does not impose one image on every subsection. Release and
26+
# Design own their section-level image policy independently.
2727
sidebar_menu_foldable: false
2828
sidebar_menu_compact: false
2929
sidebar_expand_levels: 3
3030
icon: fa-solid fa-blog
3131
---
3232

33-
Release notes and project news for PIG — the PostgreSQL extension package manager by Pigsty.
33+
Release notes, design records, and project news for PIG — the PostgreSQL extension package manager by Pigsty.

content/blog/_index.zh.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: PIG 博客
33
url: /zh/blog/
44
linkTitle: 博客
5-
description: 发布注记与项目动态
5+
description: 发布注记、设计归档与项目动态
66
weight: 40
77
type: blog
88
sidebar_root_for: self
@@ -22,12 +22,12 @@ cascade:
2222
- print
2323
- markdown
2424
params:
25-
# 刻意不设 images cascade:发布列表保持 OINK 的纯文本卡片,
26-
# 不重复铺同一张默认图片;社交卡片仍由 params.images 提供
25+
# Blog 根节点不向所有栏目强加同一张图片;Release 与 Design
26+
# 各自在栏目级决定自己的图片策略
2727
sidebar_menu_foldable: false
2828
sidebar_menu_compact: false
2929
sidebar_expand_levels: 3
3030
icon: fa-solid fa-blog
3131
---
3232

33-
PIG 的发布注记与项目动态 —— Pigsty 出品的 PostgreSQL 扩展包管理器。
33+
PIG 的发布注记、设计归档与项目动态 —— Pigsty 出品的 PostgreSQL 扩展包管理器。

content/blog/design/_index.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
title: Design Records
3+
linkTitle: Design
4+
description: The decisions, trade-offs, and implementation boundaries behind PIG.
5+
weight: 5
6+
icon: fa-solid fa-pen-ruler
7+
sidebar_expanded: true
8+
module: [BLOG]
9+
blog_index: list
10+
cascade:
11+
images: [/images/pig-design-blog.webp]
12+
---
13+
14+
Design records explain why PIG behaves the way it does. Each record identifies the decision date,
15+
the implementation and release boundary, alternatives that were rejected, and the current user
16+
documentation.
17+
18+
These articles are historical and architectural context. For current command syntax and behavior,
19+
use the linked [PIG documentation](/docs/); for delivery history, use the [release notes](/release/).

0 commit comments

Comments
 (0)