Skip to content

Commit 8c288f3

Browse files
committed
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
1 parent 18defc3 commit 8c288f3

6 files changed

Lines changed: 212 additions & 51 deletions

File tree

cmd/build.go

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -51,34 +51,59 @@ func Exec(file, lang, code, workdir string) (string, int, error) {
5151
return output, exitCode, nil
5252
}
5353

54-
// Image appends an image code block, runs the script, captures the image.
55-
func Image(file, script, workdir string) error {
54+
// Image appends an image reference to a showboat document. The input is either
55+
// a plain path to an image file or a markdown image reference of the form
56+
// ![alt text](path). When a markdown reference is provided the alt text is
57+
// preserved; otherwise it is derived from the generated filename.
58+
func Image(file, input, workdir string) error {
5659
if _, err := os.Stat(file); err != nil {
5760
return fmt.Errorf("file not found: %s", file)
5861
}
5962

63+
imgPath, altText := parseImageInput(input)
64+
6065
destDir := filepath.Dir(file)
61-
filename, err := execpkg.RunImage(script, destDir, workdir)
66+
filename, err := execpkg.CopyImage(imgPath, destDir)
6267
if err != nil {
63-
return fmt.Errorf("running image script: %w", err)
68+
return err
6469
}
6570

6671
blocks, err := readBlocks(file)
6772
if err != nil {
6873
return err
6974
}
7075

71-
// Derive alt text from the filename without UUID prefix and date
72-
altText := strings.TrimSuffix(filename, filepath.Ext(filename))
76+
if altText == "" {
77+
// Derive alt text from the filename without UUID prefix and date
78+
altText = strings.TrimSuffix(filename, filepath.Ext(filename))
79+
}
7380

7481
blocks = append(blocks,
75-
markdown.CodeBlock{Lang: "bash", Code: script, IsImage: true},
82+
markdown.CodeBlock{Lang: "bash", Code: input, IsImage: true},
7683
markdown.ImageOutputBlock{AltText: altText, Filename: filename},
7784
)
7885

7986
return writeBlocks(file, blocks)
8087
}
8188

89+
// parseImageInput checks whether input is a markdown image reference
90+
// (![alt](path)) or a plain file path. It returns the image path and any
91+
// extracted alt text (empty when the input is a plain path).
92+
func parseImageInput(input string) (path, altText string) {
93+
trimmed := strings.TrimSpace(input)
94+
if strings.HasPrefix(trimmed, "![") && strings.HasSuffix(trimmed, ")") {
95+
// Extract alt text between ![ and ]
96+
rest := trimmed[2:]
97+
closeBracket := strings.Index(rest, "](")
98+
if closeBracket != -1 {
99+
altText = rest[:closeBracket]
100+
path = rest[closeBracket+2 : len(rest)-1]
101+
return path, altText
102+
}
103+
}
104+
return trimmed, ""
105+
}
106+
82107
// readBlocks opens a file and parses its blocks.
83108
func readBlocks(file string) ([]markdown.Block, error) {
84109
f, err := os.Open(file)

cmd/build_test.go

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,19 @@ func TestExecNonZeroExit(t *testing.T) {
117117
}
118118
}
119119

120+
// minimalPNG is a valid 1x1 white PNG used in tests.
121+
var minimalPNG = []byte{
122+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
123+
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
124+
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
125+
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
126+
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
127+
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
128+
0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc,
129+
0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
130+
0x44, 0xae, 0x42, 0x60, 0x82,
131+
}
132+
120133
func TestImage(t *testing.T) {
121134
dir := t.TempDir()
122135
file := filepath.Join(dir, "demo.md")
@@ -125,27 +138,12 @@ func TestImage(t *testing.T) {
125138
t.Fatal(err)
126139
}
127140

128-
// Create a tiny valid PNG file and a script that outputs its path
129141
pngPath := filepath.Join(dir, "test.png")
130-
// Minimal 1x1 white PNG
131-
pngData := []byte{
132-
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
133-
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
134-
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
135-
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53,
136-
0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41,
137-
0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
138-
0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc,
139-
0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
140-
0x44, 0xae, 0x42, 0x60, 0x82,
141-
}
142-
if err := os.WriteFile(pngPath, pngData, 0644); err != nil {
142+
if err := os.WriteFile(pngPath, minimalPNG, 0644); err != nil {
143143
t.Fatal(err)
144144
}
145145

146-
script := "echo " + pngPath
147-
148-
if err := Image(file, script, ""); err != nil {
146+
if err := Image(file, pngPath, ""); err != nil {
149147
t.Fatal(err)
150148
}
151149

@@ -162,3 +160,75 @@ func TestImage(t *testing.T) {
162160
t.Errorf("expected image output in file, got: %s", s)
163161
}
164162
}
163+
164+
func TestImageMarkdownRef(t *testing.T) {
165+
dir := t.TempDir()
166+
file := filepath.Join(dir, "demo.md")
167+
168+
if err := Init(file, "Test", "dev"); err != nil {
169+
t.Fatal(err)
170+
}
171+
172+
pngPath := filepath.Join(dir, "test.png")
173+
if err := os.WriteFile(pngPath, minimalPNG, 0644); err != nil {
174+
t.Fatal(err)
175+
}
176+
177+
input := "![My screenshot](" + pngPath + ")"
178+
179+
if err := Image(file, input, ""); err != nil {
180+
t.Fatal(err)
181+
}
182+
183+
content, err := os.ReadFile(file)
184+
if err != nil {
185+
t.Fatal(err)
186+
}
187+
188+
s := string(content)
189+
if !strings.Contains(s, "![My screenshot](") {
190+
t.Errorf("expected alt text 'My screenshot' in image output, got: %s", s)
191+
}
192+
if !strings.Contains(s, "```bash {image}") {
193+
t.Errorf("expected image code block in file, got: %s", s)
194+
}
195+
}
196+
197+
func TestParseImageInput(t *testing.T) {
198+
tests := []struct {
199+
input string
200+
path string
201+
altText string
202+
}{
203+
{"/path/to/img.png", "/path/to/img.png", ""},
204+
{"![alt text](/path/to/img.png)", "/path/to/img.png", "alt text"},
205+
{"![](file.jpg)", "file.jpg", ""},
206+
{"![Screenshot of homepage](shot.png)", "shot.png", "Screenshot of homepage"},
207+
{" ![padded](file.png) ", "file.png", "padded"},
208+
{"not-markdown.png", "not-markdown.png", ""},
209+
}
210+
for _, tt := range tests {
211+
path, alt := parseImageInput(tt.input)
212+
if path != tt.path {
213+
t.Errorf("parseImageInput(%q): path = %q, want %q", tt.input, path, tt.path)
214+
}
215+
if alt != tt.altText {
216+
t.Errorf("parseImageInput(%q): altText = %q, want %q", tt.input, alt, tt.altText)
217+
}
218+
}
219+
}
220+
221+
func TestImageMarkdownRefBadPath(t *testing.T) {
222+
dir := t.TempDir()
223+
file := filepath.Join(dir, "demo.md")
224+
225+
if err := Init(file, "Test", "dev"); err != nil {
226+
t.Fatal(err)
227+
}
228+
229+
input := "![alt text](/nonexistent/image.png)"
230+
err := Image(file, input, "")
231+
if err == nil {
232+
t.Error("expected error for nonexistent image path in markdown ref")
233+
}
234+
}

exec/image.go

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,23 +20,11 @@ var validImageExts = map[string]bool{
2020
".svg": true,
2121
}
2222

23-
// RunImage runs a bash script that is expected to produce an image file.
24-
// The last line of stdout is treated as the path to the image.
25-
// The image is copied to destDir with a <uuid>-<date>.<ext> filename.
23+
// CopyImage copies an image file to destDir with a generated
24+
// <uuid>-<date>.<ext> filename. It validates that srcPath exists, is a
25+
// regular file, and has a recognized image extension.
2626
// Returns the new filename (not the full path).
27-
func RunImage(script, destDir, workdir string) (string, error) {
28-
output, _, err := Run("bash", script, workdir)
29-
if err != nil {
30-
return "", fmt.Errorf("running image script: %w", err)
31-
}
32-
33-
// Last non-empty line of output is the image path
34-
lines := strings.Split(strings.TrimSpace(output), "\n")
35-
if len(lines) == 0 {
36-
return "", fmt.Errorf("image script produced no output")
37-
}
38-
srcPath := strings.TrimSpace(lines[len(lines)-1])
39-
27+
func CopyImage(srcPath, destDir string) (string, error) {
4028
// Verify file exists
4129
info, err := os.Stat(srcPath)
4230
if err != nil {
@@ -77,3 +65,23 @@ func RunImage(script, destDir, workdir string) (string, error) {
7765

7866
return newFilename, nil
7967
}
68+
69+
// RunImage runs a bash script that is expected to produce an image file.
70+
// The last line of stdout is treated as the path to the image.
71+
// The image is copied to destDir with a <uuid>-<date>.<ext> filename.
72+
// Returns the new filename (not the full path).
73+
func RunImage(script, destDir, workdir string) (string, error) {
74+
output, _, err := Run("bash", script, workdir)
75+
if err != nil {
76+
return "", fmt.Errorf("running image script: %w", err)
77+
}
78+
79+
// Last non-empty line of output is the image path
80+
lines := strings.Split(strings.TrimSpace(output), "\n")
81+
if len(lines) == 0 {
82+
return "", fmt.Errorf("image script produced no output")
83+
}
84+
srcPath := strings.TrimSpace(lines[len(lines)-1])
85+
86+
return CopyImage(srcPath, destDir)
87+
}

exec/image_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,49 @@ func TestRunImageScriptBadPath(t *testing.T) {
4141
t.Error("expected error for nonexistent image path")
4242
}
4343
}
44+
45+
func TestCopyImage(t *testing.T) {
46+
tmpDir := t.TempDir()
47+
imgPath := filepath.Join(tmpDir, "photo.png")
48+
// Write a minimal PNG header so the file exists
49+
if err := os.WriteFile(imgPath, []byte("\x89PNG\r\n\x1a\n"), 0644); err != nil {
50+
t.Fatal(err)
51+
}
52+
53+
destDir := t.TempDir()
54+
filename, err := CopyImage(imgPath, destDir)
55+
if err != nil {
56+
t.Fatal(err)
57+
}
58+
59+
if !strings.HasSuffix(filename, ".png") {
60+
t.Errorf("expected .png suffix, got %q", filename)
61+
}
62+
63+
destPath := filepath.Join(destDir, filename)
64+
if _, err := os.Stat(destPath); os.IsNotExist(err) {
65+
t.Errorf("expected file at %s", destPath)
66+
}
67+
}
68+
69+
func TestCopyImageBadPath(t *testing.T) {
70+
destDir := t.TempDir()
71+
_, err := CopyImage("/nonexistent/file.png", destDir)
72+
if err == nil {
73+
t.Error("expected error for nonexistent image path")
74+
}
75+
}
76+
77+
func TestCopyImageBadExt(t *testing.T) {
78+
tmpDir := t.TempDir()
79+
txtPath := filepath.Join(tmpDir, "file.txt")
80+
if err := os.WriteFile(txtPath, []byte("hello"), 0644); err != nil {
81+
t.Fatal(err)
82+
}
83+
84+
destDir := t.TempDir()
85+
_, err := CopyImage(txtPath, destDir)
86+
if err == nil {
87+
t.Error("expected error for unrecognized image format")
88+
}
89+
}

help.txt

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ Usage:
99
showboat init <file> <title> Create a new demo document
1010
showboat note <file> [text] Append commentary (text or stdin)
1111
showboat exec <file> <lang> [code] Run code and capture output
12-
showboat image <file> [script] Run script, capture image output
12+
showboat image <file> <path> Copy image into document
13+
showboat image <file> '![alt](path)' Copy image with alt text
1314
showboat pop <file> Remove the most recent entry
1415
showboat verify <file> [--output <new>] Re-run and diff all code blocks
1516
showboat extract <file> [--filename <name>] Emit commands to recreate file
@@ -31,9 +32,11 @@ Exec output:
3132
1
3233

3334
Image:
34-
The "image" command runs a script that is expected to produce an image file.
35-
The image is saved in the same directory as the document and an image reference
36-
is appended to the markdown. The script is recorded as a bash code block.
35+
The "image" command accepts a path to an image file or a markdown image
36+
reference of the form ![alt text](path). The image is copied into the same
37+
directory as the document with a generated filename and an image reference is
38+
appended to the markdown. When a markdown reference is provided the alt text
39+
is preserved; otherwise it is derived from the generated filename.
3740

3841
Pop:
3942
The "pop" command removes the most recent entry from a document. For an "exec"
@@ -79,8 +82,11 @@ Example:
7982
# Redo it correctly
8083
showboat exec demo.md python3 "print('Hello from Python')"
8184

82-
# Capture a screenshot
83-
showboat image demo.md "python screenshot.py http://localhost:8000"
85+
# Add a screenshot
86+
showboat image demo.md screenshot.png
87+
88+
# Add a screenshot with alt text
89+
showboat image demo.md '![Homepage screenshot](screenshot.png)'
8490

8591
# Verify the demo still works
8692
showboat verify demo.md
@@ -112,8 +118,14 @@ Resulting markdown format:
112118
Hello from Python
113119
```
114120

115-
```bash
116-
python screenshot.py http://localhost:8000
121+
```bash {image}
122+
screenshot.png
117123
```
118124

119125
![screenshot](screenshot.png)
126+
127+
```bash {image}
128+
![Homepage screenshot](screenshot.png)
129+
```
130+
131+
![Homepage screenshot](screenshot.png)

main.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,15 +75,15 @@ func main() {
7575

7676
case "image":
7777
if len(args) < 2 {
78-
fmt.Fprintln(os.Stderr, "usage: showboat image <file> [script]")
78+
fmt.Fprintln(os.Stderr, "usage: showboat image <file> <image|![alt](image)>")
7979
os.Exit(1)
8080
}
81-
script, err := getTextArg(args[2:])
81+
input, err := getTextArg(args[2:])
8282
if err != nil {
8383
fmt.Fprintf(os.Stderr, "error: %v\n", err)
8484
os.Exit(1)
8585
}
86-
if err := cmd.Image(args[1], script, workdir); err != nil {
86+
if err := cmd.Image(args[1], input, workdir); err != nil {
8787
fmt.Fprintf(os.Stderr, "error: %v\n", err)
8888
os.Exit(1)
8989
}

0 commit comments

Comments
 (0)