/docs/terminal/nushell/ went 404 when the page was split into the nushell folder: a static site has nothing at an address whose page moved. @docusaurus/plugin-client-redirects (3.10.2, pinned) writes a page at each address listed in website/redirects.json that sends the browser on, and fails the build on a redirect to no page; this one goes to /docs/nushell/notebooks/. doccheck now lists every docs page the site has had since its first commit (from git's history) and fails when one is gone without a redirect, so a future move can't break old links silently. docs/contributing/site.md says how.
- commit
- 0aeb75d34c87d8b00619fb654a9a0de47432763b
- parent
- 80df9677424d34d857ff143dbe7dcf58bae45ad5
- author
- Dami <dami.etoile@gmail.com>
- kind
- feat risk score 1.1 of 3, Jev's estimate
| M | docs/ | 18 | +++++ |
| M | docs/ | 6 | +- |
| M | scripts/ | 5 | +- |
| A | scripts/ | 116 | ++++++++++++++++++++++++++++++ |
| M | website/ | 8 | ++ |
| M | website/ | 25 | ++++++ |
| M | website/ | 1 | + |
| A | website/ | 3 | + |
@@ -43,6 +43,24 @@ heading makes, a missing image or two pages at one address. It checks
4343 the same links `scripts/doccheck` does, as the site resolves them, so a4444 page that passes `make lint` but breaks on the site shows up here.4545 46+## Old addresses keep working47+48+A page's address is its path under `docs/`, so moving or deleting a49+page would break links to it from elsewhere. A page that moves or goes50+leaves a redirect in `website/redirects.json`, from its old address to51+the page its reader wants now:52+53+```json54+{"from": "/docs/terminal/nushell/", "to": "/docs/nushell/notebooks/"}55+```56+57+`@docusaurus/plugin-client-redirects` writes a page at each old address58+that sends the browser on, since a static host can't redirect by59+itself, and the build fails on a redirect to a page that doesn't exist.60+`scripts/doccheck` (in `make lint`) fails when a page the site has61+published is gone without a redirect: it reads git's history back to62+the site's first commit for every page there has been.63+4664 ## How the docs become pages4765 4866 - Pages are Markdown as GitHub reads it (`markdown.format: 'detect'`):@@ -136,8 +136,10 @@ and a line in the folder's README. `make site` builds them into the
136136 history (see [CLAUDE.md](../../CLAUDE.md)), and `scripts/doccheck`, which137137 checks the tree above, that relative links and anchors resolve, that138138 every `docs/...md` path Go code names exists, that every file in139-`docs/media` is shown by a doc and made by a tape, and that every tape140-records something a doc shows. Unit tests check what the docs say about139+`docs/media` is shown by a doc and made by a tape, that every tape140+records something a doc shows, and that a page the site has published141+isn't gone without a redirect ([The docs site](site.md#old-addresses-keep-working)).142+Unit tests check what the docs say about141143 the code: menu paths lead to menu items, command ids and keys exist and142144 every bound key is in [Keys and mouse](../reference/keys.md)143145 (`internal/ui/docs_test.go`), settings and variables are options@@ -9,7 +9,9 @@
99 // - every docs/...md path Go code names exists;1010 // - every file in docs/media is shown by some doc, comes from a VHS tape1111 // in demos/ (its Output or a Screenshot), and no GIF is over maxGIF;12-// - every tape in demos/ records something a doc shows.12+// - every tape in demos/ records something a doc shows;13+// - every docs page the site has published is still a page, or has a14+// redirect from its address in website/redirects.json (redirects.go).1315 //1416 // What the docs say about the program (keys, menus, commands, options) is1517 // checked by tests next to the code they name: internal/ui's docs_test.go@@ -44,6 +46,7 @@ func main() {
4446 problems = append(problems, checkTree(docs)...)4547 problems = append(problems, checkGoPaths()...)4648 problems = append(problems, checkMedia(docs)...)49+ problems = append(problems, checkRedirects(docs)...)4750 for _, p := range problems {4851 fmt.Println(p)4952 }@@ -0,0 +1,116 @@
1+package main2+3+import (4+ "encoding/json"5+ "fmt"6+ "os"7+ "os/exec"8+ "path"9+ "slices"10+ "strings"11+)12+13+// The docs site keeps its old addresses: a page that moves or goes14+// leaves a redirect in website/redirects.json, from its old address to15+// where its reader goes now. The site's build checks that each redirect16+// leads to a page; checkRedirects checks that none is missing, by17+// finding every docs page the site has had since it began (in git's18+// history) that isn't a page any more.19+20+// siteConfig is the file whose first commit began the site.21+const siteConfig = "website/docusaurus.config.ts"22+23+// redirectsFile lists the site's redirects.24+const redirectsFile = "website/redirects.json"25+26+// checkRedirects reports each docs page the site has published that is27+// gone without a redirect from its address. Without git or the history28+// back to the site's beginning (a shallow clone), there is nothing to29+// compare with, and it checks nothing.30+func checkRedirects(docs map[string]*doc) []string {31+ published, ok := publishedPages()32+ if !ok {33+ return nil34+ }35+ b, err := os.ReadFile(redirectsFile)36+ if err != nil {37+ return []string{redirectsFile + ": " + err.Error()}38+ }39+ var redirects []struct{ From, To string }40+ if err := json.Unmarshal(b, &redirects); err != nil {41+ return []string{redirectsFile + ": " + err.Error()}42+ }43+ from := map[string]bool{}44+ for _, r := range redirects {45+ from[r.From] = true46+ }47+ var problems []string48+ for _, p := range published {49+ if _, still := docs[p]; still {50+ continue51+ }52+ if url := pageURL(p); !from[url] {53+ problems = append(problems, fmt.Sprintf("%s: gone, but the site has published it at %s: add a redirect from there in %s", p, url, redirectsFile))54+ }55+ }56+ return problems57+}58+59+// publishedPages lists the docs pages that existed when the site began60+// or were added, changed or deleted since, sorted.61+func publishedPages() ([]string, bool) {62+ adds, err := gitLines("log", "--diff-filter=A", "--format=%H", "--", siteConfig)63+ if err != nil || len(adds) == 0 {64+ return nil, false65+ }66+ first := adds[len(adds)-1]67+ then, err := gitLines("ls-tree", "-r", "--name-only", first, "--", "docs")68+ if err != nil {69+ return nil, false70+ }71+ since, err := gitLines("log", "--no-renames", "--format=", "--name-only", first+"..HEAD", "--", "docs")72+ if err != nil {73+ return nil, false74+ }75+ var pages []string76+ for _, p := range append(then, since...) {77+ if isPage(p) {78+ pages = append(pages, p)79+ }80+ }81+ slices.Sort(pages)82+ return slices.Compact(pages), true83+}84+85+func gitLines(args ...string) ([]string, error) {86+ out, err := exec.Command("git", args...).Output()87+ if err != nil {88+ return nil, err89+ }90+ return strings.Fields(string(out)), nil91+}92+93+// isPage reports whether p, a path under docs/, is a page of the site:94+// Markdown not excluded by a leading _ (see the docs preset's exclude).95+func isPage(p string) bool {96+ if path.Ext(p) != ".md" {97+ return false98+ }99+ for _, part := range strings.Split(p, "/") {100+ if strings.HasPrefix(part, "_") {101+ return false102+ }103+ }104+ return true105+}106+107+// pageURL is where the site serves the page at p: its path without108+// .md and with a trailing slash, a folder's README.md at the folder's109+// address.110+func pageURL(p string) string {111+ p = strings.TrimSuffix(p, ".md")112+ if base := path.Base(p); base == "README" {113+ p = path.Dir(p)114+ }115+ return "/" + p + "/"116+}@@ -3,6 +3,10 @@ import type {Config} from '@docusaurus/types';
33 import type * as Preset from '@docusaurus/preset-classic';44 import {themes as prismThemes} from 'prism-react-renderer';55 import repoLinks from './src/remark/repo-links';6+// The addresses of pages that moved or went, each to where its reader7+// goes now; doccheck makes sure every docs page the site has published8+// is still a page or is here.9+import redirects from './redirects.json';610 711 const repo = 'https://github.com/FelineStateMachine/012';812 const repoDir = path.resolve(__dirname, '..');@@ -57,6 +61,10 @@ const config: Config = {
5761 ],5862 ],5963 64+ // A page at each old address that sends its reader on (a static host65+ // can't redirect by itself).66+ plugins: [['@docusaurus/plugin-client-redirects', {redirects}]],67+6068 themes: [6169 [6270 '@easyops-cn/docusaurus-search-local',@@ -7,6 +7,7 @@
77 "name": "012-website",88 "dependencies": {99 "@docusaurus/core": "3.10.2",10+ "@docusaurus/plugin-client-redirects": "3.10.2",1011 "@docusaurus/preset-classic": "3.10.2",1112 "@easyops-cn/docusaurus-search-local": "0.55.3",1213 "@fontsource/ibm-plex-mono": "5.3.0",@@ -3640,6 +3641,30 @@
36403641 "react-dom": "*"36413642 }36423643 },3644+ "node_modules/@docusaurus/plugin-client-redirects": {3645+ "version": "3.10.2",3646+ "resolved": "https://registry.npmjs.org/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-3.10.2.tgz",3647+ "integrity": "sha512-z5I5ttCXw+8y2gHVZvqAMmUw4Rb0ZzKA5eCPk87SfM/jCKOTvE24yDpPAwwipvug96ij7mOwEHXWw8G4LlWdGA==",3648+ "license": "MIT",3649+ "dependencies": {3650+ "@docusaurus/core": "3.10.2",3651+ "@docusaurus/logger": "3.10.2",3652+ "@docusaurus/utils": "3.10.2",3653+ "@docusaurus/utils-common": "3.10.2",3654+ "@docusaurus/utils-validation": "3.10.2",3655+ "eta": "^2.2.0",3656+ "fs-extra": "^11.1.1",3657+ "lodash": "^4.17.21",3658+ "tslib": "^2.6.0"3659+ },3660+ "engines": {3661+ "node": ">=20.0"3662+ },3663+ "peerDependencies": {3664+ "react": "^18.0.0 || ^19.0.0",3665+ "react-dom": "^18.0.0 || ^19.0.0"3666+ }3667+ },36433668 "node_modules/@docusaurus/plugin-content-blog": {36443669 "version": "3.10.2",36453670 "resolved": "https://registry.npmjs.org/@docusaurus/plugin-content-blog/-/plugin-content-blog-3.10.2.tgz",