From 8c288f3090c9cbb30d1c10c97636052c3e80e485 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 14 Feb 2026 16:50:29 +0000 Subject: [PATCH 1/2] Support markdown image references with alt text in image command The image command now accepts either a plain file path or a markdown image reference like ![alt text](path). When a markdown reference is detected (starts with ![ and ends with )) the alt text and path are extracted. The image is copied with a generated filename and the alt text is preserved in the output. Plain paths continue to derive alt text from the generated filename. https://claude.ai/code/session_01TKxtWqAnWzSwgPdY23Zgx6 --- cmd/build.go | 39 ++++++++++++++--- cmd/build_test.go | 104 +++++++++++++++++++++++++++++++++++++-------- exec/image.go | 40 ++++++++++------- exec/image_test.go | 46 ++++++++++++++++++++ help.txt | 28 ++++++++---- main.go | 6 +-- 6 files changed, 212 insertions(+), 51 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index 363c0a3..05498ab 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -51,16 +51,21 @@ func Exec(file, lang, code, workdir string) (string, int, error) { return output, exitCode, nil } -// Image appends an image code block, runs the script, captures the image. -func Image(file, script, workdir string) error { +// Image appends an image reference to a showboat document. The input is either +// a plain path to an image file or a markdown image reference of the form +// ![alt text](path). When a markdown reference is provided the alt text is +// preserved; otherwise it is derived from the generated filename. +func Image(file, input, workdir string) error { if _, err := os.Stat(file); err != nil { return fmt.Errorf("file not found: %s", file) } + imgPath, altText := parseImageInput(input) + destDir := filepath.Dir(file) - filename, err := execpkg.RunImage(script, destDir, workdir) + filename, err := execpkg.CopyImage(imgPath, destDir) if err != nil { - return fmt.Errorf("running image script: %w", err) + return err } blocks, err := readBlocks(file) @@ -68,17 +73,37 @@ func Image(file, script, workdir string) error { return err } - // Derive alt text from the filename without UUID prefix and date - altText := strings.TrimSuffix(filename, filepath.Ext(filename)) + if altText == "" { + // Derive alt text from the filename without UUID prefix and date + altText = strings.TrimSuffix(filename, filepath.Ext(filename)) + } blocks = append(blocks, - markdown.CodeBlock{Lang: "bash", Code: script, IsImage: true}, + markdown.CodeBlock{Lang: "bash", Code: input, IsImage: true}, markdown.ImageOutputBlock{AltText: altText, Filename: filename}, ) return writeBlocks(file, blocks) } +// parseImageInput checks whether input is a markdown image reference +// (![alt](path)) or a plain file path. It returns the image path and any +// extracted alt text (empty when the input is a plain path). +func parseImageInput(input string) (path, altText string) { + trimmed := strings.TrimSpace(input) + if strings.HasPrefix(trimmed, "![") && strings.HasSuffix(trimmed, ")") { + // Extract alt text between ![ and ] + rest := trimmed[2:] + closeBracket := strings.Index(rest, "](") + if closeBracket != -1 { + altText = rest[:closeBracket] + path = rest[closeBracket+2 : len(rest)-1] + return path, altText + } + } + return trimmed, "" +} + // readBlocks opens a file and parses its blocks. func readBlocks(file string) ([]markdown.Block, error) { f, err := os.Open(file) diff --git a/cmd/build_test.go b/cmd/build_test.go index 203a82c..f289a06 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -117,6 +117,19 @@ func TestExecNonZeroExit(t *testing.T) { } } +// minimalPNG is a valid 1x1 white PNG used in tests. +var minimalPNG = []byte{ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature + 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, + 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, + 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, + 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, + 0x44, 0xae, 0x42, 0x60, 0x82, +} + func TestImage(t *testing.T) { dir := t.TempDir() file := filepath.Join(dir, "demo.md") @@ -125,27 +138,12 @@ func TestImage(t *testing.T) { t.Fatal(err) } - // Create a tiny valid PNG file and a script that outputs its path pngPath := filepath.Join(dir, "test.png") - // Minimal 1x1 white PNG - pngData := []byte{ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature - 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk - 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, - 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, - 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, - 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, - 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, - 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, - 0x44, 0xae, 0x42, 0x60, 0x82, - } - if err := os.WriteFile(pngPath, pngData, 0644); err != nil { + if err := os.WriteFile(pngPath, minimalPNG, 0644); err != nil { t.Fatal(err) } - script := "echo " + pngPath - - if err := Image(file, script, ""); err != nil { + if err := Image(file, pngPath, ""); err != nil { t.Fatal(err) } @@ -162,3 +160,75 @@ func TestImage(t *testing.T) { t.Errorf("expected image output in file, got: %s", s) } } + +func TestImageMarkdownRef(t *testing.T) { + 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) + } + + input := "![My screenshot](" + pngPath + ")" + + if err := Image(file, input, ""); err != nil { + t.Fatal(err) + } + + content, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + + s := string(content) + if !strings.Contains(s, "![My screenshot](") { + t.Errorf("expected alt text 'My screenshot' in image output, got: %s", s) + } + if !strings.Contains(s, "```bash {image}") { + t.Errorf("expected image code block in file, got: %s", s) + } +} + +func TestParseImageInput(t *testing.T) { + tests := []struct { + input string + path string + altText string + }{ + {"/path/to/img.png", "/path/to/img.png", ""}, + {"![alt text](/path/to/img.png)", "/path/to/img.png", "alt text"}, + {"![](file.jpg)", "file.jpg", ""}, + {"![Screenshot of homepage](shot.png)", "shot.png", "Screenshot of homepage"}, + {" ![padded](file.png) ", "file.png", "padded"}, + {"not-markdown.png", "not-markdown.png", ""}, + } + for _, tt := range tests { + path, alt := parseImageInput(tt.input) + if path != tt.path { + t.Errorf("parseImageInput(%q): path = %q, want %q", tt.input, path, tt.path) + } + if alt != tt.altText { + t.Errorf("parseImageInput(%q): altText = %q, want %q", tt.input, alt, tt.altText) + } + } +} + +func TestImageMarkdownRefBadPath(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "demo.md") + + if err := Init(file, "Test", "dev"); err != nil { + t.Fatal(err) + } + + input := "![alt text](/nonexistent/image.png)" + err := Image(file, input, "") + if err == nil { + t.Error("expected error for nonexistent image path in markdown ref") + } +} diff --git a/exec/image.go b/exec/image.go index 34c66b9..6b167fe 100644 --- a/exec/image.go +++ b/exec/image.go @@ -20,23 +20,11 @@ var validImageExts = map[string]bool{ ".svg": true, } -// RunImage runs a bash script that is expected to produce an image file. -// The last line of stdout is treated as the path to the image. -// The image is copied to destDir with a -. filename. +// CopyImage copies an image file to destDir with a generated +// -. filename. It validates that srcPath exists, is a +// regular file, and has a recognized image extension. // Returns the new filename (not the full path). -func RunImage(script, destDir, workdir string) (string, error) { - output, _, err := Run("bash", script, workdir) - if err != nil { - return "", fmt.Errorf("running image script: %w", err) - } - - // Last non-empty line of output is the image path - lines := strings.Split(strings.TrimSpace(output), "\n") - if len(lines) == 0 { - return "", fmt.Errorf("image script produced no output") - } - srcPath := strings.TrimSpace(lines[len(lines)-1]) - +func CopyImage(srcPath, destDir string) (string, error) { // Verify file exists info, err := os.Stat(srcPath) if err != nil { @@ -77,3 +65,23 @@ func RunImage(script, destDir, workdir string) (string, error) { return newFilename, nil } + +// RunImage runs a bash script that is expected to produce an image file. +// The last line of stdout is treated as the path to the image. +// The image is copied to destDir with a -. filename. +// Returns the new filename (not the full path). +func RunImage(script, destDir, workdir string) (string, error) { + output, _, err := Run("bash", script, workdir) + if err != nil { + return "", fmt.Errorf("running image script: %w", err) + } + + // Last non-empty line of output is the image path + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) == 0 { + return "", fmt.Errorf("image script produced no output") + } + srcPath := strings.TrimSpace(lines[len(lines)-1]) + + return CopyImage(srcPath, destDir) +} diff --git a/exec/image_test.go b/exec/image_test.go index 5047485..6287e0f 100644 --- a/exec/image_test.go +++ b/exec/image_test.go @@ -41,3 +41,49 @@ func TestRunImageScriptBadPath(t *testing.T) { t.Error("expected error for nonexistent image path") } } + +func TestCopyImage(t *testing.T) { + tmpDir := t.TempDir() + imgPath := filepath.Join(tmpDir, "photo.png") + // Write a minimal PNG header so the file exists + if err := os.WriteFile(imgPath, []byte("\x89PNG\r\n\x1a\n"), 0644); err != nil { + t.Fatal(err) + } + + destDir := t.TempDir() + filename, err := CopyImage(imgPath, destDir) + if err != nil { + t.Fatal(err) + } + + if !strings.HasSuffix(filename, ".png") { + t.Errorf("expected .png suffix, got %q", filename) + } + + destPath := filepath.Join(destDir, filename) + if _, err := os.Stat(destPath); os.IsNotExist(err) { + t.Errorf("expected file at %s", destPath) + } +} + +func TestCopyImageBadPath(t *testing.T) { + destDir := t.TempDir() + _, err := CopyImage("/nonexistent/file.png", destDir) + if err == nil { + t.Error("expected error for nonexistent image path") + } +} + +func TestCopyImageBadExt(t *testing.T) { + tmpDir := t.TempDir() + txtPath := filepath.Join(tmpDir, "file.txt") + if err := os.WriteFile(txtPath, []byte("hello"), 0644); err != nil { + t.Fatal(err) + } + + destDir := t.TempDir() + _, err := CopyImage(txtPath, destDir) + if err == nil { + t.Error("expected error for unrecognized image format") + } +} diff --git a/help.txt b/help.txt index d279e2c..6db0960 100644 --- a/help.txt +++ b/help.txt @@ -9,7 +9,8 @@ Usage: showboat init Create a new demo document showboat note <file> [text] Append commentary (text or stdin) showboat exec <file> <lang> [code] Run code and capture output - showboat image <file> [script] Run script, capture image output + showboat image <file> <path> Copy image into document + showboat image <file> '![alt](path)' Copy image with alt text showboat pop <file> Remove the most recent entry showboat verify <file> [--output <new>] Re-run and diff all code blocks showboat extract <file> [--filename <name>] Emit commands to recreate file @@ -31,9 +32,11 @@ Exec output: 1 Image: - The "image" command runs a script that is expected to produce an image file. - The image is saved in the same directory as the document and an image reference - is appended to the markdown. The script is recorded as a bash code block. + The "image" command accepts a path to an image file or a markdown image + reference of the form ![alt text](path). The image is copied into the same + directory as the document with a generated filename and an image reference is + appended to the markdown. When a markdown reference is provided the alt text + is preserved; otherwise it is derived from the generated filename. Pop: The "pop" command removes the most recent entry from a document. For an "exec" @@ -79,8 +82,11 @@ Example: # Redo it correctly showboat exec demo.md python3 "print('Hello from Python')" - # Capture a screenshot - showboat image demo.md "python screenshot.py http://localhost:8000" + # Add a screenshot + showboat image demo.md screenshot.png + + # Add a screenshot with alt text + showboat image demo.md '![Homepage screenshot](screenshot.png)' # Verify the demo still works showboat verify demo.md @@ -112,8 +118,14 @@ Resulting markdown format: Hello from Python ``` - ```bash - python screenshot.py http://localhost:8000 + ```bash {image} + screenshot.png ``` ![screenshot](screenshot.png) + + ```bash {image} + ![Homepage screenshot](screenshot.png) + ``` + + ![Homepage screenshot](screenshot.png) diff --git a/main.go b/main.go index da49010..13a53d7 100644 --- a/main.go +++ b/main.go @@ -75,15 +75,15 @@ func main() { case "image": if len(args) < 2 { - fmt.Fprintln(os.Stderr, "usage: showboat image <file> [script]") + fmt.Fprintln(os.Stderr, "usage: showboat image <file> <image|![alt](image)>") os.Exit(1) } - script, err := getTextArg(args[2:]) + input, err := getTextArg(args[2:]) if err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } - if err := cmd.Image(args[1], script, workdir); err != nil { + if err := cmd.Image(args[1], input, workdir); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } From 3e72ae4143a4ad46c46d10ae0e0aa3646d6e6458 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 14 Feb 2026 16:52:07 +0000 Subject: [PATCH 2/2] Update README to reflect new image command interface https://claude.ai/code/session_01TKxtWqAnWzSwgPdY23Zgx6 --- README.md | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e19c3dd..eb03252 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,8 @@ Usage: showboat init <file> <title> Create a new demo document showboat note <file> [text] Append commentary (text or stdin) showboat exec <file> <lang> [code] Run code and capture output - showboat image <file> [script] Run script, capture image output + showboat image <file> <path> Copy image into document + showboat image <file> '![alt](path)' Copy image with alt text showboat pop <file> Remove the most recent entry showboat verify <file> [--output <new>] Re-run and diff all code blocks showboat extract <file> [--filename <name>] Emit commands to recreate file @@ -83,9 +84,11 @@ Exec output: 1 Image: - The "image" command runs a script that is expected to produce an image file. - The image is saved in the same directory as the document and an image reference - is appended to the markdown. The script is recorded as a bash code block. + The "image" command accepts a path to an image file or a markdown image + reference of the form ![alt text](path). The image is copied into the same + directory as the document with a generated filename and an image reference is + appended to the markdown. When a markdown reference is provided the alt text + is preserved; otherwise it is derived from the generated filename. Pop: The "pop" command removes the most recent entry from a document. For an "exec" @@ -131,8 +134,11 @@ Example: # Redo it correctly showboat exec demo.md python3 "print('Hello from Python')" - # Capture a screenshot - showboat image demo.md "python screenshot.py http://localhost:8000" + # Add a screenshot + showboat image demo.md screenshot.png + + # Add a screenshot with alt text + showboat image demo.md '![Homepage screenshot](screenshot.png)' # Verify the demo still works showboat verify demo.md @@ -164,11 +170,17 @@ Resulting markdown format: Hello from Python ``` - ```bash - python screenshot.py http://localhost:8000 + ```bash {image} + screenshot.png ``` ![screenshot](screenshot.png) + + ```bash {image} + ![Homepage screenshot](screenshot.png) + ``` + + ![Homepage screenshot](screenshot.png) ```` <!-- [[[end]]] --> @@ -187,8 +199,11 @@ showboat exec demo.md bash "python3 -m venv .venv && echo 'Done'" # Run Python and capture output showboat exec demo.md python "print('Hello from Python')" -# Capture a screenshot -showboat image demo.md "python screenshot.py http://localhost:8000" +# Add a screenshot +showboat image demo.md screenshot.png + +# Add a screenshot with alt text +showboat image demo.md '![Homepage screenshot](screenshot.png)' ``` This produces a markdown file like: