├── 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 |
2 |

{{title}}

3 | 4 | 7 | 8 |
9 |
10 | 11 |
12 |
13 | 14 |
15 |
16 | 17 |
18 |
19 | -------------------------------------------------------------------------------- /pkg/server/assets/dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --nc-tx-1: #ffffff; 3 | --nc-tx-2: #eeeeee; 4 | --nc-bg-1: #000000; 5 | --nc-bg-2: #111111; 6 | --nc-bg-3: #222222; 7 | --nc-lk-1: #3291FF; 8 | --nc-lk-2: #0070F3; 9 | --nc-lk-tx: #FFFFFF; 10 | --nc-ac-1: #7928CA; 11 | --nc-ac-tx: #FFFFFF; 12 | --nc-err-fg: #CC241D; 13 | --nc-err-bg: #8B0000; 14 | } 15 | -------------------------------------------------------------------------------- /pkg/server/assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igorhub/devcard/94c68ef2bbd585df4c95cae84631af14e82fc761/pkg/server/assets/favicon.png -------------------------------------------------------------------------------- /pkg/server/assets/gruvbox-dark.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --nc-tx-1: #FBF1C7; 3 | --nc-tx-2: #EBDBB2; 4 | --nc-bg-1: #282828; 5 | --nc-bg-2: #3C3836; 6 | --nc-bg-3: #504945; 7 | --nc-lk-1: #458588; 8 | --nc-lk-2: #458588; 9 | --nc-lk-tx: #FFFFFF; 10 | --nc-ac-1: #D65D0E; 11 | --nc-ac-tx: #FABD2F; 12 | --nc-err-fg: #CC241D; 13 | --nc-err-bg: #3D0C02; 14 | } 15 | -------------------------------------------------------------------------------- /pkg/server/assets/gruvbox-light.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --nc-tx-1: #3c3836; 3 | --nc-tx-2: #282828; 4 | --nc-bg-1: #f9f5d7; 5 | --nc-bg-2: #fbf1c7; 6 | --nc-bg-3: #ebdbb2; 7 | --nc-lk-1: #458588; 8 | --nc-lk-2: #458588; 9 | --nc-lk-tx: #FFFFFF; 10 | --nc-ac-1: #076778; 11 | --nc-ac-tx: #8F3F81; 12 | --nc-err-fg: #CC241D; 13 | --nc-err-bg: #FBCEB1; 14 | } 15 | -------------------------------------------------------------------------------- /pkg/server/assets/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{title}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | {{body}} 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /pkg/server/assets/javascript.js: -------------------------------------------------------------------------------- 1 | url = "{{url}}"; 2 | clientId = "{{clientId}}"; 3 | clientKind = "{{clientKind}}"; 4 | projectName = "{{projectName}}"; 5 | devcardName = "{{devcardName}}"; 6 | scrollPosition = 0; 7 | 8 | /** 9 | * Once the website loads, we want to apply listeners and connect to websocket 10 | * */ 11 | window.onload = function () { 12 | if (clientId == "") { 13 | return; 14 | } 15 | 16 | // Check if the browser supports WebSocket 17 | if (!window["WebSocket"]) { 18 | alert("Websockets are not supported"); 19 | return; 20 | } 21 | 22 | conn = new WebSocket("ws://" + document.location.host + "/ws?clientId=" + clientId + "&clientKind=" + clientKind + "&url=" + url + "&projectName=" + projectName + "&devcardName=" + devcardName); 23 | 24 | conn.addEventListener("close", (event) => { 25 | console.log(url) 26 | appendStatusBarContent(" 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