Skip to content
Open
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
28 changes: 23 additions & 5 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,21 @@ func Note(file, text string) error {

// Exec appends a code block, executes it, and appends the output.
// It returns the captured output, the process exit code, and any error.
// If lang has a " {table}" suffix, the output is parsed as TSV and rendered
// as a markdown table instead of a plain output fence.
func Exec(file, lang, code, workdir string) (string, int, error) {
if _, err := os.Stat(file); err != nil {
return "", 1, fmt.Errorf("file not found: %s", file)
}

output, exitCode, err := execpkg.Run(lang, code, workdir)
// Detect {table} annotation and strip it for execution.
isTable := strings.HasSuffix(lang, " {table}")
runLang := lang
if isTable {
runLang = strings.TrimSuffix(lang, " {table}")
}

output, exitCode, err := execpkg.Run(runLang, code, workdir)
if err != nil {
return "", exitCode, fmt.Errorf("running code: %w", err)
}
Expand All @@ -48,17 +57,26 @@ func Exec(file, lang, code, workdir string) (string, int, error) {
return "", exitCode, err
}

codeBlock := markdown.CodeBlock{Lang: lang, Code: code}
outputBlock := markdown.OutputBlock{Content: output}
blocks = append(blocks, codeBlock, outputBlock)
codeBlock := markdown.CodeBlock{Lang: runLang, Code: code, IsTable: isTable}
var outputBlk markdown.Block
if isTable {
headers, rows, parseErr := execpkg.ParseTSV(output)
if parseErr != nil {
return output, exitCode, fmt.Errorf("parsing table output: %w", parseErr)
}
outputBlk = markdown.TableOutputBlock{Headers: headers, Rows: rows}
} else {
outputBlk = markdown.OutputBlock{Content: output}
}
blocks = append(blocks, codeBlock, outputBlk)
Comment on lines +60 to +71
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces CodeBlock.IsTable and TableOutputBlock, but other commands still assume exec output is always an OutputBlock. In particular, showboat pop will only remove the table output block (leaving the preceding code block), and showboat verify will neither compare nor update table outputs because it only checks for a following OutputBlock. Those commands should be updated to treat TableOutputBlock the same way as OutputBlock for grouping and verification.

Copilot uses AI. Check for mistakes.

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

docID := documentID(blocks)
if docID != "" {
postSection(docID, "exec", []markdown.Block{codeBlock, outputBlock})
postSection(docID, "exec", []markdown.Block{codeBlock, outputBlk})
}
Comment on lines 77 to 80
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

postSection(..., "exec", ...) currently only serializes OutputBlock outputs. With {table} execution, outputBlk will be a TableOutputBlock, so remote exec posts will omit the output entirely. Update postSection to handle TableOutputBlock (e.g., by sending rendered markdown via markdown.Write, or adding a dedicated form field for table output).

Copilot uses AI. Check for mistakes.

return output, exitCode, nil
Expand Down
75 changes: 75 additions & 0 deletions cmd/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,81 @@ func TestImageMarkdownRefEscapedBang(t *testing.T) {
}
}

func TestExecTable(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "demo.md")

if err := Init(file, "Test", "dev"); err != nil {
t.Fatal(err)
}

code := `printf 'name\tage\nAlice\t30\nBob\t25\n'`
output, exitCode, err := Exec(file, "bash {table}", code, "")
if err != nil {
t.Fatal(err)
}
if exitCode != 0 {
t.Errorf("expected exit code 0, got %d", exitCode)
}
if output != "name\tage\nAlice\t30\nBob\t25\n" {
t.Errorf("unexpected output: %q", output)
}

content, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}

s := string(content)
if !strings.Contains(s, "```bash {table}") {
t.Errorf("expected table code block in file, got: %s", s)
}
if !strings.Contains(s, "| name | age |") {
t.Errorf("expected table header in file, got: %s", s)
}
if !strings.Contains(s, "| Alice | 30 |") {
t.Errorf("expected table row in file, got: %s", s)
}
// Should NOT contain an output fence
if strings.Contains(s, "```output") {
t.Errorf("should not have output fence for table exec, got: %s", s)
}
}

func TestPopTableEntry(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "demo.md")

if err := Init(file, "Test", "dev"); err != nil {
t.Fatal(err)
}

code := `printf 'name\tage\nAlice\t30\n'`
if _, _, err := Exec(file, "bash {table}", code, ""); err != nil {
t.Fatal(err)
}

// Verify table is in the file
content, _ := os.ReadFile(file)
if !strings.Contains(string(content), "| name | age |") {
t.Fatal("expected table in file before pop")
}

// Pop should remove both the code block and the table output
if err := Pop(file); err != nil {
t.Fatal(err)
}

content, _ = os.ReadFile(file)
s := string(content)
if strings.Contains(s, "{table}") {
t.Errorf("expected code block to be removed after pop, got: %s", s)
}
if strings.Contains(s, "| name | age |") {
t.Errorf("expected table to be removed after pop, got: %s", s)
}
}

func TestImageMarkdownRefBadPath(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "demo.md")
Expand Down
8 changes: 7 additions & 1 deletion cmd/extract.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,18 @@ func Extract(file, outputFile string) ([]string, error) {
if b.IsImage {
commands = append(commands, fmt.Sprintf("showboat image %s %s", quotedTarget, shellQuote(b.Code)))
} else {
commands = append(commands, fmt.Sprintf("showboat exec %s %s %s", quotedTarget, b.Lang, shellQuote(b.Code)))
lang := b.Lang
if b.IsTable {
lang += " {table}"
}
commands = append(commands, fmt.Sprintf("showboat exec %s %s %s", quotedTarget, shellQuote(lang), shellQuote(b.Code)))
}
case markdown.OutputBlock:
// Skip: generated by running code blocks
case markdown.ImageOutputBlock:
// Skip: generated by running image scripts
case markdown.TableOutputBlock:
// Skip: generated by running table code blocks
}
}

Expand Down
26 changes: 26 additions & 0 deletions cmd/extract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,32 @@ func TestExtractOutputOverride(t *testing.T) {
}
}

func TestExtractTable(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "demo.md")

if err := Init(file, "Test", "dev"); err != nil {
t.Fatal(err)
}
code := `printf 'name\tage\nAlice\t30\n'`
if _, _, err := Exec(file, "bash {table}", code, ""); err != nil {
t.Fatal(err)
}

commands, err := Extract(file, "")
if err != nil {
t.Fatal(err)
}

if len(commands) != 2 {
t.Fatalf("expected 2 commands, got %d: %v", len(commands), commands)
}
// The exec command should include {table} in the language
if !strings.Contains(commands[1], "{table}") {
t.Errorf("expected exec command to include {table}, got: %s", commands[1])
}
}

