internal/ui/export.go
1
package ui
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"
15
"github.com/janpuc/koment/internal/application"
16
"github.com/janpuc/koment/internal/config"
17
"github.com/janpuc/koment/internal/provenance"
18
)
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
)
31
const exportUsage = `koment site renders a repository snapshot to static HTML.
33
koment site --out <dir> [--banner <text>]
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.
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.
43
A site renders your source as well as your annotations. Publishing one from a
44
private repository publishes that source.
45
`
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
}
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
}
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
}
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
}
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
}
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
}
128
func export(repositorySnapshot *application.RepositorySnapshot, out, name string, taken *snapshot, repositories []repositoryLink) (int, error) {
129
templates := template.Must(template.ParseFS(assets, "assets/*.html"))
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
}
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
}
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
}
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
}
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
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
299
type staticPolicy struct {
300
Exception string `json:"exception"`
301
Acknowledged bool `json:"acknowledged"`
302
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}