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
35 changes: 25 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]]] -->

Expand All @@ -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:
Expand Down
39 changes: 32 additions & 7 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,34 +51,59 @@ 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)
if err != nil {
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)
Expand Down
104 changes: 87 additions & 17 deletions cmd/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
}

Expand All @@ -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")
}
}
40 changes: 24 additions & 16 deletions exec/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <uuid>-<date>.<ext> filename.
// CopyImage copies an image file to destDir with a generated
// <uuid>-<date>.<ext> 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 {
Expand Down Expand Up @@ -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 <uuid>-<date>.<ext> 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)
}
46 changes: 46 additions & 0 deletions exec/image_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
Loading