From a35b60de0aa468928c0defd5a8bf4b489e8e018c Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 15:51:56 -0600 Subject: [PATCH 01/25] Update the file test to pass in a file name --- cmd/file.go | 10 +++++++--- testdata/script/file.txtar | 17 ++++++++++++++--- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/cmd/file.go b/cmd/file.go index 4cbddca..f76aa82 100644 --- a/cmd/file.go +++ b/cmd/file.go @@ -2,25 +2,29 @@ package cmd import ( "fmt" - "io" "os" + "litdoc/internal" + "github.com/spf13/cobra" ) +var inputFile string + var fileCmd = &cobra.Command{ Use: "file", Short: "Process a file", Run: func(cmd *cobra.Command, args []string) { - data, err := io.ReadAll(os.Stdin) + _, err := internal.ProcessFile(inputFile) if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - fmt.Print(string(data)) }, } func init() { + fileCmd.Flags().StringVarP(&inputFile, "input", "i", "", "input file to process") + fileCmd.MarkFlagRequired("input") rootCmd.AddCommand(fileCmd) } diff --git a/testdata/script/file.txtar b/testdata/script/file.txtar index 5809cf5..bf0cc77 100644 --- a/testdata/script/file.txtar +++ b/testdata/script/file.txtar @@ -1,6 +1,17 @@ -stdin input.md -exec litdoc file -stdout '# Hello' +exec litdoc file -i input.md +stdout '' -- input.md -- # Hello + +```bash +echo "hello, world" +``` + +some text + + + +more text From bfe80932187e0953146302f9d6d05e7110ddc762 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 16:11:22 -0600 Subject: [PATCH 02/25] Generate output in BashCell.Execute --- internal/cell.go | 4 +++- internal/cell_test.go | 6 ++++-- internal/testdata/output.md | 8 ++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/internal/cell.go b/internal/cell.go index 8569dcb..d7f2b45 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -37,7 +37,9 @@ func MakeBashCellFromRaw(fencedCode, output string) BashCell { } func (c BashCell) Execute() (Cell, error) { - return c, nil + output := "output" // stub + wrapped := "\n" + output + "\n\n" + return BashCell{fencedCode: c.fencedCode, output: wrapped}, nil } func (c BashCell) Render() (string, error) { diff --git a/internal/cell_test.go b/internal/cell_test.go index 42a14c8..87b0f3f 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -120,7 +120,8 @@ func TestBashCellExecute(t *testing.T) { require.NoError(t, err) rendered, err := gotCell.Render() require.NoError(t, err) - assert.Equal(t, fencedCode, rendered) + want := fencedCode + "\n\noutput\n\n" + assert.Equal(t, want, rendered) } func TestBashCellRender(t *testing.T) { @@ -149,7 +150,8 @@ func TestBashCellRender(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, fencedCode, gotContent) + want := fencedCode + "\n\noutput\n\n" + assert.Equal(t, want, gotContent) }) } diff --git a/internal/testdata/output.md b/internal/testdata/output.md index 43428d8..d043472 100644 --- a/internal/testdata/output.md +++ b/internal/testdata/output.md @@ -32,8 +32,16 @@ echo "hello, world" ``` + +output + + - HTML comment + + +output + From 72c2ef09143a4b8fa2339ed964ca0afc1c4552a6 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 17:06:17 -0600 Subject: [PATCH 03/25] Separate scanning of BashCell output into a separate file --- internal/cell.go | 17 +++++++---- internal/cell_test.go | 25 +++++++++++++++ internal/output.go | 47 ++++++++++++++++++++++++++++ internal/output_test.go | 61 +++++++++++++++++++++++++++++++++++++ internal/testdata/input.md | 10 ++++++ internal/testdata/output.md | 10 ++++++ 6 files changed, 164 insertions(+), 6 deletions(-) create mode 100644 internal/output.go create mode 100644 internal/output_test.go diff --git a/internal/cell.go b/internal/cell.go index d7f2b45..47c1e53 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -38,15 +38,14 @@ func MakeBashCellFromRaw(fencedCode, output string) BashCell { func (c BashCell) Execute() (Cell, error) { output := "output" // stub - wrapped := "\n" + output + "\n\n" - return BashCell{fencedCode: c.fencedCode, output: wrapped}, nil + return BashCell{fencedCode: c.fencedCode, output: output}, nil } func (c BashCell) Render() (string, error) { if c.output == "" { return c.fencedCode, nil } - return c.fencedCode + "\n" + c.output, nil + return c.fencedCode + "\n" + FormatOutput(c.output), nil } type InfoString struct { @@ -76,17 +75,23 @@ func ParseInfoString(b Block) InfoString { func Classify(blocks []Block) ([]Cell, error) { var cells []Cell - for _, b := range blocks { + i := 0 + for i < len(blocks) { + b := blocks[i] info := ParseInfoString(b) switch { case info.IsLitdoc && info.Lang == "bash": - cell := MakeBashCellFromRaw(string(b.content), "") - cells = append(cells, cell) + fencedCode := string(b.content) + output, consumed := ScanOutput(blocks[i+1:]) + cells = append(cells, MakeBashCellFromRaw(fencedCode, output)) + i += 1 + consumed + continue case info.IsLitdoc: return nil, fmt.Errorf("unsupported language: %q", info.Lang) default: cells = append(cells, MakeStaticCellFromRaw(string(b.content))) } + i++ } return cells, nil } diff --git a/internal/cell_test.go b/internal/cell_test.go index 87b0f3f..ff26af9 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -305,6 +305,31 @@ func TestClassify(t *testing.T) { assert.Equal(t, code, rendered) }) + t.Run("output block following bash cell is loaded into BashCell output", func(t *testing.T) { + // given - mirrors what tree-sitter produces: three separate blocks for the output section + code := "```bash | litdoc\necho hello\n```\n" + blocks := []internal.Block{ + internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code)), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte("\n")), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte("\n")), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte("old output\n")), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte("\n")), + } + + // when + cells, err := internal.Classify(blocks) + + // then + require.NoError(t, err) + require.Len(t, cells, 1) + _, ok := cells[0].(internal.BashCell) + require.True(t, ok, "expected BashCell, got %T", cells[0]) + rendered, err := cells[0].Render() + require.NoError(t, err) + wantOutput := "\nold output\n\n" + assert.Equal(t, code+"\n"+wantOutput, rendered) + }) + t.Run("litdoc block with unsupported language", func(t *testing.T) { // given blocks := []internal.Block{ diff --git a/internal/output.go b/internal/output.go new file mode 100644 index 0000000..f85d6b8 --- /dev/null +++ b/internal/output.go @@ -0,0 +1,47 @@ +package internal + +import ( + "bytes" + "strings" +) + +const ( + outputBeginMarker = "" + outputEndMarker = "" +) + +func FormatOutput(output string) string { + return outputBeginMarker + "\n" + output + "\n" + outputEndMarker + "\n" +} + +func isOutputBegin(b Block) bool { + return b.kind == BlockKindHTMLComment && bytes.HasPrefix(b.content, []byte(outputBeginMarker)) +} + +func isOutputEnd(b Block) bool { + return b.kind == BlockKindHTMLComment && bytes.HasPrefix(b.content, []byte(outputEndMarker)) +} + +// ScanOutput looks for an output block at the start of blocks, skipping leading +// whitespace text blocks. Returns the raw output content and the number of +// blocks consumed. consumed is 0 if no output block was found. +func ScanOutput(blocks []Block) (output string, consumed int) { + i := 0 + for i < len(blocks) && blocks[i].kind == BlockKindText && strings.TrimSpace(string(blocks[i].content)) == "" { + i++ + } + if i >= len(blocks) || !isOutputBegin(blocks[i]) { + return "", 0 + } + i++ // skip BEGIN block + var buf strings.Builder + for i < len(blocks) { + if isOutputEnd(blocks[i]) { + i++ + break + } + buf.Write(blocks[i].content) + i++ + } + return strings.TrimSuffix(buf.String(), "\n"), i +} diff --git a/internal/output_test.go b/internal/output_test.go new file mode 100644 index 0000000..f03ea0b --- /dev/null +++ b/internal/output_test.go @@ -0,0 +1,61 @@ +package internal_test + +import ( + "testing" + + "litdoc/internal" + + "github.com/stretchr/testify/assert" +) + +func TestFormatOutput(t *testing.T) { + got := internal.FormatOutput("hello") + assert.Equal(t, "\nhello\n\n", got) +} + +func TestScanOutput(t *testing.T) { + htmlComment := func(content string) internal.Block { + return internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(content)) + } + text := func(content string) internal.Block { + return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) + } + + t.Run("output block is scanned in", func(t *testing.T) { + blocks := []internal.Block{ + htmlComment("\n"), + text("hello\n"), + htmlComment("\n"), + } + output, consumed := internal.ScanOutput(blocks) + assert.Equal(t, "hello", output) + assert.Equal(t, 3, consumed) + }) + + t.Run("leading whitespace blocks are skipped", func(t *testing.T) { + blocks := []internal.Block{ + text("\n"), + htmlComment("\n"), + text("hello\n"), + htmlComment("\n"), + } + output, consumed := internal.ScanOutput(blocks) + assert.Equal(t, "hello", output) + assert.Equal(t, 4, consumed) + }) + + t.Run("no output block returns empty and zero consumed", func(t *testing.T) { + blocks := []internal.Block{ + text("some text\n"), + } + output, consumed := internal.ScanOutput(blocks) + assert.Equal(t, "", output) + assert.Equal(t, 0, consumed) + }) + + t.Run("empty blocks returns empty and zero consumed", func(t *testing.T) { + output, consumed := internal.ScanOutput(nil) + assert.Equal(t, "", output) + assert.Equal(t, 0, consumed) + }) +} diff --git a/internal/testdata/input.md b/internal/testdata/input.md index 43428d8..2d3a1b2 100644 --- a/internal/testdata/input.md +++ b/internal/testdata/input.md @@ -37,3 +37,13 @@ echo "hello, world" + +- Fenced code block with previously generate output + +```bash | litdoc +echo "hello, world" +``` + + +output + diff --git a/internal/testdata/output.md b/internal/testdata/output.md index d043472..b72d378 100644 --- a/internal/testdata/output.md +++ b/internal/testdata/output.md @@ -45,3 +45,13 @@ echo "something to run" output + +- Fenced code block with previously generate output + +```bash | litdoc +echo "hello, world" +``` + + +output + From 1339135ee057a8de544f8a0a418e1ad1b7d4fe91 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 17:26:19 -0600 Subject: [PATCH 04/25] Create an Output struct --- internal/cell.go | 14 +++------ internal/cell_test.go | 8 ++--- internal/output.go | 28 ++++++++++++----- internal/output_test.go | 68 ++++++++++++++++++++++++++++++++--------- 4 files changed, 83 insertions(+), 35 deletions(-) diff --git a/internal/cell.go b/internal/cell.go index 47c1e53..f926f7f 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -29,23 +29,19 @@ func (t StaticCell) Render() (string, error) { type BashCell struct { fencedCode string - output string + output Output } -func MakeBashCellFromRaw(fencedCode, output string) BashCell { +func MakeBashCellFromRaw(fencedCode string, output Output) BashCell { return BashCell{fencedCode: fencedCode, output: output} } func (c BashCell) Execute() (Cell, error) { - output := "output" // stub - return BashCell{fencedCode: c.fencedCode, output: output}, nil + return BashCell{fencedCode: c.fencedCode, output: MakeOutput("output")}, nil // stub } func (c BashCell) Render() (string, error) { - if c.output == "" { - return c.fencedCode, nil - } - return c.fencedCode + "\n" + FormatOutput(c.output), nil + return c.fencedCode + c.output.Render(), nil } type InfoString struct { @@ -82,7 +78,7 @@ func Classify(blocks []Block) ([]Cell, error) { switch { case info.IsLitdoc && info.Lang == "bash": fencedCode := string(b.content) - output, consumed := ScanOutput(blocks[i+1:]) + output, consumed := OutputFromBlocks(blocks[i+1:]) cells = append(cells, MakeBashCellFromRaw(fencedCode, output)) i += 1 + consumed continue diff --git a/internal/cell_test.go b/internal/cell_test.go index ff26af9..4a17c17 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -100,7 +100,7 @@ func TestMakeBashCellFromRaw(t *testing.T) { code := "```bash\necho hello\n```\n" // when - gotCell := internal.MakeBashCellFromRaw(code, "") + gotCell := internal.MakeBashCellFromRaw(code, internal.Output{}) // then got, err := gotCell.Render() @@ -111,7 +111,7 @@ func TestMakeBashCellFromRaw(t *testing.T) { func TestBashCellExecute(t *testing.T) { // given fencedCode := "```bash\necho hello\n```\n" - cell := internal.MakeBashCellFromRaw(fencedCode, "") + cell := internal.MakeBashCellFromRaw(fencedCode, internal.Output{}) // when gotCell, err := cell.Execute() @@ -128,7 +128,7 @@ func TestBashCellRender(t *testing.T) { t.Run("without output", func(t *testing.T) { // given code := "```bash\necho hello\n```\n" - cell := internal.MakeBashCellFromRaw(code, "") + cell := internal.MakeBashCellFromRaw(code, internal.Output{}) // when gotContent, err := cell.Render() @@ -141,7 +141,7 @@ func TestBashCellRender(t *testing.T) { t.Run("with output", func(t *testing.T) { // given fencedCode := "```bash\necho hello\n```\n" - cell := internal.MakeBashCellFromRaw(fencedCode, "") + cell := internal.MakeBashCellFromRaw(fencedCode, internal.Output{}) executed, err := cell.Execute() require.NoError(t, err) diff --git a/internal/output.go b/internal/output.go index f85d6b8..81bf261 100644 --- a/internal/output.go +++ b/internal/output.go @@ -10,8 +10,19 @@ const ( outputEndMarker = "" ) -func FormatOutput(output string) string { - return outputBeginMarker + "\n" + output + "\n" + outputEndMarker + "\n" +type Output struct { + content string +} + +func MakeOutput(content string) Output { + return Output{content: content} +} + +func (o Output) Render() string { + if o.content == "" { + return "" + } + return "\n" + outputBeginMarker + "\n" + o.content + "\n" + outputEndMarker + "\n" } func isOutputBegin(b Block) bool { @@ -22,16 +33,16 @@ func isOutputEnd(b Block) bool { return b.kind == BlockKindHTMLComment && bytes.HasPrefix(b.content, []byte(outputEndMarker)) } -// ScanOutput looks for an output block at the start of blocks, skipping leading -// whitespace text blocks. Returns the raw output content and the number of -// blocks consumed. consumed is 0 if no output block was found. -func ScanOutput(blocks []Block) (output string, consumed int) { +// OutputFromBlocks looks for an output block at the start of blocks, +// skipping leading whitespace text blocks. Returns the Output and the number +// of blocks consumed. consumed is 0 if no output block was found. +func OutputFromBlocks(blocks []Block) (Output, int) { i := 0 for i < len(blocks) && blocks[i].kind == BlockKindText && strings.TrimSpace(string(blocks[i].content)) == "" { i++ } if i >= len(blocks) || !isOutputBegin(blocks[i]) { - return "", 0 + return Output{}, 0 } i++ // skip BEGIN block var buf strings.Builder @@ -43,5 +54,6 @@ func ScanOutput(blocks []Block) (output string, consumed int) { buf.Write(blocks[i].content) i++ } - return strings.TrimSuffix(buf.String(), "\n"), i + + return MakeOutput(strings.TrimSuffix(buf.String(), "\n")), i } diff --git a/internal/output_test.go b/internal/output_test.go index f03ea0b..ebf089c 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -8,54 +8,94 @@ import ( "github.com/stretchr/testify/assert" ) -func TestFormatOutput(t *testing.T) { - got := internal.FormatOutput("hello") - assert.Equal(t, "\nhello\n\n", got) +func TestOutputRender(t *testing.T) { + t.Run("empty output renders empty string", func(t *testing.T) { + // given + output := internal.Output{} + + // when + got := output.Render() + + // then + assert.Equal(t, "", got) + }) + + t.Run("non-empty output renders wrapped content", func(t *testing.T) { + // given + output := internal.MakeOutput("hello") + + // when + got := output.Render() + + // then + assert.Equal(t, "\n\nhello\n\n", got) + }) } -func TestScanOutput(t *testing.T) { +func TestOutputFromBlocks(t *testing.T) { htmlComment := func(content string) internal.Block { return internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(content)) } text := func(content string) internal.Block { return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) } + wantOutput := func(content string) string { + return "\n\n" + content + "\n\n" + } t.Run("output block is scanned in", func(t *testing.T) { + // given blocks := []internal.Block{ htmlComment("\n"), text("hello\n"), htmlComment("\n"), } - output, consumed := internal.ScanOutput(blocks) - assert.Equal(t, "hello", output) + + // when + output, consumed := internal.OutputFromBlocks(blocks) + + // then + assert.Equal(t, wantOutput("hello"), output.Render()) assert.Equal(t, 3, consumed) }) t.Run("leading whitespace blocks are skipped", func(t *testing.T) { + // given blocks := []internal.Block{ text("\n"), htmlComment("\n"), text("hello\n"), htmlComment("\n"), } - output, consumed := internal.ScanOutput(blocks) - assert.Equal(t, "hello", output) + + // when + output, consumed := internal.OutputFromBlocks(blocks) + + // then + assert.Equal(t, wantOutput("hello"), output.Render()) assert.Equal(t, 4, consumed) }) - t.Run("no output block returns empty and zero consumed", func(t *testing.T) { + t.Run("no output block returns zero value and zero consumed", func(t *testing.T) { + // given blocks := []internal.Block{ text("some text\n"), } - output, consumed := internal.ScanOutput(blocks) - assert.Equal(t, "", output) + + // when + output, consumed := internal.OutputFromBlocks(blocks) + + // then + assert.Equal(t, "", output.Render()) assert.Equal(t, 0, consumed) }) - t.Run("empty blocks returns empty and zero consumed", func(t *testing.T) { - output, consumed := internal.ScanOutput(nil) - assert.Equal(t, "", output) + t.Run("empty blocks returns zero value and zero consumed", func(t *testing.T) { + // when + output, consumed := internal.OutputFromBlocks(nil) + + // then + assert.Equal(t, "", output.Render()) assert.Equal(t, 0, consumed) }) } From 1ba83907d706b59400616e748900be0914373521 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 24 Apr 2026 17:39:10 -0600 Subject: [PATCH 05/25] Simplify Classify with BashCellFromBlocks --- internal/cell.go | 13 ++++-- internal/cell_test.go | 79 ++++++++++++++++++++++++++++++++++--- internal/output.go | 10 ++--- internal/output_test.go | 12 +++--- internal/testdata/input.md | 4 +- internal/testdata/output.md | 12 +++--- 6 files changed, 101 insertions(+), 29 deletions(-) diff --git a/internal/cell.go b/internal/cell.go index f926f7f..7c0d289 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -69,6 +69,12 @@ func ParseInfoString(b Block) InfoString { return InfoString{Lang: lang, IsLitdoc: isLitdoc} } +func BashCellFromBlocks(blocks []Block) (BashCell, int) { + fencedCode := string(blocks[0].content) + output, consumed := OutputFromBlocks(blocks[1:]) + return MakeBashCellFromRaw(fencedCode, output), 1 + consumed +} + func Classify(blocks []Block) ([]Cell, error) { var cells []Cell i := 0 @@ -77,10 +83,9 @@ func Classify(blocks []Block) ([]Cell, error) { info := ParseInfoString(b) switch { case info.IsLitdoc && info.Lang == "bash": - fencedCode := string(b.content) - output, consumed := OutputFromBlocks(blocks[i+1:]) - cells = append(cells, MakeBashCellFromRaw(fencedCode, output)) - i += 1 + consumed + cell, consumed := BashCellFromBlocks(blocks[i:]) + cells = append(cells, cell) + i += consumed continue case info.IsLitdoc: return nil, fmt.Errorf("unsupported language: %q", info.Lang) diff --git a/internal/cell_test.go b/internal/cell_test.go index 4a17c17..0b2ee7b 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -95,6 +95,74 @@ func TestParseInfoString(t *testing.T) { } } +func TestBashCellFromBlocks(t *testing.T) { + bashLitdocBlock := func(content string) internal.Block { + return internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(content)) + } + htmlComment := func(content string) internal.Block { + return internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(content)) + } + text := func(content string) internal.Block { + return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) + } + + t.Run("bash cell without output consumes one block", func(t *testing.T) { + // given + code := "```bash | litdoc\necho hello\n```\n" + blocks := []internal.Block{bashLitdocBlock(code)} + + // when + cell, consumed := internal.BashCellFromBlocks(blocks) + + // then + rendered, err := cell.Render() + require.NoError(t, err) + assert.Equal(t, code, rendered) + assert.Equal(t, 1, consumed) + }) + + t.Run("bash cell with output block consumes all output blocks", func(t *testing.T) { + // given + code := "```bash | litdoc\necho hello\n```\n" + blocks := []internal.Block{ + bashLitdocBlock(code), + htmlComment(internal.OutputBeginMarker), + text("hello\n"), + htmlComment(internal.OutputEndMarker), + } + + // when + cell, consumed := internal.BashCellFromBlocks(blocks) + + // then + rendered, err := cell.Render() + require.NoError(t, err) + assert.Equal(t, code+internal.MakeOutput("hello").Render(), rendered) + assert.Equal(t, 4, consumed) + }) + + t.Run("bash cell with whitespace gap before output consumes whitespace blocks too", func(t *testing.T) { + // given + code := "```bash | litdoc\necho hello\n```\n" + blocks := []internal.Block{ + bashLitdocBlock(code), + text("\n"), + htmlComment(internal.OutputBeginMarker), + text("hello\n"), + htmlComment(internal.OutputEndMarker), + } + + // when + cell, consumed := internal.BashCellFromBlocks(blocks) + + // then + rendered, err := cell.Render() + require.NoError(t, err) + assert.Equal(t, code+internal.MakeOutput("hello").Render(), rendered) + assert.Equal(t, 5, consumed) + }) +} + func TestMakeBashCellFromRaw(t *testing.T) { // given code := "```bash\necho hello\n```\n" @@ -120,7 +188,7 @@ func TestBashCellExecute(t *testing.T) { require.NoError(t, err) rendered, err := gotCell.Render() require.NoError(t, err) - want := fencedCode + "\n\noutput\n\n" + want := fencedCode + internal.MakeOutput("output").Render() assert.Equal(t, want, rendered) } @@ -150,7 +218,7 @@ func TestBashCellRender(t *testing.T) { // then require.NoError(t, err) - want := fencedCode + "\n\noutput\n\n" + want := fencedCode + internal.MakeOutput("output").Render() assert.Equal(t, want, gotContent) }) } @@ -311,9 +379,9 @@ func TestClassify(t *testing.T) { blocks := []internal.Block{ internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code)), internal.MakeBlockFromRaw(internal.BlockKindText, []byte("\n")), - internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte("\n")), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputBeginMarker+"\n")), internal.MakeBlockFromRaw(internal.BlockKindText, []byte("old output\n")), - internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte("\n")), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputEndMarker+"\n")), } // when @@ -326,8 +394,7 @@ func TestClassify(t *testing.T) { require.True(t, ok, "expected BashCell, got %T", cells[0]) rendered, err := cells[0].Render() require.NoError(t, err) - wantOutput := "\nold output\n\n" - assert.Equal(t, code+"\n"+wantOutput, rendered) + assert.Equal(t, code+internal.MakeOutput("old output").Render(), rendered) }) t.Run("litdoc block with unsupported language", func(t *testing.T) { diff --git a/internal/output.go b/internal/output.go index 81bf261..0c10289 100644 --- a/internal/output.go +++ b/internal/output.go @@ -6,8 +6,8 @@ import ( ) const ( - outputBeginMarker = "" - outputEndMarker = "" + OutputBeginMarker = "\n" + OutputEndMarker = "\n" ) type Output struct { @@ -22,15 +22,15 @@ func (o Output) Render() string { if o.content == "" { return "" } - return "\n" + outputBeginMarker + "\n" + o.content + "\n" + outputEndMarker + "\n" + return "\n" + OutputBeginMarker + o.content + "\n" + OutputEndMarker } func isOutputBegin(b Block) bool { - return b.kind == BlockKindHTMLComment && bytes.HasPrefix(b.content, []byte(outputBeginMarker)) + return b.kind == BlockKindHTMLComment && bytes.HasPrefix(b.content, []byte(OutputBeginMarker)) } func isOutputEnd(b Block) bool { - return b.kind == BlockKindHTMLComment && bytes.HasPrefix(b.content, []byte(outputEndMarker)) + return b.kind == BlockKindHTMLComment && bytes.HasPrefix(b.content, []byte(OutputEndMarker)) } // OutputFromBlocks looks for an output block at the start of blocks, diff --git a/internal/output_test.go b/internal/output_test.go index ebf089c..32425ab 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -28,7 +28,7 @@ func TestOutputRender(t *testing.T) { got := output.Render() // then - assert.Equal(t, "\n\nhello\n\n", got) + assert.Equal(t, "\n"+internal.OutputBeginMarker+"hello\n"+internal.OutputEndMarker, got) }) } @@ -40,15 +40,15 @@ func TestOutputFromBlocks(t *testing.T) { return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) } wantOutput := func(content string) string { - return "\n\n" + content + "\n\n" + return internal.MakeOutput(content).Render() } t.Run("output block is scanned in", func(t *testing.T) { // given blocks := []internal.Block{ - htmlComment("\n"), + htmlComment(internal.OutputBeginMarker), text("hello\n"), - htmlComment("\n"), + htmlComment(internal.OutputEndMarker), } // when @@ -63,9 +63,9 @@ func TestOutputFromBlocks(t *testing.T) { // given blocks := []internal.Block{ text("\n"), - htmlComment("\n"), + htmlComment(internal.OutputBeginMarker), text("hello\n"), - htmlComment("\n"), + htmlComment(internal.OutputEndMarker), } // when diff --git a/internal/testdata/input.md b/internal/testdata/input.md index 2d3a1b2..459717b 100644 --- a/internal/testdata/input.md +++ b/internal/testdata/input.md @@ -44,6 +44,6 @@ echo "something to run" echo "hello, world" ``` - + output - + diff --git a/internal/testdata/output.md b/internal/testdata/output.md index b72d378..a7ee721 100644 --- a/internal/testdata/output.md +++ b/internal/testdata/output.md @@ -32,9 +32,9 @@ echo "hello, world" ``` - + output - + - HTML comment @@ -42,9 +42,9 @@ output echo "something to run" --> - + output - + - Fenced code block with previously generate output @@ -52,6 +52,6 @@ output echo "hello, world" ``` - + output - + From 6538cc2b45ad869bb95f71d64ec24b1837ebbb68 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Sat, 25 Apr 2026 13:50:09 -0600 Subject: [PATCH 06/25] Handle missing closing output market --- internal/cell.go | 15 +++----- internal/cell_test.go | 84 ++++++++--------------------------------- internal/output.go | 11 +++--- internal/output_test.go | 27 +++++++++++-- 4 files changed, 51 insertions(+), 86 deletions(-) diff --git a/internal/cell.go b/internal/cell.go index 7c0d289..2569628 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -69,12 +69,6 @@ func ParseInfoString(b Block) InfoString { return InfoString{Lang: lang, IsLitdoc: isLitdoc} } -func BashCellFromBlocks(blocks []Block) (BashCell, int) { - fencedCode := string(blocks[0].content) - output, consumed := OutputFromBlocks(blocks[1:]) - return MakeBashCellFromRaw(fencedCode, output), 1 + consumed -} - func Classify(blocks []Block) ([]Cell, error) { var cells []Cell i := 0 @@ -83,9 +77,12 @@ func Classify(blocks []Block) ([]Cell, error) { info := ParseInfoString(b) switch { case info.IsLitdoc && info.Lang == "bash": - cell, consumed := BashCellFromBlocks(blocks[i:]) - cells = append(cells, cell) - i += consumed + output, consumed, err := OutputFromBlocks(blocks[i+1:]) + if err != nil { + return nil, err + } + cells = append(cells, MakeBashCellFromRaw(string(b.content), output)) + i += 1 + consumed continue case info.IsLitdoc: return nil, fmt.Errorf("unsupported language: %q", info.Lang) diff --git a/internal/cell_test.go b/internal/cell_test.go index 0b2ee7b..5f6c597 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -95,74 +95,6 @@ func TestParseInfoString(t *testing.T) { } } -func TestBashCellFromBlocks(t *testing.T) { - bashLitdocBlock := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(content)) - } - htmlComment := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(content)) - } - text := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) - } - - t.Run("bash cell without output consumes one block", func(t *testing.T) { - // given - code := "```bash | litdoc\necho hello\n```\n" - blocks := []internal.Block{bashLitdocBlock(code)} - - // when - cell, consumed := internal.BashCellFromBlocks(blocks) - - // then - rendered, err := cell.Render() - require.NoError(t, err) - assert.Equal(t, code, rendered) - assert.Equal(t, 1, consumed) - }) - - t.Run("bash cell with output block consumes all output blocks", func(t *testing.T) { - // given - code := "```bash | litdoc\necho hello\n```\n" - blocks := []internal.Block{ - bashLitdocBlock(code), - htmlComment(internal.OutputBeginMarker), - text("hello\n"), - htmlComment(internal.OutputEndMarker), - } - - // when - cell, consumed := internal.BashCellFromBlocks(blocks) - - // then - rendered, err := cell.Render() - require.NoError(t, err) - assert.Equal(t, code+internal.MakeOutput("hello").Render(), rendered) - assert.Equal(t, 4, consumed) - }) - - t.Run("bash cell with whitespace gap before output consumes whitespace blocks too", func(t *testing.T) { - // given - code := "```bash | litdoc\necho hello\n```\n" - blocks := []internal.Block{ - bashLitdocBlock(code), - text("\n"), - htmlComment(internal.OutputBeginMarker), - text("hello\n"), - htmlComment(internal.OutputEndMarker), - } - - // when - cell, consumed := internal.BashCellFromBlocks(blocks) - - // then - rendered, err := cell.Render() - require.NoError(t, err) - assert.Equal(t, code+internal.MakeOutput("hello").Render(), rendered) - assert.Equal(t, 5, consumed) - }) -} - func TestMakeBashCellFromRaw(t *testing.T) { // given code := "```bash\necho hello\n```\n" @@ -397,6 +329,22 @@ func TestClassify(t *testing.T) { assert.Equal(t, code+internal.MakeOutput("old output").Render(), rendered) }) + t.Run("unclosed output block returns error", func(t *testing.T) { + // given + code := "```bash | litdoc\necho hello\n```\n" + blocks := []internal.Block{ + bashLitdocBlock(code), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputBeginMarker)), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte("hello\n")), + } + + // when + _, err := internal.Classify(blocks) + + // then + require.ErrorContains(t, err, "unclosed output block") + }) + t.Run("litdoc block with unsupported language", func(t *testing.T) { // given blocks := []internal.Block{ diff --git a/internal/output.go b/internal/output.go index 0c10289..d894434 100644 --- a/internal/output.go +++ b/internal/output.go @@ -2,6 +2,7 @@ package internal import ( "bytes" + "fmt" "strings" ) @@ -36,24 +37,24 @@ func isOutputEnd(b Block) bool { // OutputFromBlocks looks for an output block at the start of blocks, // skipping leading whitespace text blocks. Returns the Output and the number // of blocks consumed. consumed is 0 if no output block was found. -func OutputFromBlocks(blocks []Block) (Output, int) { +// Returns an error if an opening marker is found without a closing marker. +func OutputFromBlocks(blocks []Block) (Output, int, error) { i := 0 for i < len(blocks) && blocks[i].kind == BlockKindText && strings.TrimSpace(string(blocks[i].content)) == "" { i++ } if i >= len(blocks) || !isOutputBegin(blocks[i]) { - return Output{}, 0 + return Output{}, 0, nil } i++ // skip BEGIN block var buf strings.Builder for i < len(blocks) { if isOutputEnd(blocks[i]) { i++ - break + return MakeOutput(strings.TrimSuffix(buf.String(), "\n")), i, nil } buf.Write(blocks[i].content) i++ } - - return MakeOutput(strings.TrimSuffix(buf.String(), "\n")), i + return Output{}, 0, fmt.Errorf("unclosed output block: missing %q", OutputEndMarker) } diff --git a/internal/output_test.go b/internal/output_test.go index 32425ab..422325c 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -6,6 +6,7 @@ import ( "litdoc/internal" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestOutputRender(t *testing.T) { @@ -52,9 +53,10 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, consumed := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(blocks) // then + require.NoError(t, err) assert.Equal(t, wantOutput("hello"), output.Render()) assert.Equal(t, 3, consumed) }) @@ -69,9 +71,10 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, consumed := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(blocks) // then + require.NoError(t, err) assert.Equal(t, wantOutput("hello"), output.Render()) assert.Equal(t, 4, consumed) }) @@ -83,19 +86,35 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, consumed := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(blocks) // then + require.NoError(t, err) assert.Equal(t, "", output.Render()) assert.Equal(t, 0, consumed) }) t.Run("empty blocks returns zero value and zero consumed", func(t *testing.T) { // when - output, consumed := internal.OutputFromBlocks(nil) + output, consumed, err := internal.OutputFromBlocks(nil) // then + require.NoError(t, err) assert.Equal(t, "", output.Render()) assert.Equal(t, 0, consumed) }) + + t.Run("opening marker without closing marker returns error", func(t *testing.T) { + // given + blocks := []internal.Block{ + htmlComment(internal.OutputBeginMarker), + text("hello\n"), + } + + // when + _, _, err := internal.OutputFromBlocks(blocks) + + // then + require.ErrorContains(t, err, "unclosed output block") + }) } From 160bd109db7350319800d40dc60c72a7ff6ee2b3 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Sat, 25 Apr 2026 15:12:02 -0600 Subject: [PATCH 07/25] Handle fenced block indents --- internal/block.go | 39 ++++++++++++++++++++++++------------- internal/block_test.go | 2 +- internal/cell.go | 3 ++- internal/cell_test.go | 24 +++++++++++++---------- internal/file_test.go | 24 +++++++++++++++++++++++ internal/output.go | 18 ++++++++++++++++- internal/output_test.go | 4 ++-- internal/testdata/input.md | 6 ++++++ internal/testdata/output.md | 10 ++++++++++ 9 files changed, 102 insertions(+), 28 deletions(-) diff --git a/internal/block.go b/internal/block.go index 1c09b53..c76db66 100644 --- a/internal/block.go +++ b/internal/block.go @@ -20,15 +20,16 @@ const ( type Block struct { kind BlockKind content []byte + indent string } -func MakeBlockFromRaw(kind BlockKind, raw []byte) Block { - return Block{kind: kind, content: raw} +func MakeBlockFromRaw(kind BlockKind, raw []byte, indent string) Block { + return Block{kind: kind, content: raw, indent: indent} } func (b Block) Kind() BlockKind { return b.kind } - func (b Block) Content() []byte { return b.content } +func (b Block) Indent() string { return b.indent } func MakeBlocksFromMarkdown(content []byte) ([]Block, error) { tree, err := markdown.ParseCtx(context.Background(), nil, content) @@ -43,7 +44,7 @@ func MakeBlocksFromMarkdown(content []byte) ([]Block, error) { collectBlockNodes(root, content, &pos, &blocks) if pos < uint32(len(content)) { - blocks = append(blocks, Block{kind: BlockKindText, content: content[pos:]}) + blocks = append(blocks, MakeBlockFromRaw(BlockKindText, content[pos:], "")) } return blocks, nil @@ -56,7 +57,7 @@ func collectBlockNodes( blocks *[]Block, ) { switch node.Type() { - case "document", "section": + case "document", "section", "list", "list_item": for i := 0; i < int(node.ChildCount()); i++ { collectBlockNodes(node.Child(i), content, pos, blocks) } @@ -65,20 +66,32 @@ func collectBlockNodes( end := node.EndByte() if start > *pos { - *blocks = append( - *blocks, - MakeBlockFromRaw(BlockKindText, content[*pos:start]), - ) + *blocks = append(*blocks, MakeBlockFromRaw(BlockKindText, content[*pos:start], "")) } - *blocks = append( - *blocks, - MakeBlockFromRaw(blockKind(node, content), content[start:end]), - ) + kind := blockKind(node, content) + indent := "" + if kind == BlockKindFencedCode || kind == BlockKindHTMLComment { + indent = lineIndentBefore(start, content) + } + *blocks = append(*blocks, MakeBlockFromRaw(kind, content[start:end], indent)) *pos = end } } +// lineIndentBefore returns the leading whitespace on the line containing start. +func lineIndentBefore(start uint32, content []byte) string { + lineStart := start + for lineStart > 0 && content[lineStart-1] != '\n' { + lineStart-- + } + i := lineStart + for i < start && (content[i] == ' ' || content[i] == '\t') { + i++ + } + return string(content[lineStart:i]) +} + func blockKind(node *sitter.Node, content []byte) BlockKind { switch node.Type() { case "fenced_code_block": diff --git a/internal/block_test.go b/internal/block_test.go index 6f0bab3..e9746a6 100644 --- a/internal/block_test.go +++ b/internal/block_test.go @@ -15,7 +15,7 @@ func TestMakeBlockFromRaw(t *testing.T) { content := []byte("```bash\necho hello\n```\n") // when - got := internal.MakeBlockFromRaw(kind, content) + got := internal.MakeBlockFromRaw(kind, content, "") // then assert.Equal(t, kind, got.Kind()) diff --git a/internal/cell.go b/internal/cell.go index 2569628..a4b2d62 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -37,7 +37,7 @@ func MakeBashCellFromRaw(fencedCode string, output Output) BashCell { } func (c BashCell) Execute() (Cell, error) { - return BashCell{fencedCode: c.fencedCode, output: MakeOutput("output")}, nil // stub + return BashCell{fencedCode: c.fencedCode, output: MakeOutput("output").WithIndent(c.output.indent)}, nil // stub } func (c BashCell) Render() (string, error) { @@ -81,6 +81,7 @@ func Classify(blocks []Block) ([]Cell, error) { if err != nil { return nil, err } + output = output.WithIndent(b.Indent()) cells = append(cells, MakeBashCellFromRaw(string(b.content), output)) i += 1 + consumed continue diff --git a/internal/cell_test.go b/internal/cell_test.go index 5f6c597..dbbb5b2 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -58,6 +58,7 @@ func TestParseInfoString(t *testing.T) { block: internal.MakeBlockFromRaw( internal.BlockKindText, []byte("hello"), + "", ), want: internal.InfoString{}, }, @@ -66,6 +67,7 @@ func TestParseInfoString(t *testing.T) { block: internal.MakeBlockFromRaw( internal.BlockKindFencedCode, []byte("```bash\necho hello\n```\n"), + "", ), want: internal.InfoString{Lang: "bash"}, }, @@ -74,6 +76,7 @@ func TestParseInfoString(t *testing.T) { block: internal.MakeBlockFromRaw( internal.BlockKindFencedCode, []byte("```bash | litdoc\necho hello\n```\n"), + "", ), want: internal.InfoString{Lang: "bash", IsLitdoc: true}, }, @@ -82,6 +85,7 @@ func TestParseInfoString(t *testing.T) { block: internal.MakeBlockFromRaw( internal.BlockKindHTMLComment, []byte("\n"), + "", ), want: internal.InfoString{Lang: "bash", IsLitdoc: true}, }, @@ -221,10 +225,10 @@ func TestCompose(t *testing.T) { func TestClassify(t *testing.T) { textBlock := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) + return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content), "") } bashLitdocBlock := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(content)) + return internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(content), "") } t.Run("single text block becomes StaticCell", func(t *testing.T) { @@ -291,7 +295,7 @@ func TestClassify(t *testing.T) { // given code := "```bash\necho hello\n```\n" blocks := []internal.Block{ - internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code)), + internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code), ""), } // when @@ -309,11 +313,11 @@ func TestClassify(t *testing.T) { // given - mirrors what tree-sitter produces: three separate blocks for the output section code := "```bash | litdoc\necho hello\n```\n" blocks := []internal.Block{ - internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code)), - internal.MakeBlockFromRaw(internal.BlockKindText, []byte("\n")), - internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputBeginMarker+"\n")), - internal.MakeBlockFromRaw(internal.BlockKindText, []byte("old output\n")), - internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputEndMarker+"\n")), + internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code), ""), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte("\n"), ""), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputBeginMarker+"\n"), ""), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte("old output\n"), ""), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputEndMarker+"\n"), ""), } // when @@ -334,8 +338,8 @@ func TestClassify(t *testing.T) { code := "```bash | litdoc\necho hello\n```\n" blocks := []internal.Block{ bashLitdocBlock(code), - internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputBeginMarker)), - internal.MakeBlockFromRaw(internal.BlockKindText, []byte("hello\n")), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputBeginMarker), ""), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte("hello\n"), ""), } // when diff --git a/internal/file_test.go b/internal/file_test.go index 5518685..3d36e48 100644 --- a/internal/file_test.go +++ b/internal/file_test.go @@ -6,6 +6,9 @@ import ( "testing" "litdoc/internal" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) //go:embed testdata/input.md @@ -33,3 +36,24 @@ func TestProcessFile(t *testing.T) { t.Errorf("output mismatch\ngot:\n%s\nwant:\n%s", got, renderOutput) } } + +func TestProcessFileNestedListIndent(t *testing.T) { + // given + input := "- Level 1\n\n - Level 2\n\n ```bash | litdoc\n echo hello\n ```\n" + f, err := os.CreateTemp(t.TempDir(), "*.md") + require.NoError(t, err) + _, err = f.Write([]byte(input)) + require.NoError(t, err) + f.Close() + + // when + got, err := internal.ProcessFile(f.Name()) + + // then + require.NoError(t, err) + want := "- Level 1\n\n - Level 2\n\n ```bash | litdoc\n echo hello\n ```\n" + + "\n " + internal.OutputBeginMarker + + " output\n" + + " " + internal.OutputEndMarker + assert.Equal(t, want, got) +} diff --git a/internal/output.go b/internal/output.go index d894434..9502fcf 100644 --- a/internal/output.go +++ b/internal/output.go @@ -13,17 +13,33 @@ const ( type Output struct { content string + indent string } func MakeOutput(content string) Output { return Output{content: content} } +func (o Output) WithIndent(indent string) Output { + o.indent = indent + return o +} + func (o Output) Render() string { if o.content == "" { return "" } - return "\n" + OutputBeginMarker + o.content + "\n" + OutputEndMarker + ind := o.indent + if ind == "" { + return "\n" + OutputBeginMarker + o.content + "\n" + OutputEndMarker + } + var buf strings.Builder + buf.WriteString("\n" + ind + OutputBeginMarker) + for _, line := range strings.Split(o.content, "\n") { + buf.WriteString(ind + line + "\n") + } + buf.WriteString(ind + OutputEndMarker) + return buf.String() } func isOutputBegin(b Block) bool { diff --git a/internal/output_test.go b/internal/output_test.go index 422325c..b5e445e 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -35,10 +35,10 @@ func TestOutputRender(t *testing.T) { func TestOutputFromBlocks(t *testing.T) { htmlComment := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(content)) + return internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(content), "") } text := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) + return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content), "") } wantOutput := func(content string) string { return internal.MakeOutput(content).Render() diff --git a/internal/testdata/input.md b/internal/testdata/input.md index 459717b..d84bcfb 100644 --- a/internal/testdata/input.md +++ b/internal/testdata/input.md @@ -47,3 +47,9 @@ echo "hello, world" output + +- Indented code block + + ```bash | litdoc + echo "hello, world" + ``` diff --git a/internal/testdata/output.md b/internal/testdata/output.md index a7ee721..95f0649 100644 --- a/internal/testdata/output.md +++ b/internal/testdata/output.md @@ -55,3 +55,13 @@ echo "hello, world" output + +- Indented code block + + ```bash | litdoc + echo "hello, world" + ``` + + + output + From c6b232c1d379a1253ec9ec45724512078c9ac09e Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 27 Apr 2026 13:47:53 -0600 Subject: [PATCH 08/25] Add tests for MakeOutput and WithIndent --- internal/output_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/output_test.go b/internal/output_test.go index b5e445e..74f8b41 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -9,6 +9,29 @@ import ( "github.com/stretchr/testify/require" ) +func TestMakeOutput(t *testing.T) { + // given + content := "hello" + + // when + got := internal.MakeOutput(content) + + // then + assert.Contains(t, got.Render(), content) +} + +func TestOutput_WithIndent(t *testing.T) { + // given + content := "hello" + indent := " " + + // when + got := internal.MakeOutput(content).WithIndent(indent) + + // then + assert.Contains(t, got.Render(), indent+content) +} + func TestOutputRender(t *testing.T) { t.Run("empty output renders empty string", func(t *testing.T) { // given From efe6d22c83dd5585c65ac506cad19c44e85617be Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 27 Apr 2026 14:24:59 -0600 Subject: [PATCH 09/25] Add missing Output.Render tests --- internal/output.go | 41 +++++++++++++++--------- internal/output_test.go | 70 ++++++++++++++++++++++++++++++----------- 2 files changed, 77 insertions(+), 34 deletions(-) diff --git a/internal/output.go b/internal/output.go index 9502fcf..faa57b4 100644 --- a/internal/output.go +++ b/internal/output.go @@ -29,48 +29,59 @@ func (o Output) Render() string { if o.content == "" { return "" } - ind := o.indent - if ind == "" { + + o.content = strings.TrimSuffix(o.content, "\n") + + if o.indent == "" { return "\n" + OutputBeginMarker + o.content + "\n" + OutputEndMarker } var buf strings.Builder - buf.WriteString("\n" + ind + OutputBeginMarker) + buf.WriteString("\n" + o.indent + OutputBeginMarker) for _, line := range strings.Split(o.content, "\n") { - buf.WriteString(ind + line + "\n") + buf.WriteString(o.indent + line + "\n") } - buf.WriteString(ind + OutputEndMarker) + buf.WriteString(o.indent + OutputEndMarker) return buf.String() } func isOutputBegin(b Block) bool { - return b.kind == BlockKindHTMLComment && bytes.HasPrefix(b.content, []byte(OutputBeginMarker)) + return b.kind == BlockKindHTMLComment && + bytes.HasPrefix(b.content, []byte(OutputBeginMarker)) } func isOutputEnd(b Block) bool { - return b.kind == BlockKindHTMLComment && bytes.HasPrefix(b.content, []byte(OutputEndMarker)) + return b.kind == BlockKindHTMLComment && + bytes.HasPrefix(b.content, []byte(OutputEndMarker)) } -// OutputFromBlocks looks for an output block at the start of blocks, -// skipping leading whitespace text blocks. Returns the Output and the number -// of blocks consumed. consumed is 0 if no output block was found. -// Returns an error if an opening marker is found without a closing marker. +// todo: make sure that it handles indent func OutputFromBlocks(blocks []Block) (Output, int, error) { i := 0 - for i < len(blocks) && blocks[i].kind == BlockKindText && strings.TrimSpace(string(blocks[i].content)) == "" { + // skip empty text blocks + for i < len(blocks) && + blocks[i].kind == BlockKindText && + strings.TrimSpace(string(blocks[i].content)) == "" { i++ } - if i >= len(blocks) || !isOutputBegin(blocks[i]) { + + // advance past a 'begin' block, or exit + if i < len(blocks) && isOutputBegin(blocks[i]) { + i++ + } else { return Output{}, 0, nil } - i++ // skip BEGIN block + + // accumulate text until an 'end' block and return var buf strings.Builder for i < len(blocks) { if isOutputEnd(blocks[i]) { i++ - return MakeOutput(strings.TrimSuffix(buf.String(), "\n")), i, nil + return MakeOutput(buf.String()), i, nil } buf.Write(blocks[i].content) i++ } + + // report not finding an 'end' block return Output{}, 0, fmt.Errorf("unclosed output block: missing %q", OutputEndMarker) } diff --git a/internal/output_test.go b/internal/output_test.go index 74f8b41..b371360 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -32,28 +32,60 @@ func TestOutput_WithIndent(t *testing.T) { assert.Contains(t, got.Render(), indent+content) } -func TestOutputRender(t *testing.T) { - t.Run("empty output renders empty string", func(t *testing.T) { - // given - output := internal.Output{} - - // when - got := output.Render() - - // then - assert.Equal(t, "", got) - }) +func TestOutput_Render(t *testing.T) { + tests := []struct { + name string + content string + indent string + want string + }{ + { + "empty", + "", + "", + "", + }, + { + "wrap content in markers", + "hello\n", + "", + "\n" + internal.OutputBeginMarker + "hello\n" + internal.OutputEndMarker, + }, + { + "ensure content rendered with trailing newline", + "hello", + "", + "\n" + internal.OutputBeginMarker + "hello\n" + internal.OutputEndMarker, + }, + { + "multiline content", + "hello\nworld", + "", + "\n" + internal.OutputBeginMarker + "hello\nworld\n" + internal.OutputEndMarker, + }, + { + "indent content", + "hello\n", + " ", + "\n" + + " " + internal.OutputBeginMarker + + " " + "hello\n" + + " " + internal.OutputEndMarker, + }, + } - t.Run("non-empty output renders wrapped content", func(t *testing.T) { - // given - output := internal.MakeOutput("hello") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + output := internal.MakeOutput(tt.content).WithIndent(tt.indent) - // when - got := output.Render() + // when + got := output.Render() - // then - assert.Equal(t, "\n"+internal.OutputBeginMarker+"hello\n"+internal.OutputEndMarker, got) - }) + // then + assert.Equal(t, tt.want, got) + }) + } } func TestOutputFromBlocks(t *testing.T) { From 015a76c21620944adbb93324f21ad83ddda183ff Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 27 Apr 2026 17:04:53 -0600 Subject: [PATCH 10/25] Make list indented blocks come with indent as prefix --- internal/block.go | 83 ++++++++++++++++++++++++++++++++--------- internal/block_test.go | 43 ++++++++++++++++++++- internal/cell.go | 17 ++++++++- internal/cell_test.go | 24 +++++------- internal/output.go | 58 +++++++++++++++++++++++++--- internal/output_test.go | 51 ++++++++++++++++++++++++- 6 files changed, 236 insertions(+), 40 deletions(-) diff --git a/internal/block.go b/internal/block.go index c76db66..416ade5 100644 --- a/internal/block.go +++ b/internal/block.go @@ -20,16 +20,14 @@ const ( type Block struct { kind BlockKind content []byte - indent string } -func MakeBlockFromRaw(kind BlockKind, raw []byte, indent string) Block { - return Block{kind: kind, content: raw, indent: indent} +func MakeBlockFromRaw(kind BlockKind, raw []byte) Block { + return Block{kind: kind, content: raw} } func (b Block) Kind() BlockKind { return b.kind } func (b Block) Content() []byte { return b.content } -func (b Block) Indent() string { return b.indent } func MakeBlocksFromMarkdown(content []byte) ([]Block, error) { tree, err := markdown.ParseCtx(context.Background(), nil, content) @@ -44,7 +42,7 @@ func MakeBlocksFromMarkdown(content []byte) ([]Block, error) { collectBlockNodes(root, content, &pos, &blocks) if pos < uint32(len(content)) { - blocks = append(blocks, MakeBlockFromRaw(BlockKindText, content[pos:], "")) + blocks = append(blocks, MakeBlockFromRaw(BlockKindText, content[pos:])) } return blocks, nil @@ -64,23 +62,39 @@ func collectBlockNodes( default: start := node.StartByte() end := node.EndByte() - - if start > *pos { - *blocks = append(*blocks, MakeBlockFromRaw(BlockKindText, content[*pos:start], "")) - } - kind := blockKind(node, content) - indent := "" + blockStart := start + blockEnd := lineTrailingWhitespaceStart(end, content) if kind == BlockKindFencedCode || kind == BlockKindHTMLComment { - indent = lineIndentBefore(start, content) + blockStart = lineWhitespaceStart(start, content) + if blockStart < *pos && len(*blocks) > 0 { + last := len(*blocks) - 1 + if (*blocks)[last].kind == BlockKindText && + bytes.Equal((*blocks)[last].content, content[blockStart:*pos]) { + *blocks = (*blocks)[:last] + *pos = blockStart + } + } + if blockStart < *pos { + blockStart = start + } + } + + if blockStart > *pos { + if kind == BlockKindText && !isListMarker(content[blockStart:blockEnd]) { + *blocks = append(*blocks, MakeBlockFromRaw(BlockKindText, content[*pos:blockEnd])) + *pos = blockEnd + return + } + *blocks = append(*blocks, MakeBlockFromRaw(BlockKindText, content[*pos:blockStart])) } - *blocks = append(*blocks, MakeBlockFromRaw(kind, content[start:end], indent)) - *pos = end + + *blocks = append(*blocks, MakeBlockFromRaw(kind, content[blockStart:blockEnd])) + *pos = blockEnd } } -// lineIndentBefore returns the leading whitespace on the line containing start. -func lineIndentBefore(start uint32, content []byte) string { +func lineWhitespaceStart(start uint32, content []byte) uint32 { lineStart := start for lineStart > 0 && content[lineStart-1] != '\n' { lineStart-- @@ -89,7 +103,42 @@ func lineIndentBefore(start uint32, content []byte) string { for i < start && (content[i] == ' ' || content[i] == '\t') { i++ } - return string(content[lineStart:i]) + if i == start { + return lineStart + } + return start +} + +func lineTrailingWhitespaceStart(end uint32, content []byte) uint32 { + i := end + for i > 0 && (content[i-1] == ' ' || content[i-1] == '\t') { + i-- + } + if i > 0 && content[i-1] == '\n' { + return i + } + return end +} + +func isListMarker(content []byte) bool { + if bytes.Equal(content, []byte("- ")) || + bytes.Equal(content, []byte("+ ")) || + bytes.Equal(content, []byte("* ")) { + return true + } + if len(content) < 3 || content[len(content)-1] != ' ' { + return false + } + for i, b := range content[:len(content)-2] { + if i == 0 && (b < '0' || b > '9') { + return false + } + if i > 0 && (b < '0' || b > '9') { + return false + } + } + marker := content[len(content)-2] + return marker == '.' || marker == ')' } func blockKind(node *sitter.Node, content []byte) BlockKind { diff --git a/internal/block_test.go b/internal/block_test.go index e9746a6..db8829a 100644 --- a/internal/block_test.go +++ b/internal/block_test.go @@ -15,7 +15,7 @@ func TestMakeBlockFromRaw(t *testing.T) { content := []byte("```bash\necho hello\n```\n") // when - got := internal.MakeBlockFromRaw(kind, content, "") + got := internal.MakeBlockFromRaw(kind, content) // then assert.Equal(t, kind, got.Kind()) @@ -61,6 +61,26 @@ func TestMakeBlocksFromMarkdown(t *testing.T) { {internal.BlockKindFencedCode, "```bash\necho \"hello\"\n```\n"}, }, }, + { + name: "fenced code block in nested list keeps indent in content", + input: "- Level 1\n\n - Level 2\n\n ```bash | litdoc\n echo \"hello\"\n ```\n", + want: []struct { + kind internal.BlockKind + content string + }{ + {internal.BlockKindText, "- "}, + {internal.BlockKindText, "Level 1\n"}, + {internal.BlockKindText, "\n"}, + {internal.BlockKindText, " "}, + {internal.BlockKindText, "- "}, + {internal.BlockKindText, "Level 2\n"}, + {internal.BlockKindText, "\n"}, + { + internal.BlockKindFencedCode, + " ```bash | litdoc\n echo \"hello\"\n ```\n", + }, + }, + }, { name: "tilde fenced code block", input: "~~~bash\necho \"hello\"\n~~~\n", @@ -114,6 +134,27 @@ func TestMakeBlocksFromMarkdown(t *testing.T) { {internal.BlockKindHTMLComment, "\n"}, }, }, + { + name: "html comment block in nested list keeps indent in content", + input: "- Level 1\n\n - Level 2\n\n \n text\n", + want: []struct { + kind internal.BlockKind + content string + }{ + {internal.BlockKindText, "- "}, + {internal.BlockKindText, "Level 1\n"}, + {internal.BlockKindText, "\n"}, + {internal.BlockKindText, " "}, + {internal.BlockKindText, "- "}, + {internal.BlockKindText, "Level 2\n"}, + {internal.BlockKindText, "\n"}, + { + internal.BlockKindHTMLComment, + " \n", + }, + {internal.BlockKindText, " text\n"}, + }, + }, { name: "html block is not a comment", input: "
\nhello\n
\n", diff --git a/internal/cell.go b/internal/cell.go index a4b2d62..208906d 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -54,6 +54,7 @@ func ParseInfoString(b Block) InfoString { if i := bytes.IndexByte(b.content, '\n'); i >= 0 { firstLine = b.content[:i] } + firstLine = bytes.TrimLeft(firstLine, " \t") var raw []byte switch b.kind { case BlockKindFencedCode: @@ -81,7 +82,9 @@ func Classify(blocks []Block) ([]Cell, error) { if err != nil { return nil, err } - output = output.WithIndent(b.Indent()) + if consumed == 0 { + output = output.WithIndent(blockIndent(b)) + } cells = append(cells, MakeBashCellFromRaw(string(b.content), output)) i += 1 + consumed continue @@ -95,6 +98,18 @@ func Classify(blocks []Block) ([]Cell, error) { return cells, nil } +func blockIndent(b Block) string { + line := b.content + if i := bytes.IndexByte(line, '\n'); i >= 0 { + line = line[:i] + } + i := 0 + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + return string(line[:i]) +} + func Execute(cells []Cell) ([]Cell, error) { var executedCells []Cell for _, c := range cells { diff --git a/internal/cell_test.go b/internal/cell_test.go index dbbb5b2..5f6c597 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -58,7 +58,6 @@ func TestParseInfoString(t *testing.T) { block: internal.MakeBlockFromRaw( internal.BlockKindText, []byte("hello"), - "", ), want: internal.InfoString{}, }, @@ -67,7 +66,6 @@ func TestParseInfoString(t *testing.T) { block: internal.MakeBlockFromRaw( internal.BlockKindFencedCode, []byte("```bash\necho hello\n```\n"), - "", ), want: internal.InfoString{Lang: "bash"}, }, @@ -76,7 +74,6 @@ func TestParseInfoString(t *testing.T) { block: internal.MakeBlockFromRaw( internal.BlockKindFencedCode, []byte("```bash | litdoc\necho hello\n```\n"), - "", ), want: internal.InfoString{Lang: "bash", IsLitdoc: true}, }, @@ -85,7 +82,6 @@ func TestParseInfoString(t *testing.T) { block: internal.MakeBlockFromRaw( internal.BlockKindHTMLComment, []byte("\n"), - "", ), want: internal.InfoString{Lang: "bash", IsLitdoc: true}, }, @@ -225,10 +221,10 @@ func TestCompose(t *testing.T) { func TestClassify(t *testing.T) { textBlock := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content), "") + return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) } bashLitdocBlock := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(content), "") + return internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(content)) } t.Run("single text block becomes StaticCell", func(t *testing.T) { @@ -295,7 +291,7 @@ func TestClassify(t *testing.T) { // given code := "```bash\necho hello\n```\n" blocks := []internal.Block{ - internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code), ""), + internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code)), } // when @@ -313,11 +309,11 @@ func TestClassify(t *testing.T) { // given - mirrors what tree-sitter produces: three separate blocks for the output section code := "```bash | litdoc\necho hello\n```\n" blocks := []internal.Block{ - internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code), ""), - internal.MakeBlockFromRaw(internal.BlockKindText, []byte("\n"), ""), - internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputBeginMarker+"\n"), ""), - internal.MakeBlockFromRaw(internal.BlockKindText, []byte("old output\n"), ""), - internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputEndMarker+"\n"), ""), + internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code)), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte("\n")), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputBeginMarker+"\n")), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte("old output\n")), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputEndMarker+"\n")), } // when @@ -338,8 +334,8 @@ func TestClassify(t *testing.T) { code := "```bash | litdoc\necho hello\n```\n" blocks := []internal.Block{ bashLitdocBlock(code), - internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputBeginMarker), ""), - internal.MakeBlockFromRaw(internal.BlockKindText, []byte("hello\n"), ""), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(internal.OutputBeginMarker)), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte("hello\n")), } // when diff --git a/internal/output.go b/internal/output.go index faa57b4..d287419 100644 --- a/internal/output.go +++ b/internal/output.go @@ -46,15 +46,26 @@ func (o Output) Render() string { func isOutputBegin(b Block) bool { return b.kind == BlockKindHTMLComment && - bytes.HasPrefix(b.content, []byte(OutputBeginMarker)) + bytes.HasPrefix(bytes.TrimLeft(b.content, " \t"), []byte(OutputBeginMarker)) } func isOutputEnd(b Block) bool { return b.kind == BlockKindHTMLComment && - bytes.HasPrefix(b.content, []byte(OutputEndMarker)) + bytes.HasPrefix(bytes.TrimLeft(b.content, " \t"), []byte(OutputEndMarker)) +} + +func blockLineIndent(b Block) string { + line := b.content + if i := bytes.IndexByte(line, '\n'); i >= 0 { + line = line[:i] + } + i := 0 + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + return string(line[:i]) } -// todo: make sure that it handles indent func OutputFromBlocks(blocks []Block) (Output, int, error) { i := 0 // skip empty text blocks @@ -65,7 +76,9 @@ func OutputFromBlocks(blocks []Block) (Output, int, error) { } // advance past a 'begin' block, or exit + indent := "" if i < len(blocks) && isOutputBegin(blocks[i]) { + indent = blockLineIndent(blocks[i]) i++ } else { return Output{}, 0, nil @@ -75,13 +88,48 @@ func OutputFromBlocks(blocks []Block) (Output, int, error) { var buf strings.Builder for i < len(blocks) { if isOutputEnd(blocks[i]) { + if got := blockLineIndent(blocks[i]); got != indent { + return Output{}, 0, fmt.Errorf( + "output end marker indentation: got %q, want %q", + got, + indent, + ) + } i++ - return MakeOutput(buf.String()), i, nil + return MakeOutput(buf.String()).WithIndent(indent), i, nil } - buf.Write(blocks[i].content) + unindented, err := unindentOutputContent(blocks[i].content, indent) + if err != nil { + return Output{}, 0, err + } + buf.Write(unindented) i++ } // report not finding an 'end' block return Output{}, 0, fmt.Errorf("unclosed output block: missing %q", OutputEndMarker) } + +func unindentOutputContent(content []byte, indent string) ([]byte, error) { + if indent == "" || len(content) == 0 { + return content, nil + } + + lines := bytes.SplitAfter(content, []byte("\n")) + var buf bytes.Buffer + for _, line := range lines { + if len(line) == 0 { + continue + } + trimmedLine := bytes.TrimSuffix(line, []byte("\n")) + if len(trimmedLine) == 0 { + buf.Write(line) + continue + } + if !bytes.HasPrefix(line, []byte(indent)) { + return nil, fmt.Errorf("output content indentation: got %q, want prefix %q", string(line), indent) + } + buf.Write(line[len(indent):]) + } + return buf.Bytes(), nil +} diff --git a/internal/output_test.go b/internal/output_test.go index b371360..8174290 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -90,10 +90,10 @@ func TestOutput_Render(t *testing.T) { func TestOutputFromBlocks(t *testing.T) { htmlComment := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(content), "") + return internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(content)) } text := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content), "") + return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) } wantOutput := func(content string) string { return internal.MakeOutput(content).Render() @@ -134,6 +134,53 @@ func TestOutputFromBlocks(t *testing.T) { assert.Equal(t, 4, consumed) }) + t.Run("indented output block is scanned in", func(t *testing.T) { + // given + blocks := []internal.Block{ + htmlComment(" " + internal.OutputBeginMarker), + text(" hello\n world\n"), + htmlComment(" " + internal.OutputEndMarker), + } + + // when + output, consumed, err := internal.OutputFromBlocks(blocks) + + // then + require.NoError(t, err) + assert.Equal(t, internal.MakeOutput("hello\nworld").WithIndent(" ").Render(), output.Render()) + assert.Equal(t, 3, consumed) + }) + + t.Run("indented output content must match marker indent", func(t *testing.T) { + // given + blocks := []internal.Block{ + htmlComment(" " + internal.OutputBeginMarker), + text(" hello\n"), + htmlComment(" " + internal.OutputEndMarker), + } + + // when + _, _, err := internal.OutputFromBlocks(blocks) + + // then + require.ErrorContains(t, err, "output content indentation") + }) + + t.Run("indented output end marker must match begin marker indent", func(t *testing.T) { + // given + blocks := []internal.Block{ + htmlComment(" " + internal.OutputBeginMarker), + text(" hello\n"), + htmlComment(" " + internal.OutputEndMarker), + } + + // when + _, _, err := internal.OutputFromBlocks(blocks) + + // then + require.ErrorContains(t, err, "output end marker indentation") + }) + t.Run("no output block returns zero value and zero consumed", func(t *testing.T) { // given blocks := []internal.Block{ From de67301e23d89ab96be047723cef404a83a6e1d8 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 27 Apr 2026 17:16:54 -0600 Subject: [PATCH 11/25] Handle output indent classifying blocks --- internal/cell.go | 38 +++++++++++++++++++++++++++------- internal/cell_test.go | 46 ++++++++++++++++++++++++++++++++++++++--- internal/output.go | 28 ++++++++++--------------- internal/output_test.go | 45 ++++++++++++++++++++++------------------ 4 files changed, 109 insertions(+), 48 deletions(-) diff --git a/internal/cell.go b/internal/cell.go index 208906d..3cba4d8 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -29,19 +29,28 @@ func (t StaticCell) Render() (string, error) { type BashCell struct { fencedCode string + indent string output Output } func MakeBashCellFromRaw(fencedCode string, output Output) BashCell { - return BashCell{fencedCode: fencedCode, output: output} + return BashCell{ + fencedCode: fencedCode, + indent: lineIndent([]byte(fencedCode)), + output: output, + } } func (c BashCell) Execute() (Cell, error) { - return BashCell{fencedCode: c.fencedCode, output: MakeOutput("output").WithIndent(c.output.indent)}, nil // stub + return BashCell{ + fencedCode: c.fencedCode, + indent: c.indent, + output: MakeOutput("output"), + }, nil // stub } func (c BashCell) Render() (string, error) { - return c.fencedCode + c.output.Render(), nil + return c.fencedCode + c.output.Render(c.indent), nil } type InfoString struct { @@ -78,14 +87,23 @@ func Classify(blocks []Block) ([]Cell, error) { info := ParseInfoString(b) switch { case info.IsLitdoc && info.Lang == "bash": - output, consumed, err := OutputFromBlocks(blocks[i+1:]) + indent := blockIndent(b) + output, outputIndent, consumed, err := OutputFromBlocks(blocks[i+1:]) if err != nil { return nil, err } - if consumed == 0 { - output = output.WithIndent(blockIndent(b)) + if consumed > 0 && outputIndent != indent { + return nil, fmt.Errorf( + "output indentation %q does not match bash cell indentation %q", + outputIndent, + indent, + ) } - cells = append(cells, MakeBashCellFromRaw(string(b.content), output)) + cells = append(cells, BashCell{ + fencedCode: string(b.content), + indent: indent, + output: output, + }) i += 1 + consumed continue case info.IsLitdoc: @@ -99,7 +117,11 @@ func Classify(blocks []Block) ([]Cell, error) { } func blockIndent(b Block) string { - line := b.content + return lineIndent(b.content) +} + +func lineIndent(content []byte) string { + line := content if i := bytes.IndexByte(line, '\n'); i >= 0 { line = line[:i] } diff --git a/internal/cell_test.go b/internal/cell_test.go index 5f6c597..ac9c10f 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -120,7 +120,7 @@ func TestBashCellExecute(t *testing.T) { require.NoError(t, err) rendered, err := gotCell.Render() require.NoError(t, err) - want := fencedCode + internal.MakeOutput("output").Render() + want := fencedCode + internal.MakeOutput("output").Render("") assert.Equal(t, want, rendered) } @@ -150,7 +150,7 @@ func TestBashCellRender(t *testing.T) { // then require.NoError(t, err) - want := fencedCode + internal.MakeOutput("output").Render() + want := fencedCode + internal.MakeOutput("output").Render("") assert.Equal(t, want, gotContent) }) } @@ -326,7 +326,47 @@ func TestClassify(t *testing.T) { require.True(t, ok, "expected BashCell, got %T", cells[0]) rendered, err := cells[0].Render() require.NoError(t, err) - assert.Equal(t, code+internal.MakeOutput("old output").Render(), rendered) + assert.Equal(t, code+internal.MakeOutput("old output").Render(""), rendered) + }) + + t.Run("indented output block must match bash cell indent", func(t *testing.T) { + // given + code := " ```bash | litdoc\n echo hello\n ```\n" + blocks := []internal.Block{ + internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code)), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte("\n")), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(" "+internal.OutputBeginMarker)), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte(" old output\n")), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(" "+internal.OutputEndMarker)), + } + + // when + cells, err := internal.Classify(blocks) + + // then + require.NoError(t, err) + require.Len(t, cells, 1) + rendered, err := cells[0].Render() + require.NoError(t, err) + assert.Equal(t, code+internal.MakeOutput("old output").Render(" "), rendered) + }) + + t.Run("output block with mismatched indent returns error", func(t *testing.T) { + // given + code := " ```bash | litdoc\n echo hello\n ```\n" + blocks := []internal.Block{ + internal.MakeBlockFromRaw(internal.BlockKindFencedCode, []byte(code)), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte("\n")), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(" "+internal.OutputBeginMarker)), + internal.MakeBlockFromRaw(internal.BlockKindText, []byte(" old output\n")), + internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(" "+internal.OutputEndMarker)), + } + + // when + _, err := internal.Classify(blocks) + + // then + require.ErrorContains(t, err, "does not match bash cell indentation") }) t.Run("unclosed output block returns error", func(t *testing.T) { diff --git a/internal/output.go b/internal/output.go index d287419..18452bf 100644 --- a/internal/output.go +++ b/internal/output.go @@ -13,34 +13,28 @@ const ( type Output struct { content string - indent string } func MakeOutput(content string) Output { return Output{content: content} } -func (o Output) WithIndent(indent string) Output { - o.indent = indent - return o -} - -func (o Output) Render() string { +func (o Output) Render(indent string) string { if o.content == "" { return "" } o.content = strings.TrimSuffix(o.content, "\n") - if o.indent == "" { + if indent == "" { return "\n" + OutputBeginMarker + o.content + "\n" + OutputEndMarker } var buf strings.Builder - buf.WriteString("\n" + o.indent + OutputBeginMarker) + buf.WriteString("\n" + indent + OutputBeginMarker) for _, line := range strings.Split(o.content, "\n") { - buf.WriteString(o.indent + line + "\n") + buf.WriteString(indent + line + "\n") } - buf.WriteString(o.indent + OutputEndMarker) + buf.WriteString(indent + OutputEndMarker) return buf.String() } @@ -66,7 +60,7 @@ func blockLineIndent(b Block) string { return string(line[:i]) } -func OutputFromBlocks(blocks []Block) (Output, int, error) { +func OutputFromBlocks(blocks []Block) (Output, string, int, error) { i := 0 // skip empty text blocks for i < len(blocks) && @@ -81,7 +75,7 @@ func OutputFromBlocks(blocks []Block) (Output, int, error) { indent = blockLineIndent(blocks[i]) i++ } else { - return Output{}, 0, nil + return Output{}, "", 0, nil } // accumulate text until an 'end' block and return @@ -89,25 +83,25 @@ func OutputFromBlocks(blocks []Block) (Output, int, error) { for i < len(blocks) { if isOutputEnd(blocks[i]) { if got := blockLineIndent(blocks[i]); got != indent { - return Output{}, 0, fmt.Errorf( + return Output{}, "", 0, fmt.Errorf( "output end marker indentation: got %q, want %q", got, indent, ) } i++ - return MakeOutput(buf.String()).WithIndent(indent), i, nil + return MakeOutput(buf.String()), indent, i, nil } unindented, err := unindentOutputContent(blocks[i].content, indent) if err != nil { - return Output{}, 0, err + return Output{}, "", 0, err } buf.Write(unindented) i++ } // report not finding an 'end' block - return Output{}, 0, fmt.Errorf("unclosed output block: missing %q", OutputEndMarker) + return Output{}, "", 0, fmt.Errorf("unclosed output block: missing %q", OutputEndMarker) } func unindentOutputContent(content []byte, indent string) ([]byte, error) { diff --git a/internal/output_test.go b/internal/output_test.go index 8174290..9b60a8b 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -17,19 +17,19 @@ func TestMakeOutput(t *testing.T) { got := internal.MakeOutput(content) // then - assert.Contains(t, got.Render(), content) + assert.Contains(t, got.Render(""), content) } -func TestOutput_WithIndent(t *testing.T) { +func TestOutput_RenderWithIndent(t *testing.T) { // given content := "hello" indent := " " // when - got := internal.MakeOutput(content).WithIndent(indent) + got := internal.MakeOutput(content) // then - assert.Contains(t, got.Render(), indent+content) + assert.Contains(t, got.Render(indent), indent+content) } func TestOutput_Render(t *testing.T) { @@ -77,10 +77,10 @@ func TestOutput_Render(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // given - output := internal.MakeOutput(tt.content).WithIndent(tt.indent) + output := internal.MakeOutput(tt.content) // when - got := output.Render() + got := output.Render(tt.indent) // then assert.Equal(t, tt.want, got) @@ -96,7 +96,7 @@ func TestOutputFromBlocks(t *testing.T) { return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) } wantOutput := func(content string) string { - return internal.MakeOutput(content).Render() + return internal.MakeOutput(content).Render("") } t.Run("output block is scanned in", func(t *testing.T) { @@ -108,11 +108,12 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, consumed, err := internal.OutputFromBlocks(blocks) + output, indent, consumed, err := internal.OutputFromBlocks(blocks) // then require.NoError(t, err) - assert.Equal(t, wantOutput("hello"), output.Render()) + assert.Equal(t, "", indent) + assert.Equal(t, wantOutput("hello"), output.Render(indent)) assert.Equal(t, 3, consumed) }) @@ -126,11 +127,12 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, consumed, err := internal.OutputFromBlocks(blocks) + output, indent, consumed, err := internal.OutputFromBlocks(blocks) // then require.NoError(t, err) - assert.Equal(t, wantOutput("hello"), output.Render()) + assert.Equal(t, "", indent) + assert.Equal(t, wantOutput("hello"), output.Render(indent)) assert.Equal(t, 4, consumed) }) @@ -143,11 +145,12 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, consumed, err := internal.OutputFromBlocks(blocks) + output, indent, consumed, err := internal.OutputFromBlocks(blocks) // then require.NoError(t, err) - assert.Equal(t, internal.MakeOutput("hello\nworld").WithIndent(" ").Render(), output.Render()) + assert.Equal(t, " ", indent) + assert.Equal(t, internal.MakeOutput("hello\nworld").Render(" "), output.Render(indent)) assert.Equal(t, 3, consumed) }) @@ -160,7 +163,7 @@ func TestOutputFromBlocks(t *testing.T) { } // when - _, _, err := internal.OutputFromBlocks(blocks) + _, _, _, err := internal.OutputFromBlocks(blocks) // then require.ErrorContains(t, err, "output content indentation") @@ -175,7 +178,7 @@ func TestOutputFromBlocks(t *testing.T) { } // when - _, _, err := internal.OutputFromBlocks(blocks) + _, _, _, err := internal.OutputFromBlocks(blocks) // then require.ErrorContains(t, err, "output end marker indentation") @@ -188,21 +191,23 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, consumed, err := internal.OutputFromBlocks(blocks) + output, indent, consumed, err := internal.OutputFromBlocks(blocks) // then require.NoError(t, err) - assert.Equal(t, "", output.Render()) + assert.Equal(t, "", indent) + assert.Equal(t, "", output.Render(indent)) assert.Equal(t, 0, consumed) }) t.Run("empty blocks returns zero value and zero consumed", func(t *testing.T) { // when - output, consumed, err := internal.OutputFromBlocks(nil) + output, indent, consumed, err := internal.OutputFromBlocks(nil) // then require.NoError(t, err) - assert.Equal(t, "", output.Render()) + assert.Equal(t, "", indent) + assert.Equal(t, "", output.Render(indent)) assert.Equal(t, 0, consumed) }) @@ -214,7 +219,7 @@ func TestOutputFromBlocks(t *testing.T) { } // when - _, _, err := internal.OutputFromBlocks(blocks) + _, _, _, err := internal.OutputFromBlocks(blocks) // then require.ErrorContains(t, err, "unclosed output block") From 9f5f564806dc253cc61638f19722185d9c8c84f7 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 1 May 2026 11:54:00 -0600 Subject: [PATCH 12/25] Remove make test dependency on build cmd/testscript_test.go no longer uses the built binary, so we can remove that dependency --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 4710f80..cfae2e9 100644 --- a/Makefile +++ b/Makefile @@ -36,7 +36,7 @@ bin/litdoc: $(GO_FILES) build: bin/litdoc .PHONY: test -test: build +test: @GOCACHE=$(GOCACHE) go test ./... --count=1 .PHONY: clean From 6165275cff4ad15977a9160a001f7357b468ad49 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 1 May 2026 15:26:42 -0600 Subject: [PATCH 13/25] Unify go caching across local, claude sandbox, and CI --- .github/workflows/ci.yml | 9 +++++++++ .gitignore | 7 +++++-- Makefile | 21 +++++++++++++++------ flake.nix | 2 +- 4 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9aa71ba..c0a524b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,6 +22,15 @@ jobs: name: litdoc authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' + - uses: actions/cache@v4 + with: + path: .go-cache + key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-build- + + - run: nix develop --ignore-environment --command make vendor + - run: nix develop --ignore-environment --command make fmt-check - run: nix develop --ignore-environment --command make vet - run: nix develop --ignore-environment --command make test \ No newline at end of file diff --git a/.gitignore b/.gitignore index 36fd9d0..2e14748 100644 --- a/.gitignore +++ b/.gitignore @@ -18,8 +18,8 @@ coverage.* *.coverprofile profile.cov -# Dependency directories (remove the comment below to include it) -# vendor/ +# Dependency directories +vendor/ # Go workspace file go.work @@ -28,6 +28,9 @@ go.work.sum # env file .env +# Go build cache +.go-cache/ + # Editor/IDE .idea/ # .vscode/ diff --git a/Makefile b/Makefile index cfae2e9..0cfeeae 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +GO ?= go + .PHONY: pre-pr pre-pr: clean mock fmt-check vet test @@ -16,7 +18,7 @@ fmt-check: .PHONY: vet vet: - @go vet ./... + @$(GO) vet ./... .PHONY: mock mock: @@ -27,17 +29,24 @@ mock-clean: @find . \( -name '*_mock_test.go' -o -name '*_mock.go' \) -not -path './vendor/*' -delete GO_FILES := $(shell find . -name '*.go' -not -path './vendor/*') -GOCACHE ?= /tmp/litdoc-go-build +GOCACHE ?= $(CURDIR)/.go-cache + +vendor: go.mod go.sum + @$(GO) mod vendor -bin/litdoc: $(GO_FILES) - @GOCACHE=$(GOCACHE) go build -o bin/litdoc . +bin/litdoc: vendor $(GO_FILES) + @GOCACHE=$(GOCACHE) $(GO) build -mod=vendor -o bin/litdoc . .PHONY: build build: bin/litdoc .PHONY: test -test: - @GOCACHE=$(GOCACHE) go test ./... --count=1 +test: vendor + @GOCACHE=$(GOCACHE) $(GO) test -mod=vendor ./... --count=1 + +.PHONY: vendor-clean +vendor-clean: + @rm -rf vendor/ .PHONY: clean clean: mock-clean diff --git a/flake.nix b/flake.nix index ee76d83..59db2a8 100644 --- a/flake.nix +++ b/flake.nix @@ -19,7 +19,7 @@ shellHook = '' export HOME=$(mktemp -d) export GOPATH=$HOME/go - export GOCACHE=$HOME/.cache/go-build + export GOCACHE=$PWD/.go-cache export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt export NIX_SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt ''; From af89cf0db60c196b2f10f6a1fd725b5ba4c7b81d Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 1 May 2026 16:05:42 -0600 Subject: [PATCH 14/25] Adapt to the new behavior of MakeBlocksFromMarkdown --- internal/cell.go | 60 ++++++++++++++++++++++++------- internal/cell_test.go | 13 +++---- internal/file_test.go | 4 +-- internal/output.go | 80 +++++++++++------------------------------ internal/output_test.go | 76 ++++++++++++++++++++++----------------- 5 files changed, 121 insertions(+), 112 deletions(-) diff --git a/internal/cell.go b/internal/cell.go index 00da053..ed55f3e 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -28,22 +28,28 @@ func (t StaticCell) Render() (string, error) { type BashCell struct { fencedCode string - output string + indent string + output Output } -func MakeBashCellFromRaw(fencedCode, output string) BashCell { - return BashCell{fencedCode: fencedCode, output: output} +func MakeBashCellFromRaw(fencedCode string, output Output) BashCell { + return BashCell{ + fencedCode: fencedCode, + indent: leadingIndent(fencedCode), + output: output, + } } func (c BashCell) Execute() (Cell, error) { - return c, nil + return BashCell{ + fencedCode: c.fencedCode, + indent: c.indent, + output: MakeOutput("output"), + }, nil } func (c BashCell) Render() (string, error) { - if c.output == "" { - return c.fencedCode, nil - } - return c.fencedCode + "\n" + c.output, nil + return c.fencedCode + c.output.Render(c.indent), nil } type InfoString struct { @@ -71,17 +77,34 @@ func ParseInfoString(b Block) InfoString { return InfoString{Lang: lang, Litdoc: litdoc} } -// Classify todo: review this function func Classify(blocks []Block) ([]Cell, error) { var cells []Cell - for _, b := range blocks { + i := 0 + for i < len(blocks) { + b := blocks[i] switch b.kind { case BlockKindFencedCode, BlockKindHTMLComment: info := ParseInfoString(b) switch { case info.Litdoc && info.Lang == "bash": - cell := MakeBashCellFromRaw(renderStaticBlock(b), "") - cells = append(cells, cell) + output, outputIndent, consumed, err := OutputFromBlocks(blocks[i+1:]) + if err != nil { + return nil, err + } + if consumed > 0 && outputIndent != b.indent { + return nil, fmt.Errorf( + "output indentation %q does not match bash cell indentation %q", + outputIndent, + b.indent, + ) + } + cells = append(cells, BashCell{ + fencedCode: renderStaticBlock(b), + indent: b.indent, + output: output, + }) + i += 1 + consumed + continue case info.Litdoc: return nil, fmt.Errorf("unsupported language: %q", info.Lang) default: @@ -90,6 +113,7 @@ func Classify(blocks []Block) ([]Cell, error) { default: cells = append(cells, MakeStaticCellFromRaw(renderStaticBlock(b))) } + i++ } return cells, nil } @@ -132,6 +156,18 @@ func renderIndent(indent string) string { return strings.Repeat(" ", len(indent)) } +func leadingIndent(s string) string { + line := s + if i := strings.IndexByte(s, '\n'); i >= 0 { + line = s[:i] + } + i := 0 + for i < len(line) && (line[i] == ' ' || line[i] == '\t') { + i++ + } + return line[:i] +} + func Execute(cells []Cell) ([]Cell, error) { var executedCells []Cell for _, c := range cells { diff --git a/internal/cell_test.go b/internal/cell_test.go index 6f56870..a711cfa 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -192,7 +192,7 @@ func TestBashCell(t *testing.T) { "```", "", ) - cell := internal.MakeBashCellFromRaw(code, "") + cell := internal.MakeBashCellFromRaw(code, internal.Output{}) // when gotContent, err := cell.Render() @@ -208,8 +208,9 @@ func TestBashCell(t *testing.T) { "```bash", "echo hello", "```", + "", ) - output := "hello" + output := internal.MakeOutput("hello") cell := internal.MakeBashCellFromRaw(fencedCode, output) // when @@ -217,10 +218,10 @@ func TestBashCell(t *testing.T) { // then require.NoError(t, err) - assert.Equal(t, fencedCode+"\n"+output, gotContent) + assert.Equal(t, fencedCode+output.Render(""), gotContent) }) - t.Run("executes to itself", func(t *testing.T) { + t.Run("execute produces stub output", func(t *testing.T) { // given fencedCode := joinLines( "```bash", @@ -228,7 +229,7 @@ func TestBashCell(t *testing.T) { "```", "", ) - cell := internal.MakeBashCellFromRaw(fencedCode, "") + cell := internal.MakeBashCellFromRaw(fencedCode, internal.Output{}) // when gotCell, err := cell.Execute() @@ -237,7 +238,7 @@ func TestBashCell(t *testing.T) { require.NoError(t, err) rendered, err := gotCell.Render() require.NoError(t, err) - assert.Equal(t, fencedCode, rendered) + assert.Equal(t, fencedCode+internal.MakeOutput("output").Render(""), rendered) }) } diff --git a/internal/file_test.go b/internal/file_test.go index 3d36e48..915421c 100644 --- a/internal/file_test.go +++ b/internal/file_test.go @@ -52,8 +52,8 @@ func TestProcessFileNestedListIndent(t *testing.T) { // then require.NoError(t, err) want := "- Level 1\n\n - Level 2\n\n ```bash | litdoc\n echo hello\n ```\n" + - "\n " + internal.OutputBeginMarker + + "\n " + internal.OutputBeginMarker + "\n" + " output\n" + - " " + internal.OutputEndMarker + " " + internal.OutputEndMarker + "\n" assert.Equal(t, want, got) } diff --git a/internal/output.go b/internal/output.go index 18452bf..6c81e9e 100644 --- a/internal/output.go +++ b/internal/output.go @@ -1,14 +1,13 @@ package internal import ( - "bytes" "fmt" "strings" ) const ( - OutputBeginMarker = "\n" - OutputEndMarker = "\n" + OutputBeginMarker = "" + OutputEndMarker = "" ) type Output struct { @@ -27,103 +26,64 @@ func (o Output) Render(indent string) string { o.content = strings.TrimSuffix(o.content, "\n") if indent == "" { - return "\n" + OutputBeginMarker + o.content + "\n" + OutputEndMarker + return "\n" + OutputBeginMarker + "\n" + o.content + "\n" + OutputEndMarker + "\n" } var buf strings.Builder - buf.WriteString("\n" + indent + OutputBeginMarker) + buf.WriteString("\n" + indent + OutputBeginMarker + "\n") for _, line := range strings.Split(o.content, "\n") { buf.WriteString(indent + line + "\n") } - buf.WriteString(indent + OutputEndMarker) + buf.WriteString(indent + OutputEndMarker + "\n") return buf.String() } func isOutputBegin(b Block) bool { return b.kind == BlockKindHTMLComment && - bytes.HasPrefix(bytes.TrimLeft(b.content, " \t"), []byte(OutputBeginMarker)) + strings.HasPrefix(b.content, OutputBeginMarker) } func isOutputEnd(b Block) bool { return b.kind == BlockKindHTMLComment && - bytes.HasPrefix(bytes.TrimLeft(b.content, " \t"), []byte(OutputEndMarker)) -} - -func blockLineIndent(b Block) string { - line := b.content - if i := bytes.IndexByte(line, '\n'); i >= 0 { - line = line[:i] - } - i := 0 - for i < len(line) && (line[i] == ' ' || line[i] == '\t') { - i++ - } - return string(line[:i]) + strings.HasPrefix(b.content, OutputEndMarker) } func OutputFromBlocks(blocks []Block) (Output, string, int, error) { i := 0 - // skip empty text blocks for i < len(blocks) && blocks[i].kind == BlockKindText && - strings.TrimSpace(string(blocks[i].content)) == "" { + strings.TrimSpace(blocks[i].content) == "" { i++ } - // advance past a 'begin' block, or exit - indent := "" - if i < len(blocks) && isOutputBegin(blocks[i]) { - indent = blockLineIndent(blocks[i]) - i++ - } else { + if i >= len(blocks) || !isOutputBegin(blocks[i]) { return Output{}, "", 0, nil } + indent := blocks[i].indent + i++ - // accumulate text until an 'end' block and return var buf strings.Builder for i < len(blocks) { if isOutputEnd(blocks[i]) { - if got := blockLineIndent(blocks[i]); got != indent { + if blocks[i].indent != indent { return Output{}, "", 0, fmt.Errorf( "output end marker indentation: got %q, want %q", - got, + blocks[i].indent, indent, ) } i++ return MakeOutput(buf.String()), indent, i, nil } - unindented, err := unindentOutputContent(blocks[i].content, indent) - if err != nil { - return Output{}, "", 0, err + if blocks[i].indent != indent { + return Output{}, "", 0, fmt.Errorf( + "output content indentation: got %q, want %q", + blocks[i].indent, + indent, + ) } - buf.Write(unindented) + buf.WriteString(blocks[i].content) i++ } - // report not finding an 'end' block return Output{}, "", 0, fmt.Errorf("unclosed output block: missing %q", OutputEndMarker) } - -func unindentOutputContent(content []byte, indent string) ([]byte, error) { - if indent == "" || len(content) == 0 { - return content, nil - } - - lines := bytes.SplitAfter(content, []byte("\n")) - var buf bytes.Buffer - for _, line := range lines { - if len(line) == 0 { - continue - } - trimmedLine := bytes.TrimSuffix(line, []byte("\n")) - if len(trimmedLine) == 0 { - buf.Write(line) - continue - } - if !bytes.HasPrefix(line, []byte(indent)) { - return nil, fmt.Errorf("output content indentation: got %q, want prefix %q", string(line), indent) - } - buf.Write(line[len(indent):]) - } - return buf.Bytes(), nil -} diff --git a/internal/output_test.go b/internal/output_test.go index 9b60a8b..1e73ff7 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -49,28 +49,46 @@ func TestOutput_Render(t *testing.T) { "wrap content in markers", "hello\n", "", - "\n" + internal.OutputBeginMarker + "hello\n" + internal.OutputEndMarker, + joinLines( + "", + internal.OutputBeginMarker, + "hello", + internal.OutputEndMarker+"\n", + ), }, { "ensure content rendered with trailing newline", "hello", "", - "\n" + internal.OutputBeginMarker + "hello\n" + internal.OutputEndMarker, + joinLines( + "", + internal.OutputBeginMarker, + "hello", + internal.OutputEndMarker+"\n", + ), }, { "multiline content", "hello\nworld", "", - "\n" + internal.OutputBeginMarker + "hello\nworld\n" + internal.OutputEndMarker, + joinLines( + "", + internal.OutputBeginMarker, + "hello", + "world", + internal.OutputEndMarker+"\n", + ), }, { "indent content", "hello\n", " ", - "\n" + - " " + internal.OutputBeginMarker + - " " + "hello\n" + - " " + internal.OutputEndMarker, + joinLines( + "", + " "+internal.OutputBeginMarker, + " hello", + " "+internal.OutputEndMarker+"\n", + ), }, } @@ -89,12 +107,6 @@ func TestOutput_Render(t *testing.T) { } func TestOutputFromBlocks(t *testing.T) { - htmlComment := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindHTMLComment, []byte(content)) - } - text := func(content string) internal.Block { - return internal.MakeBlockFromRaw(internal.BlockKindText, []byte(content)) - } wantOutput := func(content string) string { return internal.MakeOutput(content).Render("") } @@ -102,9 +114,9 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("output block is scanned in", func(t *testing.T) { // given blocks := []internal.Block{ - htmlComment(internal.OutputBeginMarker), - text("hello\n"), - htmlComment(internal.OutputEndMarker), + cmnt("", internal.OutputBeginMarker, false), + text("", "hello\n", false), + cmnt("", internal.OutputEndMarker, false), } // when @@ -120,10 +132,10 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("leading whitespace blocks are skipped", func(t *testing.T) { // given blocks := []internal.Block{ - text("\n"), - htmlComment(internal.OutputBeginMarker), - text("hello\n"), - htmlComment(internal.OutputEndMarker), + text("", "\n", false), + cmnt("", internal.OutputBeginMarker, false), + text("", "hello\n", false), + cmnt("", internal.OutputEndMarker, false), } // when @@ -139,9 +151,9 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("indented output block is scanned in", func(t *testing.T) { // given blocks := []internal.Block{ - htmlComment(" " + internal.OutputBeginMarker), - text(" hello\n world\n"), - htmlComment(" " + internal.OutputEndMarker), + cmnt(" ", internal.OutputBeginMarker, false), + text(" ", "hello\nworld\n", false), + cmnt(" ", internal.OutputEndMarker, false), } // when @@ -157,9 +169,9 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("indented output content must match marker indent", func(t *testing.T) { // given blocks := []internal.Block{ - htmlComment(" " + internal.OutputBeginMarker), - text(" hello\n"), - htmlComment(" " + internal.OutputEndMarker), + cmnt(" ", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), } // when @@ -172,9 +184,9 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("indented output end marker must match begin marker indent", func(t *testing.T) { // given blocks := []internal.Block{ - htmlComment(" " + internal.OutputBeginMarker), - text(" hello\n"), - htmlComment(" " + internal.OutputEndMarker), + cmnt(" ", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), } // when @@ -187,7 +199,7 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("no output block returns zero value and zero consumed", func(t *testing.T) { // given blocks := []internal.Block{ - text("some text\n"), + text("", "some text\n", false), } // when @@ -214,8 +226,8 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("opening marker without closing marker returns error", func(t *testing.T) { // given blocks := []internal.Block{ - htmlComment(internal.OutputBeginMarker), - text("hello\n"), + cmnt("", internal.OutputBeginMarker, false), + text("", "hello\n", false), } // when From 259c91b40487019b7ce858239443ba0b6a5355a6 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 1 May 2026 16:15:02 -0600 Subject: [PATCH 15/25] Don't verify if output indent same as that of the bash cell --- internal/cell.go | 9 +-------- internal/output.go | 12 ++++++------ internal/output_test.go | 35 +++++++++++++++-------------------- 3 files changed, 22 insertions(+), 34 deletions(-) diff --git a/internal/cell.go b/internal/cell.go index ed55f3e..24153bf 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -87,17 +87,10 @@ func Classify(blocks []Block) ([]Cell, error) { info := ParseInfoString(b) switch { case info.Litdoc && info.Lang == "bash": - output, outputIndent, consumed, err := OutputFromBlocks(blocks[i+1:]) + output, consumed, err := OutputFromBlocks(blocks[i+1:]) if err != nil { return nil, err } - if consumed > 0 && outputIndent != b.indent { - return nil, fmt.Errorf( - "output indentation %q does not match bash cell indentation %q", - outputIndent, - b.indent, - ) - } cells = append(cells, BashCell{ fencedCode: renderStaticBlock(b), indent: b.indent, diff --git a/internal/output.go b/internal/output.go index 6c81e9e..81d606c 100644 --- a/internal/output.go +++ b/internal/output.go @@ -47,7 +47,7 @@ func isOutputEnd(b Block) bool { strings.HasPrefix(b.content, OutputEndMarker) } -func OutputFromBlocks(blocks []Block) (Output, string, int, error) { +func OutputFromBlocks(blocks []Block) (Output, int, error) { i := 0 for i < len(blocks) && blocks[i].kind == BlockKindText && @@ -56,7 +56,7 @@ func OutputFromBlocks(blocks []Block) (Output, string, int, error) { } if i >= len(blocks) || !isOutputBegin(blocks[i]) { - return Output{}, "", 0, nil + return Output{}, 0, nil } indent := blocks[i].indent i++ @@ -65,17 +65,17 @@ func OutputFromBlocks(blocks []Block) (Output, string, int, error) { for i < len(blocks) { if isOutputEnd(blocks[i]) { if blocks[i].indent != indent { - return Output{}, "", 0, fmt.Errorf( + return Output{}, 0, fmt.Errorf( "output end marker indentation: got %q, want %q", blocks[i].indent, indent, ) } i++ - return MakeOutput(buf.String()), indent, i, nil + return MakeOutput(buf.String()), i, nil } if blocks[i].indent != indent { - return Output{}, "", 0, fmt.Errorf( + return Output{}, 0, fmt.Errorf( "output content indentation: got %q, want %q", blocks[i].indent, indent, @@ -85,5 +85,5 @@ func OutputFromBlocks(blocks []Block) (Output, string, int, error) { i++ } - return Output{}, "", 0, fmt.Errorf("unclosed output block: missing %q", OutputEndMarker) + return Output{}, 0, fmt.Errorf("unclosed output block: missing %q", OutputEndMarker) } diff --git a/internal/output_test.go b/internal/output_test.go index 1e73ff7..4f35acb 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -107,8 +107,8 @@ func TestOutput_Render(t *testing.T) { } func TestOutputFromBlocks(t *testing.T) { - wantOutput := func(content string) string { - return internal.MakeOutput(content).Render("") + wantOutput := func(indent, content string) string { + return internal.MakeOutput(content).Render(indent) } t.Run("output block is scanned in", func(t *testing.T) { @@ -120,12 +120,11 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, indent, consumed, err := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(blocks) // then require.NoError(t, err) - assert.Equal(t, "", indent) - assert.Equal(t, wantOutput("hello"), output.Render(indent)) + assert.Equal(t, wantOutput("", "hello"), output.Render("")) assert.Equal(t, 3, consumed) }) @@ -139,12 +138,11 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, indent, consumed, err := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(blocks) // then require.NoError(t, err) - assert.Equal(t, "", indent) - assert.Equal(t, wantOutput("hello"), output.Render(indent)) + assert.Equal(t, wantOutput("", "hello"), output.Render("")) assert.Equal(t, 4, consumed) }) @@ -157,12 +155,11 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, indent, consumed, err := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(blocks) // then require.NoError(t, err) - assert.Equal(t, " ", indent) - assert.Equal(t, internal.MakeOutput("hello\nworld").Render(" "), output.Render(indent)) + assert.Equal(t, wantOutput(" ", "hello\nworld"), output.Render(" ")) assert.Equal(t, 3, consumed) }) @@ -175,7 +172,7 @@ func TestOutputFromBlocks(t *testing.T) { } // when - _, _, _, err := internal.OutputFromBlocks(blocks) + _, _, err := internal.OutputFromBlocks(blocks) // then require.ErrorContains(t, err, "output content indentation") @@ -190,7 +187,7 @@ func TestOutputFromBlocks(t *testing.T) { } // when - _, _, _, err := internal.OutputFromBlocks(blocks) + _, _, err := internal.OutputFromBlocks(blocks) // then require.ErrorContains(t, err, "output end marker indentation") @@ -203,23 +200,21 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, indent, consumed, err := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(blocks) // then require.NoError(t, err) - assert.Equal(t, "", indent) - assert.Equal(t, "", output.Render(indent)) + assert.Equal(t, "", output.Render("")) assert.Equal(t, 0, consumed) }) t.Run("empty blocks returns zero value and zero consumed", func(t *testing.T) { // when - output, indent, consumed, err := internal.OutputFromBlocks(nil) + output, consumed, err := internal.OutputFromBlocks(nil) // then require.NoError(t, err) - assert.Equal(t, "", indent) - assert.Equal(t, "", output.Render(indent)) + assert.Equal(t, "", output.Render("")) assert.Equal(t, 0, consumed) }) @@ -231,7 +226,7 @@ func TestOutputFromBlocks(t *testing.T) { } // when - _, _, _, err := internal.OutputFromBlocks(blocks) + _, _, err := internal.OutputFromBlocks(blocks) // then require.ErrorContains(t, err, "unclosed output block") From d2f23cbe6461098d7289dca5aca5a7c9856aabc7 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Fri, 1 May 2026 16:43:14 -0600 Subject: [PATCH 16/25] Handle indented output in input markdown --- internal/block.go | 5 +++++ internal/cell.go | 24 +++++++++++++++++------- internal/cell_test.go | 28 ++++++++++++++++++++++++++++ internal/output.go | 8 +++++++- internal/output_test.go | 11 +++++++++++ internal/testdata/input.md | 22 +++++++++++++++++++++- internal/testdata/output.md | 22 +++++++++++++++++++++- 7 files changed, 110 insertions(+), 10 deletions(-) diff --git a/internal/block.go b/internal/block.go index 8d64c5d..a33688d 100644 --- a/internal/block.go +++ b/internal/block.go @@ -112,6 +112,11 @@ func (c *blockCollector) collectBlockQuote( indent []byte, stripPrefix []byte, ) { + c.appendTextGap(node.StartByte(), stripPrefix, indent) + if c.pos < node.StartByte() { + c.pos = node.StartByte() + } + childIndent := concatenate(indent, []byte("> ")) childStripPrefix := concatenate(stripPrefix, []byte("> ")) for i := 0; i < int(node.ChildCount()); i++ { diff --git a/internal/cell.go b/internal/cell.go index 24153bf..803e6aa 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -119,19 +119,22 @@ func renderStaticBlock(b Block) string { lines := strings.Split(b.content, "\n") var rendered strings.Builder renderedIndent := renderIndent(b.indent) + blankLineIndent := renderBlankLineIndent(b.indent) for i, line := range lines { if i == len(lines)-1 && len(line) == 0 { break } if i > 0 { rendered.WriteByte('\n') - if len(line) > 0 { - rendered.WriteString(renderedIndent) - } - } else { - if len(line) > 0 && !b.continuation { - rendered.WriteString(b.indent) - } + } + if len(line) == 0 { + rendered.WriteString(blankLineIndent) + continue + } + if i > 0 { + rendered.WriteString(renderedIndent) + } else if !b.continuation { + rendered.WriteString(b.indent) } rendered.WriteString(line) } @@ -141,6 +144,13 @@ func renderStaticBlock(b Block) string { return rendered.String() } +func renderBlankLineIndent(indent string) string { + if idx := strings.LastIndex(indent, ">"); idx >= 0 { + return indent[:idx+1] + } + return "" +} + func renderIndent(indent string) string { if idx := strings.LastIndex(indent, "> "); idx >= 0 { prefixLen := idx + len("> ") diff --git a/internal/cell_test.go b/internal/cell_test.go index a711cfa..06fd766 100644 --- a/internal/cell_test.go +++ b/internal/cell_test.go @@ -447,6 +447,34 @@ func TestClassify(t *testing.T) { }, }, }, + { + name: "FencedCode/litdoc/bash/blockquote-with-output", + blocks: []internal.Block{ + code("> ", joinLines( + "```bash | litdoc", + "echo hello", + "```", + "", + ), false), + text("> ", "\n", false), + cmnt("> ", internal.OutputBeginMarker, false), + text("> ", "hello\n", false), + cmnt("> ", internal.OutputEndMarker, false), + }, + want: []wantCell{ + { + kind: "BashCell", rendered: joinLines( + "> ```bash | litdoc", + "> echo hello", + "> ```", + ">", + "> "+internal.OutputBeginMarker, + "> hello", + "> "+internal.OutputEndMarker+"\n", + ), + }, + }, + }, { name: "FencedCode/litdoc/unsupported-language", blocks: []internal.Block{ diff --git a/internal/output.go b/internal/output.go index 81d606c..0cede9a 100644 --- a/internal/output.go +++ b/internal/output.go @@ -29,7 +29,13 @@ func (o Output) Render(indent string) string { return "\n" + OutputBeginMarker + "\n" + o.content + "\n" + OutputEndMarker + "\n" } var buf strings.Builder - buf.WriteString("\n" + indent + OutputBeginMarker + "\n") + if blankLineIndent := renderBlankLineIndent(indent); blankLineIndent != "" { + buf.WriteString(blankLineIndent) + buf.WriteByte('\n') + } else { + buf.WriteByte('\n') + } + buf.WriteString(indent + OutputBeginMarker + "\n") for _, line := range strings.Split(o.content, "\n") { buf.WriteString(indent + line + "\n") } diff --git a/internal/output_test.go b/internal/output_test.go index 4f35acb..17f5afd 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -90,6 +90,17 @@ func TestOutput_Render(t *testing.T) { " "+internal.OutputEndMarker+"\n", ), }, + { + "blockquote content", + "hello\n", + "> ", + joinLines( + ">", + "> "+internal.OutputBeginMarker, + "> hello", + "> "+internal.OutputEndMarker+"\n", + ), + }, } for _, tt := range tests { diff --git a/internal/testdata/input.md b/internal/testdata/input.md index d84bcfb..736743e 100644 --- a/internal/testdata/input.md +++ b/internal/testdata/input.md @@ -38,7 +38,7 @@ echo "hello, world" echo "something to run" --> -- Fenced code block with previously generate output +- Fenced code block with previously generated output ```bash | litdoc echo "hello, world" @@ -53,3 +53,23 @@ output ```bash | litdoc echo "hello, world" ``` + +- Indented code block with previously generated output + + ```bash | litdoc + echo "hello, world" + ``` + + + output + + +> Block quoted fenced code block with previously generated output +> +> ```bash | litdoc +> echo "hello, world" +> ``` +> +> +> output +> diff --git a/internal/testdata/output.md b/internal/testdata/output.md index 95f0649..251ad21 100644 --- a/internal/testdata/output.md +++ b/internal/testdata/output.md @@ -46,7 +46,7 @@ echo "something to run" output -- Fenced code block with previously generate output +- Fenced code block with previously generated output ```bash | litdoc echo "hello, world" @@ -65,3 +65,23 @@ output output + +- Indented code block with previously generated output + + ```bash | litdoc + echo "hello, world" + ``` + + + output + + +> Block quoted fenced code block with previously generated output +> +> ```bash | litdoc +> echo "hello, world" +> ``` +> +> +> output +> From 46fe595e395d5fbdda9aa545b985fbe53325fedd Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 4 May 2026 11:08:42 -0600 Subject: [PATCH 17/25] Extend TestProcessFile to test idempotency --- internal/cell.go | 4 +- internal/file_test.go | 36 +++--- internal/output.go | 30 ++++- internal/output_test.go | 23 ++++ internal/testdata/input.md | 130 ++++++++++++++++++++++ internal/testdata/output.md | 214 ++++++++++++++++++++++++++++++++++++ 6 files changed, 420 insertions(+), 17 deletions(-) diff --git a/internal/cell.go b/internal/cell.go index 803e6aa..6122be5 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -49,7 +49,7 @@ func (c BashCell) Execute() (Cell, error) { } func (c BashCell) Render() (string, error) { - return c.fencedCode + c.output.Render(c.indent), nil + return c.fencedCode + c.output.Render(renderIndent(c.indent)), nil } type InfoString struct { @@ -89,7 +89,7 @@ func Classify(blocks []Block) ([]Cell, error) { case info.Litdoc && info.Lang == "bash": output, consumed, err := OutputFromBlocks(blocks[i+1:]) if err != nil { - return nil, err + return nil, fmt.Errorf("parsing output: %w", err) } cells = append(cells, BashCell{ fencedCode: renderStaticBlock(b), diff --git a/internal/file_test.go b/internal/file_test.go index 915421c..2de4c52 100644 --- a/internal/file_test.go +++ b/internal/file_test.go @@ -18,22 +18,32 @@ var renderInput []byte var renderOutput []byte func TestProcessFile(t *testing.T) { - f, err := os.CreateTemp(t.TempDir(), "*.md") - if err != nil { - t.Fatal(err) - } - if _, err := f.Write(renderInput); err != nil { - t.Fatal(err) + tests := []struct { + name string + input []byte + want []byte + }{ + {"input to output", renderInput, renderOutput}, + {"output to output", renderOutput, renderOutput}, } - f.Close() - got, err := internal.ProcessFile(f.Name()) - if err != nil { - t.Fatalf("ProcessFile: %v", err) - } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + f, err := os.CreateTemp(t.TempDir(), "*.md") + require.NoError(t, err) + _, err = f.Write(tt.input) + require.NoError(t, err) + err = f.Close() + require.NoError(t, err) + + // when + got, err := internal.ProcessFile(f.Name()) - if got != string(renderOutput) { - t.Errorf("output mismatch\ngot:\n%s\nwant:\n%s", got, renderOutput) + // then + require.NoError(t, err) + assert.Equal(t, string(tt.want), got) + }) } } diff --git a/internal/output.go b/internal/output.go index 0cede9a..036ff55 100644 --- a/internal/output.go +++ b/internal/output.go @@ -66,24 +66,32 @@ func OutputFromBlocks(blocks []Block) (Output, int, error) { } indent := blocks[i].indent i++ + i = skipOutputMarkerLineRemainder(blocks, i) var buf strings.Builder for i < len(blocks) { + if isWhitespaceBeforeOutputEnd(blocks, i) { + i++ + continue + } if isOutputEnd(blocks[i]) { if blocks[i].indent != indent { return Output{}, 0, fmt.Errorf( - "output end marker indentation: got %q, want %q", + "output end marker indentation: got %q for content %q, want %q", blocks[i].indent, + blocks[i].content, indent, ) } i++ + i = skipOutputMarkerLineRemainder(blocks, i) return MakeOutput(buf.String()), i, nil } if blocks[i].indent != indent { return Output{}, 0, fmt.Errorf( - "output content indentation: got %q, want %q", + "output content indentation: got %q for content %q, want %q", blocks[i].indent, + blocks[i].content, indent, ) } @@ -93,3 +101,21 @@ func OutputFromBlocks(blocks []Block) (Output, int, error) { return Output{}, 0, fmt.Errorf("unclosed output block: missing %q", OutputEndMarker) } + +func skipOutputMarkerLineRemainder(blocks []Block, i int) int { + if i < len(blocks) && + blocks[i].kind == BlockKindText && + blocks[i].continuation && + strings.TrimSpace(blocks[i].content) == "" { + return i + 1 + } + return i +} + +func isWhitespaceBeforeOutputEnd(blocks []Block, i int) bool { + return i+1 < len(blocks) && + blocks[i].kind == BlockKindText && + !strings.Contains(blocks[i].content, "\n") && + strings.TrimSpace(blocks[i].content) == "" && + isOutputEnd(blocks[i+1]) +} diff --git a/internal/output_test.go b/internal/output_test.go index 17f5afd..175c441 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -174,6 +174,29 @@ func TestOutputFromBlocks(t *testing.T) { assert.Equal(t, 3, consumed) }) + t.Run("inline marker line remainders are consumed", func(t *testing.T) { + // given + blocks := []internal.Block{ + text("", "\n", false), + text("", " ", false), + cmnt("", internal.OutputBeginMarker, false), + text("", "\n", true), + text("", " output\n", false), + text("", " ", false), + cmnt("", internal.OutputEndMarker, false), + text("", "\n", true), + text("", "\n", false), + } + + // when + output, consumed, err := internal.OutputFromBlocks(blocks) + + // then + require.NoError(t, err) + assert.Equal(t, wantOutput("", " output"), output.Render("")) + assert.Equal(t, 8, consumed) + }) + t.Run("indented output content must match marker indent", func(t *testing.T) { // given blocks := []internal.Block{ diff --git a/internal/testdata/input.md b/internal/testdata/input.md index 736743e..2fb75d9 100644 --- a/internal/testdata/input.md +++ b/internal/testdata/input.md @@ -73,3 +73,133 @@ output > > output > + +### Fenced code block indentation cases + +Fenced code block indented one space: + + ```bash | litdoc + echo "hello, world" + ``` + +Fenced code block indented three spaces: + + ```bash | litdoc + echo "hello, world" + ``` + +> Block quoted fenced code block +> +> ```bash | litdoc +> echo "hello, world" +> ``` + +> Nested block quoted fenced code block +> +> > ```bash | litdoc +> > echo "hello, world" +> > ``` + +Fenced code block in an unordered list: + +- ```bash | litdoc + echo "hello, world" + ``` + +Fenced code block in a nested unordered list: + + - ```bash | litdoc + echo "hello, world" + ``` + +Fenced code block in a plus list with a tilde fence: + ++ ~~~bash | litdoc + echo "hello, world" + ~~~ + +Fenced code block in an ordered list: + +2. ```bash | litdoc + echo "hello, world" + ``` + +Fenced code block in a nested ordered list: + + 1. ```bash | litdoc + echo "hello, world" + ``` + +Fenced code block in a list blockquote: + + > ```bash | litdoc + > echo "hello, world" + > ``` + +> Fenced code block in a blockquote nested list: +> +> - ```bash | litdoc +> echo "hello, world" +> ``` + +> Fenced code block in a blockquote ordered list: +> +> 1. ```bash | litdoc +> echo "hello, world" +> ``` + +### HTML comment indentation cases + +> Block quoted HTML comment +> +> + +> Nested block quoted HTML comment +> +> > + +HTML comment in an unordered list: + +- + +HTML comment in a nested unordered list: + + - + +HTML comment in a star list: + +* + +HTML comment in an ordered list: + +2. + +> HTML comment in a blockquote list: +> +> - + +> HTML comment in a nested blockquote list: +> +> > - + +HTML comment in an ordered-list blockquote: + + > diff --git a/internal/testdata/output.md b/internal/testdata/output.md index 251ad21..5e08ea5 100644 --- a/internal/testdata/output.md +++ b/internal/testdata/output.md @@ -85,3 +85,217 @@ output > > output > + +### Fenced code block indentation cases + +Fenced code block indented one space: + + ```bash | litdoc + echo "hello, world" + ``` + + + output + + +Fenced code block indented three spaces: + + ```bash | litdoc + echo "hello, world" + ``` + + + output + + +> Block quoted fenced code block +> +> ```bash | litdoc +> echo "hello, world" +> ``` +> +> +> output +> + +> Nested block quoted fenced code block +> +> > ```bash | litdoc +> > echo "hello, world" +> > ``` +> > +> > +> > output +> > + +Fenced code block in an unordered list: + +- ```bash | litdoc + echo "hello, world" + ``` + + + output + + +Fenced code block in a nested unordered list: + + - ```bash | litdoc + echo "hello, world" + ``` + + + output + + +Fenced code block in a plus list with a tilde fence: + ++ ~~~bash | litdoc + echo "hello, world" + ~~~ + + + output + + +Fenced code block in an ordered list: + +2. ```bash | litdoc + echo "hello, world" + ``` + + + output + + +Fenced code block in a nested ordered list: + + 1. ```bash | litdoc + echo "hello, world" + ``` + + + output + + +Fenced code block in a list blockquote: + + > ```bash | litdoc + > echo "hello, world" + > ``` + > + > + > output + > + +> Fenced code block in a blockquote nested list: +> +> - ```bash | litdoc +> echo "hello, world" +> ``` +> +> +> output +> + +> Fenced code block in a blockquote ordered list: +> +> 1. ```bash | litdoc +> echo "hello, world" +> ``` +> +> +> output +> + +### HTML comment indentation cases + +> Block quoted HTML comment +> +> +> +> +> output +> + +> Nested block quoted HTML comment +> +> > +> > +> > +> > output +> > + +HTML comment in an unordered list: + +- + + + output + + +HTML comment in a nested unordered list: + + - + + + output + + +HTML comment in a star list: + +* + + + output + + +HTML comment in an ordered list: + +2. + + + output + + +> HTML comment in a blockquote list: +> +> - +> +> +> output +> + +> HTML comment in a nested blockquote list: +> +> > - +> > +> > +> > output +> > + +HTML comment in an ordered-list blockquote: + + > + > + > + > output + > From d77db1daf4e284a2124e0c41dc3b1e64270fea3a Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 4 May 2026 12:30:21 -0600 Subject: [PATCH 18/25] Simplify Output implementation --- internal/cell.go | 4 ++-- internal/output.go | 30 ++++++++++++++---------------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/internal/cell.go b/internal/cell.go index 6122be5..6c4b092 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -119,7 +119,7 @@ func renderStaticBlock(b Block) string { lines := strings.Split(b.content, "\n") var rendered strings.Builder renderedIndent := renderIndent(b.indent) - blankLineIndent := renderBlankLineIndent(b.indent) + blankLineIndent := blankBlockQuoteLinePrefix(b.indent) for i, line := range lines { if i == len(lines)-1 && len(line) == 0 { break @@ -144,7 +144,7 @@ func renderStaticBlock(b Block) string { return rendered.String() } -func renderBlankLineIndent(indent string) string { +func blankBlockQuoteLinePrefix(indent string) string { if idx := strings.LastIndex(indent, ">"); idx >= 0 { return indent[:idx+1] } diff --git a/internal/output.go b/internal/output.go index 036ff55..6e45214 100644 --- a/internal/output.go +++ b/internal/output.go @@ -18,28 +18,20 @@ func MakeOutput(content string) Output { return Output{content: content} } -func (o Output) Render(indent string) string { +func (o Output) Render(linePrefix string) string { if o.content == "" { return "" } - o.content = strings.TrimSuffix(o.content, "\n") - - if indent == "" { - return "\n" + OutputBeginMarker + "\n" + o.content + "\n" + OutputEndMarker + "\n" - } var buf strings.Builder - if blankLineIndent := renderBlankLineIndent(indent); blankLineIndent != "" { - buf.WriteString(blankLineIndent) - buf.WriteByte('\n') - } else { - buf.WriteByte('\n') + buf.WriteString(blankBlockQuoteLinePrefix(linePrefix)) + buf.WriteByte('\n') + buf.WriteString(linePrefix + OutputBeginMarker + "\n") + for _, line := range strings.Split(strings.TrimSuffix(o.content, "\n"), "\n") { + buf.WriteString(linePrefix + line + "\n") } - buf.WriteString(indent + OutputBeginMarker + "\n") - for _, line := range strings.Split(o.content, "\n") { - buf.WriteString(indent + line + "\n") - } - buf.WriteString(indent + OutputEndMarker + "\n") + buf.WriteString(linePrefix + OutputEndMarker + "\n") + return buf.String() } @@ -66,10 +58,14 @@ func OutputFromBlocks(blocks []Block) (Output, int, error) { } indent := blocks[i].indent i++ + // Inline HTML comments can leave a whitespace-only continuation block for + // the rest of the marker line. It is parser residue, not output content. i = skipOutputMarkerLineRemainder(blocks, i) var buf strings.Builder for i < len(blocks) { + // A space before an inline-split end marker is emitted as a separate + // text block, not as marker indentation. Drop it before matching END. if isWhitespaceBeforeOutputEnd(blocks, i) { i++ continue @@ -84,6 +80,8 @@ func OutputFromBlocks(blocks []Block) (Output, int, error) { ) } i++ + // Consume the newline or trailing whitespace after an inline end + // marker so callers resume at the next meaningful block. i = skipOutputMarkerLineRemainder(blocks, i) return MakeOutput(buf.String()), i, nil } From 31d1c6b9904e48fd7783b0f98430531057114de0 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 4 May 2026 13:28:48 -0600 Subject: [PATCH 19/25] Validate litdoc output indentation --- internal/block.go | 57 +++++++++++++- internal/block_test.go | 23 ++++++ internal/cell.go | 2 +- internal/output.go | 158 +++++++++++++++++++++++++++++++------ internal/output_test.go | 170 ++++++++++++++++++++++++++++++++-------- 5 files changed, 349 insertions(+), 61 deletions(-) diff --git a/internal/block.go b/internal/block.go index a33688d..8f3588a 100644 --- a/internal/block.go +++ b/internal/block.go @@ -148,7 +148,11 @@ func (c *blockCollector) collectLeaf(node *sitter.Node, indent, stripPrefix []by end := node.EndByte() blockIndent, blockStripPrefix := blockPrefixes(node, c.content, indent, stripPrefix) - c.appendTextGap(start, stripPrefix, indent) + gapStripPrefix := stripPrefix + if isOutputMarkerRaw(c.content[start:end]) { + gapStripPrefix = blockStripPrefix + } + c.appendTextGap(start, gapStripPrefix, blockIndent) raw := stripIndent(c.content[start:end], blockStripPrefix) kind := blockKind(node, c.content) @@ -176,6 +180,9 @@ func (c *blockCollector) appendTextGap(end uint32, stripPrefix, indent []byte) { return } gap := stripIndent(c.content[c.pos:end], stripPrefix) + if len(bytes.TrimSpace(gap)) == 0 { + indent = []byte(renderIndent(string(indent))) + } c.appendBlock(BlockKindText, gap, indent) } @@ -247,6 +254,14 @@ func blockPrefixes( if len(linePrefix) > 0 && !bytes.Equal(linePrefix, indent) { blockIndent = linePrefix blockStripPrefix = linePrefix + if normalizedIndent, ok := normalizeHTMLCommentPrefix( + content[start:end], + linePrefix, + indent, + ); ok { + blockIndent = normalizedIndent + return blockIndent, blockStripPrefix + } if isSpaceIndent(linePrefix) { rawLeading := leadingLineSpaces(content[start:end]) if len(rawLeading) > 0 { @@ -271,6 +286,26 @@ func blockPrefixes( return blockIndent, blockStripPrefix } +func normalizeHTMLCommentPrefix(raw, linePrefix, containerIndent []byte) ([]byte, bool) { + renderedIndent := []byte(renderIndent(string(containerIndent))) + if !bytes.HasPrefix(linePrefix, renderedIndent) { + return nil, false + } + if len(bytes.Trim(linePrefix[len(renderedIndent):], " ")) > 0 { + return nil, false + } + if !isOutputMarkerRaw(raw) { + return nil, false + } + return renderedIndent, true +} + +func isOutputMarkerRaw(raw []byte) bool { + raw = bytes.TrimLeft(raw, " ") + return bytes.HasPrefix(raw, []byte(OutputBeginMarker)) || + bytes.HasPrefix(raw, []byte(OutputEndMarker)) +} + func isClosedFencedCodeBlock(content []byte) bool { openLineEnd := bytes.IndexByte(content, '\n') if openLineEnd < 0 { @@ -479,7 +514,9 @@ func splitInlineHTMLComments(blocks []Block) []Block { } pos := 0 for _, loc := range locs { - if loc[0] > pos { + wholeBlock := isWholeTextBlock(content, loc) + outputMarker := isOutputMarkerContent(content[loc[0]:loc[1]]) + if loc[0] > pos && !isWholeBlockPrefix(content[pos:loc[0]], wholeBlock, outputMarker) { result = append( result, MakeBlockFromRaw( @@ -490,15 +527,18 @@ func splitInlineHTMLComments(blocks []Block) []Block { ), ) } - wholeBlock := isWholeTextBlock(content, loc) commentContinuation := b.continuation if !wholeBlock { commentContinuation = b.continuation || loc[0] > 0 } + commentIndent := b.indent + if wholeBlock && outputMarker && b.indent == "" { + commentIndent = content[pos:loc[0]] + } result = append(result, MakeBlockFromRaw( BlockKindHTMLComment, content[loc[0]:loc[1]], - b.indent, + commentIndent, commentContinuation, )) pos = loc[1] @@ -513,6 +553,15 @@ func splitInlineHTMLComments(blocks []Block) []Block { return result } +func isWholeBlockPrefix(content string, wholeBlock, outputMarker bool) bool { + return wholeBlock && outputMarker && strings.TrimSpace(content) == "" +} + +func isOutputMarkerContent(content string) bool { + return strings.HasPrefix(content, OutputBeginMarker) || + strings.HasPrefix(content, OutputEndMarker) +} + func isWholeTextBlock(content string, loc []int) bool { return len(strings.TrimSpace(content[:loc[0]])) == 0 && len(strings.TrimSpace(content[loc[1]:])) == 0 diff --git a/internal/block_test.go b/internal/block_test.go index 20cf9a8..274188d 100644 --- a/internal/block_test.go +++ b/internal/block_test.go @@ -417,6 +417,15 @@ func TestMakeBlocksFromMarkdown(t *testing.T) { cmnt("", "", true), }, }, + { + name: "HTMLComment/top-level/leading-spaces", + input: " \n", + want: []internal.Block{ + text("", " ", false), + cmnt("", "", false), + text("", "\n", true), + }, + }, { name: "HTMLComment/top-level/block", input: joinLines( @@ -524,6 +533,20 @@ func TestMakeBlocksFromMarkdown(t *testing.T) { text("- ", "text", false), }, }, + { + name: "HTMLComment/list/dash/blank-before-indented-block", + input: joinLines( + "- item", + "", + " ", + ) + "\n", + want: []internal.Block{ + text("- ", "item\n", false), + text(" ", "\n", false), + cmnt(" ", "", false), + text(" ", "\n", true), + }, + }, { name: "HTMLComment/list/dash/nested/block", input: joinLines( diff --git a/internal/cell.go b/internal/cell.go index 6c4b092..5d618a4 100644 --- a/internal/cell.go +++ b/internal/cell.go @@ -87,7 +87,7 @@ func Classify(blocks []Block) ([]Cell, error) { info := ParseInfoString(b) switch { case info.Litdoc && info.Lang == "bash": - output, consumed, err := OutputFromBlocks(blocks[i+1:]) + output, consumed, err := OutputFromBlocks(b, blocks[i+1:]) if err != nil { return nil, fmt.Errorf("parsing output: %w", err) } diff --git a/internal/output.go b/internal/output.go index 6e45214..735ed16 100644 --- a/internal/output.go +++ b/internal/output.go @@ -45,18 +45,33 @@ func isOutputEnd(b Block) bool { strings.HasPrefix(b.content, OutputEndMarker) } -func OutputFromBlocks(blocks []Block) (Output, int, error) { +func OutputFromBlocks(litdoc Block, blocks []Block) (Output, int, error) { + // Output belongs to the preceding litdoc block, so its lines must use the + // indentation that rendering that block would use for following content. + outputIndent := renderIndent(litdoc.indent) i := 0 - for i < len(blocks) && - blocks[i].kind == BlockKindText && - strings.TrimSpace(blocks[i].content) == "" { + + var err error + i, err = skipWhitespaceLines(blocks, i, litdoc.indent, outputIndent) + if err != nil { + return Output{}, 0, err + } + + beginLinePrefix := false + if isWhitespaceBeforeOutputBegin(blocks, i) { + if err := validateInlineMarkerPrefix(blocks[i], outputIndent, "begin"); err != nil { + return Output{}, 0, err + } + beginLinePrefix = true i++ } if i >= len(blocks) || !isOutputBegin(blocks[i]) { return Output{}, 0, nil } - indent := blocks[i].indent + if err := validateOutputMarkerIndent(blocks[i], outputIndent, beginLinePrefix, "begin"); err != nil { + return Output{}, 0, err + } i++ // Inline HTML comments can leave a whitespace-only continuation block for // the rest of the marker line. It is parser residue, not output content. @@ -65,19 +80,19 @@ func OutputFromBlocks(blocks []Block) (Output, int, error) { var buf strings.Builder for i < len(blocks) { // A space before an inline-split end marker is emitted as a separate - // text block, not as marker indentation. Drop it before matching END. + // text block, not as marker indentation. Validate and consume it before + // matching END. + endLinePrefix := false if isWhitespaceBeforeOutputEnd(blocks, i) { + if err := validateInlineMarkerPrefix(blocks[i], outputIndent, "end"); err != nil { + return Output{}, 0, err + } + endLinePrefix = true i++ - continue } if isOutputEnd(blocks[i]) { - if blocks[i].indent != indent { - return Output{}, 0, fmt.Errorf( - "output end marker indentation: got %q for content %q, want %q", - blocks[i].indent, - blocks[i].content, - indent, - ) + if err := validateOutputMarkerIndent(blocks[i], outputIndent, endLinePrefix, "end"); err != nil { + return Output{}, 0, err } i++ // Consume the newline or trailing whitespace after an inline end @@ -85,21 +100,120 @@ func OutputFromBlocks(blocks []Block) (Output, int, error) { i = skipOutputMarkerLineRemainder(blocks, i) return MakeOutput(buf.String()), i, nil } - if blocks[i].indent != indent { - return Output{}, 0, fmt.Errorf( - "output content indentation: got %q for content %q, want %q", - blocks[i].indent, - blocks[i].content, - indent, - ) + content, err := outputContent(blocks[i], outputIndent) + if err != nil { + return Output{}, 0, err } - buf.WriteString(blocks[i].content) + buf.WriteString(content) i++ } return Output{}, 0, fmt.Errorf("unclosed output block: missing %q", OutputEndMarker) } +func isWhitespaceText(b Block) bool { + return b.kind == BlockKindText && strings.TrimSpace(b.content) == "" +} + +func skipWhitespaceLines(blocks []Block, i int, litdocIndent, outputIndent string) (int, error) { + for i < len(blocks) && + isWhitespaceText(blocks[i]) && + strings.Contains(blocks[i].content, "\n") { + if err := validateOutputBlankLineIndent(blocks[i], litdocIndent, outputIndent); err != nil { + return 0, err + } + i++ + } + return i, nil +} + +func validateOutputBlankLineIndent(b Block, litdocIndent, outputIndent string) error { + if litdocIndent == outputIndent || b.indent != litdocIndent { + return nil + } + return fmt.Errorf( + "output blank line indentation: got %q for content %q, want %q", + b.indent, + b.content, + outputIndent, + ) +} + +func isWhitespaceBeforeOutputBegin(blocks []Block, i int) bool { + return i+1 < len(blocks) && + blocks[i].kind == BlockKindText && + !strings.Contains(blocks[i].content, "\n") && + strings.TrimSpace(blocks[i].content) == "" && + isOutputBegin(blocks[i+1]) +} + +func validateInlineMarkerPrefix(b Block, indent, marker string) error { + if b.content == indent { + return nil + } + return fmt.Errorf( + "output %s marker indentation: got %q for content %q, want %q", + marker, + b.content, + b.content, + indent, + ) +} + +func validateOutputMarkerIndent(b Block, indent string, inlinePrefix bool, marker string) error { + want := indent + if inlinePrefix { + want = "" + } + if b.indent == want { + return nil + } + return fmt.Errorf( + "output %s marker indentation: got %q for content %q, want %q", + marker, + b.indent, + b.content, + indent, + ) +} + +func outputContent(b Block, indent string) (string, error) { + if b.indent == indent { + return b.content, nil + } + if b.indent == "" && isSpaceIndent([]byte(indent)) { + if content, ok := stripLinePrefix(b.content, indent); ok { + return content, nil + } + } + return "", fmt.Errorf( + "output content indentation: got %q for content %q, want %q", + b.indent, + b.content, + indent, + ) +} + +func stripLinePrefix(content, prefix string) (string, bool) { + if prefix == "" { + return content, true + } + + var buf strings.Builder + for len(content) > 0 { + line := content + if i := strings.IndexByte(content, '\n'); i >= 0 { + line = content[:i+1] + } + if !strings.HasPrefix(line, prefix) { + return "", false + } + buf.WriteString(strings.TrimPrefix(line, prefix)) + content = content[len(line):] + } + return buf.String(), true +} + func skipOutputMarkerLineRemainder(blocks []Block, i int) int { if i < len(blocks) && blocks[i].kind == BlockKindText && diff --git a/internal/output_test.go b/internal/output_test.go index 175c441..d93e2f7 100644 --- a/internal/output_test.go +++ b/internal/output_test.go @@ -40,16 +40,16 @@ func TestOutput_Render(t *testing.T) { want string }{ { - "empty", - "", - "", - "", + name: "empty", + content: "", + indent: "", + want: "", }, { - "wrap content in markers", - "hello\n", - "", - joinLines( + name: "wrap content in markers", + content: "hello\n", + indent: "", + want: joinLines( "", internal.OutputBeginMarker, "hello", @@ -57,10 +57,10 @@ func TestOutput_Render(t *testing.T) { ), }, { - "ensure content rendered with trailing newline", - "hello", - "", - joinLines( + name: "ensure content rendered with trailing newline", + content: "hello", + indent: "", + want: joinLines( "", internal.OutputBeginMarker, "hello", @@ -68,10 +68,10 @@ func TestOutput_Render(t *testing.T) { ), }, { - "multiline content", - "hello\nworld", - "", - joinLines( + name: "multiline content", + content: "hello\nworld", + indent: "", + want: joinLines( "", internal.OutputBeginMarker, "hello", @@ -80,10 +80,10 @@ func TestOutput_Render(t *testing.T) { ), }, { - "indent content", - "hello\n", - " ", - joinLines( + name: "indent content", + content: "hello\n", + indent: " ", + want: joinLines( "", " "+internal.OutputBeginMarker, " hello", @@ -91,10 +91,10 @@ func TestOutput_Render(t *testing.T) { ), }, { - "blockquote content", - "hello\n", - "> ", - joinLines( + name: "blockquote content", + content: "hello\n", + indent: "> ", + want: joinLines( ">", "> "+internal.OutputBeginMarker, "> hello", @@ -124,6 +124,7 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("output block is scanned in", func(t *testing.T) { // given + litdoc := code("", "```bash | litdoc\n```\n", false) blocks := []internal.Block{ cmnt("", internal.OutputBeginMarker, false), text("", "hello\n", false), @@ -131,7 +132,7 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, consumed, err := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) // then require.NoError(t, err) @@ -141,6 +142,7 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("leading whitespace blocks are skipped", func(t *testing.T) { // given + litdoc := code("", "```bash | litdoc\n```\n", false) blocks := []internal.Block{ text("", "\n", false), cmnt("", internal.OutputBeginMarker, false), @@ -149,7 +151,7 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, consumed, err := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) // then require.NoError(t, err) @@ -159,6 +161,7 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("indented output block is scanned in", func(t *testing.T) { // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) blocks := []internal.Block{ cmnt(" ", internal.OutputBeginMarker, false), text(" ", "hello\nworld\n", false), @@ -166,7 +169,7 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, consumed, err := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) // then require.NoError(t, err) @@ -176,6 +179,7 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("inline marker line remainders are consumed", func(t *testing.T) { // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) blocks := []internal.Block{ text("", "\n", false), text("", " ", false), @@ -189,16 +193,76 @@ func TestOutputFromBlocks(t *testing.T) { } // when - output, consumed, err := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) // then require.NoError(t, err) - assert.Equal(t, wantOutput("", " output"), output.Render("")) + assert.Equal(t, wantOutput(" ", "output"), output.Render(" ")) assert.Equal(t, 8, consumed) }) + t.Run("parser-space-prefixed output is normalized", func(t *testing.T) { + // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + text("", "\n", false), + text("", " ", false), + cmnt("", internal.OutputBeginMarker, false), + text("", "\n", true), + text("", " hello\n world\n", false), + text("", " ", false), + cmnt("", internal.OutputEndMarker, false), + text("", "\n", true), + } + + // when + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.NoError(t, err) + assert.Equal(t, wantOutput(" ", "hello\nworld"), output.Render(" ")) + assert.Equal(t, 8, consumed) + }) + + t.Run("list item output uses rendered indentation", func(t *testing.T) { + // given + litdoc := code("- ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + text(" ", "\n", false), + cmnt(" ", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), + } + + // when + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.NoError(t, err) + assert.Equal(t, wantOutput(" ", "hello"), output.Render(" ")) + assert.Equal(t, 4, consumed) + }) + + t.Run("list item output blank line must use rendered indentation", func(t *testing.T) { + // given + litdoc := code("- ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + text("- ", "\n", false), + cmnt(" ", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), + } + + // when + _, _, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.ErrorContains(t, err, "output blank line indentation") + }) + t.Run("indented output content must match marker indent", func(t *testing.T) { // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) blocks := []internal.Block{ cmnt(" ", internal.OutputBeginMarker, false), text(" ", "hello\n", false), @@ -206,7 +270,7 @@ func TestOutputFromBlocks(t *testing.T) { } // when - _, _, err := internal.OutputFromBlocks(blocks) + _, _, err := internal.OutputFromBlocks(litdoc, blocks) // then require.ErrorContains(t, err, "output content indentation") @@ -214,6 +278,7 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("indented output end marker must match begin marker indent", func(t *testing.T) { // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) blocks := []internal.Block{ cmnt(" ", internal.OutputBeginMarker, false), text(" ", "hello\n", false), @@ -221,20 +286,53 @@ func TestOutputFromBlocks(t *testing.T) { } // when - _, _, err := internal.OutputFromBlocks(blocks) + _, _, err := internal.OutputFromBlocks(litdoc, blocks) // then require.ErrorContains(t, err, "output end marker indentation") }) + t.Run("output begin marker must match litdoc indent", func(t *testing.T) { + // given + litdoc := code(" ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + cmnt("", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), + } + + // when + _, _, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.ErrorContains(t, err, "output begin marker indentation") + }) + + t.Run("list item output marker must use rendered indentation", func(t *testing.T) { + // given + litdoc := code("- ", "```bash | litdoc\n```\n", false) + blocks := []internal.Block{ + cmnt("- ", internal.OutputBeginMarker, false), + text(" ", "hello\n", false), + cmnt(" ", internal.OutputEndMarker, false), + } + + // when + _, _, err := internal.OutputFromBlocks(litdoc, blocks) + + // then + require.ErrorContains(t, err, "output begin marker indentation") + }) + t.Run("no output block returns zero value and zero consumed", func(t *testing.T) { // given + litdoc := code("", "```bash | litdoc\n```\n", false) blocks := []internal.Block{ text("", "some text\n", false), } // when - output, consumed, err := internal.OutputFromBlocks(blocks) + output, consumed, err := internal.OutputFromBlocks(litdoc, blocks) // then require.NoError(t, err) @@ -243,8 +341,11 @@ func TestOutputFromBlocks(t *testing.T) { }) t.Run("empty blocks returns zero value and zero consumed", func(t *testing.T) { + // given + litdoc := code("", "```bash | litdoc\n```\n", false) + // when - output, consumed, err := internal.OutputFromBlocks(nil) + output, consumed, err := internal.OutputFromBlocks(litdoc, nil) // then require.NoError(t, err) @@ -254,13 +355,14 @@ func TestOutputFromBlocks(t *testing.T) { t.Run("opening marker without closing marker returns error", func(t *testing.T) { // given + litdoc := code("", "```bash | litdoc\n```\n", false) blocks := []internal.Block{ cmnt("", internal.OutputBeginMarker, false), text("", "hello\n", false), } // when - _, _, err := internal.OutputFromBlocks(blocks) + _, _, err := internal.OutputFromBlocks(litdoc, blocks) // then require.ErrorContains(t, err, "unclosed output block") From 133155c2eef3cf8ad5b4d3db61cd9206fde8fe10 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 4 May 2026 13:36:52 -0600 Subject: [PATCH 20/25] Fix validateInlineMarkerPrefix error to report b.indent as "got" The "got" field in the error message was using b.content (the whitespace prefix text) twice. For inline-split marker prefixes, b.indent is the structural indent value that should be reported as the actual value. --- internal/output.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/output.go b/internal/output.go index 735ed16..d2ebcf1 100644 --- a/internal/output.go +++ b/internal/output.go @@ -154,7 +154,7 @@ func validateInlineMarkerPrefix(b Block, indent, marker string) error { return fmt.Errorf( "output %s marker indentation: got %q for content %q, want %q", marker, - b.content, + b.indent, b.content, indent, ) From 74ecad55c8c9ee8cef8e99fcb1b1cedf4b24ddd6 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 4 May 2026 13:37:05 -0600 Subject: [PATCH 21/25] Check f.Close() error in TestProcessFileNestedListIndent --- internal/file_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/file_test.go b/internal/file_test.go index 2de4c52..8e63423 100644 --- a/internal/file_test.go +++ b/internal/file_test.go @@ -54,7 +54,8 @@ func TestProcessFileNestedListIndent(t *testing.T) { require.NoError(t, err) _, err = f.Write([]byte(input)) require.NoError(t, err) - f.Close() + err = f.Close() + require.NoError(t, err) // when got, err := internal.ProcessFile(f.Name()) From 2928f8032bda6e998935ac876356d6481c7dc004 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 4 May 2026 13:37:33 -0600 Subject: [PATCH 22/25] Comment the parser quirks behind skipWhitespaceLines and normalizeHTMLCommentPrefix --- internal/block.go | 4 ++++ internal/output.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/internal/block.go b/internal/block.go index 8f3588a..d6ef63f 100644 --- a/internal/block.go +++ b/internal/block.go @@ -286,6 +286,10 @@ func blockPrefixes( return blockIndent, blockStripPrefix } +// normalizeHTMLCommentPrefix handles a tree-sitter quirk: output markers +// inside list items are reported with a space-only linePrefix (the list +// continuation indent) rather than the rendered block indent. Detect this case +// and return the rendered indent so the marker is classified correctly. func normalizeHTMLCommentPrefix(raw, linePrefix, containerIndent []byte) ([]byte, bool) { renderedIndent := []byte(renderIndent(string(containerIndent))) if !bytes.HasPrefix(linePrefix, renderedIndent) { diff --git a/internal/output.go b/internal/output.go index d2ebcf1..3323f6d 100644 --- a/internal/output.go +++ b/internal/output.go @@ -115,6 +115,10 @@ func isWhitespaceText(b Block) bool { return b.kind == BlockKindText && strings.TrimSpace(b.content) == "" } +// skipWhitespaceLines skips blank-line text blocks between a litdoc cell and +// its output block. Only blocks that contain a newline are skipped here; +// single-line whitespace-only blocks are inline marker prefixes emitted by +// the HTML comment splitter and are handled separately. func skipWhitespaceLines(blocks []Block, i int, litdocIndent, outputIndent string) (int, error) { for i < len(blocks) && isWhitespaceText(blocks[i]) && From 1600298efc612faa232df6b4f6e34f4821a95e8b Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 4 May 2026 13:42:20 -0600 Subject: [PATCH 23/25] Exclude vendor from fmt and fmt-check targets --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 0cfeeae..ecbc794 100644 --- a/Makefile +++ b/Makefile @@ -5,11 +5,11 @@ pre-pr: clean mock fmt-check vet test .PHONY: fmt fmt: - @gofmt -w . + @gofmt -w $(GO_FILES) .PHONY: fmt-check fmt-check: - @unformatted=$$(gofmt -l .); \ + @unformatted=$$(gofmt -l $(GO_FILES)); \ if [ -n "$$unformatted" ]; then \ echo "unformatted files:"; \ echo "$$unformatted"; \ From 97a29a20629dae3a2ae2fcd73b396e862d1b8e12 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 4 May 2026 13:44:42 -0600 Subject: [PATCH 24/25] Add mockery to flake.nix dev shell packages --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index 59db2a8..a5f54c9 100644 --- a/flake.nix +++ b/flake.nix @@ -14,6 +14,7 @@ devShells.default = pkgs.mkShell { packages = with pkgs; [ go + mockery cacert ]; shellHook = '' From b1128b921ae754e782bf8d2f0d74945b913fcc29 Mon Sep 17 00:00:00 2001 From: Mike Wittie Date: Mon, 4 May 2026 13:54:51 -0600 Subject: [PATCH 25/25] Fix mockery nix package name to go-mockery --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index a5f54c9..ca92971 100644 --- a/flake.nix +++ b/flake.nix @@ -14,7 +14,7 @@ devShells.default = pkgs.mkShell { packages = with pkgs; [ go - mockery + go-mockery cacert ]; shellHook = ''