diff --git a/README.md b/README.md index eb03252..f83898d 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,46 @@ By default the commands reference the original filename. Use `--filename` to sub showboat extract demo.md --filename copy.md ``` +## Remote Document Streaming + +When the `SHOWBOAT_REMOTE_URL` environment variable is set, each `init`, `note`, `exec`, `image`, and `pop` command will POST its content to the specified URL. This enables real-time streaming of document updates to a remote viewer as the document is built. + +Each document created with `showboat init` receives a UUID that ties all subsequent commands together into a single document stream. The UUID is stored as an HTML comment in the markdown: + +``` + +``` + +### Configuration + +Set the environment variable to your receiver's URL: + +```bash +export SHOWBOAT_REMOTE_URL=https://www.example.com/showboat +``` + +Authentication can be handled by an optional query string argument: + +```bash +export SHOWBOAT_REMOTE_URL=https://www.example.com/showboat?token=secret-token-here +``` + +Remote POST errors are printed as warnings to stderr but never fail the main command. If the URL is unset or empty, no POSTs are made. + +### POST Body Format + +All POSTs use `application/x-www-form-urlencoded` except `image`, which uses `multipart/form-data`. Every POST includes `uuid` and `command` fields. + +| Command | Content-Type | Form Fields | +| --- | --- | --- | +| `init` | `application/x-www-form-urlencoded` | `uuid`, `command=init`, `title` | +| `note` | `application/x-www-form-urlencoded` | `uuid`, `command=note`, `markdown` | +| `exec` | `application/x-www-form-urlencoded` | `uuid`, `command=exec`, `language`, `input`, `output` | +| `image` | `multipart/form-data` | `uuid`, `command=image`, `input`, `alt`, `image` (file upload) | +| `pop` | `application/x-www-form-urlencoded` | `uuid`, `command=pop` | + +For `exec`, `language` is the interpreter name (e.g. `bash`, `python3`), `input` is the source code, and `output` is the captured stdout/stderr. For `image`, the `image` field is the copied image file. For `note`, `markdown` contains the rendered markdown of the commentary block. + ## Building the Python wheels The Python wheel versions are built using [go-to-wheel](https://github.com/simonw/go-to-wheel): diff --git a/cmd/build.go b/cmd/build.go index 05498ab..21a5715 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -17,9 +17,18 @@ func Note(file, text string) error { return err } - blocks = append(blocks, markdown.CommentaryBlock{Text: text}) + newBlock := markdown.CommentaryBlock{Text: text} + blocks = append(blocks, newBlock) - return writeBlocks(file, blocks) + if err := writeBlocks(file, blocks); err != nil { + return err + } + + docID := documentID(blocks) + if docID != "" { + postSection(docID, "note", []markdown.Block{newBlock}) + } + return nil } // Exec appends a code block, executes it, and appends the output. @@ -39,15 +48,19 @@ func Exec(file, lang, code, workdir string) (string, int, error) { return "", exitCode, err } - blocks = append(blocks, - markdown.CodeBlock{Lang: lang, Code: code}, - markdown.OutputBlock{Content: output}, - ) + codeBlock := markdown.CodeBlock{Lang: lang, Code: code} + outputBlock := markdown.OutputBlock{Content: output} + blocks = append(blocks, codeBlock, outputBlock) if err := writeBlocks(file, blocks); err != nil { return output, exitCode, err } + docID := documentID(blocks) + if docID != "" { + postSection(docID, "exec", []markdown.Block{codeBlock, outputBlock}) + } + return output, exitCode, nil } @@ -78,12 +91,20 @@ func Image(file, input, workdir string) error { altText = strings.TrimSuffix(filename, filepath.Ext(filename)) } - blocks = append(blocks, - markdown.CodeBlock{Lang: "bash", Code: input, IsImage: true}, - markdown.ImageOutputBlock{AltText: altText, Filename: filename}, - ) + codeBlock := markdown.CodeBlock{Lang: "bash", Code: input, IsImage: true} + imgBlock := markdown.ImageOutputBlock{AltText: altText, Filename: filename} + blocks = append(blocks, codeBlock, imgBlock) + + if err := writeBlocks(file, blocks); err != nil { + return err + } - return writeBlocks(file, blocks) + docID := documentID(blocks) + if docID != "" { + copiedImagePath := filepath.Join(destDir, filename) + postImage(docID, []markdown.Block{codeBlock, imgBlock}, copiedImagePath) + } + return nil } // parseImageInput checks whether input is a markdown image reference diff --git a/cmd/build_test.go b/cmd/build_test.go index f289a06..87ce719 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -1,6 +1,9 @@ package cmd import ( + "io" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" @@ -194,6 +197,101 @@ func TestImageMarkdownRef(t *testing.T) { } } +func TestNoteCallsRemotePost(t *testing.T) { + var gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + t.Setenv("SHOWBOAT_REMOTE_URL", server.URL) + + dir := t.TempDir() + file := filepath.Join(dir, "demo.md") + + if err := Init(file, "Test", "dev"); err != nil { + t.Fatal(err) + } + + // Reset gotBody so we only check the note POST + gotBody = "" + if err := Note(file, "Hello world"); err != nil { + t.Fatal(err) + } + + if !strings.Contains(gotBody, "command=note") { + t.Errorf("expected command=note in remote POST body, got %q", gotBody) + } + if !strings.Contains(gotBody, "uuid=") { + t.Errorf("expected uuid in remote POST body, got %q", gotBody) + } +} + +func TestExecCallsRemotePost(t *testing.T) { + var gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + gotBody = string(body) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + t.Setenv("SHOWBOAT_REMOTE_URL", server.URL) + + dir := t.TempDir() + file := filepath.Join(dir, "demo.md") + + if err := Init(file, "Test", "dev"); err != nil { + t.Fatal(err) + } + + gotBody = "" + if _, _, err := Exec(file, "bash", "echo hello", ""); err != nil { + t.Fatal(err) + } + + if !strings.Contains(gotBody, "command=exec") { + t.Errorf("expected command=exec in remote POST body, got %q", gotBody) + } + if !strings.Contains(gotBody, "language=bash") { + t.Errorf("expected language=bash in remote POST body, got %q", gotBody) + } +} + +func TestImageCallsRemotePost(t *testing.T) { + var gotContentType string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotContentType = r.Header.Get("Content-Type") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + t.Setenv("SHOWBOAT_REMOTE_URL", server.URL) + + dir := t.TempDir() + file := filepath.Join(dir, "demo.md") + + if err := Init(file, "Test", "dev"); err != nil { + t.Fatal(err) + } + + pngPath := filepath.Join(dir, "test.png") + if err := os.WriteFile(pngPath, minimalPNG, 0644); err != nil { + t.Fatal(err) + } + + gotContentType = "" + if err := Image(file, pngPath, ""); err != nil { + t.Fatal(err) + } + + if !strings.Contains(gotContentType, "multipart/form-data") { + t.Errorf("expected multipart/form-data content type for image POST, got %q", gotContentType) + } +} + func TestParseImageInput(t *testing.T) { tests := []struct { input string diff --git a/cmd/init.go b/cmd/init.go index 5a5a221..6f64d39 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -5,6 +5,7 @@ import ( "os" "time" + "github.com/google/uuid" "github.com/simonw/showboat/markdown" ) @@ -16,8 +17,9 @@ func Init(file, title, version string) error { } timestamp := time.Now().UTC().Format(time.RFC3339) + docID := uuid.New().String() blocks := []markdown.Block{ - markdown.TitleBlock{Title: title, Timestamp: timestamp, Version: version}, + markdown.TitleBlock{Title: title, Timestamp: timestamp, Version: version, DocumentID: docID}, } f, err := os.Create(file) @@ -26,5 +28,10 @@ func Init(file, title, version string) error { } defer f.Close() - return markdown.Write(f, blocks) + if err := markdown.Write(f, blocks); err != nil { + return err + } + + postSection(docID, "init", blocks) + return nil } diff --git a/cmd/init_test.go b/cmd/init_test.go index 8f5d015..4ed5076 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -5,6 +5,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/simonw/showboat/markdown" ) func TestInitCreatesFile(t *testing.T) { @@ -33,6 +35,56 @@ func TestInitCreatesFile(t *testing.T) { } } +func TestInitContainsShowboatID(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "demo.md") + + err := Init(file, "My Demo", "v0.3.0") + if err != nil { + t.Fatal(err) + } + + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + + s := string(content) + if !strings.Contains(s, "") { + docID = strings.TrimPrefix(lines[i], "") + i++ + } + blocks = append(blocks, TitleBlock{Title: title, Timestamp: ts, Version: ver, DocumentID: docID}) skipSeparator() continue } diff --git a/markdown/parser_test.go b/markdown/parser_test.go index 238e8e0..db985f8 100644 --- a/markdown/parser_test.go +++ b/markdown/parser_test.go @@ -174,6 +174,89 @@ func TestRoundTripWithBackticksInOutput(t *testing.T) { } } +func TestParseTitleWithDocumentID(t *testing.T) { + input := "# My Demo\n\n*2026-02-06T15:30:00Z by Showboat v0.3.0*\n\n" + blocks, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + tb, ok := blocks[0].(TitleBlock) + if !ok { + t.Fatalf("expected TitleBlock, got %T", blocks[0]) + } + if tb.DocumentID != "abc-123" { + t.Errorf("expected DocumentID 'abc-123', got %q", tb.DocumentID) + } + if tb.Title != "My Demo" { + t.Errorf("expected title 'My Demo', got %q", tb.Title) + } + if tb.Timestamp != "2026-02-06T15:30:00Z" { + t.Errorf("expected timestamp '2026-02-06T15:30:00Z', got %q", tb.Timestamp) + } +} + +func TestParseTitleWithoutDocumentID(t *testing.T) { + input := "# My Demo\n\n*2026-02-06T15:30:00Z*\n" + blocks, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + if len(blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(blocks)) + } + tb, ok := blocks[0].(TitleBlock) + if !ok { + t.Fatalf("expected TitleBlock, got %T", blocks[0]) + } + if tb.DocumentID != "" { + t.Errorf("expected empty DocumentID, got %q", tb.DocumentID) + } +} + +func TestParseTitleWithDocumentIDFollowedByContent(t *testing.T) { + input := "# My Demo\n\n*2026-02-06T15:30:00Z*\n\n\nHello world.\n" + blocks, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + if len(blocks) != 2 { + t.Fatalf("expected 2 blocks, got %d: %+v", len(blocks), blocks) + } + tb, ok := blocks[0].(TitleBlock) + if !ok { + t.Fatalf("expected TitleBlock, got %T", blocks[0]) + } + if tb.DocumentID != "abc-123" { + t.Errorf("expected DocumentID 'abc-123', got %q", tb.DocumentID) + } + cb, ok := blocks[1].(CommentaryBlock) + if !ok { + t.Fatalf("expected CommentaryBlock, got %T", blocks[1]) + } + if cb.Text != "Hello world." { + t.Errorf("unexpected text: %q", cb.Text) + } +} + +func TestRoundTripWithDocumentID(t *testing.T) { + input := "# Demo\n\n*2026-02-06T00:00:00Z by Showboat v0.3.0*\n\n\nLet's begin.\n\n```bash\necho hi\n```\n\n```output\nhi\n```\n\nDone.\n" + blocks, err := Parse(strings.NewReader(input)) + if err != nil { + t.Fatal(err) + } + var buf strings.Builder + err = Write(&buf, blocks) + if err != nil { + t.Fatal(err) + } + if buf.String() != input { + t.Errorf("round trip mismatch.\nexpected:\n%s\ngot:\n%s", input, buf.String()) + } +} + func TestRoundTrip(t *testing.T) { input := "# Demo\n\n*2026-02-06T00:00:00Z by Showboat v0.3.0*\n\nLet's begin.\n\n```bash\necho hi\n```\n\n```output\nhi\n```\n\nDone.\n" blocks, err := Parse(strings.NewReader(input)) diff --git a/markdown/writer.go b/markdown/writer.go index d195c82..06d8593 100644 --- a/markdown/writer.go +++ b/markdown/writer.go @@ -28,8 +28,15 @@ func writeBlock(w io.Writer, block Block) error { if b.Version != "" { dateline += " by Showboat " + b.Version } - _, err := fmt.Fprintf(w, "# %s\n\n*%s*\n", b.Title, dateline) - return err + if _, err := fmt.Fprintf(w, "# %s\n\n*%s*\n", b.Title, dateline); err != nil { + return err + } + if b.DocumentID != "" { + if _, err := fmt.Fprintf(w, "\n", b.DocumentID); err != nil { + return err + } + } + return nil case CommentaryBlock: _, err := fmt.Fprintf(w, "%s\n", b.Text) return err diff --git a/markdown/writer_test.go b/markdown/writer_test.go index 38f56a0..391a936 100644 --- a/markdown/writer_test.go +++ b/markdown/writer_test.go @@ -128,6 +128,36 @@ func TestWriteOutputNoBackticks(t *testing.T) { } } +func TestWriteTitleWithDocumentID(t *testing.T) { + var buf strings.Builder + blocks := []Block{ + TitleBlock{Title: "My Demo", Timestamp: "2026-02-06T15:30:00Z", Version: "v0.3.0", DocumentID: "abc-123"}, + } + err := Write(&buf, blocks) + if err != nil { + t.Fatal(err) + } + expected := "# My Demo\n\n*2026-02-06T15:30:00Z by Showboat v0.3.0*\n\n" + if buf.String() != expected { + t.Errorf("expected:\n%q\ngot:\n%q", expected, buf.String()) + } +} + +func TestWriteTitleWithDocumentIDNoVersion(t *testing.T) { + var buf strings.Builder + blocks := []Block{ + TitleBlock{Title: "My Demo", Timestamp: "2026-02-06T15:30:00Z", DocumentID: "abc-123"}, + } + err := Write(&buf, blocks) + if err != nil { + t.Fatal(err) + } + expected := "# My Demo\n\n*2026-02-06T15:30:00Z*\n\n" + if buf.String() != expected { + t.Errorf("expected:\n%q\ngot:\n%q", expected, buf.String()) + } +} + func TestWriteFullDocument(t *testing.T) { var buf strings.Builder blocks := []Block{