Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

```
<!-- showboat-id: 550e8400-e29b-41d4-a716-446655440000 -->
```

### 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):
Expand Down
43 changes: 32 additions & 11 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions cmd/build_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package cmd

import (
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"time"

"github.com/google/uuid"
"github.com/simonw/showboat/markdown"
)

Expand All @@ -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)
Expand All @@ -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
}
52 changes: 52 additions & 0 deletions cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"path/filepath"
"strings"
"testing"

"github.com/simonw/showboat/markdown"
)

func TestInitCreatesFile(t *testing.T) {
Expand Down Expand Up @@ -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, "<!-- showboat-id: ") {
t.Errorf("expected showboat-id comment in output, got: %q", s)
}
}

func TestInitUUIDRoundTrips(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)
}

blocks, err := readBlocks(file)
if err != nil {
t.Fatal(err)
}

if len(blocks) == 0 {
t.Fatal("expected at least one block")
}
tb, ok := blocks[0].(markdown.TitleBlock)
if !ok {
t.Fatalf("expected TitleBlock, got %T", blocks[0])
}
if tb.DocumentID == "" {
t.Error("expected non-empty DocumentID after init")
}
// UUID should be 36 chars (8-4-4-4-12)
if len(tb.DocumentID) != 36 {
t.Errorf("expected UUID format (36 chars), got %q (%d chars)", tb.DocumentID, len(tb.DocumentID))
}
}

func TestInitErrorsIfExists(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "demo.md")
Expand Down
11 changes: 10 additions & 1 deletion cmd/pop.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ func Pop(file string) error {
}
}

docID := documentID(blocks)

last := blocks[len(blocks)-1]

switch last.(type) {
Expand All @@ -41,5 +43,12 @@ func Pop(file string) error {
blocks = blocks[:len(blocks)-1]
}

return writeBlocks(file, blocks)
if err := writeBlocks(file, blocks); err != nil {
return err
}

if docID != "" {
postPop(docID)
}
return nil
}
Loading