├── LICENSE
├── README.md
├── cell.go
├── cmd
└── devcards
│ └── main.go
├── devcard.go
├── docs
└── screenshot.png
├── go.mod
├── go.sum
├── pkg
├── internal
│ ├── file
│ │ └── file.go
│ └── project
│ │ ├── bundle_updates.go
│ │ ├── devcard_main.go
│ │ ├── devcard_main.template
│ │ ├── project.go
│ │ ├── repo.go
│ │ └── run.go
├── runtime
│ └── produce.go
└── server
│ ├── assets
│ ├── body.html
│ ├── dark.css
│ ├── favicon.png
│ ├── gruvbox-dark.css
│ ├── gruvbox-light.css
│ ├── head.html
│ ├── javascript.js
│ ├── light.css
│ └── new.css
│ ├── client-devcard.go
│ ├── client-error.go
│ ├── client-list-devcards.go
│ ├── client.go
│ ├── config.go
│ ├── highlight.go
│ ├── open.go
│ ├── page.go
│ ├── render.go
│ ├── routes.go
│ └── server.go
├── producer.go
└── text.go
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 igorhub
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Devcards
2 |
3 | Devcards provides interactive visual environment for Go,
4 | in a way that's similar to REPL and computational notebooks such as Jupyter.
5 |
6 |
7 |
8 | # How it works
9 |
10 | A devcard is a playground for visualizations and quick experiments.
11 | You write its code with our own editor in you own codebase,
12 | and the devcards web app turns it into a web page that's shown in your browser.
13 | The app watches your project's source files and re-renders the page on each save.
14 |
15 | See this [short video](https://youtu.be/0wthHbPtnuc) for a quick demonstration.
16 |
17 |
18 | # Getting started
19 |
20 | Perhaps the easiest place to start is to the [devcard examples repo](https://github.com/igorhub/devcard-examples).
21 |
22 | If you went through the examples already
23 | (or have no patience for toy code)
24 | follow the following instructions.
25 |
26 | Install the devcards web app:
27 |
28 | go install github.com/igorhub/devcard/cmd/devcards@latest
29 |
30 | Add devcard dependency to your Go modules (I recommend making a separate branch for it):
31 |
32 | go get github.com/igorhub/devcard
33 |
34 | Start devcards from your project's directory (alternatively, add your project to the config file):
35 |
36 | cd /path/to/your/project
37 | devcards
38 |
39 |
40 | Write your first devcard:
41 | ```go
42 | package yourpackage
43 |
44 | import "github.com/igorhub/devcard"
45 |
46 | func DevcardFoobar(dc *devcard.Devcard) {
47 | dc.SetTitle("Untitled")
48 |
49 | dc.Md("This is a new devcard...")
50 | }
51 | ```
52 |
53 |
54 | # Documentation
55 |
56 | For introduction into devcards, see [devcard examples pages](https://igorhub.github.io/devcard-examples/DevcardAnatomy.html).
57 |
58 | For API reference, see https://godocs.io/github.com/igorhub/devcard.
59 |
60 |
61 | # Troubleshooting
62 |
63 | Devcards is a young project.
64 | I've done reasonable job ironing out the bugs, but I expect some to still lurk beneath the surface.
65 | In most cases simply refreshing the page will fix everything.
66 | Still, please let me know about the errors you encounter.
67 | This will make the project better.
68 | I appreciate your help.
69 |
70 |
71 | # Acknowledgements
72 |
73 | * Devcards owes its name and primary idea to Bruce Hauman's [devcards](https://github.com/bhauman/devcards),
74 | although it's more bare-bones and limited in scope.
75 |
76 | * Devcards' builtin CSS style is based upon the excellent [new.css](https://github.com/xz/new.css).
77 |
78 | * Ace of Spades icon is designed by [DesignContest](http://www.designcontest.com/) / [CC BY](http://creativecommons.org/licenses/by/4.0/).
79 |
--------------------------------------------------------------------------------
/cell.go:
--------------------------------------------------------------------------------
1 | package devcard
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "image"
7 | "image/png"
8 | "io"
9 | "os"
10 | "path/filepath"
11 | "strings"
12 | )
13 |
14 | // Cell is a basic building block of a devcard.
15 | type Cell interface {
16 | Type() string
17 | Append(...any)
18 | Erase()
19 | }
20 |
21 | // UnmarshalCell unmarshalls JSON data into a Cell instance.
22 | func UnmarshalCell(cellType string, data []byte) (Cell, error) {
23 | candidates := []Cell{
24 | &MarkdownCell{},
25 | &HTMLCell{},
26 | &ErrorCell{},
27 | &MonospaceCell{},
28 | &ValueCell{},
29 | &AnnotatedValueCell{},
30 | &SourceCell{},
31 | &ImageCell{},
32 | &JumpCell{},
33 | &CustomCell{},
34 | }
35 |
36 | for _, c := range candidates {
37 | if cellType == c.Type() {
38 | err := json.Unmarshal(data, c)
39 | return c, err
40 | }
41 | }
42 | return nil, fmt.Errorf("unknown type of cell (%s)", cellType)
43 | }
44 |
45 | // HTMLCell is a cell with markdown-formatted text.
46 | type HTMLCell struct {
47 | HTML string `json:"html"`
48 | }
49 |
50 | // Returns "HTMLCell". Used for marshaling.
51 | func (c *HTMLCell) Type() string {
52 | return "HTMLCell"
53 | }
54 |
55 | // Append converts vals to strings and appends them to the cell.
56 | func (c *HTMLCell) Append(vals ...any) {
57 | c.HTML += valsToString(vals)
58 | }
59 |
60 | // Erase clears the content of the cell.
61 | func (c *HTMLCell) Erase() {
62 | c.HTML = ""
63 | }
64 |
65 | // NewHTMLCell creates [HTMLCell].
66 | func NewHTMLCell(vals ...any) *HTMLCell {
67 | c := &HTMLCell{}
68 | c.Append(vals...)
69 | return c
70 | }
71 |
72 | // MarkdownCell is a cell with markdown-formatted text.
73 | type MarkdownCell struct {
74 | Text string `json:"text"`
75 | }
76 |
77 | // Returns "MarkdownCell". Used for marshaling.
78 | func (c *MarkdownCell) Type() string {
79 | return "MarkdownCell"
80 | }
81 |
82 | // Append converts vals to strings and appends them to the cell.
83 | func (c *MarkdownCell) Append(vals ...any) {
84 | s := new(strings.Builder)
85 | for _, val := range vals {
86 | if str, ok := val.(string); ok {
87 | s.WriteString(str)
88 | } else {
89 | s.WriteString("`" + valToString(val) + "`")
90 | }
91 | }
92 | if c.Text != "" && s.Len() > 0 {
93 | c.Text += " "
94 | }
95 | c.Text += s.String()
96 | }
97 |
98 | // Erase clears the content of the cell.
99 | func (c *MarkdownCell) Erase() {
100 | c.Text = ""
101 | }
102 |
103 | // NewMarkdownCell creates [MarkdownCell].
104 | func NewMarkdownCell(vals ...any) *MarkdownCell {
105 | c := &MarkdownCell{}
106 | c.Append(vals...)
107 | return c
108 | }
109 |
110 | // ErrorCell is a cell for error reporting.
111 | type ErrorCell struct {
112 | Title string
113 | Body string
114 | }
115 |
116 | // Returns "ErrorCell". Used for marshaling.
117 | func (c *ErrorCell) Type() string {
118 | return "ErrorCell"
119 | }
120 |
121 | // Append appends vals to the [ErrorCell].
122 | //
123 | // When the cell is blank, the first of vals becomes the cell's title and the
124 | // rest become its body. If the cell is not blank, all vals become the cell's
125 | // body.
126 | func (c *ErrorCell) Append(vals ...any) {
127 | switch {
128 | case c.Title == "" && len(vals) == 1:
129 | c.Title = valsToString(vals)
130 | case c.Title == "" && len(vals) > 1:
131 | c.Title = valsToString(vals[:1])
132 | c.Body = valsToString(vals[1:])
133 | case c.Title != "":
134 | if c.Body != "" {
135 | c.Body += "\n"
136 | }
137 | c.Body += valsToString(vals)
138 | }
139 | }
140 |
141 | // Erase clears the content of the cell.
142 | func (c *ErrorCell) Erase() {
143 | c.Title = ""
144 | c.Body = ""
145 | }
146 |
147 | // NewErrorCell creates [ErrorCell].
148 | func NewErrorCell(vals ...any) *ErrorCell {
149 | c := &ErrorCell{}
150 | c.Append(vals...)
151 | return c
152 | }
153 |
154 | // MonospaceCell is a cell that's supposed to be rendered as monospace, such as block of code.
155 | type MonospaceCell struct {
156 | Text string `json:"text"`
157 | Highlighting string `json:"highlighting"`
158 | }
159 |
160 | // Returns "MonospaceCell". Used for marshaling.
161 | func (c *MonospaceCell) Type() string {
162 | return "MonospaceCell"
163 | }
164 |
165 | type monospaceCellOption func(*MonospaceCell)
166 |
167 | // WithHighlighting is an option for [Devcard.Mono]. It enables syntax
168 | // highlighting for the code in a [MonospaceCell].
169 | func WithHighlighting(lang string) monospaceCellOption {
170 | return func(c *MonospaceCell) {
171 | c.Highlighting = lang
172 | }
173 | }
174 |
175 | // Append converts vals to strings and appends them to the cell.
176 | // [WithHighlighting] option can be used at any position to enable syntax
177 | // highlighting. See [Devcard.Mono] for example.
178 | func (c *MonospaceCell) Append(vals ...any) {
179 | i := 0
180 | for _, val := range vals {
181 | if opt, ok := val.(monospaceCellOption); ok {
182 | opt(c)
183 | } else {
184 | vals[i] = val
185 | i++
186 | }
187 | }
188 | vals = vals[:i]
189 |
190 | s := valsToString(vals)
191 | if c.Text != "" {
192 | c.Text += "\n"
193 | }
194 | c.Text += s
195 | }
196 |
197 | // Erase clears the content of the cell.
198 | func (c *MonospaceCell) Erase() {
199 | c.Text = ""
200 | }
201 |
202 | // NewMonospaceCell creates [MonospaceCell].
203 | func NewMonospaceCell(vals ...any) *MonospaceCell {
204 | c := &MonospaceCell{Text: ""}
205 | c.Append(vals...)
206 | return c
207 | }
208 |
209 | // ValueCell is a cell with pretty-printed Go values.
210 | type ValueCell struct {
211 | Values []string `json:"values"`
212 | }
213 |
214 | // Returns "ValueCell". Used for marshaling.
215 | func (c *ValueCell) Type() string {
216 | return "ValueCell"
217 | }
218 |
219 | // Append appends pretty-printed vals to the cell.
220 | func (c *ValueCell) Append(vals ...any) {
221 | for _, v := range vals {
222 | c.Values = append(c.Values, pprint(v))
223 | }
224 | }
225 |
226 | // Erase clears the content of the cell.
227 | func (c *ValueCell) Erase() {
228 | c.Values = []string{}
229 | }
230 |
231 | // NewValueCell creates [ValueCell].
232 | func NewValueCell(vals ...any) *ValueCell {
233 | c := &ValueCell{Values: []string{}}
234 | c.Append(vals...)
235 | return c
236 | }
237 |
238 | // AnnotatedValueCell is a cell with pretty-printed Go values that have comments
239 | // attached to them.
240 | type AnnotatedValueCell struct {
241 | AnnotatedValues []AnnotatedValue `json:"marked_values"`
242 | }
243 |
244 | // AnnotatedValueCell contains pretty-printed Go value and its description/annotation.
245 | type AnnotatedValue struct {
246 | Annotation string `json:"annotation"`
247 | Value string `json:"value"`
248 | }
249 |
250 | // Returns "AnnotatedValueCell". Used for marshaling.
251 | func (c *AnnotatedValueCell) Type() string {
252 | return "AnnotatedValueCell"
253 | }
254 |
255 | type annotatedVal struct {
256 | annotation string
257 | val any
258 | }
259 |
260 | func splitAnnotations(avals []any) []annotatedVal {
261 | var result []annotatedVal
262 | for i := 0; i < len(avals); i += 2 {
263 | var av annotatedVal
264 | if i+1 < len(avals) {
265 | av.annotation = valToString(avals[i])
266 | av.val = avals[i+1]
267 | } else {
268 | av.val = avals[i]
269 | }
270 | result = append(result, av)
271 | }
272 | return result
273 | }
274 |
275 | // Append appends one or more AnnotatedValues to the cell. annotationsAndVals
276 | // are converted to annotated values by the rules described in [Devcard.Ann].
277 | func (c *AnnotatedValueCell) Append(annotationsAndVals ...any) {
278 | for _, av := range splitAnnotations(annotationsAndVals) {
279 | c.AnnotatedValues = append(c.AnnotatedValues, AnnotatedValue{av.annotation, pprint(av.val)})
280 | }
281 | }
282 |
283 | // Erase clears the content of the cell.
284 | func (c *AnnotatedValueCell) Erase() {
285 | c.AnnotatedValues = []AnnotatedValue{}
286 | }
287 |
288 | // NewAnnotatedValueCell creates [AnnotatedValueCell].
289 | func NewAnnotatedValueCell(annotationsAndVals ...any) *AnnotatedValueCell {
290 | c := &AnnotatedValueCell{AnnotatedValues: []AnnotatedValue{}}
291 | c.Append(annotationsAndVals...)
292 | return c
293 | }
294 |
295 | // SourceCell is a cell with source code of a function.
296 | type SourceCell struct {
297 | Decls []string `json:"decls"`
298 | }
299 |
300 | // Returns "SourceCell". Used for marshaling.
301 | func (c *SourceCell) Type() string {
302 | return "SourceCell"
303 | }
304 |
305 | // Append converts vals to strings and appends them to the cell.
306 | func (c *SourceCell) Append(vals ...any) {
307 | for _, val := range vals {
308 | c.Decls = append(c.Decls, valToString(val))
309 | }
310 | }
311 |
312 | // Erase clears the content of the cell.
313 | func (c *SourceCell) Erase() {
314 | c.Decls = c.Decls[0:0:0]
315 | }
316 |
317 | // NewSourceCell creates [SourceCell].
318 | func NewSourceCell(decls ...string) *SourceCell {
319 | c := &SourceCell{}
320 | for _, decl := range decls {
321 | c.Append(decl)
322 | }
323 | return c
324 | }
325 |
326 | // ImageCell is a cell with annotated images.
327 | type ImageCell struct {
328 | Images []AnnotatedImage `json:"images"`
329 | Error *ErrorCell `json:"error"`
330 |
331 | tempDir string
332 | }
333 |
334 | // AnnotatedImage as an image with its description.
335 | type AnnotatedImage struct {
336 | Annotation string `json:"comment"`
337 | Path string `json:"value"`
338 | }
339 |
340 | // Returns "ImageCell". Used for marshaling.
341 | func (c *ImageCell) Type() string {
342 | return "ImageCell"
343 | }
344 |
345 | func annotatedImages(tempDir string, vals []any) ([]AnnotatedImage, *ErrorCell) {
346 | var result []AnnotatedImage
347 | for _, av := range splitAnnotations(vals) {
348 | switch x := av.val.(type) {
349 | case string:
350 | in, err := os.Open(x)
351 | if err != nil {
352 | return nil, NewErrorCell("ImageCell error: unable to read image file", err.Error())
353 | }
354 | defer in.Close()
355 | f, err := os.CreateTemp(tempDir, "temp-image-*"+filepath.Ext(x))
356 | if err != nil {
357 | return nil, NewErrorCell("ImageCell error: unable to create a temporary file for an image", err.Error())
358 | }
359 | defer f.Close()
360 | _, err = io.Copy(f, in)
361 | if err != nil {
362 | return nil, NewErrorCell("ImageCell error: unable to copy image to the temporary directory", err.Error())
363 | }
364 | result = append(result, AnnotatedImage{av.annotation, f.Name()})
365 | case image.Image:
366 | f, err := os.CreateTemp(tempDir, "temp-image-*.png")
367 | if err != nil {
368 | return nil, NewErrorCell("ImageCell error: unable to create a temporary file for an image", err.Error())
369 | }
370 | defer f.Close()
371 | err = png.Encode(f, x)
372 | if err != nil {
373 | return nil, NewErrorCell("ImageCell error: unable to encode an image", err.Error())
374 | }
375 | result = append(result, AnnotatedImage{av.annotation, f.Name()})
376 | case nil:
377 | panic("image must not be nil")
378 | default:
379 | panic("image must be either a path to an image file or an instance of image.Image")
380 | }
381 | }
382 | return result, nil
383 | }
384 |
385 | // Append appends one or more AnnotatedImages to the cell. vals are converted to
386 | // annotated images by the rules described in [Devcard.Image].
387 | func (c *ImageCell) Append(vals ...any) {
388 | // Empty tempDir means we're dealing with a dummy devcard; return immediately.
389 | if c.tempDir == "" {
390 | return
391 | }
392 |
393 | ai, err := annotatedImages(c.tempDir, vals)
394 | if err != nil {
395 | c.Error = err
396 | } else {
397 | c.Images = append(c.Images, ai...)
398 | }
399 | }
400 |
401 | // Erase clears the content of the cell.
402 | func (c *ImageCell) Erase() {
403 | c.Images = c.Images[0:0:0]
404 | }
405 |
406 | // NewImageCell creates [ImageCell].
407 | func NewImageCell(tempDir string, vals ...any) *ImageCell {
408 | c := &ImageCell{tempDir: tempDir, Images: []AnnotatedImage{}}
409 | c.Append(vals...)
410 | return c
411 | }
412 |
413 | type customCell interface {
414 | Cell
415 | Cast() Cell
416 | }
417 |
418 | // CustomCell provides a base for user-defined cells.
419 | //
420 | // It implements [Cell] interface by providing Type, Append, and Erase methods
421 | // that don't do anything.
422 | type CustomCell struct{}
423 |
424 | // Returns "CustomCell".
425 | // Not used anywhere; implemented to satisfy [Cell] interface.
426 | func (c *CustomCell) Type() string {
427 | return "CustomCell"
428 | }
429 |
430 | // Append panics by default. Custom Append might be implemented by user.
431 | func (c *CustomCell) Append(vals ...any) {
432 | panic("method Append is not implemented for this custom cell")
433 | }
434 |
435 | // Erase panics by default. Custom Erase might be implemented by user.
436 | func (c *CustomCell) Erase() {
437 | panic("method Erase is not implemented for this custom cell")
438 | }
439 |
440 | // Custom appends a custom cell to the bottom of the devcard.
441 | //
442 | // The appended HTMLCell is immediately sent to the client.
443 | func (d *Devcard) Custom(cell customCell) {
444 | d.lock.Lock()
445 | defer d.lock.Unlock()
446 | d.Cells = append(d.Cells, cell)
447 | d.sendLastCell()
448 | }
449 |
450 | // Default JumpCell delay, in milliseconds.
451 | var DefaultJumpDelay = 50
452 |
453 | // JumpCell is a cell to which we scroll when it's rendered.
454 | type JumpCell struct {
455 | // Delay in milliseconds.
456 | Delay int
457 | }
458 |
459 | // Returns "JumpCell". Used for marshaling.
460 | func (c *JumpCell) Type() string {
461 | return "JumpCell"
462 | }
463 |
464 | // Noop.
465 | func (c *JumpCell) Append(vals ...any) {
466 | }
467 |
468 | // Noop.
469 | func (c *JumpCell) Erase() {
470 | }
471 |
472 | // NewJumpCell creates [JumpCell].
473 | func NewJumpCell() *JumpCell {
474 | cell := &JumpCell{Delay: DefaultJumpDelay}
475 | return cell
476 | }
477 |
--------------------------------------------------------------------------------
/cmd/devcards/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "context"
5 | "flag"
6 | "fmt"
7 | "log"
8 | "net/http"
9 | "os"
10 | "os/signal"
11 | "time"
12 |
13 | "github.com/igorhub/devcard/pkg/server"
14 | )
15 |
16 | const version = "v0.11.0"
17 |
18 | func run(cfg server.Config) (restart bool) {
19 | restartC := make(chan struct{})
20 | server := server.NewServer(cfg, restartC)
21 | httpServer := http.Server{
22 | Addr: fmt.Sprintf(":%d", cfg.Port),
23 | Handler: server,
24 | }
25 |
26 | ctx := context.Background()
27 | ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
28 | defer cancel()
29 |
30 | go func() {
31 | log.Printf("Starting devcards...")
32 | log.Printf("Access the app via the following URL: http://127.0.0.1:%d\n", cfg.Port)
33 | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
34 | log.Println("Error running httpServer.ListenAndServe:", err)
35 | os.Exit(1)
36 | }
37 | }()
38 |
39 | done := make(chan struct{})
40 | go func() {
41 | select {
42 | case <-restartC:
43 | restart = true
44 | case <-ctx.Done():
45 | }
46 | log.Println("Shutting down the server...")
47 | server.Shutdown()
48 |
49 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
50 | defer cancel()
51 | if err := httpServer.Shutdown(shutdownCtx); err != nil {
52 | log.Println("Error running httpServer.Shutdown:", err)
53 | }
54 | close(done)
55 | }()
56 | <-done
57 | return restart
58 | }
59 |
60 | func main() {
61 | var port int
62 | var showVersion bool
63 | flag.IntVar(&port, "port", 0, "Port for the devcards server")
64 | flag.BoolVar(&showVersion, "version", false, "Show version")
65 | flag.Parse()
66 |
67 | if showVersion {
68 | fmt.Println(version)
69 | os.Exit(0)
70 | }
71 |
72 | for {
73 | cfg := server.LoadConfig()
74 | if port != 0 {
75 | cfg.Port = port
76 | }
77 | restart := run(cfg)
78 | if !restart {
79 | break
80 | }
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/devcard.go:
--------------------------------------------------------------------------------
1 | // Package devcard describes the [Devcard] and its primary building block, [Cell].
2 | //
3 | // For proper introduction, see README.
4 | package devcard
5 |
6 | import (
7 | "encoding/json"
8 | "fmt"
9 | "slices"
10 | "strconv"
11 | "sync"
12 | )
13 |
14 | // Devcard struct represents the devcard shown to the user.
15 | //
16 | // It's responsible for maintaining its list of renderable cells and for
17 | // interaction with the server.
18 | //
19 | // It's safe for concurrent use.
20 | type Devcard struct {
21 | Title string `json:"title"`
22 | TempDir string `json:"temp_dir"`
23 | Cells []Cell `json:"cells"`
24 |
25 | lock sync.RWMutex
26 | control chan string
27 | updates chan string
28 | }
29 |
30 | func newDevcard(title, tempDir string) *Devcard {
31 | return &Devcard{
32 | Title: title,
33 | TempDir: tempDir,
34 | Cells: []Cell{},
35 |
36 | control: make(chan string),
37 | updates: make(chan string, 4096),
38 | }
39 | }
40 |
41 | // Debug facilitates debugging. To debug a devcard, either put a call to Debug
42 | // in the main(), or wrap it in a test function, and then use "Debug Test"
43 | // feature of your IDE.
44 | //
45 | // Example:
46 | //
47 | // func TestDevcardFoobar(t *testing.T) {
48 | // devcard.Debug(DevcardFoobar)
49 | // }
50 | func Debug(producer DevcardProducer) {
51 | current = &Devcard{
52 | Title: "Dummy devcard",
53 | TempDir: "",
54 | Cells: []Cell{},
55 | }
56 | producer(current)
57 | }
58 |
59 | // DevcardInfo describes devcard's metadata.
60 | type DevcardInfo struct {
61 | // ImportPath is the import path of the devcard's package.
62 | ImportPath string
63 |
64 | // Package is the name of the package that the devcard belongs to.
65 | Package string
66 |
67 | // Path is the relative path (from the project dir) to the source file where
68 | // the devcard is located.
69 | Path string
70 |
71 | // Line is a line number in the source file where the devcard is located.
72 | Line int
73 |
74 | // Name is the name of the devcard-producing function.
75 | Name string
76 |
77 | // Title is the title of the devcard.
78 | Title string
79 | }
80 |
81 | // Caption returns the devcard's title, or, in case it's empty, the name of
82 | // devcard-producing function.
83 | func (di DevcardInfo) Caption() string {
84 | if di.Title != "" {
85 | return di.Title
86 | }
87 | return di.Name
88 | }
89 |
90 | var current *Devcard
91 |
92 | // Current returns a global pointer to the devcard that's currently being
93 | // produced. It's exposed to allow the user to access the current devcard from
94 | // any arbitrary place.
95 | func Current() *Devcard {
96 | return current
97 | }
98 |
99 | // Message types are used for communication with devcards server via TCP connection.
100 | const (
101 | MessageTypeCell = "cell"
102 | MessageTypeInfo = "info"
103 | MessageTypeError = "internal error"
104 | )
105 |
106 | func (d *Devcard) send(msg map[string]any) {
107 | if d.updates == nil {
108 | return
109 | }
110 | data, err := json.Marshal(msg)
111 | if err != nil {
112 | data, _ = json.Marshal(map[string]string{
113 | "msg_type": MessageTypeError,
114 | "error": err.Error(),
115 | })
116 | }
117 | d.updates <- string(data)
118 | }
119 |
120 | func (d *Devcard) sendCell(index int) {
121 | cell := d.Cells[index]
122 | if customCell, ok := cell.(customCell); ok {
123 | cell = customCell.Cast()
124 | }
125 | d.send(map[string]any{
126 | "msg_type": MessageTypeCell,
127 | "cell_type": cell.Type(),
128 | "id": "b" + strconv.Itoa(index),
129 | "cell": cell,
130 | })
131 | }
132 |
133 | func (d *Devcard) sendLastCell() {
134 | d.sendCell(len(d.Cells) - 1)
135 | }
136 |
137 | func (d *Devcard) sendInfo() {
138 | d.send(map[string]any{
139 | "msg_type": MessageTypeInfo,
140 | "title": d.Title,
141 | })
142 | }
143 |
144 | // SetTitle sets the devcard's title and updates it on the client.
145 | func (d *Devcard) SetTitle(title string) {
146 | d.lock.Lock()
147 | defer d.lock.Unlock()
148 | d.Title = title
149 | d.sendInfo()
150 | }
151 |
152 | // Md appends a [MarkdownCell] to the bottom of the devcard. vals are converted into
153 | // strings and concatenated.
154 | //
155 | // The appended MarkdownCell is immediately sent to the client.
156 | func (d *Devcard) Md(vals ...any) *MarkdownCell {
157 | d.lock.Lock()
158 | defer d.lock.Unlock()
159 | cell := NewMarkdownCell(vals...)
160 | d.Cells = append(d.Cells, cell)
161 | d.sendLastCell()
162 | return cell
163 | }
164 |
165 | // Html appends an [HTMLCell] to the bottom of the devcard. vals are converted
166 | // into strings and concatenated.
167 | //
168 | // The appended HTMLCell is immediately sent to the client.
169 | func (d *Devcard) Html(vals ...any) *HTMLCell {
170 | d.lock.Lock()
171 | defer d.lock.Unlock()
172 | cell := NewHTMLCell(vals...)
173 | d.Cells = append(d.Cells, cell)
174 | d.sendLastCell()
175 | return cell
176 | }
177 |
178 | // MdFmt is a convenience wrapper for [Devcard.Md].
179 | //
180 | // It's implemented as `return d.Md(fmt.Sprintf(format, a...))`.
181 | func (d *Devcard) MdFmt(format string, a ...any) *MarkdownCell {
182 | return d.Md(fmt.Sprintf(format, a...))
183 | }
184 |
185 | // Error appends an [ErrorCell] to the bottom of the devcard.
186 | //
187 | // The first of vals becomes the cell's title; the rest are converted into
188 | // strings, concatenated, and become the cell's body.
189 | //
190 | // The appended ErrorCell is immediately sent to the client.
191 | func (d *Devcard) Error(vals ...any) *ErrorCell {
192 | d.lock.Lock()
193 | defer d.lock.Unlock()
194 | cell := NewErrorCell(vals...)
195 | d.Cells = append(d.Cells, cell)
196 | d.sendLastCell()
197 | return cell
198 | }
199 |
200 | // Mono appends a [MonospaceCell] to the bottom of the devcard. vals are
201 | // converted into strings and concatenated.
202 | //
203 | // [WithHighlighting] option can be used at any position to enable syntax highlighting. For example:
204 | //
205 | // c.Mono(devcard.WithHighlighting("clojure"), "(def ^:private *registry (atom {}))")
206 | //
207 | // The appended MonospaceCell is immediately sent to the client.
208 | func (d *Devcard) Mono(vals ...any) *MonospaceCell {
209 | d.lock.Lock()
210 | defer d.lock.Unlock()
211 | cell := NewMonospaceCell(vals...)
212 | d.Cells = append(d.Cells, cell)
213 | d.sendLastCell()
214 | return cell
215 | }
216 |
217 | // MonoFmt is a convenience wrapper for Mono.
218 | //
219 | // It's implemented as `return d.Mono(fmt.Sprintf(format, a...))`.
220 | func (d *Devcard) MonoFmt(format string, a ...any) *MonospaceCell {
221 | return d.Mono(fmt.Sprintf(format, a...))
222 | }
223 |
224 | // Val appends a [ValueCell] to the bottom of the devcard. vals are
225 | // pretty-printed and joined together.
226 | //
227 | // The appended ValueCell is immediately sent to the client.
228 | func (d *Devcard) Val(vals ...any) *ValueCell {
229 | d.lock.Lock()
230 | defer d.lock.Unlock()
231 | cell := NewValueCell(vals...)
232 | d.Cells = append(d.Cells, cell)
233 | d.sendLastCell()
234 | return cell
235 | }
236 |
237 | // Ann appends an [AnnotatedValueCell] to the bottom of the devcard.
238 | // annotationsAndVals are split into pairs: the first value of each pair becomes
239 | // an annotation, the second value becomes a pretty-printed value.
240 | //
241 | // Example:
242 | //
243 | // c.Ann("Loaded config:", cfg, "Default config:", defaultConfig())
244 | //
245 | // The appended AnnotatedValueCell is immediately sent to the client.
246 | func (d *Devcard) Ann(annotationsAndVals ...any) *AnnotatedValueCell {
247 | d.lock.Lock()
248 | defer d.lock.Unlock()
249 | cell := NewAnnotatedValueCell(annotationsAndVals...)
250 | d.Cells = append(d.Cells, cell)
251 | d.sendLastCell()
252 | return cell
253 | }
254 |
255 | // Source appends a [SourceCell] to the bottom of the devcard.
256 | //
257 | // The cell contains the source of the declarations decls. As of now, only
258 | // function declarations are supported. Declarations must be prefixed with the
259 | // name of their package. For example:
260 | //
261 | // c.Source("examples.DevcardTextCells")
262 | //
263 | // The appended SourceCell is immediately sent to the client.
264 | func (d *Devcard) Source(decls ...string) *SourceCell {
265 | d.lock.Lock()
266 | defer d.lock.Unlock()
267 | cell := NewSourceCell(decls...)
268 | d.Cells = append(d.Cells, cell)
269 | d.sendLastCell()
270 | return cell
271 | }
272 |
273 | // Image appends an [ImageCell] to the bottom of the devcard. annotationsAndVals
274 | // are split into pairs: the first value of each pair becomes an annotation, the
275 | // second value becomes an image.
276 | //
277 | // An image can be either an absolute path to the image file, or an instance of
278 | // [image.Image].
279 | //
280 | // When called with a single argument, the argument is treated as image, not
281 | // annotation. For example:
282 | //
283 | // c.Image("/home/ivk/Pictures/wallhaven-n6mrgl.jpg")
284 | //
285 | // // With annotation
286 | // c.Image("Two cats sitting on a tree", "/home/ivk/Pictures/wallhaven-n6mrgl.jpg")
287 | //
288 | // The appended ImageCell is immediately sent to the client.
289 | func (d *Devcard) Image(annotationsAndImages ...any) *ImageCell {
290 | d.lock.Lock()
291 | defer d.lock.Unlock()
292 | cell := NewImageCell(d.TempDir)
293 | cell.Append(annotationsAndImages...)
294 | d.Cells = append(d.Cells, cell)
295 | d.sendLastCell()
296 | return cell
297 | }
298 |
299 | // Not documented. Subject to change.
300 | func (d *Devcard) Jump() *JumpCell {
301 | d.lock.Lock()
302 | defer d.lock.Unlock()
303 | cell := NewJumpCell()
304 | d.Cells = append(d.Cells, cell)
305 | d.sendLastCell()
306 | return cell
307 | }
308 |
309 | // Append appends values to the bottom cell of the devcard. The exact behavior
310 | // is dictated by the concrete type of the bottom cell.
311 | //
312 | // - For [MarkdownCell], same rules as in [Devcard.Md] apply.
313 | // - For [ErrorCell], same rules as in [Devcard.Error] apply.
314 | // - For [MonospaceCell], same rules as in [Devcard.Mono] apply.
315 | // - For [ValueCell], same rules as in [Devcard.Val] apply.
316 | // - Fro [AnnotatedValueCell], same rules as in [Devcard.Ann] apply.
317 | // - For [ImageCell], same rules as in [Devcard.Image] apply.
318 | // - For other types of cells, Append is a noop.
319 | //
320 | // The bottom cell is immediately sent to the client.
321 | func (d *Devcard) Append(vals ...any) {
322 | d.lock.Lock()
323 | defer d.lock.Unlock()
324 | i := len(d.Cells) - 1
325 | if i < 0 {
326 | // If there are no cells yet, create a new one.
327 | d.Cells = append(d.Cells, NewMonospaceCell())
328 | i = 0
329 | }
330 | d.Cells[i].Append(vals...)
331 | d.sendLastCell()
332 | }
333 |
334 | type cellError struct {
335 | cell Cell
336 | }
337 |
338 | func (e *cellError) Error() string {
339 | return "this cell is not included in the devcard"
340 | }
341 |
342 | // Erase clears the content of the cell.
343 | //
344 | // The cell is not removed from the devcard, and can be reused later on.
345 | //
346 | // The resulting blank cell is immediately sent to the client.
347 | func (d *Devcard) Erase(cell Cell) {
348 | d.lock.Lock()
349 | defer d.lock.Unlock()
350 | i := slices.Index(d.Cells, cell)
351 | if i == -1 {
352 | panic(&cellError{cell})
353 | }
354 | d.Cells[i].Erase()
355 | d.sendCell(i)
356 | }
357 |
358 | // EraseLast clears the content of the bottom cell of the devcard.
359 | //
360 | // The cell is not removed from the devcard, and can be reused later on.
361 | //
362 | // The blank bottom cell is immediately sent to the client.
363 | func (d *Devcard) EraseLast() {
364 | d.lock.Lock()
365 | defer d.lock.Unlock()
366 | i := len(d.Cells) - 1
367 | if i < 0 {
368 | return
369 | }
370 | d.Cells[i].Erase()
371 | d.sendLastCell()
372 | }
373 |
374 | // Replace replaces oldCell with newCell.
375 | //
376 | // The new cell is immediately sent to the client.
377 | func (d *Devcard) Replace(oldCell, newCell Cell) {
378 | d.lock.Lock()
379 | defer d.lock.Unlock()
380 | i := slices.Index(d.Cells, oldCell)
381 | if i == -1 {
382 | panic(&cellError{oldCell})
383 | }
384 | d.Cells[i] = newCell
385 | d.sendCell(i)
386 | }
387 |
388 | // ReplaceLast replaces the bottom cell of the devcard with newCell.
389 | //
390 | // The new cell is immediately sent to the client.
391 | func (d *Devcard) ReplaceLast(newCell Cell) {
392 | d.lock.Lock()
393 | defer d.lock.Unlock()
394 | i := len(d.Cells) - 1
395 | if i < 0 {
396 | return
397 | }
398 | d.Cells[i] = newCell
399 | d.sendLastCell()
400 | }
401 |
402 | // Update sends the cell to the client.
403 | //
404 | // The cell must be contained by the devcard.
405 | func (d *Devcard) Update(cell Cell) {
406 | d.lock.Lock()
407 | defer d.lock.Unlock()
408 | i := slices.Index(d.Cells, cell)
409 | if i == -1 {
410 | panic(&cellError{cell})
411 | }
412 | d.sendCell(i)
413 | }
414 |
415 | // MarshalJSON marshals the devcard into JSON data.
416 | func (d *Devcard) MarshalJSON() ([]byte, error) {
417 | d.lock.RLock()
418 | defer d.lock.RUnlock()
419 | jsoncells := make([]struct {
420 | Type string `json:"type"`
421 | Cell Cell `json:"cell"`
422 | }, len(d.Cells))
423 |
424 | for i, c := range d.Cells {
425 | jsoncells[i].Type = c.Type()
426 | jsoncells[i].Cell = c
427 | }
428 |
429 | return json.Marshal(map[string]any{
430 | "title": d.Title,
431 | "cells": jsoncells,
432 | })
433 | }
434 |
435 | // UnmarshalJSON unmarshals JSON data into the devcard.
436 | func (d *Devcard) UnmarshalJSON(data []byte) error {
437 | d.lock.Lock()
438 | defer d.lock.Unlock()
439 |
440 | type jsoncell struct {
441 | Type string `json:"type"`
442 | Cell *json.RawMessage `json:"cell"`
443 | }
444 |
445 | jsondevcard := struct {
446 | Title string `json:"title"`
447 | Cells []jsoncell `json:"cells"`
448 | }{}
449 |
450 | err := json.Unmarshal(data, &jsondevcard)
451 | if err != nil {
452 | return fmt.Errorf("unmarshal devcard: %w", err)
453 | }
454 |
455 | d.Title = jsondevcard.Title
456 | for _, c := range jsondevcard.Cells {
457 | if c.Cell == nil {
458 | return fmt.Errorf("unmarshal devcard cell: nil cell")
459 | }
460 | cell, err := UnmarshalCell(c.Type, *c.Cell)
461 | if err != nil {
462 | return fmt.Errorf("unmarshal devcard cell: %w", err)
463 | }
464 | d.Cells = append(d.Cells, cell)
465 | }
466 |
467 | return nil
468 | }
469 |
--------------------------------------------------------------------------------
/docs/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/igorhub/devcard/94c68ef2bbd585df4c95cae84631af14e82fc761/docs/screenshot.png
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/igorhub/devcard
2 |
3 | require github.com/sanity-io/litter v1.5.5
4 |
5 | require (
6 | github.com/BurntSushi/toml v1.3.2
7 | github.com/alecthomas/chroma/v2 v2.13.0
8 | github.com/fsnotify/fsnotify v1.7.0
9 | github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47
10 | github.com/gorilla/websocket v1.5.1
11 | golang.org/x/mod v0.15.0
12 | )
13 |
14 | require (
15 | github.com/dlclark/regexp2 v1.11.0 // indirect
16 | golang.org/x/net v0.17.0 // indirect
17 | golang.org/x/sys v0.13.0 // indirect
18 | )
19 |
20 | go 1.22
21 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
3 | github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
4 | github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
5 | github.com/alecthomas/chroma/v2 v2.13.0 h1:VP72+99Fb2zEcYM0MeaWJmV+xQvz5v5cxRHd+ooU1lI=
6 | github.com/alecthomas/chroma/v2 v2.13.0/go.mod h1:BUGjjsD+ndS6eX37YgTchSEG+Jg9Jv1GiZs9sqPqztk=
7 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
8 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
9 | github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b h1:XxMZvQZtTXpWMNWK82vdjCLCe7uGMFXdTsJH0v3Hkvw=
10 | github.com/davecgh/go-spew v0.0.0-20161028175848-04cdfd42973b/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11 | github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
12 | github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
13 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
14 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
15 | github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 h1:k4Tw0nt6lwro3Uin8eqoET7MDA4JnT8YgbCjc/g5E3k=
16 | github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
17 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
18 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
19 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
20 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
21 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0 h1:GD+A8+e+wFkqje55/2fOVnZPkoDIu1VooBWfNrnY8Uo=
22 | github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
23 | github.com/sanity-io/litter v1.5.5 h1:iE+sBxPBzoK6uaEP5Lt3fHNgpKcHXc/A2HGETy0uJQo=
24 | github.com/sanity-io/litter v1.5.5/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
25 | github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312 h1:UsFdQ3ZmlzS0BqZYGxvYaXvFGUbCmPGy8DM7qWJJiIQ=
26 | github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
27 | golang.org/x/mod v0.15.0 h1:SernR4v+D55NyBH2QiEQrlBAnj1ECL6AGrA5+dPaMY8=
28 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
29 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
30 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
31 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
32 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
33 |
--------------------------------------------------------------------------------
/pkg/internal/file/file.go:
--------------------------------------------------------------------------------
1 | package file
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "os"
7 | )
8 |
9 | func Copy(src, dst string) error {
10 | in, err := os.Open(src)
11 | if err != nil {
12 | return fmt.Errorf("copy '%s' to '%s': %w", src, dst, err)
13 | }
14 | defer in.Close()
15 | out, err := os.Create(dst)
16 | if err != nil {
17 | return fmt.Errorf("copy '%s' to '%s': %w", src, dst, err)
18 | }
19 | defer out.Close()
20 | _, err = io.Copy(out, in)
21 | if err != nil {
22 | return fmt.Errorf("copy '%s' to '%s': %w", src, dst, err)
23 | }
24 | return nil
25 | }
26 |
--------------------------------------------------------------------------------
/pkg/internal/project/bundle_updates.go:
--------------------------------------------------------------------------------
1 | package project
2 |
3 | import "time"
4 |
5 | func bundle(dur time.Duration, src <-chan struct{}, dst chan<- struct{}) {
6 | ch := make(chan uint64)
7 | var currentId uint64
8 | go func() {
9 | for {
10 | select {
11 | case _, ok := <-src:
12 | if !ok {
13 | dst <- struct{}{}
14 | return
15 | }
16 | currentId++
17 | go func(id uint64) {
18 | time.Sleep(dur)
19 | ch <- id
20 | }(currentId)
21 | case id := <-ch:
22 | if id == currentId {
23 | dst <- struct{}{}
24 | }
25 | }
26 | }
27 | }()
28 | }
29 |
--------------------------------------------------------------------------------
/pkg/internal/project/devcard_main.go:
--------------------------------------------------------------------------------
1 | package project
2 |
3 | import (
4 | "bytes"
5 | _ "embed"
6 | "fmt"
7 | "os"
8 | "path/filepath"
9 | "slices"
10 | "strings"
11 | "text/template"
12 |
13 | "github.com/igorhub/devcard"
14 | )
15 |
16 | const (
17 | generatedMainDir = "generated_devcard_main"
18 | generatedMainFile = "generated_devcard_main.go"
19 | generatedInjectionFile = "generated_devcard_injection.go"
20 | )
21 |
22 | var devcardMainTemplate = makeDevcardMainTemplate()
23 |
24 | //go:embed devcard_main.template
25 | var templateText string
26 |
27 | func makeDevcardMainTemplate() *template.Template {
28 | result := template.New("devcard_main.template")
29 | result.Funcs(template.FuncMap{"producerName": producerName})
30 | result, err := result.Parse(templateText)
31 | if err != nil {
32 | panic(err)
33 | }
34 | return result
35 | }
36 |
37 | func producerName(devcard string) string {
38 | return strings.TrimPrefix(devcard, "main.")
39 | }
40 |
41 | type templateData = struct {
42 | Import string
43 | Card string
44 | }
45 |
46 | // devcardMain generates a Go source for the file with the main function.
47 | func devcardMain(info devcard.DevcardInfo) []byte {
48 | data := templateData{}
49 | if info.Package != "main" {
50 | data.Import = fmt.Sprintf("dc \"%s\"", info.ImportPath)
51 | data.Card = "dc." + info.Name
52 | } else {
53 | data.Card = info.Name
54 | }
55 |
56 | var b bytes.Buffer
57 | err := devcardMainTemplate.Execute(&b, data)
58 | if err != nil {
59 | panic(fmt.Errorf("devcardMainTemplate failed to execute: %w", err))
60 | }
61 | return b.Bytes()
62 | }
63 |
64 | func FindMainDir(info devcard.DevcardInfo) string {
65 | // If the devcard is located in a main package, our main function must be
66 | // placed in the same director.
67 | if info.Package == "main" {
68 | return filepath.Dir(info.Path)
69 | }
70 |
71 | // If a devcard path is located in an internal directory, our main
72 | // package must be placed at its root.
73 | parts := splitPath(info.Path)
74 | if i := indexLast(parts, "internal"); i > 0 {
75 | parts = append(parts[:i+1], generatedMainDir)
76 | return filepath.Join(parts...)
77 | }
78 |
79 | // Otherwise, we may create the directory for our main package anywhere;
80 | // we'll use a new directory in the root of the repo.
81 | return generatedMainDir
82 | }
83 |
84 | func GenerateMain(projectDir string, info devcard.DevcardInfo) error {
85 | dir := filepath.Join(projectDir, FindMainDir(info))
86 | os.Mkdir(dir, 0775)
87 | err := os.WriteFile(filepath.Join(dir, generatedMainFile), devcardMain(info), 0664)
88 | if err != nil {
89 | return fmt.Errorf("generate main: %w", err)
90 | }
91 | return nil
92 | }
93 |
94 | func splitPath(path string) []string {
95 | var result []string
96 | for {
97 | var last string
98 | path, last = filepath.Dir(path), filepath.Base(path)
99 | result = append(result, last)
100 | if last == path {
101 | break
102 | }
103 | }
104 | slices.Reverse(result)
105 | return result
106 | }
107 |
108 | func indexLast(s []string, v string) int {
109 | for i := len(s) - 1; i >= 0; i-- {
110 | if s[i] == v {
111 | return i
112 | }
113 | }
114 | return -1
115 | }
116 |
--------------------------------------------------------------------------------
/pkg/internal/project/devcard_main.template:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/igorhub/devcard/pkg/runtime"
8 | {{.Import}}
9 | )
10 |
11 | func main() {
12 | if len(os.Args) < 2 {
13 | fmt.Fprintf(os.Stderr, "Usage: %s REPO_DIR [TCP_ADDRESS]\n", os.Args[0])
14 | os.Exit(2)
15 | }
16 |
17 | repoDir := os.Args[1]
18 | if err := os.Chdir(repoDir); err != nil {
19 | fmt.Fprintf(os.Stderr, "WARNING: failed to chdir: %s", err)
20 | }
21 |
22 | transientDir := runtime.TransientDir(repoDir)
23 | if len(os.Args) == 2 {
24 | runtime.ProduceDevcardWithJSON(transientDir, {{.Card}})
25 | } else {
26 | addr := os.Args[2]
27 | runtime.ProduceDevcardWithTCP(addr, transientDir, {{.Card}})
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/pkg/internal/project/project.go:
--------------------------------------------------------------------------------
1 | package project
2 |
3 | import (
4 | "bytes"
5 | "cmp"
6 | "errors"
7 | "fmt"
8 | "go/ast"
9 | "go/format"
10 | "go/parser"
11 | "go/printer"
12 | "go/token"
13 | "io/fs"
14 | "log"
15 | "os"
16 | "os/exec"
17 | "path/filepath"
18 | "slices"
19 | "strings"
20 | "time"
21 |
22 | "github.com/fsnotify/fsnotify"
23 | "github.com/igorhub/devcard"
24 | "github.com/igorhub/devcard/pkg/internal/file"
25 | "golang.org/x/mod/modfile"
26 | )
27 |
28 | const bundlingPeriod = 30 * time.Millisecond
29 |
30 | type ProjectConfig struct {
31 | Name string
32 | Dir string
33 | Injection string
34 | PreBuildAction *struct {
35 | Cmd string
36 | Args []string
37 | }
38 | }
39 |
40 | type Project struct {
41 | ProjectConfig
42 | Module string
43 |
44 | Err error
45 | Update chan struct{}
46 |
47 | cardsInfo []devcard.DevcardInfo
48 | cache map[string][]byte
49 | packages map[string]string
50 | events chan projectMessage
51 | clones map[string]bool
52 |
53 | fset *token.FileSet
54 | decls map[string]*printer.CommentedNode
55 | }
56 |
57 | func NewProject(projectConfig ProjectConfig) *Project {
58 | p := &Project{
59 | ProjectConfig: projectConfig,
60 | Update: make(chan struct{}, 256),
61 | cache: make(map[string][]byte),
62 | packages: make(map[string]string),
63 | events: make(chan projectMessage, 256),
64 | clones: make(map[string]bool),
65 |
66 | fset: token.NewFileSet(),
67 | decls: make(map[string]*printer.CommentedNode),
68 | }
69 | mod, err := moduleName(p.Dir)
70 | if err != nil {
71 | p.Err = err
72 | return p
73 | }
74 | p.Module = mod
75 | p.startWatching()
76 | return p
77 | }
78 |
79 | func (p *Project) CreateRepo(devcardName string) (*Repo, error) {
80 | resultC, errC := make(chan *Repo), make(chan error)
81 | p.events <- msgCreateRepo{devcardName: devcardName, result: resultC, err: errC}
82 | return <-resultC, <-errC
83 | }
84 |
85 | func (p *Project) DevcardInfo(devcardName string) devcard.DevcardInfo {
86 | if p == nil {
87 | return devcard.DevcardInfo{}
88 | }
89 | resultC := make(chan []devcard.DevcardInfo)
90 | p.events <- msgGetDevcards{resultC}
91 | info := <-resultC
92 | return lookupDevcardInfo(info, devcardName)
93 | }
94 |
95 | func (p *Project) DevcardsInfo() []devcard.DevcardInfo {
96 | if p == nil {
97 | return nil
98 | }
99 | resultC := make(chan []devcard.DevcardInfo)
100 | p.events <- msgGetDevcards{resultC}
101 | return <-resultC
102 | }
103 |
104 | func (p *Project) Source(decl string) (string, error) {
105 | if p == nil {
106 | return "", nil
107 | }
108 | resultC := make(chan msgGetSource)
109 | p.events <- msgGetSource{decl: decl, result: resultC}
110 | result := <-resultC
111 | return result.source, result.err
112 | }
113 |
114 | func (p *Project) RemoveRepo(repo *Repo) {
115 | if repo != nil {
116 | p.events <- msgRemoveRepo{repo.Dir}
117 | }
118 | }
119 |
120 | func (p *Project) Shutdown() {
121 | done := make(chan struct{})
122 | p.events <- msgShutdown{done}
123 | <-done
124 | }
125 |
126 | type projectMessage interface{ projectMessage() }
127 |
128 | type msgCreateRepo struct {
129 | devcardName string
130 | result chan<- *Repo
131 | err chan<- error
132 | }
133 |
134 | type msgRemoveRepo struct {
135 | repoDir string
136 | }
137 |
138 | type msgUpdateFile struct {
139 | path string
140 | }
141 |
142 | type msgRemoveFile struct {
143 | path string
144 | }
145 |
146 | type msgGetDevcards struct {
147 | result chan<- []devcard.DevcardInfo
148 | }
149 |
150 | type msgGetSource struct {
151 | decl string
152 | source string
153 | err error
154 | result chan<- msgGetSource
155 | }
156 |
157 | type msgRestart struct{}
158 |
159 | type msgFail struct {
160 | err error
161 | }
162 |
163 | type msgShutdown struct {
164 | done chan<- struct{}
165 | }
166 |
167 | func (msgCreateRepo) projectMessage() {}
168 | func (msgRemoveRepo) projectMessage() {}
169 | func (msgUpdateFile) projectMessage() {}
170 | func (msgRemoveFile) projectMessage() {}
171 | func (msgGetDevcards) projectMessage() {}
172 | func (msgGetSource) projectMessage() {}
173 | func (msgRestart) projectMessage() {}
174 | func (msgFail) projectMessage() {}
175 | func (msgShutdown) projectMessage() {}
176 |
177 | func (p *Project) startWatching() {
178 | watcher, err := startWatcher(p.Dir, p.events)
179 | if err != nil {
180 | p.Err = err
181 | return
182 | }
183 |
184 | p.decls = make(map[string]*printer.CommentedNode)
185 | err = filepath.WalkDir(p.Dir, func(path string, d fs.DirEntry, err error) error {
186 | switch {
187 | case err != nil:
188 | return err
189 | case d.IsDir():
190 | return nil
191 | default:
192 | return p.updateFile(path)
193 | }
194 | })
195 | if err != nil {
196 | p.Err = err
197 | return
198 | }
199 |
200 | for r := range p.clones {
201 | err := p.syncDir(r)
202 | if err != nil {
203 | p.events <- msgFail{err}
204 | break
205 | }
206 | }
207 | p.Update <- struct{}{}
208 |
209 | go func() {
210 | update := make(chan struct{}, cap(p.Update))
211 | bundle(bundlingPeriod, update, p.Update)
212 | defer close(update)
213 |
214 | for e := range p.events {
215 | log.Printf("project %s event: %#v", filepath.Base(p.Dir), e)
216 |
217 | switch e := e.(type) {
218 | case msgCreateRepo:
219 | repo, err := p.createRepo(e.devcardName)
220 | e.result <- repo
221 | close(e.result)
222 | e.err <- err
223 | close(e.err)
224 |
225 | case msgRemoveRepo:
226 | os.RemoveAll(e.repoDir)
227 | delete(p.clones, e.repoDir)
228 |
229 | case msgUpdateFile:
230 | if p.IsBroken() {
231 | break
232 | }
233 | p.Err = p.updateFile(e.path)
234 | if p.Err == nil && p.PreBuildAction != nil {
235 | p.Err = p.runPreBuildAction(e.path)
236 | }
237 | for repo := range p.clones {
238 | p.syncFile(e.path, repo)
239 | }
240 | update <- struct{}{}
241 |
242 | case msgRemoveFile:
243 | p.removeFile(e.path)
244 | for repo := range p.clones {
245 | path := replaceRootDir(p.Dir, repo, e.path)
246 | os.Remove(path)
247 | }
248 | update <- struct{}{}
249 |
250 | case msgGetDevcards:
251 | e.result <- slices.Clone(p.cardsInfo)
252 | close(e.result)
253 |
254 | case msgGetSource:
255 | e.source, e.err = p.source(e.decl)
256 | e.result <- e
257 | close(e.result)
258 |
259 | case msgRestart:
260 | watcher.Close()
261 | p.startWatching()
262 | return
263 |
264 | case msgFail:
265 | // FIXME: the following code doesn't fail properly
266 | p.Err = e.err
267 | watcher.Close()
268 | update <- struct{}{}
269 | return
270 |
271 | case msgShutdown:
272 | for dir := range p.clones {
273 | os.RemoveAll(dir)
274 | }
275 | watcher.Close()
276 | close(e.done)
277 | return
278 | }
279 | }
280 | }()
281 | }
282 |
283 | func (p *Project) createRepo(devcardName string) (*Repo, error) {
284 | info := lookupDevcardInfo(p.cardsInfo, devcardName)
285 | if info == (devcard.DevcardInfo{}) {
286 | return nil, fmt.Errorf("create repo: devcard %s not found in %s", devcardName, p.Name)
287 | }
288 |
289 | repo, err := newRepo(p, info)
290 | if err != nil {
291 | return nil, err
292 | }
293 |
294 | p.clones[repo.Dir] = true
295 | err = p.syncDir(repo.Dir)
296 | if err != nil {
297 | p.events <- msgFail{err}
298 | return nil, err
299 | }
300 |
301 | return repo, nil
302 | }
303 |
304 | func lookupDevcardInfo(cards []devcard.DevcardInfo, devcardName string) devcard.DevcardInfo {
305 | i := slices.IndexFunc(cards, func(info devcard.DevcardInfo) bool {
306 | return info.Name == devcardName
307 | })
308 | if i == -1 {
309 | return devcard.DevcardInfo{}
310 | }
311 | return cards[i]
312 | }
313 |
314 | func (p *Project) syncDir(repoDir string) error {
315 | err := filepath.WalkDir(p.Dir, func(path string, d fs.DirEntry, err error) error {
316 | switch {
317 | case err != nil:
318 | return err
319 | case d.Name() == ".git":
320 | return fs.SkipDir
321 | case d.IsDir():
322 | _ = os.Mkdir(replaceRootDir(p.Dir, repoDir, path), 0700)
323 | return nil
324 | default:
325 | return p.syncFile(path, repoDir)
326 | }
327 | })
328 | if err != nil {
329 | return fmt.Errorf("sync repo %s for %s: %w", repoDir, p.Name, err)
330 | }
331 | return p.generateInjections(repoDir)
332 | }
333 |
334 | func (p *Project) syncFile(path, repoDir string) error {
335 | dst := replaceRootDir(p.Dir, repoDir, path)
336 | if data := p.cache[path]; data != nil {
337 | return os.WriteFile(dst, data, 0600)
338 | }
339 | return linkOrCopy(path, dst)
340 | }
341 |
342 | func (p *Project) generateInjections(repoDir string) error {
343 | if p.Injection == "" {
344 | return nil
345 | }
346 | var errs []error
347 | for dir, pkg := range p.packages {
348 | path := filepath.Join(repoDir, dir, generatedInjectionFile)
349 | content := "package " + pkg + "\n\n" + p.Injection
350 | err := os.WriteFile(path, []byte(content), 0664)
351 | if err != nil {
352 | errs = append(errs, fmt.Errorf("cannot write code injection into %q", dir))
353 | }
354 | }
355 | return errors.Join(errs...)
356 | }
357 |
358 | func replaceRootDir(dirFrom, dirTo, path string) string {
359 | if dirTo == "" {
360 | panic("dirTo must not be empty")
361 | }
362 | if dirTo == dirFrom {
363 | panic("dirTo must not be the same as dirFrom")
364 | }
365 | rel, err := filepath.Rel(dirFrom, path)
366 | if err != nil {
367 | panic(fmt.Errorf("path %q must be located in %q", path, dirFrom))
368 | }
369 | return filepath.Join(dirTo, rel)
370 | }
371 |
372 | func linkOrCopy(src, dst string) error {
373 | os.Remove(dst)
374 | err := os.Link(src, dst)
375 | if err != nil {
376 | err = file.Copy(src, dst)
377 | }
378 | return err
379 | }
380 |
381 | func (p *Project) updateFile(path string) error {
382 | if filepath.Ext(path) != ".go" {
383 | return nil
384 | }
385 |
386 | f, err := os.Open(path)
387 | if err != nil {
388 | return err
389 | }
390 | defer f.Close()
391 |
392 | file, err := parser.ParseFile(p.fset, path, f, parser.ParseComments|parser.SkipObjectResolution)
393 | if err != nil {
394 | data, err := os.ReadFile(path)
395 | if err != nil {
396 | return err
397 | }
398 | p.cache[path] = data
399 | return nil
400 | }
401 | p.collectDecls(file)
402 | p.collectPackage(path, file)
403 | p.updateDevcardsInfo(path, file)
404 | data, err := p.rewriteFile(file)
405 | if err != nil {
406 | return err
407 | }
408 | p.cache[path] = data
409 | return nil
410 | }
411 |
412 | type preBuildError struct {
413 | err error
414 | out string
415 | }
416 |
417 | func (e *preBuildError) Error() string {
418 | return e.err.Error() + "\n\n" + e.out
419 | }
420 |
421 | func isFatal(err error) bool {
422 | if _, ok := err.(*preBuildError); ok {
423 | return false
424 | }
425 | return true
426 | }
427 |
428 | func (p *Project) IsBroken() bool {
429 | return p.Err != nil && isFatal(p.Err)
430 | }
431 |
432 | func (p *Project) runPreBuildAction(path string) error {
433 | if p.PreBuildAction == nil {
434 | return nil
435 | }
436 | args := slices.Clone(p.PreBuildAction.Args)
437 | for i, arg := range args {
438 | if arg == "$file" {
439 | args[i] = path
440 | }
441 | }
442 | cmd := exec.Command(p.PreBuildAction.Cmd, args...)
443 | cmd.Dir = p.Dir
444 | out, err := cmd.CombinedOutput()
445 | if err != nil {
446 | return &preBuildError{err, string(out)}
447 | }
448 | return nil
449 | }
450 |
451 | func (p *Project) collectDecls(f *ast.File) {
452 | for _, decl := range f.Decls {
453 | if fn, ok := decl.(*ast.FuncDecl); ok {
454 | p.decls[f.Name.Name+"."+fn.Name.Name] = &printer.CommentedNode{Node: fn, Comments: f.Comments}
455 | }
456 | }
457 | }
458 |
459 | func (p *Project) collectPackage(path string, f *ast.File) {
460 | dir, _ := filepath.Split(path)
461 | relDir, err := filepath.Rel(p.Dir, dir)
462 | if err != nil {
463 | panic(fmt.Errorf("collecting package: %w", err))
464 | }
465 | p.packages[relDir] = f.Name.Name
466 | }
467 |
468 | func (p *Project) source(decl string) (string, error) {
469 | d, ok := p.decls[decl]
470 | if !ok {
471 | return "", errors.New("can't locate the source for " + decl)
472 | }
473 |
474 | buf := new(bytes.Buffer)
475 | err := format.Node(buf, p.fset, d)
476 | if err != nil {
477 | return "", err
478 | }
479 | return buf.String(), nil
480 | }
481 |
482 | func (p *Project) updateDevcardsInfo(path string, f *ast.File) {
483 | p.cardsInfo = slices.DeleteFunc(p.cardsInfo, func(info devcard.DevcardInfo) bool {
484 | return filepath.Join(p.Dir, info.Path) == path
485 | })
486 |
487 | for _, decl := range f.Decls {
488 | if fn, ok := decl.(*ast.FuncDecl); ok && isDevcardProducer(p.fset, fn) {
489 | devcardPath, err := filepath.Rel(p.Dir, path)
490 | if err != nil {
491 | // We can't reach here, but let's panic just in case.
492 | panic(fmt.Errorf("updateDevcardsInfo: %w", err))
493 | }
494 | info := devcard.DevcardInfo{
495 | ImportPath: importPath(p.Module, p.Dir, path),
496 | Package: f.Name.Name,
497 | Path: devcardPath,
498 | Line: p.fset.Position(fn.Pos()).Line,
499 | Name: fn.Name.Name,
500 | Title: devcardTitle(p.fset, fn),
501 | }
502 | p.cardsInfo = append(p.cardsInfo, info)
503 | }
504 | }
505 |
506 | slices.SortStableFunc(p.cardsInfo, func(a, b devcard.DevcardInfo) int { return cmp.Compare(a.Path, b.Path) })
507 | }
508 |
509 | func (p *Project) rewriteFile(f *ast.File) ([]byte, error) {
510 | for _, decl := range f.Decls {
511 | if f, ok := decl.(*ast.FuncDecl); ok && f.Name.Name == "main" {
512 | f.Name.Name = "_main_orig"
513 | }
514 | }
515 |
516 | buf := new(bytes.Buffer)
517 | err := format.Node(buf, p.fset, f)
518 | return buf.Bytes(), err
519 | }
520 |
521 | func isDevcardProducer(fset *token.FileSet, fn *ast.FuncDecl) bool {
522 | if !strings.HasPrefix(fn.Name.Name, "Devcard") {
523 | return false
524 | }
525 |
526 | if fn.Type.TypeParams != nil {
527 | return false
528 | }
529 |
530 | if fn.Type.Results != nil {
531 | return false
532 | }
533 |
534 | if len(fn.Type.Params.List) != 1 {
535 | return false
536 | }
537 |
538 | s := new(strings.Builder)
539 | format.Node(s, fset, fn.Type.Params.List[0].Type)
540 | return s.String() == "*devcard.Devcard"
541 | }
542 |
543 | func devcardTitle(fset *token.FileSet, fn *ast.FuncDecl) string {
544 | for _, stmt := range fn.Body.List {
545 | expr, ok := stmt.(*ast.ExprStmt)
546 | if !ok {
547 | continue
548 | }
549 |
550 | x, ok := expr.X.(*ast.CallExpr)
551 | if !ok {
552 | continue
553 | }
554 |
555 | fun, ok := x.Fun.(*ast.SelectorExpr)
556 | if !ok {
557 | continue
558 | }
559 |
560 | if fun.Sel.Name != "SetTitle" || len(x.Args) != 1 {
561 | continue
562 | }
563 |
564 | buf := new(bytes.Buffer)
565 | format.Node(buf, fset, x.Args[0])
566 | s := buf.String()
567 | if _, ok := x.Args[0].(*ast.BasicLit); ok && len(s) > 1 {
568 | s = s[1 : len(s)-1]
569 | }
570 | s = strings.ReplaceAll(s, "\\\"", "\"")
571 | return s
572 | }
573 |
574 | return ""
575 | }
576 |
577 | func (p *Project) removeFile(path string) {
578 | p.cardsInfo = slices.DeleteFunc(p.cardsInfo, func(info devcard.DevcardInfo) bool {
579 | return filepath.Join(p.Dir, info.Path) == path
580 | })
581 | delete(p.cache, path)
582 | }
583 |
584 | func importPath(mod, dir, path string) string {
585 | relPath, err := filepath.Rel(dir, path)
586 | if err != nil {
587 | panic(fmt.Errorf("incorrect call to importPath: %w", err))
588 | }
589 | parent, _ := filepath.Split(relPath)
590 | if parent == "" {
591 | return mod
592 | }
593 | return mod + "/" + filepath.Dir(relPath)
594 | }
595 |
596 | func startWatcher(dir string, events chan projectMessage) (*fsnotify.Watcher, error) {
597 | watcher, err := fsnotify.NewWatcher()
598 | if err != nil {
599 | return nil, fmt.Errorf("start watcher in %q: %w", dir, err)
600 | }
601 |
602 | watchDirs, err := subdirs(dir)
603 | if err != nil {
604 | return nil, fmt.Errorf("start watcher: %w", err)
605 | }
606 | slices.Sort(watchDirs)
607 | for _, dir := range watchDirs {
608 | err = watcher.Add(dir)
609 | if err != nil {
610 | watcher.Close()
611 | return nil, fmt.Errorf("start watcher in %q: %w", dir, err)
612 | }
613 | }
614 |
615 | isDir := func(path string) bool {
616 | if _, ok := slices.BinarySearch(watchDirs, path); ok {
617 | return true
618 | }
619 | info, err := os.Stat(path)
620 | if err != nil {
621 | return false
622 | }
623 | return info.IsDir()
624 | }
625 |
626 | go func() {
627 | for {
628 | select {
629 | case e, ok := <-watcher.Events:
630 | if !ok {
631 | return
632 | }
633 |
634 | switch e.Op {
635 | case fsnotify.Create, fsnotify.Write:
636 | if isDir(e.Name) {
637 | events <- msgRestart{}
638 | } else {
639 | events <- msgUpdateFile{path: e.Name}
640 | }
641 | case fsnotify.Remove, fsnotify.Rename:
642 | if isDir(e.Name) {
643 | events <- msgRestart{}
644 | } else {
645 | events <- msgRemoveFile{path: e.Name}
646 | }
647 | default:
648 | // Ignore fsnotify.Chmod, as recommended by fsnotify docs.
649 | }
650 | case err, ok := <-watcher.Errors:
651 | if !ok {
652 | return
653 | }
654 | events <- msgFail{err: fmt.Errorf("%s watcher error: %w", dir, err)}
655 | }
656 | }
657 | }()
658 |
659 | return watcher, nil
660 | }
661 |
662 | func subdirs(projectDir string) ([]string, error) {
663 | var result []string
664 | if !filepath.IsAbs(projectDir) {
665 | panic(fmt.Errorf("projectDir %q must be an absolute path", projectDir))
666 | }
667 | err := filepath.WalkDir(projectDir, func(path string, d fs.DirEntry, err error) error {
668 | switch {
669 | case err != nil:
670 | return err
671 | case !d.IsDir():
672 | return nil
673 | case d.Name() == ".git":
674 | return filepath.SkipDir
675 | }
676 | result = append(result, path)
677 | return nil
678 | })
679 | if err != nil {
680 | return nil, fmt.Errorf("building directory structure of %s: %w", projectDir, err)
681 | }
682 | return result, nil
683 | }
684 |
685 | func moduleName(projectDir string) (string, error) {
686 | path := filepath.Join(projectDir, "go.mod")
687 | data, err := os.ReadFile(path)
688 | if err != nil {
689 | return "", err
690 | }
691 | mod, err := modfile.Parse(path, data, nil)
692 | if err != nil {
693 | return "", err
694 | }
695 | return mod.Module.Syntax.Token[1], nil
696 | }
697 |
--------------------------------------------------------------------------------
/pkg/internal/project/repo.go:
--------------------------------------------------------------------------------
1 | package project
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "sync"
7 |
8 | "github.com/igorhub/devcard"
9 | "github.com/igorhub/devcard/pkg/runtime"
10 | )
11 |
12 | type Repo struct {
13 | Dir string
14 | TransientDir string
15 |
16 | DevcardInfo devcard.DevcardInfo
17 |
18 | runLock sync.Mutex
19 | }
20 |
21 | func newRepo(project *Project, devcardInfo devcard.DevcardInfo) (*Repo, error) {
22 | r := &Repo{
23 | DevcardInfo: devcardInfo,
24 | }
25 |
26 | var err error
27 | r.Dir, err = os.MkdirTemp("", "devcards-"+project.Name+"-")
28 | if err != nil {
29 | return nil, fmt.Errorf("new repo: %w", err)
30 | }
31 |
32 | r.TransientDir = runtime.TransientDir(r.Dir)
33 |
34 | return r, nil
35 | }
36 |
37 | func (r *Repo) Delete() error {
38 | if r == nil {
39 | return nil
40 | }
41 | err := os.RemoveAll(r.Dir)
42 | if err != nil {
43 | return fmt.Errorf("delete repo: %w", err)
44 | }
45 | return nil
46 | }
47 |
48 | // Prepare creates files and directories required for building/running the project.
49 | func (r *Repo) Prepare() error {
50 | os.RemoveAll(r.TransientDir)
51 | err := os.Mkdir(r.TransientDir, 0700)
52 | if err != nil {
53 | return err
54 | }
55 | return GenerateMain(r.Dir, r.DevcardInfo)
56 | }
57 |
--------------------------------------------------------------------------------
/pkg/internal/project/run.go:
--------------------------------------------------------------------------------
1 | package project
2 |
3 | import (
4 | "bufio"
5 | "context"
6 | "encoding/json"
7 | "errors"
8 | "fmt"
9 | "io"
10 | "io/fs"
11 | "log"
12 | "net"
13 | "os/exec"
14 | "path/filepath"
15 | "strings"
16 | "sync"
17 |
18 | "github.com/igorhub/devcard"
19 | )
20 |
21 | type UpdateMessage interface{ updateMessage() }
22 |
23 | type MsgReady struct{}
24 |
25 | type MsgError struct {
26 | Title string
27 | Err error
28 | }
29 |
30 | type MsgInfo struct {
31 | Title string
32 | }
33 |
34 | type MsgCell struct {
35 | Id string
36 | Cell devcard.Cell
37 | }
38 |
39 | const (
40 | PipeStdout = "Stdout"
41 | PipeStderr = "Stderr"
42 | )
43 |
44 | type MsgPipeOut struct {
45 | Pipe string
46 | Line string
47 | }
48 |
49 | func (MsgReady) updateMessage() {}
50 | func (MsgError) updateMessage() {}
51 | func (MsgInfo) updateMessage() {}
52 | func (MsgCell) updateMessage() {}
53 | func (MsgPipeOut) updateMessage() {}
54 |
55 | // Run uses "go run" to produce and return a devcard.
56 | //
57 | // If errors occur, they're written into devcard.Error field of the devcard.
58 | func (r *Repo) Run(ctx context.Context, control <-chan string, updates chan<- UpdateMessage) {
59 | r.runLock.Lock()
60 | defer r.runLock.Unlock()
61 | defer close(updates)
62 |
63 | err := r.Prepare()
64 | if err != nil {
65 | updates <- MsgReady{}
66 | updates <- MsgError{Title: "Failed to prepare the repo", Err: err}
67 | return
68 | }
69 |
70 | ctx, cancel := context.WithCancel(ctx)
71 | defer cancel()
72 |
73 | listener, err := net.Listen("tcp4", "127.0.0.1:0")
74 | if err != nil {
75 | updates <- MsgReady{}
76 | updates <- MsgError{Title: "Failed to create TCP listener", Err: err}
77 | return
78 | }
79 | defer listener.Close()
80 |
81 | wg := sync.WaitGroup{}
82 | go func() {
83 | conn, err := listener.Accept()
84 | if err != nil && errors.Is(err, net.ErrClosed) {
85 | return
86 | } else if err != nil {
87 | updates <- MsgReady{}
88 | updates <- MsgError{Title: "Failed to accept TCP connection from the devcard", Err: err}
89 | cancel()
90 | return
91 | }
92 | defer conn.Close()
93 | updates <- MsgReady{} // FIXME: updates can be closed at this point!!!
94 |
95 | go func() {
96 | wg.Add(1)
97 | defer wg.Done()
98 | r := bufio.NewReader(conn)
99 | for {
100 | s, err := r.ReadString('\n')
101 | if err != nil && errors.Is(err, io.EOF) {
102 | return
103 | } else if err != nil {
104 | log.Printf("Failed to read from the devcard's TCP connection: %s\n%#v", err, err)
105 | updates <- MsgError{Title: "Failed to read from the devcard's TCP connection", Err: err}
106 | cancel()
107 | return
108 | }
109 | updates <- unmarshalDevcardMessage(s)
110 | }
111 | }()
112 |
113 | for {
114 | select {
115 | case <-ctx.Done():
116 | _, err := conn.Write([]byte("exit\n"))
117 | if err != nil {
118 | log.Println("Error writing \"exit\" to conn:", err)
119 | }
120 | conn.Close()
121 | return
122 | case s, ok := <-control:
123 | if ok {
124 | _, err := conn.Write([]byte(s + "\n"))
125 | if err != nil {
126 | log.Printf("Error writing %q to conn: %s", s, err)
127 | cancel()
128 | return
129 | }
130 | }
131 | }
132 | }
133 | }()
134 |
135 | cmd := exec.CommandContext(ctx, "go", "run", "-tags", "devcard", ".", r.Dir, listener.Addr().String())
136 | cmd.Dir = filepath.Join(r.Dir, FindMainDir(r.DevcardInfo))
137 |
138 | stdout, err := cmd.StdoutPipe()
139 | if err != nil {
140 | updates <- MsgReady{}
141 | updates <- MsgError{Title: "Failed to create stdout pipe", Err: err}
142 | return
143 | }
144 |
145 | stderr, err := cmd.StderrPipe()
146 | if err != nil {
147 | updates <- MsgReady{}
148 | updates <- MsgError{Title: "Failed to create stderr pipe", Err: err}
149 | return
150 | }
151 |
152 | stderrC := readFromPipe(stderr, PipeStderr)
153 | stdoutC := readFromPipe(stdout, PipeStdout)
154 | wg.Add(2)
155 | go func() {
156 | for msg := range stdoutC {
157 | updates <- msg
158 | }
159 | wg.Done()
160 | }()
161 | go func() {
162 | for msg := range stderrC {
163 | updates <- msg
164 | }
165 | wg.Done()
166 | }()
167 |
168 | err = cmd.Run()
169 | if err != nil {
170 | err := fmt.Errorf("go run: %w", err)
171 | updates <- MsgError{Title: "Execution failure", Err: err}
172 | }
173 |
174 | wg.Wait()
175 | }
176 |
177 | const maxPipeLines = 10000
178 |
179 | func readFromPipe(pipe io.Reader, pipeName string) <-chan UpdateMessage {
180 | updates := make(chan UpdateMessage)
181 | go func() {
182 | defer close(updates)
183 | var initialized bool
184 | r := bufio.NewReader(pipe)
185 | n := maxPipeLines
186 | for {
187 | line, err := r.ReadString('\n')
188 | if err == io.EOF || errors.Is(err, fs.ErrClosed) {
189 | break
190 | }
191 | if err != nil {
192 | updates <- MsgError{Title: "Failed to read from devcard's " + pipeName, Err: err}
193 | break
194 | }
195 | if !initialized {
196 | updates <- MsgReady{}
197 | initialized = true
198 | }
199 | n--
200 | if n <= 0 {
201 | if n == 0 {
202 | updates <- MsgPipeOut{Pipe: pipeName, Line: "\n... output limit exceeded"}
203 | }
204 | continue
205 | }
206 | updates <- MsgPipeOut{Pipe: pipeName, Line: line}
207 | }
208 | }()
209 | return updates
210 | }
211 |
212 | func unmarshalDevcardMessage(msg string) UpdateMessage {
213 | x := struct {
214 | MsgType string `json:"msg_type"`
215 |
216 | // Cell type
217 | Id string
218 | CellType string `json:"cell_type"`
219 | Cell json.RawMessage
220 |
221 | // Info type
222 | Title string
223 | }{}
224 |
225 | err := json.Unmarshal([]byte(msg), &x)
226 | if err != nil {
227 | err = fmt.Errorf("error: %s\n\nmessage: %s", err, msg)
228 | return MsgError{Title: "Failed to decode message from the devcard", Err: err}
229 | }
230 |
231 | switch x.MsgType {
232 | case devcard.MessageTypeCell:
233 | cell, err := devcard.UnmarshalCell(x.CellType, x.Cell)
234 | if err != nil {
235 | err = fmt.Errorf("error: %s\n\ncell type: %s\n\ncell: %s", err, x.CellType, string(x.Cell))
236 | return MsgError{Title: "Failed to decode a cell from the devcard", Err: err}
237 | }
238 | return MsgCell{Id: x.Id, Cell: cell}
239 |
240 | case devcard.MessageTypeInfo:
241 | return MsgInfo{
242 | Title: x.Title,
243 | }
244 |
245 | case devcard.MessageTypeError:
246 | return MsgError{
247 | Title: "Internal error",
248 | Err: fmt.Errorf("message type %q is not supported", x.MsgType),
249 | }
250 |
251 | default:
252 | return MsgError{
253 | Title: "Internal error: malformed message from the devcard",
254 | Err: fmt.Errorf("message type %q is not supported", x.MsgType),
255 | }
256 | }
257 | }
258 |
259 | func (r *Repo) Test(ctx context.Context) int {
260 | r.runLock.Lock()
261 | defer r.runLock.Unlock()
262 |
263 | cmd := exec.CommandContext(ctx, "go", "test", r.DevcardInfo.ImportPath)
264 | cmd.Dir = r.Dir
265 | b, err := cmd.CombinedOutput()
266 | if err == nil {
267 | return 0
268 | }
269 |
270 | failedTests := 0
271 | for _, line := range strings.Split(string(b), "\n") {
272 | if strings.HasPrefix(line, "--- FAIL:") {
273 | failedTests++
274 | }
275 | }
276 |
277 | return failedTests
278 | }
279 |
--------------------------------------------------------------------------------
/pkg/runtime/produce.go:
--------------------------------------------------------------------------------
1 | package runtime
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "reflect"
9 | "runtime"
10 | "strings"
11 | _ "unsafe"
12 |
13 | "github.com/igorhub/devcard"
14 | )
15 |
16 | // write marshals the devcard to JSON and writes it to outFile, or to standard
17 | // output (if outFile is "-").
18 | //
19 | // If an error occurs, it writes the error message into standard error.
20 | func write(outFile string, devcard *devcard.Devcard) {
21 | data, err := json.MarshalIndent(devcard, "", " ")
22 | if err != nil {
23 | fmt.Fprintf(os.Stderr, "Can't marshal devcard: %s", err)
24 | }
25 |
26 | if outFile == "-" {
27 | fmt.Println(string(data))
28 | } else {
29 | err := os.WriteFile(outFile, data, 0666)
30 | if err != nil {
31 | fmt.Fprintf(os.Stderr, "Can't write output for the devcard.\nReason: %s", err)
32 | }
33 | }
34 | }
35 |
36 | // writeStubDevcard writes a stub devcard to outFile.
37 | // If the process exits while building a devcard, this stub will be transmitted down the line.
38 | // Otherwise, outFile will be overwritten with the real devcard data.
39 | func writeStubDevcard(outFile string, card string) {
40 | if outFile == "-" {
41 | return
42 | }
43 | dc := &devcard.Devcard{Title: card}
44 | dc.Error("Process exited prematurely")
45 | write(outFile, dc)
46 | }
47 |
48 | func functionName(fn interface{}) string {
49 | fullName := runtime.FuncForPC(reflect.ValueOf(fn).Pointer()).Name()
50 | i := strings.LastIndex(fullName, ".")
51 | return fullName[i+1:]
52 | }
53 |
54 | // ProduceDevcard creates an empty devcard, fills it with content by running the producer function,
55 | // marshals it to JSON, and writes to outFile.
56 | func ProduceDevcardWithTCP(address, tempDir string, producer devcard.DevcardProducer) {
57 | produce(address, tempDir, producer)
58 | }
59 |
60 | // ProduceDevcard creates an empty devcard, fills it with content by running the producer function,
61 | // marshals it to JSON, and writes to outFile.
62 | func ProduceDevcardWithJSON(tempDir string, producer devcard.DevcardProducer) {
63 | outFile := filepath.Join(tempDir, "devcard.json")
64 | writeStubDevcard(outFile, functionName(producer))
65 | dc := produce("", tempDir, producer)
66 | write(outFile, dc)
67 | }
68 |
69 | func TransientDir(dir string) string {
70 | return filepath.Join(dir, "_transient_")
71 | }
72 |
73 | //go:linkname produce github.com/igorhub/devcard.produce
74 | func produce(netAddress, tempDir string, producer devcard.DevcardProducer) *devcard.Devcard
75 |
--------------------------------------------------------------------------------
/pkg/server/assets/body.html:
--------------------------------------------------------------------------------
1 |
connection lost: reload
")
27 | });
28 |
29 | conn.onmessage = function(rawMsg) {
30 | msg = JSON.parse(rawMsg.data)
31 | dispatchMessage(msg)
32 | }
33 | };
34 |
35 | dispatchMessage = function(msg) {
36 | switch (msg.msgType) {
37 | case "batch":
38 | for (m of msg.messages) {
39 | innerDispatch(m)
40 | }
41 | break;
42 | default:
43 | innerDispatch(msg)
44 | }
45 | }
46 |
47 | innerDispatch = function(msg) {
48 | switch (msg.msgType) {
49 | case "clear":
50 | clearDevcard();
51 | break;
52 | case "setTitle":
53 | setTitle(msg.title);
54 | break;
55 | case "appendCell":
56 | appendCell(msg.cellId)
57 | setCellContent(msg.cellId, msg.html)
58 | break;
59 | case "appendToCell":
60 | appendToCell(msg.cellId, msg.html)
61 | break;
62 | case "setCellContent":
63 | setCellContent(msg.cellId, msg.html)
64 | break;
65 | case "setStatusBarContent":
66 | setStatusBarContent(msg.html)
67 | break;
68 | case "saveScrollPosition":
69 | scrollPosition = window.pageYOffset
70 | break;
71 | case "restoreScrollPosition":
72 | window.scrollTo(0, scrollPosition);
73 | break;
74 | case "jump":
75 | document.getElementById(msg.id).scrollIntoView();
76 | break;
77 | default:
78 | alert("unsupported message type:" + msg.msgType);
79 | break;
80 | }
81 | }
82 |
83 | clearDevcard = function() {
84 | document.getElementById("-devcard-cells").innerHTML = ""
85 | document.getElementById("-devcard-stdout").innerHTML = ""
86 | document.getElementById("-devcard-stderr").innerHTML = ""
87 | }
88 |
89 | setTitle = function(title) {
90 | e = document.getElementById("-devcard-title")
91 | e.innerHTML = '📂 ' +title
92 | }
93 |
94 | appendCell = function(cellId) {
95 | e = document.getElementById(cellId)
96 | if (e != null) {
97 | return
98 | }
99 | e = document.getElementById("-devcard-cells")
100 | cell = ""
101 | e.innerHTML = e.innerHTML + cell
102 | }
103 |
104 | setCellContent = function(cellId, html) {
105 | e = document.getElementById(cellId)
106 | e.innerHTML = html
107 | }
108 |
109 | appendToCell = function(cellId, html) {
110 | e = document.getElementById(cellId)
111 | e.innerHTML = e.innerHTML + html
112 | }
113 |
114 | setStatusBarContent = function(html) {
115 | e = document.getElementById("-devcard-status-bar")
116 | e.innerHTML = html
117 | }
118 |
119 | appendStatusBarContent = function(html) {
120 | e = document.getElementById("-devcard-status-bar")
121 | e.innerHTML = e.innerHTML + html
122 | }
123 |
124 | generateDebugCode = function() {
125 | fetch("/debug/"+projectName+"/"+devcardName)
126 | .then((response) => response.text())
127 | .then((text) => {
128 | alert(text)
129 | })
130 | }
131 |
132 | openInEditor = function() {
133 | fetch("/open/"+projectName+"/"+devcardName)
134 | .then((response) => response.text())
135 | .then((text) => {
136 | if (text != "") {
137 | alert(text)
138 | }
139 | })
140 | }
141 |
--------------------------------------------------------------------------------
/pkg/server/assets/light.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --nc-tx-1: #000000;
3 | --nc-tx-2: #1A1A1A;
4 | --nc-bg-1: #FFFFFF;
5 | --nc-bg-2: #F8F8F8;
6 | --nc-bg-3: #E5E7EB;
7 | --nc-lk-1: #0070F3;
8 | --nc-lk-2: #0366D6;
9 | --nc-lk-tx: #FFFFFF;
10 | --nc-ac-1: #79FFE1;
11 | --nc-ac-tx: #0C4047;
12 | --nc-err-fg: #CC241D;
13 | --nc-err-bg: #FFE4E1;
14 | }
15 |
--------------------------------------------------------------------------------
/pkg/server/assets/new.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --nc-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
3 | --nc-font-mono: Consolas, monaco, 'Ubuntu Mono', 'Liberation Mono', 'Courier New', Courier, monospace;
4 |
5 | /* Light theme */
6 | --nc-tx-1: #000000;
7 | --nc-tx-2: #1A1A1A;
8 | --nc-bg-1: #FFFFFF;
9 | --nc-bg-2: #F8F8F8;
10 | --nc-bg-3: #E5E7EB;
11 | --nc-lk-1: #0070F3;
12 | --nc-lk-2: #0366D6;
13 | --nc-lk-tx: #FFFFFF;
14 | --nc-ac-1: #79FFE1;
15 | --nc-ac-tx: #0C4047;
16 | --nc-err-fg: #CC241D;
17 | --nc-err-bg: #FFE4E1;
18 |
19 | /* Dark theme */
20 | --nc-d-tx-1: #ffffff;
21 | --nc-d-tx-2: #eeeeee;
22 | --nc-d-bg-1: #000000;
23 | --nc-d-bg-2: #111111;
24 | --nc-d-bg-3: #222222;
25 | --nc-d-lk-1: #3291FF;
26 | --nc-d-lk-2: #0070F3;
27 | --nc-d-lk-tx: #FFFFFF;
28 | --nc-d-ac-1: #7928CA;
29 | --nc-d-ac-tx: #FFFFFF;
30 | --nc-d-err-fg: #CC241D;
31 | --nc-d-err-bg: #8B0000;
32 | }
33 |
34 | @media (prefers-color-scheme: dark) {
35 | :root {
36 | --nc-tx-1: var(--nc-d-tx-1);
37 | --nc-tx-2: var(--nc-d-tx-2);
38 | --nc-bg-1: var(--nc-d-bg-1);
39 | --nc-bg-2: var(--nc-d-bg-2);
40 | --nc-bg-3: var(--nc-d-bg-3);
41 | --nc-lk-1: var(--nc-d-lk-1);
42 | --nc-lk-2: var(--nc-d-lk-2);
43 | --nc-lk-tx: var(--nc--dlk-tx);
44 | --nc-ac-1: var(--nc-d-ac-1);
45 | --nc-err-fg: var(--nc-d-err-fg);
46 | --nc-err-bg: var(--nc-d-err-bg);
47 | }
48 | }
49 |
50 | h3.err {
51 | color: var(--nc-err-fg);
52 | }
53 |
54 | div.err {
55 | color: var(--nc-err-fg);
56 | font-size: 1.25rem;
57 | }
58 |
59 | pre.err {
60 | background: var(--nc-err-bg);
61 | border: 1px solid var(--nc-err-fg);
62 | }
63 |
64 | code.err {
65 | background: var(--nc-err-bg);
66 | border: 1px solid var(--nc-err-fg);
67 | }
68 |
69 | code, pre {
70 | white-space: pre-wrap;
71 | word-wrap: break-word;
72 | }
73 |
74 | .import-path {
75 | font-weight: 300;
76 | }
77 |
78 | #-devcard-navigation {
79 | text-align: center;
80 | }
81 |
82 | #-devcard-edit {
83 | text-align: center;
84 | }
85 |
86 | * {
87 | /* Reset margins and padding */
88 | margin: 0;
89 | padding: 0;
90 | }
91 |
92 | address,
93 | area,
94 | article,
95 | aside,
96 | audio,
97 | blockquote,
98 | datalist,
99 | details,
100 | dl,
101 | fieldset,
102 | figure,
103 | form,
104 | input,
105 | iframe,
106 | img,
107 | meter,
108 | nav,
109 | ol,
110 | optgroup,
111 | option,
112 | output,
113 | p,
114 | pre,
115 | progress,
116 | ruby,
117 | section,
118 | table,
119 | textarea,
120 | ul,
121 | video {
122 | /* Margins for most elements */
123 | margin-bottom: 1rem;
124 | }
125 |
126 | html,input,select,button {
127 | /* Set body font family and some finicky elements */
128 | font-family: var(--nc-font-sans);
129 | }
130 |
131 | body {
132 | /* Center body in page */
133 | margin: 0 auto;
134 | max-width: 750px;
135 | padding: 2rem;
136 | border-radius: 6px;
137 | overflow-x: hidden;
138 | word-break: break-word;
139 | overflow-wrap: break-word;
140 | background: var(--nc-bg-1);
141 |
142 | /* Main body text */
143 | color: var(--nc-tx-2);
144 | font-size: 1.03rem;
145 | line-height: 1.5;
146 | }
147 |
148 | ::selection {
149 | /* Set background color for selected text */
150 | background: var(--nc-ac-1);
151 | color: var(--nc-ac-tx);
152 | }
153 |
154 | h1,h2,h3,h4,h5,h6 {
155 | line-height: 1;
156 | color: var(--nc-tx-1);
157 | padding-top: .875rem;
158 | }
159 |
160 | h1,
161 | h2,
162 | h3 {
163 | color: var(--nc-tx-1);
164 | padding-bottom: 2px;
165 | margin-bottom: 8px;
166 | border-bottom: 1px solid var(--nc-bg-2);
167 | }
168 |
169 | h4,
170 | h5,
171 | h6 {
172 | margin-bottom: .3rem;
173 | }
174 |
175 | h1 {
176 | font-size: 2.25rem;
177 | }
178 |
179 | h2 {
180 | font-size: 1.85rem;
181 | }
182 |
183 | h3 {
184 | font-size: 1.55rem;
185 | }
186 |
187 | h4 {
188 | font-size: 1.25rem;
189 | }
190 |
191 | h5 {
192 | font-size: 1rem;
193 | }
194 |
195 | h6 {
196 | font-size: .875rem;
197 | }
198 |
199 | a {
200 | color: var(--nc-lk-1);
201 | }
202 |
203 | a:hover {
204 | color: var(--nc-lk-2);
205 | }
206 |
207 | abbr:hover {
208 | /* Set the '?' cursor while hovering an abbreviation */
209 | cursor: help;
210 | }
211 |
212 | blockquote {
213 | padding: 1.5rem;
214 | background: var(--nc-bg-2);
215 | border-left: 5px solid var(--nc-bg-3);
216 | }
217 |
218 | abbr {
219 | cursor: help;
220 | }
221 |
222 | blockquote *:last-child {
223 | padding-bottom: 0;
224 | margin-bottom: 0;
225 | }
226 |
227 | figure {
228 | margin: auto;
229 | position: relative;
230 | }
231 |
232 | figure img {
233 | display: block;
234 | margin-left: auto;
235 | margin-right: auto;
236 | }
237 |
238 | figcaption {
239 | background-color: var(--nc-bg-3);
240 | color: var(--nc-fg-1);
241 | position: absolute;
242 | bottom: 0;
243 | left: 0;
244 | right: 0;
245 | margin-top: .4rem;
246 | padding-left: .2rem;
247 | }
248 |
249 | header {
250 | background: var(--nc-bg-2);
251 | border-bottom: 1px solid var(--nc-bg-3);
252 | padding: 2rem 1.5rem;
253 |
254 | /* This sets the right and left margins to cancel out the body's margins. It's width is still the same, but the background stretches across the page's width. */
255 |
256 | margin: -2rem calc(50% - 50vw) 2rem;
257 |
258 | /* Shorthand for:
259 |
260 | margin-top: -2rem;
261 | margin-bottom: 2rem;
262 |
263 | margin-left: calc(50% - 50vw);
264 | margin-right: calc(50% - 50vw); */
265 |
266 | padding-left: calc(50vw - 50%);
267 | padding-right: calc(50vw - 50%);
268 | }
269 |
270 | header h1,
271 | header h2,
272 | header h3 {
273 | padding-bottom: 0;
274 | border-bottom: 0;
275 | }
276 |
277 | header > *:first-child {
278 | margin-top: 0;
279 | padding-top: 0;
280 | }
281 |
282 | header > *:last-child {
283 | margin-bottom: 0;
284 | }
285 |
286 | a button,
287 | button,
288 | input[type="submit"],
289 | input[type="reset"],
290 | input[type="button"] {
291 | font-size: 1rem;
292 | display: inline-block;
293 | padding: 6px 12px;
294 | text-align: center;
295 | text-decoration: none;
296 | white-space: nowrap;
297 | background: var(--nc-lk-1);
298 | color: var(--nc-lk-tx);
299 | border: 0;
300 | border-radius: 4px;
301 | box-sizing: border-box;
302 | cursor: pointer;
303 | color: var(--nc-lk-tx);
304 | }
305 |
306 | a button[disabled],
307 | button[disabled],
308 | input[type="submit"][disabled],
309 | input[type="reset"][disabled],
310 | input[type="button"][disabled] {
311 | cursor: default;
312 | opacity: .5;
313 |
314 | /* Set the [X] cursor while hovering a disabled link */
315 | cursor: not-allowed;
316 | }
317 |
318 | .button:focus,
319 | .button:enabled:hover,
320 | button:focus,
321 | button:enabled:hover,
322 | input[type="submit"]:focus,
323 | input[type="submit"]:enabled:hover,
324 | input[type="reset"]:focus,
325 | input[type="reset"]:enabled:hover,
326 | input[type="button"]:focus,
327 | input[type="button"]:enabled:hover {
328 | background: var(--nc-lk-2);
329 | }
330 |
331 | a img {
332 | margin-bottom: 0px;
333 | }
334 |
335 | code,
336 | pre,
337 | kbd,
338 | samp {
339 | /* Set the font family for monospaced elements */
340 | font-family: var(--nc-font-mono);
341 | tab-size: 4;
342 | }
343 |
344 | code,
345 | samp,
346 | kbd,
347 | pre {
348 | /* The main preformatted style. This is changed slightly across different cases. */
349 | background: var(--nc-bg-2);
350 | border: 1px solid var(--nc-bg-3);
351 | border-radius: 4px;
352 | padding: 3px 6px;
353 | /* ↓ font-size is relative to containing element, so it scales for titles*/
354 | font-size: 0.9em;
355 | }
356 |
357 | kbd {
358 | /* Makes the kbd element look like a keyboard key */
359 | border-bottom: 3px solid var(--nc-bg-3);
360 | }
361 |
362 | pre {
363 | padding: 1rem 1.4rem;
364 | max-width: 100%;
365 | overflow: auto;
366 | }
367 |
368 | pre code {
369 | /* When is in a , reset it's formatting to blend in */
370 | background: inherit;
371 | font-size: inherit;
372 | color: inherit;
373 | border: 0;
374 | padding: 0;
375 | margin: 0;
376 | }
377 |
378 | code pre {
379 | /* When is in a , reset it's formatting to blend in */
380 | display: inline;
381 | background: inherit;
382 | font-size: inherit;
383 | color: inherit;
384 | border: 0;
385 | padding: 0;
386 | margin: 0;
387 | }
388 |
389 | details {
390 | /* Make the look more "clickable" */
391 | padding: .6rem 1rem;
392 | background: var(--nc-bg-2);
393 | border: 1px solid var(--nc-bg-3);
394 | border-radius: 4px;
395 | }
396 |
397 | summary {
398 | /* Makes the look more like a "clickable" link with the pointer cursor */
399 | cursor: pointer;
400 | font-weight: bold;
401 | }
402 |
403 | details[open] {
404 | /* Adjust the padding while open */
405 | padding-bottom: .75rem;
406 | }
407 |
408 | details[open] summary {
409 | /* Adjust the padding while open */
410 | margin-bottom: 6px;
411 | }
412 |
413 | details[open]>*:last-child {
414 | /* Resets the bottom margin of the last element in the while is opened. This prevents double margins/paddings. */
415 | margin-bottom: 0;
416 | }
417 |
418 | dt {
419 | font-weight: bold;
420 | }
421 |
422 | dd::before {
423 | /* Add an arrow to data table definitions */
424 | content: '→ ';
425 | }
426 |
427 | hr {
428 | /* Reset the border of the
separator, then set a better line */
429 | border: 0;
430 | border-bottom: 1px solid var(--nc-bg-3);
431 | margin: 1rem auto;
432 | }
433 |
434 | fieldset {
435 | margin-top: 1rem;
436 | padding: 2rem;
437 | border: 1px solid var(--nc-bg-3);
438 | border-radius: 4px;
439 | }
440 |
441 | legend {
442 | padding: auto .5rem;
443 | }
444 |
445 | table {
446 | /* border-collapse sets the table's elements to share borders, rather than floating as separate "boxes". */
447 | border-collapse: collapse;
448 | width: 100%
449 | }
450 |
451 | td,
452 | th {
453 | border: 1px solid var(--nc-bg-3);
454 | text-align: left;
455 | padding: .5rem;
456 | }
457 |
458 | th {
459 | background: var(--nc-bg-2);
460 | }
461 |
462 | tr:nth-child(even) {
463 | /* Set every other cell slightly darker. Improves readability. */
464 | background: var(--nc-bg-2);
465 | }
466 |
467 | table caption {
468 | font-weight: bold;
469 | margin-bottom: .5rem;
470 | }
471 |
472 | textarea {
473 | /* Don't let the