internal/ui/export.go
1
package ui
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"
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
)
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
)
33
const exportUsage = `koment site renders a repository snapshot to static HTML.
35
koment site --out <dir> [--banner <text>]
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.
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.
45
A site renders your source as well as your annotations. Publishing one from a
46
private repository publishes that source.
47
`
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
}
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
}
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
}
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
}
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
}
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
}
130
func export(repositorySnapshot *application.RepositorySnapshot, out, name string, taken *snapshot, repositories []repositoryLink) (int, error) {
131
templates := template.Must(template.ParseFS(assets, "assets/*.html"))
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
}
143
pages := map[string]string{indexPage: ""}
144
for _, file := range repositorySnapshot.Files {
145
pages[publishedPagePath(file.Path)] = file.Path
146
}
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
}
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
}
175
func publishableComponent(part string) string {
176
if strings.HasPrefix(part, ".") {
177
return dotReplacement + strings.TrimPrefix(part, ".")
178
}
179
return part
180
}
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
}
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
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}
317
type staticPolicy struct {
318
Exception string `json:"exception"`
319
Acknowledged bool `json:"acknowledged"`
320
}
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
}
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
}
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
}
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
}
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
}
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
}
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
}