func TestExtractShellQuote(t *testing.T) {
tests := []struct {
input string
Expand Down
2 changes: 1 addition & 1 deletion cmd/pop.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ func Pop(file string) error {
last := blocks[len(blocks)-1]

switch last.(type) {
case markdown.OutputBlock, markdown.ImageOutputBlock:
case markdown.OutputBlock, markdown.ImageOutputBlock, markdown.TableOutputBlock:
// Output blocks are always preceded by a code block — remove both.
if len(blocks) >= 2 {
blocks = blocks[:len(blocks)-2]
Expand Down
26 changes: 26 additions & 0 deletions exec/table.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package exec

import (
"fmt"
"strings"
)

// ParseTSV parses tab-separated output into headers and rows.
// The first line is treated as the header row. Empty trailing lines are ignored.
func ParseTSV(output string) ([]string, [][]string, error) {
lines := strings.Split(output, "\n")
// Remove empty trailing lines
for len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if len(lines) == 0 {
return nil, nil, fmt.Errorf("empty output: no header row found")
}

headers := strings.Split(lines[0], "\t")
var rows [][]string
for _, line := range lines[1:] {
rows = append(rows, strings.Split(line, "\t"))
Comment on lines +12 to +23
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ParseTSV should validate row widths and normalize line endings. As written, CRLF output will leave trailing \r in the last header/cell, and rows with a different number of columns than the header will silently produce malformed markdown tables later. Consider trimming \r from each line, rejecting empty/blank header rows, and returning an error when any row has a column count != len(headers) (or explicitly pad/truncate).

Suggested change
// Remove empty trailing lines
for len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
if len(lines) == 0 {
return nil, nil, fmt.Errorf("empty output: no header row found")
}
headers := strings.Split(lines[0], "\t")
var rows [][]string
for _, line := range lines[1:] {
rows = append(rows, strings.Split(line, "\t"))
for i, line := range lines {
lines[i] = strings.TrimSuffix(line, "\r")
}
// Remove empty trailing lines
for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" {
lines = lines[:len(lines)-1]
}
if len(lines) == 0 {
return nil, nil, fmt.Errorf("empty output: no header row found")
}
if strings.TrimSpace(lines[0]) == "" {
return nil, nil, fmt.Errorf("empty output: no header row found")
}
headers := strings.Split(lines[0], "\t")
var rows [][]string
for i, line := range lines[1:] {
row := strings.Split(line, "\t")
if len(row) != len(headers) {
return nil, nil, fmt.Errorf("invalid TSV row %d: expected %d columns, got %d", i+2, len(headers), len(row))
}
rows = append(rows, row)

Copilot uses AI. Check for mistakes.
}
return headers, rows, nil
}
74 changes: 74 additions & 0 deletions exec/table_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package exec

import (
"testing"
)

func TestParseTSV(t *testing.T) {
input := "name\tage\nAlice\t30\nBob\t25\n"
headers, rows, err := ParseTSV(input)
if err != nil {
t.Fatal(err)
}
if len(headers) != 2 || headers[0] != "name" || headers[1] != "age" {
t.Errorf("unexpected headers: %v", headers)
}
if len(rows) != 2 {
t.Fatalf("expected 2 rows, got %d", len(rows))
}
if rows[0][0] != "Alice" || rows[0][1] != "30" {
t.Errorf("unexpected row 0: %v", rows[0])
}
if rows[1][0] != "Bob" || rows[1][1] != "25" {
t.Errorf("unexpected row 1: %v", rows[1])
}
}

func TestParseTSVSingleColumn(t *testing.T) {
input := "count\n42\n"
headers, rows, err := ParseTSV(input)
if err != nil {
t.Fatal(err)
}
if len(headers) != 1 || headers[0] != "count" {
t.Errorf("unexpected headers: %v", headers)
}
if len(rows) != 1 || rows[0][0] != "42" {
t.Errorf("unexpected rows: %v", rows)
}
}

func TestParseTSVHeaderOnly(t *testing.T) {
input := "name\tage\n"
headers, rows, err := ParseTSV(input)
if err != nil {
t.Fatal(err)
}
if len(headers) != 2 {
t.Errorf("expected 2 headers, got %d", len(headers))
}
if len(rows) != 0 {
t.Errorf("expected 0 rows, got %d", len(rows))
}
}

func TestParseTSVEmpty(t *testing.T) {
_, _, err := ParseTSV("")
if err == nil {
t.Error("expected error for empty input")
}
}

func TestParseTSVTrailingNewlines(t *testing.T) {
input := "a\tb\n1\t2\n\n"
headers, rows, err := ParseTSV(input)
if err != nil {
t.Fatal(err)
}
if len(headers) != 2 {
t.Errorf("expected 2 headers, got %d", len(headers))
}
if len(rows) != 1 {
t.Errorf("expected 1 row, got %d", len(rows))
}
}
32 changes: 32 additions & 0 deletions help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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 exec <file> '<lang> {table}' [code] Run code, render output as table
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
Expand All @@ -31,6 +32,25 @@ Exec output:
$ echo $?
1

Table output:
When the language argument includes a {table} suffix, the exec command expects
the script to produce tab-separated (TSV) output. The first line is treated as
column headers and subsequent lines as data rows. The output is rendered as a
markdown pipe table instead of a plain output fence.

Any language works — the script just needs to print TSV to stdout:

$ showboat exec demo.md 'bash {table}' "printf 'name\tage\nAlice\t30\nBob\t25\n'"
name age
Alice 30
Bob 25

Common patterns:
SQLite: showboat exec d.md 'bash {table}' "sqlite3 -header -separator $'\t' db 'SELECT * FROM t'"
Postgres: showboat exec d.md 'bash {table}' "psql -A -F $'\t' -c 'SELECT * FROM t'"
Python: showboat exec d.md 'python3 {table}' "import csv,sys; ..."
PySpark: showboat exec d.md 'python3 {table}' "df.toPandas().to_csv(sys.stdout,sep='\t',index=False)"

Image:
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
Expand Down Expand Up @@ -82,6 +102,9 @@ Example:
# Redo it correctly
showboat exec demo.md python3 "print('Hello from Python')"

# Run a query and render the result as a table
showboat exec demo.md 'python3 {table}' "print('name\tage\nAlice\t30\nBob\t25')"

# Add a screenshot
showboat image demo.md screenshot.png

Expand Down Expand Up @@ -118,6 +141,15 @@ Resulting markdown format:
Hello from Python
```

```python3 {table}
print('name\tage\nAlice\t30\nBob\t25')
```

| name | age |
| --- | --- |
| Alice | 30 |
| Bob | 25 |

```bash {image}
screenshot.png
```
Expand Down
9 changes: 9 additions & 0 deletions markdown/blocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type CodeBlock struct {
Lang string
Code string
IsImage bool
IsTable bool
}

func (b CodeBlock) Type() string { return "code" }
Expand All @@ -45,3 +46,11 @@ type ImageOutputBlock struct {
}

func (b ImageOutputBlock) Type() string { return "output-image" }

// TableOutputBlock is captured tabular output rendered as a markdown table.
type TableOutputBlock struct {
Headers []string
Rows [][]string
}

func (b TableOutputBlock) Type() string { return "output-table" }
16 changes: 16 additions & 0 deletions markdown/blocks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,19 @@ func TestTitleBlock(t *testing.T) {
t.Errorf("expected type title, got %s", b.Type())
}
}

func TestTableOutputBlock(t *testing.T) {
b := TableOutputBlock{
Headers: []string{"name", "age"},
Rows: [][]string{{"Alice", "30"}, {"Bob", "25"}},
}
if b.Type() != "output-table" {
t.Errorf("expected type output-table, got %s", b.Type())
}
if len(b.Headers) != 2 {
t.Errorf("expected 2 headers, got %d", len(b.Headers))
}
if len(b.Rows) != 2 {
t.Errorf("expected 2 rows, got %d", len(b.Rows))
}
}
Loading