snapshot of eee7b5cfc0776c68816d7fdaa8af9d7a0e0da2e6 Annotations about the code that implements koment.

internal/ui/export.go

1 package ui
2
3 import (
4 "encoding/json"
5 "errors"
6 "flag"
7 "fmt"
8 "html/template"
9 "io"
10 "os"
11 "path/filepath"
12 "strings"
13 "time"
14
15 "github.com/janpuc/koment/internal/application"
16 "github.com/janpuc/koment/internal/config"
17 "github.com/janpuc/koment/internal/provenance"
18 )
19
20 const (
21 exportedSuffix = ".html"
22 indexPage = "index.html"
23 stylesheetName = "style.css"
24 scriptName = "koment.js"
25 logoSVGName = "koment-logo.svg"
26 logoPNGName = "koment-logo.png"
27 annotationsName = "annotations.json"
28 searchName = "search.json"
29 )
30
31 const exportUsage = `koment site renders a repository snapshot to static HTML.
32
33 koment site --out <dir> [--banner <text>]
34
35 This is the published tier (ADR 0103): everyone reads the annotations in a
36 browser, with no server to run and no authentication to design. Point it at a
37 directory, commit a workflow, and GitHub Pages serves it — see docs/publishing.md.
38
39 It renders a snapshot of one commit rather than your working tree, and every
40 page says which commit. Read your own tree with koment ui instead, which
41 re-resolves on every request.
42
43 A site renders your source as well as your annotations. Publishing one from a
44 private repository publishes that source.
45 `
46
47 // Export writes the same pages koment ui serves, with relative links so the
48 // tree survives being hosted under a subpath.
49 func Site(args []string, stderr io.Writer) error {
50 flags := flag.NewFlagSet("site", flag.ContinueOnError)
51 flags.SetOutput(stderr)
52 flags.Usage = func() {
53 fmt.Fprint(stderr, exportUsage, "\nFlags (each also settable from the environment):\n", config.Usage(flags))
54 }
55
56 out := flags.String("out", "", "directory to write into")
57 name := flags.String("name", "", "repository name shown on every page; defaults to the repository's own name")
58 named := flags.String("repository", "", "which repository to render; required when several are configured")
59 commit := flags.String("commit", "", "commit this snapshot renders; read from git when omitted")
60 commitURL := flags.String("commit-link", "", "URL the commit links to")
61 banner := flags.String("banner", "", "notice shown on every page, beside the commit")
62 bannerHref := flags.String("banner-link", "", "URL shown beside the banner")
63 repositoryLinks := flags.String("repository-links", "", "comma-separated name=URL entries for the contextual repository switcher")
64 if err := flags.Parse(args); err != nil {
65 return err
66 }
67 if err := config.FromEnvironment(flags); err != nil {
68 return err
69 }
70 if *out == "" {
71 return fmt.Errorf("site needs --out")
72 }
73
74 repositories, err := selectedRepositories(*named)
75 if err != nil {
76 return err
77 }
78 chosen, single := repositories.Only()
79 if !single {
80 return fmt.Errorf("%d repositories are configured (%s); a site renders one, so pass --repository",
81 repositories.Len(), strings.Join(repositories.IDs(), ", "))
82 }
83
84 taken := &snapshot{
85 Commit: *commit,
86 CommitURL: *commitURL,
87 Banner: *banner,
88 BannerHref: *bannerHref,
89 }
90 if taken.Commit, err = commitOf(chosen.Root, *commit); err != nil {
91 return err
92 }
93
94 label := *name
95 if label == "" {
96 label = chosen.Display()
97 }
98 linked, err := parseRepositoryLinks(*repositoryLinks, label)
99 if err != nil {
100 return err
101 }
102 repositorySnapshot, err := application.BuildSnapshot(chosen)
103 if err != nil {
104 return err
105 }
106 written, err := publish(repositorySnapshot, *out, chosen.Root, label, taken, linked)
107 if err != nil {
108 return err
109 }
110 fmt.Fprintf(stderr, "koment: wrote %d pages to %s at %s\n", written, *out, taken.Commit)
111 return nil
112 }
113
114 func commitOf(root, given string) (string, error) {
115 if given != "" {
116 return given, nil
117 }
118 commit, err := provenance.HeadCommit(root)
119 if err != nil {
120 return "", fmt.Errorf("cannot read the commit at %s: every published page names the commit it renders; pass --commit", root)
121 }
122 if provenance.TreeIsDirty(root) {
123 return commit + "-dirty", nil
124 }
125 return commit, nil
126 }
127
128 func export(repositorySnapshot *application.RepositorySnapshot, out, name string, taken *snapshot, repositories []repositoryLink) (int, error) {
129 templates := template.Must(template.ParseFS(assets, "assets/*.html"))
130
131 for _, asset := range []string{stylesheetName, scriptName, logoSVGName, logoPNGName} {
132 content, err := assets.ReadFile("assets/" + asset)
133 if err != nil {
134 return 0, err
135 }
136 if err := writeFile(filepath.Join(out, asset), content); err != nil {
137 return 0, err
138 }
139 }
140
141 pages := map[string]string{indexPage: ""}
142 for _, file := range repositorySnapshot.Files {
143 pages[filepath.ToSlash(filepath.Join("f", file.Path+exportedSuffix))] = file.Path
144 }
145
146 for page, file := range pages {
147 rendered, err := renderPage(templates, repositorySnapshot, file, exportedLinks(page), name, taken,
148 exportedRepositoryLinks(page, repositories))
149 if err != nil {
150 return 0, err
151 }
152 if err := writeFile(filepath.Join(out, filepath.FromSlash(page)), rendered); err != nil {
153 return 0, err
154 }
155 }
156 if err := writeJSON(filepath.Join(out, annotationsName), staticData(repositorySnapshot, name, taken)); err != nil {
157 return 0, err
158 }
159 if err := writeJSON(filepath.Join(out, searchName), searchData(repositorySnapshot)); err != nil {
160 return 0, err
161 }
162 return len(pages), nil
163 }
164
165 func exportedLinks(page string) links {
166 up := strings.Repeat("../", strings.Count(page, "/"))
167 return links{
168 file: func(target string) string { return up + "f/" + escapedFilePath(target) + exportedSuffix },
169 home: up + indexPage,
170 stylesheet: up + stylesheetName,
171 script: up + scriptName,
172 logoSVG: up + logoSVGName,
173 logoPNG: up + logoPNGName,
174 }
175 }
176
177 func renderPage(templates *template.Template, repositorySnapshot *application.RepositorySnapshot, file string, how links, name string,
178 taken *snapshot, repositories []repositoryLink,
179 ) ([]byte, error) {
180 built, err := build(repositorySnapshot, file, how)
181 if err != nil {
182 return nil, err
183 }
184 built.Repository = name
185 built.Snapshot = taken
186 built.Repositories = repositories
187
188 var page strings.Builder
189 if err := templates.ExecuteTemplate(&page, "page.html", built); err != nil {
190 return nil, err
191 }
192 return []byte(page.String()), nil
193 }
194
195 func parseRepositoryLinks(specification, current string) ([]repositoryLink, error) {
196 if strings.TrimSpace(specification) == "" {
197 return nil, nil
198 }
199 var links []repositoryLink
200 currentCount := 0
201 for _, entry := range strings.Split(specification, ",") {
202 name, target, found := strings.Cut(entry, "=")
203 name = strings.TrimSpace(name)
204 target = strings.TrimSpace(target)
205 if !found || name == "" || target == "" {
206 return nil, fmt.Errorf("repository-links entry %q must be name=URL", entry)
207 }
208 isCurrent := name == current
209 if isCurrent {
210 currentCount++
211 }
212 links = append(links, repositoryLink{Name: name, Href: target, Current: isCurrent})
213 }
214 if len(links) < 2 {
215 return nil, fmt.Errorf("repository-links needs at least two entries")
216 }
217 if currentCount != 1 {
218 return nil, fmt.Errorf("repository-links must contain the current repository %q exactly once", current)
219 }
220 return links, nil
221 }
222
223 func exportedRepositoryLinks(page string, repositories []repositoryLink) []repositoryLink {
224 if len(repositories) == 0 {
225 return nil
226 }
227 up := strings.Repeat("../", strings.Count(page, "/"))
228 linked := make([]repositoryLink, len(repositories))
229 for index, repository := range repositories {
230 linked[index] = repository
231 if !strings.Contains(repository.Href, "://") && !strings.HasPrefix(repository.Href, "/") {
232 linked[index].Href = up + repository.Href
233 }
234 }
235 return linked
236 }
237
238 type staticPublication struct {
239 Version int `json:"version"`
240 Repository staticRepository `json:"repository"`
241 GeneratedAt string `json:"generated_at"`
242 Files []staticFile `json:"files"`
243 }
244
245 type staticRepository struct {
246 ID string `json:"id"`
247 Name string `json:"name"`
248 Commit string `json:"commit"`
249 CloneURL string `json:"clone_url,omitempty"`
250 DefaultBranch string `json:"default_branch,omitempty"`
251 }
252
253 type staticFile struct {
254 Path string `json:"path"`
255 Exists bool `json:"exists"`
256 Source string `json:"source,omitempty"`
257 Annotations []staticAnnotation `json:"annotations"`
258 }
259
260 type staticAnnotation struct {
261 ID string `json:"id"`
262 Kind string `json:"kind"`
263 Body string `json:"body"`
264 Created string `json:"created"`
265 Status string `json:"status"`
266 Line int `json:"line,omitempty"`
267 Occurrences int `json:"occurrences"`
268 Warning string `json:"warning,omitempty"`
269 Anchor staticAnchor `json:"anchor"`
270 Author staticAuthor `json:"author"`
271 Git *staticGit `json:"git,omitempty"`
272 Policy *staticPolicy `json:"policy,omitempty"`
273 }
274
275 type staticAnchor struct {
276 Scope string `json:"scope"`
277 Excerpt string `json:"excerpt,omitempty"`
278 Before string `json:"before,omitempty"`
279 After string `json:"after,omitempty"`
280 LastSeenLine int `json:"last_seen_line,omitempty"`
281 }
282
283 type staticAuthor struct {
284 Name string `json:"name"`
285 Email string `json:"email,omitempty"`
286 Kind string `json:"kind"`
287 Source string `json:"source"`
288 Account string `json:"account,omitempty"`
289 Verified string `json:"verified,omitempty"`
290 }
291
292 type staticGit struct {
293 Commit string `json:"commit"`
294 Path string `json:"path"`
295 Line int `json:"line,omitempty"`
296 EndLine int `json:"end_line,omitempty"`
297 }
298
299 type staticPolicy struct {
300 Exception string `json:"exception"`
301 Acknowledged bool `json:"acknowledged"`
302 }
303
304 type searchEntry struct {
305 File string `json:"file"`
306 ID string `json:"id"`
307 Kind string `json:"kind"`
308 Body string `json:"body"`
309 Author string `json:"author"`
310 Status string `json:"status"`
311 Warning string `json:"warning,omitempty"`
312 Line int `json:"line,omitempty"`
313 }
314
315 func staticData(repositorySnapshot *application.RepositorySnapshot, name string, taken *snapshot) staticPublication {
316 published := staticPublication{
317 Version: 1,
318 Repository: staticRepository{
319 ID: repositorySnapshot.Repository.ID, Name: name,
320 Commit: taken.Commit, CloneURL: repositorySnapshot.Repository.CloneURL,
321 DefaultBranch: repositorySnapshot.Repository.DefaultBranch,
322 },
323 GeneratedAt: repositorySnapshot.GeneratedAt.Format(time.RFC3339Nano),
324 }
325 for _, file := range repositorySnapshot.Files {
326 publishedFile := staticFile{Path: file.Path, Exists: file.Exists, Source: string(file.Content)}
327 for _, annotation := range file.Annotations {
328 record := annotation.Record
329 publishedAnnotation := staticAnnotation{
330 ID: record.ID, Kind: string(record.Kind), Body: record.Body,
331 Created: record.Created.Format("2006-01-02"), Status: string(annotation.Status),
332 Line: annotation.Line, Occurrences: annotation.Occurrences, Warning: annotation.Warning,
333 Anchor: staticAnchor{
334 Scope: string(record.Anchor.Scope), Excerpt: record.Anchor.Excerpt,
335 Before: record.Anchor.Before, After: record.Anchor.After, LastSeenLine: record.Anchor.LastSeenLine,
336 },
337 Author: staticAuthor{
338 Name: record.Author.Name, Email: record.Author.Email, Kind: string(record.Author.Kind),
339 Source: string(record.Author.Source), Account: record.Author.Account, Verified: record.Author.Verified,
340 },
341 }
342 if record.Git != nil {
343 publishedAnnotation.Git = &staticGit{
344 Commit: record.Git.Commit, Path: record.Git.Path, Line: record.Git.Line, EndLine: record.Git.EndLine,
345 }
346 }
347 if record.Policy != nil {
348 publishedAnnotation.Policy = &staticPolicy{
349 Exception: record.Policy.Exception, Acknowledged: record.Policy.Acknowledged,
350 }
351 }
352 publishedFile.Annotations = append(publishedFile.Annotations, publishedAnnotation)
353 }
354 published.Files = append(published.Files, publishedFile)
355 }
356 return published
357 }
358
359 func searchData(repositorySnapshot *application.RepositorySnapshot) []searchEntry {
360 var entries []searchEntry
361 for _, file := range repositorySnapshot.Files {
362 for _, annotation := range file.Annotations {
363 entries = append(entries, searchEntry{
364 File: file.Path, ID: annotation.Record.ID, Kind: string(annotation.Record.Kind),
365 Body: annotation.Record.Body, Author: annotation.Record.Author.Name,
366 Status: string(annotation.Status), Warning: annotation.Warning, Line: annotation.Line,
367 })
368 }
369 }
370 return entries
371 }
372
373 func writeJSON(name string, value any) error {
374 content, err := json.MarshalIndent(value, "", " ")
375 if err != nil {
376 return fmt.Errorf("encoding %s: %w", name, err)
377 }
378 return writeFile(name, append(content, '\n'))
379 }
380
381 func publish(repositorySnapshot *application.RepositorySnapshot, out, repositoryRoot, name string, taken *snapshot,
382 repositories []repositoryLink,
383 ) (_ int, returnedError error) {
384 absoluteOut, err := filepath.Abs(out)
385 if err != nil {
386 return 0, fmt.Errorf("resolving output directory %s: %w", out, err)
387 }
388 absoluteRoot, err := filepath.Abs(repositoryRoot)
389 if err != nil {
390 return 0, fmt.Errorf("resolving repository root %s: %w", repositoryRoot, err)
391 }
392 if filepath.Clean(absoluteOut) == filepath.Clean(string(filepath.Separator)) || filepath.Clean(absoluteOut) == filepath.Clean(absoluteRoot) {
393 return 0, fmt.Errorf("refusing to replace unsafe output directory %s", absoluteOut)
394 }
395 parent := filepath.Dir(absoluteOut)
396 if err := os.MkdirAll(parent, 0o755); err != nil {
397 return 0, fmt.Errorf("creating output parent %s: %w", parent, err)
398 }
399 staging, err := os.MkdirTemp(parent, "."+filepath.Base(absoluteOut)+".staging-")
400 if err != nil {
401 return 0, fmt.Errorf("creating staging directory beside %s: %w", absoluteOut, err)
402 }
403 defer func() {
404 if staging != "" {
405 returnedError = errors.Join(returnedError, os.RemoveAll(staging))
406 }
407 }()
408 written, err := export(repositorySnapshot, staging, name, taken, repositories)
409 if err != nil {
410 return 0, err
411 }
412 if err := replaceDirectory(staging, absoluteOut); err != nil {
413 return 0, err
414 }
415 staging = ""
416 return written, nil
417 }
418
419 func replaceDirectory(staging, destination string) error {
420 information, err := os.Stat(destination)
421 if errors.Is(err, os.ErrNotExist) {
422 return os.Rename(staging, destination)
423 }
424 if err != nil {
425 return fmt.Errorf("inspecting output directory %s: %w", destination, err)
426 }
427 if !information.IsDir() {
428 return fmt.Errorf("output path %s is not a directory", destination)
429 }
430 parent := filepath.Dir(destination)
431 backup, err := os.MkdirTemp(parent, "."+filepath.Base(destination)+".previous-")
432 if err != nil {
433 return fmt.Errorf("reserving backup beside %s: %w", destination, err)
434 }
435 if err := os.Remove(backup); err != nil {
436 return fmt.Errorf("preparing backup path %s: %w", backup, err)
437 }
438 if err := os.Rename(destination, backup); err != nil {
439 return fmt.Errorf("moving previous output %s aside: %w", destination, err)
440 }
441 if err := os.Rename(staging, destination); err != nil {
442 return errors.Join(fmt.Errorf("publishing output %s: %w", destination, err), os.Rename(backup, destination))
443 }
444 if err := os.RemoveAll(backup); err != nil {
445 return fmt.Errorf("removing previous output %s: %w", backup, err)
446 }
447 return nil
448 }
449
450 func writeFile(path string, content []byte) error {
451 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
452 return fmt.Errorf("creating %s: %w", filepath.Dir(path), err)
453 }
454 if err := os.WriteFile(path, content, 0o644); err != nil {
455 return fmt.Errorf("writing %s: %w", path, err)
456 }
457 return nil
458 }

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close