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

Find an annotation

Search file paths, rationale, kinds, and authors.

moveEnter openEsc close