carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Package docsite loads the documentation corpus under docs/site and
2// exposes it in the shape the gates need.
3//
4// The corpus is markdown plus one nav.json holding the table of
5// contents. Everything here is parsing: the tests beside this file are
6// the point, and they are what stop the docs and the code drifting
7// apart. Nothing in this package ships in a binary — docs/site is
8// rendered by the website, which vendors a copy. This package exists so
9// CARLOS's own CI can fail a pull request whose docs no longer describe
10// the platform.
11//
12// The markdown parsing is deliberately small and deliberately
13// fence-aware. It is not a markdown implementation and must never grow
14// into one: it finds ATX headings, fenced code blocks and internal
15// links, and it knows that a "#" inside a shell fence is a comment
16// rather than a heading. Anything more belongs to the renderer.
17package docsite
18
19import (
20 "encoding/json"
21 "fmt"
22 "io/fs"
23 "os"
24 "path"
25 "path/filepath"
26 "strings"
27)
28
29// Heading is one ATX heading, with the anchor the renderer will give
30// it. Anchor equivalence with the website's markdown-it-anchor
31// configuration is pinned from the website side; see Anchor.
32type Heading struct {
33 Level int
34 Text string
35 Anchor string
36 Line int // 1-based line in the page source
37}
38
39// Link is one internal /docs link found in a page's source, with the
40// line it sits on so a failure can name it.
41type Link struct {
42 Target string // page slug, e.g. "migrations" or "reference/db"
43 Fragment string // heading anchor, or "" for a whole-page link
44 Line int
45}
46
47// Fence is one fenced code block and the language on its info string.
48type Fence struct {
49 Lang string
50 Body string
51 Line int
52}
53
54// Page is one documentation page: its nav metadata, its source, and
55// everything the gates read out of it.
56type Page struct {
57 Slug string // "migrations", "reference/db"
58 Label string // nav label, usually shorter than Title
59 Blurb string // one line, rendered on the /docs index
60 Title string // the page's own "# " heading
61 Path string // path on disk
62
63 Body string
64 Headings []Heading
65 Links []Link
66 Fences []Fence
67}
68
69// Section is one group in the sidebar.
70type Section struct {
71 Title string
72 Pages []*Page
73}
74
75// Site is the whole corpus, in reading order.
76type Site struct {
77 // Index is docs/site/index.md, the /docs overview. It sits outside
78 // Sections because it is the page the sidebar's sections are listed
79 // on: an entry for itself would render as a card linking to the page
80 // you are already reading.
81 Index *Page
82 Sections []Section
83 BySlug map[string]*Page
84}
85
86// Pages returns every page in reading order, the index first.
87func (s *Site) Pages() []*Page {
88 var out []*Page
89 if s.Index != nil {
90 out = append(out, s.Index)
91 }
92 for _, sec := range s.Sections {
93 out = append(out, sec.Pages...)
94 }
95 return out
96}
97
98// navFile is nav.json's shape.
99type navFile struct {
100 Sections []struct {
101 Title string `json:"title"`
102 Pages []struct {
103 Slug string `json:"slug"`
104 Label string `json:"label"`
105 Blurb string `json:"blurb"`
106 } `json:"pages"`
107 } `json:"sections"`
108}
109
110// Load reads nav.json and every page it names. A page named by nav.json
111// that does not exist is an error here; a page that exists and is not
112// named is not — that asymmetry is deliberate, because the nav test
113// reports it with a better message than a load failure could.
114func Load(root string) (*Site, error) {
115 raw, err := os.ReadFile(filepath.Join(root, "nav.json"))
116 if err != nil {
117 return nil, fmt.Errorf("read nav.json: %w", err)
118 }
119 var nav navFile
120 if err := json.Unmarshal(raw, &nav); err != nil {
121 return nil, fmt.Errorf("parse nav.json: %w", err)
122 }
123
124 site := &Site{BySlug: map[string]*Page{}}
125 index, err := loadPage(root, "index", "Documentation", "")
126 if err != nil {
127 return nil, err
128 }
129 site.Index = index
130 site.BySlug[index.Slug] = index
131
132 for _, sec := range nav.Sections {
133 out := Section{Title: sec.Title}
134 for _, entry := range sec.Pages {
135 p, err := loadPage(root, entry.Slug, entry.Label, entry.Blurb)
136 if err != nil {
137 return nil, err
138 }
139 out.Pages = append(out.Pages, p)
140 site.BySlug[p.Slug] = p
141 }
142 site.Sections = append(site.Sections, out)
143 }
144 return site, nil
145}
146
147// loadPage reads one page off disk and parses it.
148func loadPage(root, slug, label, blurb string) (*Page, error) {
149 p := &Page{
150 Slug: slug,
151 Label: label,
152 Blurb: blurb,
153 Path: filepath.Join(root, filepath.FromSlash(slug)+".md"),
154 }
155 body, err := os.ReadFile(p.Path)
156 if err != nil {
157 return nil, fmt.Errorf("nav.json names %q: %w", slug, err)
158 }
159 p.Body = string(body)
160 parsePage(p)
161 return p, nil
162}
163
164// Parse parses a page's source that is not on disk, such as an older
165// revision of one. The changelog uses it to find which section a past
166// commit changed.
167func Parse(slug, body string) *Page {
168 p := &Page{Slug: slug, Body: body}
169 parsePage(p)
170 return p
171}
172
173// parsePage fills in everything derived from a page's source.
174func parsePage(p *Page) {
175 lines := strings.Split(p.Body, "\n")
176
177 inFence := false
178 fenceMark := ""
179 var fence Fence
180
181 for i, line := range lines {
182 lineNo := i + 1
183 trimmed := strings.TrimSpace(line)
184
185 // Fence open/close. The marker must match in kind and be at
186 // least as long as the opener, so a ``` inside a ~~~~ block is
187 // content rather than a close.
188 if mark, info, ok := fenceDelim(trimmed); ok {
189 switch {
190 case !inFence:
191 inFence, fenceMark = true, mark
192 fence = Fence{Lang: info, Line: lineNo}
193 continue
194 case sameFence(mark, fenceMark):
195 inFence = false
196 p.Fences = append(p.Fences, fence)
197 continue
198 }
199 }
200 if inFence {
201 fence.Body += line + "\n"
202 continue
203 }
204
205 if level, text, ok := heading(trimmed); ok {
206 h := Heading{Level: level, Text: text, Anchor: Anchor(text), Line: lineNo}
207 p.Headings = append(p.Headings, h)
208 if level == 1 && p.Title == "" {
209 p.Title = text
210 }
211 }
212
213 for _, l := range docsLinks(line) {
214 l.Line = lineNo
215 p.Links = append(p.Links, l)
216 }
217 }
218}
219
220// fenceDelim reports whether a trimmed line opens or closes a fence,
221// returning the marker run and the info string.
222func fenceDelim(line string) (mark, info string, ok bool) {
223 var r byte
224 switch {
225 case strings.HasPrefix(line, "```"):
226 r = '`'
227 case strings.HasPrefix(line, "~~~"):
228 r = '~'
229 default:
230 return "", "", false
231 }
232 n := 0
233 for n < len(line) && line[n] == r {
234 n++
235 }
236 return line[:n], strings.TrimSpace(line[n:]), true
237}
238
239// sameFence reports whether a closing marker can close an opener: same
240// character, and at least as long.
241func sameFence(closing, opening string) bool {
242 if closing == "" || opening == "" || closing[0] != opening[0] {
243 return false
244 }
245 return len(closing) >= len(opening)
246}
247
248// heading parses an ATX heading.
249func heading(line string) (level int, text string, ok bool) {
250 n := 0
251 for n < len(line) && line[n] == '#' {
252 n++
253 }
254 if n == 0 || n > 6 || n >= len(line) || line[n] != ' ' {
255 return 0, "", false
256 }
257 return n, strings.TrimSpace(strings.TrimRight(line[n+1:], " #")), true
258}
259
260// docsLinks finds every markdown link whose target starts with /docs.
261func docsLinks(line string) []Link {
262 var out []Link
263 rest := line
264 for {
265 i := strings.Index(rest, "](/docs")
266 if i < 0 {
267 return out
268 }
269 rest = rest[i+2:]
270 end := strings.IndexByte(rest, ')')
271 if end < 0 {
272 return out
273 }
274 out = append(out, parseDocsHref(rest[:end]))
275 rest = rest[end:]
276 }
277}
278
279// parseDocsHref turns "/docs/reference/db#open" into its slug and
280// fragment. A bare "/docs" is the index, whose slug is "index".
281func parseDocsHref(href string) Link {
282 var l Link
283 if i := strings.IndexByte(href, '#'); i >= 0 {
284 l.Fragment, href = href[i+1:], href[:i]
285 }
286 href = strings.TrimSuffix(href, "/")
287 slug := strings.TrimPrefix(href, "/docs")
288 slug = strings.TrimPrefix(slug, "/")
289 if slug == "" {
290 slug = "index"
291 }
292 l.Target = path.Clean(slug)
293 return l
294}
295
296// Anchor is the heading anchor the rendered page will carry.
297//
298// The website configures markdown-it-anchor with a slugify that matches
299// this function, and both sides assert against docs/site/anchors.json.
300// Both are deliberately plain — lowercase alphanumerics, everything else
301// a separator — because markdown-it-anchor's own default URI-encodes
302// punctuation, which would anchor a heading naming `carlos ship -app`
303// differently on each side.
304func Anchor(text string) string {
305 var b strings.Builder
306 pendingSep := false
307 for _, r := range strings.ToLower(text) {
308 switch {
309 case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
310 if pendingSep && b.Len() > 0 {
311 b.WriteByte('-')
312 }
313 pendingSep = false
314 b.WriteRune(r)
315 default:
316 pendingSep = true
317 }
318 }
319 return b.String()
320}
321
322// MarkdownFiles walks root and returns every page slug on disk, with
323// "reference/" prefixes preserved and ".md" stripped.
324func MarkdownFiles(root string) ([]string, error) {
325 var out []string
326 err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
327 if err != nil {
328 return err
329 }
330 if d.IsDir() || !strings.HasSuffix(p, ".md") {
331 return nil
332 }
333 rel, err := filepath.Rel(root, p)
334 if err != nil {
335 return err
336 }
337 out = append(out, strings.TrimSuffix(filepath.ToSlash(rel), ".md"))
338 return nil
339 })
340 return out, err
341}
342