├── .gitattributes ├── .gitignore ├── codecov.yml ├── .github ├── release.yml ├── dependabot.yml ├── workflows │ ├── release.yaml │ ├── tagpr.yaml │ ├── test.yaml │ └── reviewdog.yaml └── actions │ └── release │ └── action.yaml ├── version.go ├── .tagpr ├── go.mod ├── testdata ├── test_config_asterisk_first.yaml ├── test_config_no_cache.yaml ├── test_config.yaml └── stub_image_generator.go ├── cmd └── laminate │ └── main.go ├── template.go ├── go.sum ├── matcher.go ├── LICENSE ├── Makefile ├── CHANGELOG.md ├── template_test.go ├── laminate.go ├── matcher_test.go ├── cache.go ├── config.go ├── SKETCH.md ├── executor.go ├── config_test.go ├── laminate_test.go ├── README.md └── install.sh /.gitattributes: -------------------------------------------------------------------------------- 1 | install.sh linguist-generated 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | dist 3 | laminate 4 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | comment: false 2 | github_checks: false 3 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - tagpr 5 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package laminate 2 | 3 | const version = "0.0.4" 4 | 5 | var revision = "HEAD" 6 | -------------------------------------------------------------------------------- /.tagpr: -------------------------------------------------------------------------------- 1 | [tagpr] 2 | vPrefix = true 3 | releaseBranch = main 4 | versionFile = version.go 5 | release = draft 6 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Songmu/laminate 2 | 3 | go 1.24.6 4 | 5 | require ( 6 | github.com/gobwas/glob v0.2.3 7 | github.com/goccy/go-yaml v1.18.0 8 | github.com/k1LoW/exec v0.4.0 9 | github.com/spf13/pathologize v0.0.0-20241128024251-dd52ec459c9d 10 | ) 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "gomod" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - "v[0-9]+.[0-9]+.[0-9]+" 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: checkout 11 | uses: actions/checkout@v5 12 | - uses: ./.github/actions/release 13 | with: 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /testdata/test_config_asterisk_first.yaml: -------------------------------------------------------------------------------- 1 | cache: 1h 2 | commands: 3 | - lang: go 4 | run: 'go run testdata/stub_image_generator.go -l "{{lang}}" -o "{{output}}" "{{input}}"' 5 | ext: png 6 | - lang: '*' 7 | run: 'go run testdata/stub_image_generator.go -o "{{output}}"' 8 | ext: jpg 9 | - lang: '' 10 | run: 'go run testdata/stub_image_generator.go -o "{{output}}"' 11 | ext: png 12 | -------------------------------------------------------------------------------- /testdata/test_config_no_cache.yaml: -------------------------------------------------------------------------------- 1 | # cache disabled (not specified) 2 | commands: 3 | - lang: go 4 | run: 'go run testdata/stub_image_generator.go -l "{{lang}}" -o "{{output}}" "{{input}}"' 5 | ext: png 6 | - lang: python 7 | run: ['go', 'run', 'testdata/stub_image_generator.go', '-l', '{{lang}}', '-o', '{{output}}', '{{input}}'] 8 | ext: png 9 | - lang: '*' 10 | run: 'go run testdata/stub_image_generator.go -o "{{output}}"' 11 | ext: png 12 | -------------------------------------------------------------------------------- /cmd/laminate/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | "os" 8 | 9 | "github.com/Songmu/laminate" 10 | ) 11 | 12 | func main() { 13 | log.SetFlags(0) 14 | err := laminate.Run(context.Background(), os.Args[1:], os.Stdout, os.Stderr) 15 | if err != nil && err != flag.ErrHelp { 16 | log.Println(err) 17 | exitCode := 1 18 | if ecoder, ok := err.(interface{ ExitCode() int }); ok { 19 | exitCode = ecoder.ExitCode() 20 | } 21 | os.Exit(exitCode) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/actions/release/action.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | description: release laminate 3 | inputs: 4 | tag: 5 | description: tag name to be released 6 | default: '' 7 | token: 8 | description: GitHub token 9 | required: true 10 | runs: 11 | using: composite 12 | steps: 13 | - name: setup go 14 | uses: actions/setup-go@v5 15 | with: 16 | go-version: stable 17 | - name: release 18 | run: | 19 | make crossbuild upload 20 | shell: bash 21 | env: 22 | GITHUB_TOKEN: ${{ inputs.token }} 23 | -------------------------------------------------------------------------------- /template.go: -------------------------------------------------------------------------------- 1 | package laminate 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var templateVarPattern = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9]+)\s*\}\}`) 9 | 10 | // ExpandTemplate expands template variables in a command string 11 | func ExpandTemplate(template string, vars map[string]string) (string, error) { 12 | result := templateVarPattern.ReplaceAllStringFunc(template, func(match string) string { 13 | varName := strings.TrimSpace(match[2 : len(match)-2]) // Remove '{{' and '}}' 14 | if value, ok := vars[varName]; ok { 15 | return value 16 | } 17 | return match 18 | }) 19 | return result, nil 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/tagpr.yaml: -------------------------------------------------------------------------------- 1 | name: tagpr 2 | on: 3 | push: 4 | branches: 5 | - "main" 6 | jobs: 7 | tagpr: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: setup go 11 | uses: actions/setup-go@v5 12 | with: 13 | go-version: stable 14 | - name: checkout 15 | uses: actions/checkout@v5 16 | - name: tagpr 17 | id: tagpr 18 | uses: Songmu/tagpr@v1 19 | env: 20 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 21 | - uses: ./.github/actions/release 22 | with: 23 | tag: ${{ steps.tagpr.outputs.tag }} 24 | token: ${{ secrets.GITHUB_TOKEN }} 25 | if: "steps.tagpr.outputs.tag != ''" 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= 2 | github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= 3 | github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= 4 | github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= 5 | github.com/k1LoW/exec v0.4.0 h1:Wc01vrKXOAa1HfIRiDWcn3p2ebl2qVk+kOLqL7mYBL0= 6 | github.com/k1LoW/exec v0.4.0/go.mod h1:LSd4t5/1qGJHUdB2RUtoHuHfaZ3ks+BfQ+sGHzvwhnE= 7 | github.com/spf13/pathologize v0.0.0-20241128024251-dd52ec459c9d h1:1Brmj8oaj+YFzNYuwzQRYkYVJ5tYyr+JY9W1ZklGkio= 8 | github.com/spf13/pathologize v0.0.0-20241128024251-dd52ec459c9d/go.mod h1:CwE+2y5kdp5EBv1kxhXujQo2SrY0s+rYAM02KluGFEs= 9 | -------------------------------------------------------------------------------- /matcher.go: -------------------------------------------------------------------------------- 1 | package laminate 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/gobwas/glob" 7 | ) 8 | 9 | // FindMatchingCommand finds the first command that matches the given language 10 | func FindMatchingCommand(commands []*Command, lang string) (*Command, error) { 11 | for _, cmd := range commands { 12 | matched, err := matchLanguage(cmd.Lang, lang) 13 | if err != nil { 14 | return nil, fmt.Errorf("failed to match language pattern %q: %w", cmd.Lang, err) 15 | } 16 | if matched { 17 | return cmd, nil 18 | } 19 | } 20 | return nil, fmt.Errorf("no matching command found for language: %s", lang) 21 | } 22 | 23 | // matchLanguage checks if a language matches a pattern 24 | func matchLanguage(pattern, lang string) (bool, error) { 25 | g, err := glob.Compile(pattern) 26 | if err != nil { 27 | return false, err 28 | } 29 | return g.Match(lang), nil 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | push: 4 | branches: 5 | - "**" 6 | jobs: 7 | test: 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | os: 13 | - ubuntu-latest 14 | - macOS-latest 15 | - windows-latest 16 | steps: 17 | - name: Set git to use LF 18 | run: | 19 | git config --global core.autocrlf false 20 | git config --global core.eol lf 21 | if: "matrix.os == 'windows-latest'" 22 | - name: checkout 23 | uses: actions/checkout@v5 24 | - name: setup go 25 | uses: actions/setup-go@v5 26 | with: 27 | go-version: stable 28 | - name: test 29 | run: go test -race -coverprofile coverage.out -covermode atomic ./... 30 | - name: Upload coverage to Codecov 31 | uses: codecov/codecov-action@v5 32 | -------------------------------------------------------------------------------- /testdata/test_config.yaml: -------------------------------------------------------------------------------- 1 | cache: 1h 2 | commands: 3 | - lang: go 4 | run: 'go run testdata/stub_image_generator.go -l "{{lang}}" -o "{{output}}" "{{input}}"' 5 | ext: png 6 | - lang: python 7 | run: ['go', 'run', 'testdata/stub_image_generator.go', '-l', '{{lang}}', '-o', '{{output}}', '{{input}}'] 8 | ext: png 9 | - lang: rust 10 | run: 'go run testdata/stub_image_generator.go -l "{{lang}}" -o "{{output}}"' 11 | ext: jpg 12 | - lang: text 13 | run: 'go run testdata/stub_image_generator.go -o "{{output}}"' 14 | ext: png 15 | - lang: '{java,kotlin}' 16 | run: 'go run testdata/stub_image_generator.go -l "{{lang}}" -o "{{output}}"' 17 | # ext not specified - should default to png 18 | - lang: '' 19 | run: 'go run testdata/stub_image_generator.go -o "{{output}}"' 20 | ext: png 21 | - lang: '*' 22 | run: 'go run testdata/stub_image_generator.go -o "{{output}}"' 23 | ext: png 24 | -------------------------------------------------------------------------------- /.github/workflows/reviewdog.yaml: -------------------------------------------------------------------------------- 1 | name: reviewdog 2 | on: [pull_request] 3 | jobs: 4 | staticcheck: 5 | name: staticcheck 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v5 9 | with: 10 | persist-credentials: false 11 | - uses: reviewdog/action-staticcheck@v1 12 | with: 13 | reporter: github-pr-review 14 | fail_on_error: true 15 | misspell: 16 | name: misspell 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v5 20 | with: 21 | persist-credentials: false 22 | - name: misspell 23 | uses: reviewdog/action-misspell@v1 24 | with: 25 | reporter: github-pr-review 26 | level: warning 27 | locale: "US" 28 | actionlint: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v5 32 | with: 33 | persist-credentials: false 34 | - uses: reviewdog/action-actionlint@v1 35 | with: 36 | reporter: github-pr-review 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Songmu 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION = $(shell godzil show-version) 2 | CURRENT_REVISION = $(shell git rev-parse --short HEAD) 3 | BUILD_LDFLAGS = "-s -w -X github.com/Songmu/laminate.revision=$(CURRENT_REVISION)" 4 | u := $(if $(update),-u) 5 | 6 | .PHONY: deps 7 | deps: 8 | go get ${u} 9 | go mod tidy 10 | 11 | .PHONY: devel-deps 12 | devel-deps: 13 | go install github.com/Songmu/godzil/cmd/godzil@latest 14 | go install github.com/tcnksm/ghr@latest 15 | 16 | .PHONY: test 17 | test: 18 | go test 19 | 20 | .PHONY: build 21 | build: 22 | go build -ldflags=$(BUILD_LDFLAGS) ./cmd/laminate 23 | 24 | .PHONY: install 25 | install: 26 | go install -ldflags=$(BUILD_LDFLAGS) ./cmd/laminate 27 | 28 | .PHONY: release 29 | release: devel-deps 30 | godzil release 31 | 32 | CREDITS: go.sum deps devel-deps 33 | godzil credits -w 34 | 35 | DIST_DIR = dist/v$(VERSION) 36 | .PHONY: crossbuild 37 | crossbuild: CREDITS 38 | rm -rf $(DIST_DIR) 39 | godzil crossbuild -pv=v$(VERSION) -build-ldflags=$(BUILD_LDFLAGS) \ 40 | -os=linux,darwin -d=$(DIST_DIR) ./cmd/* 41 | cd $(DIST_DIR) && shasum -a 256 $$(find * -type f -maxdepth 0) > SHA256SUMS 42 | 43 | .PHONY: upload 44 | upload: 45 | ghr -body="$$(godzil changelog --latest -F markdown)" v$(VERSION) dist/v$(VERSION) 46 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v0.0.4](https://github.com/Songmu/laminate/compare/v0.0.3...v0.0.4) - 2025-09-05 4 | - build(deps): bump actions/checkout from 4 to 5 by @dependabot[bot] in https://github.com/Songmu/laminate/pull/10 5 | - build(deps): bump github.com/k1LoW/exec from 0.3.0 to 0.4.0 by @dependabot[bot] in https://github.com/Songmu/laminate/pull/11 6 | - feat: respect windows and SHELL var for shell detection by @Songmu in https://github.com/Songmu/laminate/pull/13 7 | 8 | ## [v0.0.3](https://github.com/Songmu/laminate/compare/v0.0.2...v0.0.3) - 2025-08-13 9 | - chore: use type time.Duration directly for Config.Cache by @Songmu in https://github.com/Songmu/laminate/pull/7 10 | - chore: update testing for cache by @Songmu in https://github.com/Songmu/laminate/pull/9 11 | 12 | ## [v0.0.2](https://github.com/Songmu/laminate/compare/v0.0.1...v0.0.2) - 2025-08-13 13 | - chore: remove redundant code by @Songmu in https://github.com/Songmu/laminate/pull/3 14 | - chore: clenup again by @Songmu in https://github.com/Songmu/laminate/pull/4 15 | - chore: udpate docs by @Songmu in https://github.com/Songmu/laminate/pull/5 16 | - fix: redirect cmd.Stdout to os.Stderr by default to prevent contamination by @Songmu in https://github.com/Songmu/laminate/pull/6 17 | 18 | ## [v0.0.1](https://github.com/Songmu/laminate/commits/v0.0.1) - 2025-08-10 19 | -------------------------------------------------------------------------------- /template_test.go: -------------------------------------------------------------------------------- 1 | package laminate 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestExpandTemplate(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | template string 11 | vars map[string]string 12 | expected string 13 | }{ 14 | { 15 | "no_variables", 16 | "echo hello", 17 | map[string]string{}, 18 | "echo hello", 19 | }, 20 | { 21 | "single_variable", 22 | "echo {{input}}", 23 | map[string]string{"input": "world"}, 24 | "echo world", 25 | }, 26 | { 27 | "multiple_variables", 28 | "convert {{input}} -o {{output}}", 29 | map[string]string{"input": "test.txt", "output": "test.png"}, 30 | "convert test.txt -o test.png", 31 | }, 32 | { 33 | "missing_variable", 34 | "echo {{input}} {{missing}}", 35 | map[string]string{"input": "hello"}, 36 | "echo hello {{missing}}", 37 | }, 38 | { 39 | "repeated_variable", 40 | "echo {{input}} and {{input}} again", 41 | map[string]string{"input": "test"}, 42 | "echo test and test again", 43 | }, 44 | } 45 | 46 | for _, tt := range tests { 47 | t.Run(tt.name, func(t *testing.T) { 48 | result, err := ExpandTemplate(tt.template, tt.vars) 49 | if err != nil { 50 | t.Errorf("Unexpected error: %v", err) 51 | } 52 | if result != tt.expected { 53 | t.Errorf("Expected %s, got %s", tt.expected, result) 54 | } 55 | }) 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /laminate.go: -------------------------------------------------------------------------------- 1 | package laminate 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | ) 12 | 13 | const cmdName = "laminate" 14 | 15 | // Run the laminate 16 | func Run(ctx context.Context, argv []string, outStream, errStream io.Writer) error { 17 | log.SetOutput(errStream) 18 | fs := flag.NewFlagSet( 19 | fmt.Sprintf("%s (v%s rev:%s)", cmdName, version, revision), flag.ContinueOnError) 20 | fs.SetOutput(errStream) 21 | ver := fs.Bool("version", false, "display version") 22 | lang := fs.String("lang", "", "code language (can also be set via CODEBLOCK_LANG env var)") 23 | if err := fs.Parse(argv); err != nil { 24 | return err 25 | } 26 | if *ver { 27 | return printVersion(outStream) 28 | } 29 | 30 | // Get language from flag or environment 31 | // --lang flag takes precedence over CODEBLOCK_LANG environment variable 32 | var codeLang = os.Getenv("CODEBLOCK_LANG") 33 | if *lang != "" { 34 | codeLang = *lang 35 | } 36 | 37 | // Load configuration 38 | config, err := LoadConfig() 39 | if err != nil { 40 | return fmt.Errorf("failed to load config: %w", err) 41 | } 42 | 43 | // Check if we have any commands configured 44 | if len(config.Commands) == 0 { 45 | return fmt.Errorf("no commands configured. Please create a config file at %s", getConfigPath()) 46 | } 47 | 48 | // Read input from stdin 49 | var inputBuffer bytes.Buffer 50 | if _, err := io.Copy(&inputBuffer, os.Stdin); err != nil { 51 | return fmt.Errorf("failed to read input: %w", err) 52 | } 53 | input := inputBuffer.String() 54 | 55 | if input == "" { 56 | return fmt.Errorf("no input provided") 57 | } 58 | 59 | // Execute with cache support 60 | if err := ExecuteWithCache(ctx, config, codeLang, input, outStream); err != nil { 61 | return fmt.Errorf("execution failed: %w", err) 62 | } 63 | 64 | return nil 65 | } 66 | 67 | func printVersion(out io.Writer) error { 68 | _, err := fmt.Fprintf(out, "%s v%s (rev:%s)\n", cmdName, version, revision) 69 | return err 70 | } 71 | -------------------------------------------------------------------------------- /testdata/stub_image_generator.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "strings" 8 | ) 9 | 10 | func main() { 11 | var output string 12 | var lang string 13 | 14 | flag.StringVar(&output, "o", "", "output file") 15 | flag.StringVar(&lang, "l", "", "language") 16 | flag.Parse() 17 | 18 | // Read input from stdin or args (we don't actually use it in this stub) 19 | var input string 20 | if len(flag.Args()) > 0 { 21 | input = strings.Join(flag.Args(), " ") 22 | } else { 23 | // Read from stdin 24 | buf := make([]byte, 1024) 25 | n, _ := os.Stdin.Read(buf) 26 | input = string(buf[:n]) 27 | } 28 | 29 | // Use input to avoid unused variable error 30 | _ = input 31 | 32 | // Generate fake image content 33 | var imageContent []byte 34 | 35 | // Check output file extension to determine format 36 | if output != "" && strings.HasSuffix(strings.ToLower(output), ".jpg") { 37 | // Generate fake JPEG content 38 | imageContent = []byte{ 39 | 0xFF, 0xD8, 0xFF, 0xE0, // JPEG signature 40 | 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, // JFIF header 41 | 0x01, 0x01, 0x00, 0x48, 0x00, 0x48, 0x00, 0x00, 42 | 0xFF, 0xDB, 0x00, 0x43, 0x00, // Quantization table 43 | // Minimal JPEG data (truncated for brevity) 44 | 0xFF, 0xD9, // End of Image 45 | } 46 | } else { 47 | // Generate fake PNG content (default) 48 | imageContent = []byte{ 49 | 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 50 | 0x00, 0x00, 0x00, 0x0D, // IHDR chunk length 51 | 0x49, 0x48, 0x44, 0x52, // IHDR 52 | 0x00, 0x00, 0x00, 0x10, // width: 16 53 | 0x00, 0x00, 0x00, 0x10, // height: 16 54 | 0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, compression, filter, interlace 55 | 0x90, 0x91, 0x68, 0x36, // CRC 56 | 0x00, 0x00, 0x00, 0x0C, // IEND chunk length 57 | 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, // IEND 58 | } 59 | } 60 | 61 | if output != "" { 62 | // Write to file 63 | err := os.WriteFile(output, imageContent, 0644) 64 | if err != nil { 65 | fmt.Fprintf(os.Stderr, "Failed to write output: %v\n", err) 66 | os.Exit(1) 67 | } 68 | } else { 69 | // Write to stdout 70 | os.Stdout.Write(imageContent) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /matcher_test.go: -------------------------------------------------------------------------------- 1 | package laminate 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestMatchLanguage(t *testing.T) { 8 | tests := []struct { 9 | name string 10 | pattern string 11 | lang string 12 | expected bool 13 | hasError bool 14 | }{ 15 | {"exact_match", "go", "go", true, false}, 16 | {"no_match", "go", "python", false, false}, 17 | {"wildcard", "*", "anything", true, false}, 18 | {"glob_pattern", "go*", "golang", true, false}, 19 | {"glob_pattern_no_match", "go*", "python", false, false}, 20 | {"brace_expansion", "{go,python,rust}", "go", true, false}, 21 | {"brace_expansion_match", "{go,python,rust}", "python", true, false}, 22 | {"brace_expansion_no_match", "{go,python,rust}", "java", false, false}, 23 | {"complex_brace", "{c,cpp,c++}", "cpp", true, false}, 24 | } 25 | 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | result, err := matchLanguage(tt.pattern, tt.lang) 29 | 30 | if tt.hasError { 31 | if err == nil { 32 | t.Error("Expected error, got nil") 33 | } 34 | } else { 35 | if err != nil { 36 | t.Errorf("Unexpected error: %v", err) 37 | } 38 | if result != tt.expected { 39 | t.Errorf("Pattern %s, Lang %s: expected %v, got %v", tt.pattern, tt.lang, tt.expected, result) 40 | } 41 | } 42 | }) 43 | } 44 | } 45 | 46 | func TestFindMatchingCommand(t *testing.T) { 47 | commands := []*Command{ 48 | {Lang: "go", Run: RunCommand{str: "cmd1"}, Ext: "png"}, 49 | {Lang: "{python,py}", Run: RunCommand{str: "cmd2"}, Ext: "jpg"}, 50 | {Lang: "*", Run: RunCommand{str: "cmd3"}, Ext: "gif"}, 51 | } 52 | 53 | tests := []struct { 54 | name string 55 | lang string 56 | expectedCmd string 57 | hasError bool 58 | }{ 59 | {"go_match", "go", "cmd1", false}, 60 | {"python_brace_match", "python", "cmd2", false}, 61 | {"py_brace_match", "py", "cmd2", false}, 62 | {"wildcard_match", "unknown", "cmd3", false}, 63 | {"first_match_wins", "go", "cmd1", false}, // Should match first, not wildcard 64 | } 65 | 66 | for _, tt := range tests { 67 | t.Run(tt.name, func(t *testing.T) { 68 | cmd, err := FindMatchingCommand(commands, tt.lang) 69 | 70 | if tt.hasError { 71 | if err == nil { 72 | t.Error("Expected error, got nil") 73 | } 74 | } else { 75 | if err != nil { 76 | t.Errorf("Unexpected error: %v", err) 77 | } 78 | if cmd == nil { 79 | t.Error("Expected command, got nil") 80 | } else if cmd.Run.String() != tt.expectedCmd { 81 | t.Errorf("Expected command %s, got %s", tt.expectedCmd, cmd.Run.String()) 82 | } 83 | } 84 | }) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package laminate 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/spf13/pathologize" 11 | ) 12 | 13 | // Cache manages the cache for laminate 14 | type Cache struct { 15 | dir string 16 | duration time.Duration 17 | } 18 | 19 | // NewCache creates a new cache instance 20 | func NewCache(duration time.Duration) *Cache { 21 | return &Cache{ 22 | dir: getCachePath(), 23 | duration: duration, 24 | } 25 | } 26 | 27 | // Get retrieves cached data if it exists and is not expired 28 | func (c *Cache) Get(lang, input, ext string) ([]byte, bool) { 29 | if c.duration == 0 { 30 | return nil, false 31 | } 32 | cachePath := c.getCacheFilePath(lang, input, ext) 33 | 34 | // Check if cache file exists 35 | info, err := os.Stat(cachePath) 36 | if err != nil { 37 | return nil, false 38 | } 39 | 40 | // Check if cache is expired 41 | if time.Since(info.ModTime()) > c.duration { 42 | return nil, false 43 | } 44 | 45 | // Read cache file 46 | data, err := os.ReadFile(cachePath) 47 | if err != nil { 48 | return nil, false 49 | } 50 | return data, true 51 | } 52 | 53 | // Set stores data in cache 54 | func (c *Cache) Set(lang, input, ext string, data []byte) error { 55 | if c.duration == 0 { 56 | return nil 57 | } 58 | cachePath := c.getCacheFilePath(lang, input, ext) 59 | 60 | // Create cache directory if it doesn't exist 61 | dir := filepath.Dir(cachePath) 62 | if err := os.MkdirAll(dir, 0700); err != nil { 63 | return fmt.Errorf("failed to create cache directory: %w", err) 64 | } 65 | // Write cache file 66 | if err := os.WriteFile(cachePath, data, 0600); err != nil { 67 | return fmt.Errorf("failed to write cache file: %w", err) 68 | } 69 | return nil 70 | } 71 | 72 | // getCacheFilePath returns the cache file path for given parameters 73 | func (c *Cache) getCacheFilePath(lang, input, ext string) string { 74 | // Calculate MD5 hash of input 75 | hash := md5.Sum([]byte(input)) 76 | hashStr := fmt.Sprintf("%x", hash) 77 | 78 | // Sanitize lang for filesystem safety 79 | safeLang := pathologize.Clean(lang) 80 | 81 | // Build cache file path: {{lang}}/{{hash(input)}}.{{ext}} 82 | return filepath.Join(c.dir, safeLang, hashStr+"."+ext) 83 | } 84 | 85 | // Clean removes expired cache files 86 | func (c *Cache) Clean() error { 87 | if c.duration == 0 { 88 | return nil 89 | } 90 | now := time.Now() 91 | err := filepath.Walk(c.dir, func(path string, info os.FileInfo, err error) error { 92 | if err != nil { 93 | return nil // Skip errors 94 | } 95 | if !info.IsDir() && now.Sub(info.ModTime()) > c.duration { 96 | os.Remove(path) // Ignore errors 97 | } 98 | return nil 99 | }) 100 | return err 101 | } 102 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package laminate 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "time" 9 | 10 | "github.com/goccy/go-yaml" 11 | "github.com/spf13/pathologize" 12 | ) 13 | 14 | // Config represents the configuration for laminate 15 | type Config struct { 16 | Cache time.Duration `yaml:"cache"` 17 | Commands []*Command `yaml:"commands"` 18 | } 19 | 20 | // RunCommand represents a command that can be either a string or []string 21 | type RunCommand struct { 22 | isArray bool 23 | str string 24 | array []string 25 | } 26 | 27 | // UnmarshalYAML implements yaml.Unmarshaler 28 | func (r *RunCommand) UnmarshalYAML(unmarshal func(any) error) error { 29 | // Try to unmarshal as string first 30 | var str string 31 | if err := unmarshal(&str); err == nil { 32 | r.str = str 33 | r.isArray = false 34 | return nil 35 | } 36 | 37 | // Try to unmarshal as []string 38 | var array []string 39 | if err := unmarshal(&array); err == nil { 40 | r.array = array 41 | r.isArray = true 42 | return nil 43 | } 44 | 45 | return fmt.Errorf("run must be string or array of strings") 46 | } 47 | 48 | // IsArray returns true if the command is an array 49 | func (r *RunCommand) IsArray() bool { 50 | return r.isArray 51 | } 52 | 53 | // String returns the command as string (only valid if IsArray() == false) 54 | func (r *RunCommand) String() string { 55 | return r.str 56 | } 57 | 58 | // Array returns the command as []string (only valid if IsArray() == true) 59 | func (r *RunCommand) Array() []string { 60 | return r.array 61 | } 62 | 63 | // Command represents a single command configuration 64 | type Command struct { 65 | Lang string `yaml:"lang"` 66 | Run RunCommand `yaml:"run"` 67 | Ext string `yaml:"ext"` 68 | Shell string `yaml:"shell"` 69 | } 70 | 71 | // GetExt returns the file extension for the output 72 | func (cmd *Command) GetExt() string { 73 | if cmd.Ext != "" { 74 | return pathologize.Clean(cmd.Ext) 75 | } 76 | return "png" 77 | } 78 | 79 | // LoadConfig loads the configuration from the config file 80 | func LoadConfig() (*Config, error) { 81 | configPath := getConfigPath() 82 | 83 | data, err := os.ReadFile(configPath) 84 | if err != nil { 85 | return nil, fmt.Errorf("failed to read config file: %w", err) 86 | } 87 | 88 | var config Config 89 | if err := yaml.Unmarshal(data, &config); err != nil { 90 | return nil, fmt.Errorf("failed to parse config file: %w", err) 91 | } 92 | 93 | return &config, nil 94 | } 95 | 96 | // getConfigPath returns the path to the config file 97 | func getConfigPath() string { 98 | if configPath := os.Getenv("LAMINATE_CONFIG_PATH"); configPath != "" { 99 | return configPath 100 | } 101 | 102 | if runtime.GOOS == "windows" { 103 | configDir, _ := os.UserConfigDir() 104 | return filepath.Join(configDir, "laminate", "config.yaml") 105 | } 106 | 107 | // Use XDG_CONFIG_HOME for non-Windows 108 | configDir := os.Getenv("XDG_CONFIG_HOME") 109 | if configDir == "" { 110 | home, _ := os.UserHomeDir() 111 | configDir = filepath.Join(home, ".config") 112 | } 113 | return filepath.Join(configDir, "laminate", "config.yaml") 114 | } 115 | 116 | // getCachePath returns the path to the cache directory 117 | func getCachePath() string { 118 | if cachePath := os.Getenv("LAMINATE_CACHE_PATH"); cachePath != "" { 119 | return cachePath 120 | } 121 | 122 | if runtime.GOOS == "windows" { 123 | cacheDir, _ := os.UserCacheDir() 124 | return filepath.Join(cacheDir, "laminate", "cache") 125 | } 126 | 127 | // Use XDG_CACHE_HOME for non-Windows 128 | cacheDir := os.Getenv("XDG_CACHE_HOME") 129 | if cacheDir == "" { 130 | home, _ := os.UserHomeDir() 131 | cacheDir = filepath.Join(home, ".cache") 132 | } 133 | return filepath.Join(cacheDir, "laminate", "cache") 134 | } 135 | -------------------------------------------------------------------------------- /SKETCH.md: -------------------------------------------------------------------------------- 1 | # laminate: image generator bridge 2 | 3 | ## 概要 4 | 5 | - このツールは文字列(主にコード文字列)を画像に変換するためのツールです 6 | - 標準入力から文字列を読み取り、それを画像変換した結果を標準出力に出力します 7 | - 入力 8 | - コード文字列: 標準入力 9 | - コードの言語(ex. QRコード、Mermaid、その他のプログラミング言語) : `CODEBLOCK_LANG` 環境変数 or `--lang` flag (Optional) 10 | - 出力 11 | - 生成された画像: 標準出力 12 | - langの値に応じた画像生成コマンドを実行します 13 | - 画像生成コマンドはYAML設定ファイルで指定します 14 | - キャッシュ機構を備えます 15 | 16 | 17 | ## 設定ファイル 18 | 19 | ### 例 20 | 21 | ```yaml 22 | # example 23 | cache: 1h 24 | commands: 25 | - lang: qr 26 | run: 'qrencode -o "{{output}}" -t png "{{input}}"' 27 | - lang: mermaid 28 | run: 'mmdc -i - -o "{{output}}" --quiet' 29 | - lang: '{c,cpp,python,rust,go,java,js,ts,html,css,sh}' 30 | run: 'silicon -l "{{lang}}" -o "{{output}}"' 31 | ext: png 32 | - lang: '*' 33 | run: [convert, -background, none, -fill, black, label:{{input}}, {{output}}] 34 | ext: png 35 | ``` 36 | 37 | ### スキーマ 38 | 39 | ```yaml 40 | # schema 41 | type: object 42 | properties: 43 | cache: 44 | type: string 45 | description: | 46 | キャッシュの有効期限を指定します。 47 | 例: `1h`, `30m`, `15s` など。省略時はキャッシュは無効になります。 48 | キャッシュは、入力文字列とlangに基づいて生成されます。 49 | キャッシュが有効な場合、コマンドは実行されず、キャッシュから画像が読み込まれます。 50 | commands: 51 | type: array 52 | items: 53 | type: object 54 | properties: 55 | lang: 56 | type: string 57 | description: | 58 | 言語を指定します。 59 | zsh的なglobパターンで指定できます。例: `{c,cpp,python,rust,go,java,js,ts,html,css,sh}`, `*` 60 | run: 61 | type: [string, array] 62 | items: 63 | type: string 64 | description: | 65 | コマンドラインで実行されるコマンドを指定します。 66 | {{input}}、{{output}}、{{lang}} 変数が展開されたあとに実行されます。 67 | 文字列で指定された場合、 `{shell} -c` で実行されます。 68 | 配列で指定された場合、そのまま実行されます。 69 | ext: 70 | type: string 71 | description: '出力画像の拡張子を指定します。省略時は png が使用されます。' 72 | pattern: '^[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)*$' 73 | shell: 74 | type: string 75 | description: | 76 | コマンドを実行するシェルを指定します。 77 | 省略時はbash、bashが見つからない場合は sh が使用されます。 78 | required: 79 | - lang 80 | - run 81 | 82 | ``` 83 | 84 | - 実行コマンドの決定 85 | - `commands:` 配列の先頭から走査し、最初にマッチしたlangに対応するコマンドが実行されます 86 | - 変数展開に関するルール 87 | - `{{input}}`: `laminate` が標準入力から読み取ったコード文字列 88 | - この変数がない場合は、コマンドに対して標準入力経由で文字列が渡されます 89 | - `{{output}}`: 生成される画像の出力先 90 | - 標準では `.png` 拡張子が使用されますが、`ext` が指定されている場合はその拡張子が使用されます 91 | - この変数がない場合は、コマンドの標準出力を画像データとして読み取ります 92 | - `{{lang}}`: コマンド実行時に指定された言語を表します 93 | 94 | ## 利用ライブラリ 95 | - github.com/gobwas/glob 96 | - lang指定の文字列マッチのために利用 97 | - github.com/goccy/go-yaml 98 | - YAML設定ファイルの読み書きのために利用。YAMLライブラリは色々ありますがこれを使います 99 | - github.com/k1LoW/exec 100 | - `os/exec` を使う代わりに一律このライブラリを使います 101 | - github.com/spf13/pathologize 102 | - ファイルネームのサニタイズのために利用します 103 | 104 | ## 設定ファイルやキャッシュの位置 105 | - XDG Base Directory に準じます 106 | - macであっても同様です 107 | - これは、 `os.UserConfigDir()`, `os.UserCacheDir()`等を「使わない」と言うことです 108 | - これらはmacの場合 `~/Library/Application Support` などを返すため 109 | - デスクトップアプリケーションであればそれで良いが、CLIツールでは `XDG` に寄せた方が分かりやすいと考えます 110 | - windowsでは、 `os.UserConfigDir()` 及び `os.UserCacheDir()` を使用します 111 | - 設定ファイルは `${XDG\_CONFIG\_HOME:-~/.config}/laminate/config.yaml` に配置されます 112 | - キャッシュは `${XDG\_CACHE\_HOME:-~/.cache}/laminate/cache` に配置されます 113 | 114 | ## キャッシュキー 115 | - キャッシュファイル名は、入力文字列とlang、拡張子に基づいて以下のように導出されます 116 | - `{{lang}}/{{hash(input)}}.{{ext}}` 117 | - 例: `qr/5d41402abc4b2a76b9719d911017c592.png` 118 | - hashはmd5を使用します 119 | - langはファイルシステムセーフな文字列にサニタイズされます 120 | - キャッシュファイルの内容は、画像データそのものです 121 | - キャッシュファイルのmtimeを見て有効期限の判定を行います 122 | 123 | ## 参考 124 | - 125 | - 126 | -------------------------------------------------------------------------------- /executor.go: -------------------------------------------------------------------------------- 1 | package laminate 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | "runtime" 12 | "strings" 13 | 14 | "github.com/k1LoW/exec" 15 | ) 16 | 17 | // Executor handles command execution 18 | type Executor struct { 19 | cmd *Command 20 | lang string 21 | input string 22 | output string 23 | } 24 | 25 | // Execute runs the command and returns the output 26 | func (e *Executor) Execute(ctx context.Context) ([]byte, error) { 27 | argv, err := e.getArgv() 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to get command arguments: %w", err) 30 | } 31 | return e.exceute(ctx, argv) 32 | } 33 | 34 | func (e *Executor) getArgv() ([]string, error) { 35 | vars := map[string]string{ 36 | "input": e.input, 37 | "output": e.output, 38 | "lang": e.lang, 39 | } 40 | if e.cmd.Run.IsArray() { 41 | templates := e.cmd.Run.Array() 42 | var result = make([]string, len(templates)) 43 | for i, template := range templates { 44 | expanded, err := ExpandTemplate(template, vars) 45 | if err != nil { 46 | return nil, err 47 | } 48 | result[i] = expanded 49 | } 50 | return result, nil 51 | } 52 | expanded, err := ExpandTemplate(e.cmd.Run.String(), vars) 53 | if err != nil { 54 | return nil, err 55 | } 56 | return e.cmd.buildCommand(expanded) 57 | } 58 | 59 | func (e *Executor) exceute(ctx context.Context, argv []string) ([]byte, error) { 60 | cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) 61 | cmd.Stderr = os.Stderr 62 | cmd.Stdin = strings.NewReader(e.input) 63 | var buf bytes.Buffer 64 | cmd.Stdout = &buf 65 | if err := cmd.Run(); err != nil { 66 | return nil, fmt.Errorf("command failed: %w", err) 67 | } 68 | 69 | // If the result is written to a temporary file, read it from that file. 70 | if b, err := os.ReadFile(e.output); err == nil { 71 | fmt.Fprint(os.Stderr, buf.String()) 72 | return b, nil 73 | } else if !os.IsNotExist(err) { 74 | return nil, fmt.Errorf("failed to read output file: %w\nstdout: %s", err, buf.String()) 75 | } 76 | // If it does not exist, read the result from stdout. 77 | return buf.Bytes(), nil 78 | } 79 | 80 | // ExecuteWithCache executes a command with caching support 81 | func ExecuteWithCache(ctx context.Context, config *Config, lang, input string, output io.Writer) error { 82 | cmd, err := FindMatchingCommand(config.Commands, lang) 83 | if err != nil { 84 | return err 85 | } 86 | ext := cmd.GetExt() 87 | 88 | cache := NewCache(config.Cache) 89 | if data, found := cache.Get(lang, input, ext); found { 90 | _, err := output.Write(data) 91 | return err 92 | } 93 | 94 | tempDir, err := os.MkdirTemp("", "laminate-") 95 | if err != nil { 96 | return fmt.Errorf("failed to create temp directory: %w", err) 97 | } 98 | defer os.RemoveAll(tempDir) 99 | 100 | executor := &Executor{ 101 | cmd: cmd, 102 | lang: lang, 103 | input: input, 104 | output: filepath.Join(tempDir, "output."+ext), 105 | } 106 | data, err := executor.Execute(ctx) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | if cacheErr := cache.Set(lang, input, ext, data); cacheErr != nil { 112 | // Log cache error but don't fail the operation 113 | fmt.Fprintf(os.Stderr, "Warning: failed to cache result: %v\n", cacheErr) 114 | } 115 | _, err = output.Write(data) 116 | return err 117 | } 118 | 119 | var standaloneCommandReg = regexp.MustCompile(`^[-_.+a-zA-Z0-9]+$`) 120 | 121 | func (cmd *Command) buildCommand(c string) ([]string, error) { 122 | if standaloneCommandReg.MatchString(c) { 123 | return []string{c}, nil 124 | } 125 | sh, err := cmd.detectShell() 126 | if err != nil { 127 | return nil, err 128 | } 129 | return []string{sh, "-c", c}, nil 130 | } 131 | 132 | // detectShell returns the shell to use for command execution 133 | func (cmd *Command) detectShell() (string, error) { 134 | if cmd.Shell != "" { 135 | return cmd.Shell, nil 136 | } 137 | if sh := os.Getenv("SHELL"); sh != "" { 138 | return sh, nil 139 | } 140 | for _, sh := range []string{"bash", "sh"} { 141 | if path, err := exec.LookPath(sh); err == nil { 142 | return path, nil 143 | } 144 | } 145 | if runtime.GOOS == "windows" { 146 | return "cmd", nil 147 | } 148 | return "", fmt.Errorf("no suitable shell found") 149 | } 150 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package laminate 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | "time" 8 | 9 | "github.com/goccy/go-yaml" 10 | ) 11 | 12 | func TestCommand_GetExt(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | ext string 16 | expected string 17 | }{ 18 | {"default", "", "png"}, 19 | {"custom", "jpg", "jpg"}, 20 | {"with_dot", "svg.gz", "svg.gz"}, 21 | } 22 | 23 | for _, tt := range tests { 24 | t.Run(tt.name, func(t *testing.T) { 25 | cmd := &Command{Ext: tt.ext} 26 | result := cmd.GetExt() 27 | if result != tt.expected { 28 | t.Errorf("Expected %s, got %s", tt.expected, result) 29 | } 30 | }) 31 | } 32 | } 33 | 34 | func TestRunCommand_UnmarshalYAML_WithActualYAML(t *testing.T) { 35 | tests := []struct { 36 | name string 37 | yaml string 38 | isArray bool 39 | expected any 40 | }{ 41 | { 42 | "string_command", 43 | `run: "echo hello"`, 44 | false, 45 | "echo hello", 46 | }, 47 | { 48 | "array_command", 49 | `run: ["echo", "hello"]`, 50 | true, 51 | []string{"echo", "hello"}, 52 | }, 53 | } 54 | 55 | for _, tt := range tests { 56 | t.Run(tt.name, func(t *testing.T) { 57 | var cmd struct { 58 | Run RunCommand `yaml:"run"` 59 | } 60 | 61 | err := yaml.Unmarshal([]byte(tt.yaml), &cmd) 62 | if err != nil { 63 | t.Errorf("Unexpected error: %v", err) 64 | } 65 | 66 | if cmd.Run.IsArray() != tt.isArray { 67 | t.Errorf("Expected isArray=%v, got %v", tt.isArray, cmd.Run.IsArray()) 68 | } 69 | 70 | if tt.isArray { 71 | result := cmd.Run.Array() 72 | expected := tt.expected.([]string) 73 | if len(result) != len(expected) { 74 | t.Errorf("Expected %v, got %v", expected, result) 75 | } 76 | for i, v := range result { 77 | if v != expected[i] { 78 | t.Errorf("Expected %v, got %v", expected, result) 79 | } 80 | } 81 | } else { 82 | result := cmd.Run.String() 83 | if result != tt.expected.(string) { 84 | t.Errorf("Expected %s, got %s", tt.expected.(string), result) 85 | } 86 | } 87 | }) 88 | } 89 | } 90 | 91 | func TestPathOverride(t *testing.T) { 92 | tests := []struct { 93 | name string 94 | envVar string 95 | envValue string 96 | getFunc func() string 97 | }{ 98 | { 99 | name: "config_path_override", 100 | envVar: "LAMINATE_CONFIG_PATH", 101 | envValue: "/tmp/test-config.yaml", 102 | getFunc: getConfigPath, 103 | }, 104 | { 105 | name: "cache_path_override", 106 | envVar: "LAMINATE_CACHE_PATH", 107 | envValue: "/tmp/test-cache", 108 | getFunc: getCachePath, 109 | }, 110 | } 111 | 112 | for _, tt := range tests { 113 | t.Run(tt.name, func(t *testing.T) { 114 | t.Setenv(tt.envVar, tt.envValue) 115 | 116 | result := tt.getFunc() 117 | if result != tt.envValue { 118 | t.Errorf("Expected %s, got %s", tt.envValue, result) 119 | } 120 | }) 121 | } 122 | } 123 | 124 | func mustParseDuration(value string) time.Duration { 125 | d, err := time.ParseDuration(value) 126 | if err != nil { 127 | panic("Invalid duration: " + value) 128 | } 129 | return d 130 | } 131 | 132 | func TestLoadConfig(t *testing.T) { 133 | tests := []struct { 134 | name string 135 | configContent string 136 | expectedCache time.Duration 137 | expectedCmdLen int 138 | expectedLang string 139 | expectedExt string 140 | expectError bool 141 | }{ 142 | { 143 | name: "file_not_exists", 144 | expectedCmdLen: 0, 145 | expectError: true, 146 | }, 147 | { 148 | name: "valid_file", 149 | configContent: `cache: 1h 150 | commands: 151 | - lang: test 152 | run: echo test 153 | ext: png 154 | `, 155 | expectedCache: mustParseDuration("1h"), 156 | expectedCmdLen: 1, 157 | expectedLang: "test", 158 | expectedExt: "png", 159 | }, 160 | { 161 | name: "valid_file_without_cache", 162 | configContent: `commands: 163 | - lang: test 164 | run: echo test 165 | ext: png 166 | `, 167 | expectedCmdLen: 1, 168 | expectedLang: "test", 169 | expectedExt: "png", 170 | }, 171 | } 172 | 173 | for _, tt := range tests { 174 | t.Run(tt.name, func(t *testing.T) { 175 | if tt.configContent != "" { 176 | tmpDir := t.TempDir() 177 | configFile := filepath.Join(tmpDir, "config.yaml") 178 | 179 | err := os.WriteFile(configFile, []byte(tt.configContent), 0644) 180 | if err != nil { 181 | t.Fatalf("Failed to create test config: %v", err) 182 | } 183 | t.Setenv("LAMINATE_CONFIG_PATH", configFile) 184 | } else { 185 | t.Setenv("LAMINATE_CONFIG_PATH", "/non/existent/config.yaml") 186 | } 187 | 188 | config, err := LoadConfig() 189 | 190 | if tt.expectError { 191 | if err == nil { 192 | t.Error("Expected error, got nil") 193 | } 194 | return 195 | } 196 | 197 | if err != nil { 198 | t.Errorf("Unexpected error: %v", err) 199 | } 200 | 201 | if config == nil { 202 | t.Error("Expected config, got nil") 203 | return 204 | } 205 | 206 | if config.Cache != tt.expectedCache { 207 | t.Errorf("Expected cache '%s', got '%s'", tt.expectedCache, config.Cache) 208 | } 209 | 210 | if len(config.Commands) != tt.expectedCmdLen { 211 | t.Errorf("Expected %d commands, got %d", tt.expectedCmdLen, len(config.Commands)) 212 | } 213 | 214 | if tt.expectedCmdLen > 0 { 215 | cmd := config.Commands[0] 216 | if cmd.Lang != tt.expectedLang { 217 | t.Errorf("Expected lang '%s', got '%s'", tt.expectedLang, cmd.Lang) 218 | } 219 | if cmd.GetExt() != tt.expectedExt { 220 | t.Errorf("Expected ext '%s', got '%s'", tt.expectedExt, cmd.GetExt()) 221 | } 222 | } 223 | }) 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /laminate_test.go: -------------------------------------------------------------------------------- 1 | package laminate_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "math" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | "time" 12 | 13 | "github.com/Songmu/laminate" 14 | ) 15 | 16 | func setupTestEnv(t *testing.T) (configPath, cachePath string) { 17 | t.Helper() 18 | 19 | // Create temporary directories 20 | tmpDir := t.TempDir() 21 | configPath = filepath.Join(tmpDir, "config.yaml") 22 | cachePath = filepath.Join(tmpDir, "cache") 23 | 24 | t.Setenv("LAMINATE_CONFIG_PATH", configPath) 25 | t.Setenv("LAMINATE_CACHE_PATH", cachePath) 26 | 27 | return configPath, cachePath 28 | } 29 | 30 | func createTestConfigFromFile(t *testing.T, configPath, configType string) { 31 | t.Helper() 32 | 33 | var sourceConfig string 34 | switch configType { 35 | case "default": 36 | sourceConfig = "testdata/test_config.yaml" 37 | case "no_cache": 38 | sourceConfig = "testdata/test_config_no_cache.yaml" 39 | case "asterisk_first": 40 | sourceConfig = "testdata/test_config_asterisk_first.yaml" 41 | default: 42 | t.Fatalf("Unknown config type: %s", configType) 43 | } 44 | 45 | configContent, err := os.ReadFile(sourceConfig) 46 | if err != nil { 47 | t.Fatalf("Failed to read test config template: %v", err) 48 | } 49 | 50 | err = os.WriteFile(configPath, configContent, 0644) 51 | if err != nil { 52 | t.Fatalf("Failed to create test config: %v", err) 53 | } 54 | } 55 | 56 | func setupStdinWithInput(input string) func() { 57 | oldStdin := os.Stdin 58 | r, w, _ := os.Pipe() 59 | os.Stdin = r 60 | 61 | if input != "" { 62 | go func() { 63 | w.Write([]byte(input)) 64 | w.Close() 65 | }() 66 | } else { 67 | w.Close() 68 | } 69 | 70 | return func() { os.Stdin = oldStdin } 71 | } 72 | 73 | func assertImageFormat(t *testing.T, output []byte, format string) { 74 | t.Helper() 75 | if len(output) == 0 { 76 | t.Error("Expected output, got empty") 77 | return 78 | } 79 | 80 | switch format { 81 | case "png": 82 | if len(output) < 8 || output[0] != 0x89 || output[1] != 0x50 || output[2] != 0x4E || output[3] != 0x47 { 83 | t.Errorf("Expected PNG signature, got: %v", output[:min(8, len(output))]) 84 | } 85 | case "jpg", "jpeg": 86 | if len(output) < 4 || output[0] != 0xFF || output[1] != 0xD8 || output[2] != 0xFF { 87 | t.Errorf("Expected JPEG signature, got: %v", output[:min(4, len(output))]) 88 | } 89 | default: 90 | t.Errorf("Unknown image format: %s", format) 91 | } 92 | } 93 | 94 | func TestRun_Version(t *testing.T) { 95 | var outBuf, errBuf bytes.Buffer 96 | 97 | err := laminate.Run(context.Background(), []string{"--version"}, &outBuf, &errBuf) 98 | if err != nil { 99 | t.Errorf("Expected no error, got: %v", err) 100 | } 101 | 102 | output := outBuf.String() 103 | if !strings.Contains(output, "laminate") { 104 | t.Errorf("Expected version output to contain 'laminate', got: %s", output) 105 | } 106 | } 107 | 108 | func TestRun_ErrorCases(t *testing.T) { 109 | tests := []struct { 110 | name string 111 | setupConfig bool 112 | args []string 113 | input string 114 | expectedErrMsg string 115 | }{ 116 | { 117 | name: "no_config_file", 118 | setupConfig: false, 119 | args: []string{"--lang", "test"}, 120 | input: "test input", 121 | expectedErrMsg: "failed to read config file", 122 | }, 123 | { 124 | name: "no_input_provided", 125 | setupConfig: true, 126 | args: []string{"--lang", "text"}, 127 | input: "", 128 | expectedErrMsg: "no input provided", 129 | }, 130 | } 131 | 132 | for _, tt := range tests { 133 | t.Run(tt.name, func(t *testing.T) { 134 | configPath, _ := setupTestEnv(t) 135 | 136 | if tt.setupConfig { 137 | createTestConfigFromFile(t, configPath, "default") 138 | } 139 | 140 | var outBuf, errBuf bytes.Buffer 141 | 142 | // Set stdin 143 | cleanupStdin := setupStdinWithInput(tt.input) 144 | defer cleanupStdin() 145 | 146 | err := laminate.Run(context.Background(), tt.args, &outBuf, &errBuf) 147 | if err == nil { 148 | t.Errorf("Expected error, got nil: %s", tt.name) 149 | return 150 | } 151 | 152 | if !strings.Contains(err.Error(), tt.expectedErrMsg) { 153 | t.Errorf("Expected error containing %q, got: %v", tt.expectedErrMsg, err) 154 | } 155 | }) 156 | } 157 | } 158 | 159 | func TestRun_ImageGeneration(t *testing.T) { 160 | tests := []struct { 161 | name string 162 | configType string 163 | args []string 164 | envLang string 165 | input string 166 | format string 167 | }{ 168 | { 169 | name: "go_language_png", 170 | configType: "default", 171 | args: []string{"--lang", "go"}, 172 | input: "package main\n\nfunc main() {}", 173 | format: "png", 174 | }, 175 | { 176 | name: "python_with_env_var", 177 | configType: "default", 178 | args: []string{}, 179 | envLang: "python", 180 | input: "print('hello')", 181 | format: "png", 182 | }, 183 | { 184 | name: "rust_language_jpg", 185 | configType: "default", 186 | args: []string{"--lang", "rust"}, 187 | input: "fn main() { println!(\"Hello, Rust!\"); }", 188 | format: "jpg", 189 | }, 190 | { 191 | name: "brace_expansion_default_extension", 192 | configType: "default", 193 | args: []string{"--lang", "java"}, 194 | input: "public class Hello { }", 195 | format: "png", 196 | }, 197 | { 198 | name: "empty_lang_string", 199 | configType: "default", 200 | args: []string{"--lang", ""}, 201 | input: "content with empty lang", 202 | format: "png", 203 | }, 204 | { 205 | name: "wildcard_pattern", 206 | configType: "default", 207 | args: []string{"--lang", "unknown"}, 208 | input: "unknown language content", 209 | format: "png", 210 | }, 211 | { 212 | name: "pattern_matching_priority_asterisk_first", 213 | configType: "asterisk_first", 214 | args: []string{"--lang", ""}, 215 | input: "pattern priority test content", 216 | format: "jpg", 217 | }, 218 | { 219 | name: "lang_flag_precedence_over_env", 220 | configType: "default", 221 | args: []string{"--lang", "go"}, 222 | envLang: "python", // Should be ignored since --lang is specified 223 | input: "package main", 224 | format: "png", // Go lang should produce PNG 225 | }, 226 | } 227 | 228 | for _, tt := range tests { 229 | t.Run(tt.name, func(t *testing.T) { 230 | configPath, _ := setupTestEnv(t) 231 | 232 | createTestConfigFromFile(t, configPath, tt.configType) 233 | 234 | // Set environment variable if specified 235 | t.Setenv("CODEBLOCK_LANG", tt.envLang) 236 | 237 | var outBuf, errBuf bytes.Buffer 238 | cleanupStdin := setupStdinWithInput(tt.input) 239 | defer cleanupStdin() 240 | 241 | err := laminate.Run(context.Background(), tt.args, &outBuf, &errBuf) 242 | if err != nil { 243 | t.Errorf("Expected no error, got: %v", err) 244 | } 245 | 246 | // Verify output format 247 | assertImageFormat(t, outBuf.Bytes(), tt.format) 248 | }) 249 | } 250 | } 251 | 252 | func TestRun_CacheBehavior(t *testing.T) { 253 | tests := []struct { 254 | name string 255 | configType string 256 | lang string 257 | expectCache bool 258 | }{ 259 | { 260 | name: "with_cache_enabled", 261 | configType: "default", 262 | lang: "text", 263 | expectCache: true, 264 | }, 265 | { 266 | name: "with_cache_disabled", 267 | configType: "no_cache", 268 | lang: "go", 269 | expectCache: false, 270 | }, 271 | } 272 | 273 | for _, tt := range tests { 274 | t.Run(tt.name, func(t *testing.T) { 275 | configPath, _ := setupTestEnv(t) 276 | 277 | createTestConfigFromFile(t, configPath, tt.configType) 278 | 279 | input := "cache test content" 280 | 281 | // First run 282 | var outBuf1, errBuf1 bytes.Buffer 283 | cleanupStdin1 := setupStdinWithInput(input) 284 | 285 | start1 := time.Now() 286 | err := laminate.Run(context.Background(), []string{"--lang", tt.lang}, &outBuf1, &errBuf1) 287 | duration1 := time.Since(start1) 288 | cleanupStdin1() 289 | 290 | if err != nil { 291 | t.Errorf("First run failed: %v", err) 292 | } 293 | 294 | // Second run 295 | var outBuf2, errBuf2 bytes.Buffer 296 | cleanupStdin2 := setupStdinWithInput(input) 297 | 298 | start2 := time.Now() 299 | err = laminate.Run(context.Background(), []string{"--lang", tt.lang}, &outBuf2, &errBuf2) 300 | duration2 := time.Since(start2) 301 | cleanupStdin2() 302 | 303 | if err != nil { 304 | t.Errorf("Second run failed: %v", err) 305 | } 306 | 307 | // Compare outputs - should always be the same 308 | if !bytes.Equal(outBuf1.Bytes(), outBuf2.Bytes()) { 309 | t.Error("Both runs should generate same output") 310 | } 311 | 312 | if tt.expectCache { 313 | if almostEqual(duration1, duration2, 0.5) { 314 | t.Errorf("Expected different durations with cache, got %v and %v", duration1, duration2) 315 | } 316 | } else if !almostEqual(duration1, duration2, 0.5) { 317 | t.Errorf("Expected similar durations without cache, got %v and %v", duration1, duration2) 318 | } 319 | // Verify outputs are valid images 320 | assertImageFormat(t, outBuf1.Bytes(), "png") 321 | assertImageFormat(t, outBuf2.Bytes(), "png") 322 | }) 323 | } 324 | } 325 | 326 | func almostEqual(a, b time.Duration, relTol float64) bool { 327 | aa := float64(a) 328 | bb := float64(b) 329 | return math.Abs(aa-bb) <= relTol*math.Max(aa, bb) 330 | } 331 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | laminate 2 | ======== 3 | 4 | [![Test Status](https://github.com/Songmu/laminate/actions/workflows/test.yaml/badge.svg?branch=main)][actions] 5 | [![Coverage Status](https://codecov.io/gh/Songmu/laminate/branch/main/graph/badge.svg)][codecov] 6 | [![MIT License](https://img.shields.io/github/license/Songmu/laminate)][license] 7 | [![PkgGoDev](https://pkg.go.dev/badge/github.com/Songmu/laminate)][PkgGoDev] 8 | 9 | [actions]: https://github.com/Songmu/laminate/actions?workflow=test 10 | [codecov]: https://codecov.io/gh/Songmu/laminate 11 | [license]: https://github.com/Songmu/laminate/blob/main/LICENSE 12 | [PkgGoDev]: https://pkg.go.dev/github.com/Songmu/laminate 13 | 14 | A command-line bridge tool that orchestrates external image generation commands to convert text/code strings to images. 15 | 16 | > [!IMPORTANT] 17 | > `laminate` itself does not generate images. Instead, it acts as a **bridge** that routes input text to appropriate external tools (like `qrencode`, `silicon`, `mmdc`, `convert`, etc.) based on configurable patterns and manages the execution flow. 18 | 19 | ## How It Works 20 | 21 | 1. **Input**: Reads text from stdin and language specification via `--lang` flag or `CODEBLOCK_LANG` environment variable 22 | 2. **Routing**: Matches the language against configured patterns to select the appropriate external command 23 | 3. **Execution**: Runs the selected external command with proper input/output handling 24 | 4. **Output**: Returns the generated image data to stdout 25 | 5. **Caching**: Optionally caches results to avoid re-executing expensive commands 26 | 27 | ## Synopsis 28 | 29 | ```bash 30 | # Generate QR code from text 31 | echo "https://github.com/Songmu/laminate" | laminate --lang qr > qr.png 32 | 33 | # Convert code to syntax-highlighted image 34 | cat main.go | laminate --lang go > code.png 35 | 36 | # Use with environment variable 37 | export CODEBLOCK_LANG=python 38 | cat script.py | laminate > python_code.png 39 | 40 | # Generate image from any text (fallback to wildcard pattern) 41 | echo "Hello World" | laminate --lang unknown > text.png 42 | 43 | # Integration with k1LoW/deck for slide generation 44 | deck apply -c laminate deck.md # deck sets CODEBLOCK_LANG automatically 45 | ``` 46 | 47 | > [!TIP] 48 | > `laminate` works seamlessly with [k1LoW/deck](https://github.com/k1LoW/deck) for generating slides with embedded code images. Use `deck apply -c laminate deck.md` to automatically convert code blocks in your markdown slides to images. 49 | 50 | ## Prerequisites 51 | 52 | > [!IMPORTANT] 53 | > You need to install the actual image generation tools that you want to use. `laminate` will fail if the required external commands are not available in your PATH. 54 | 55 | The following are just examples of popular tools. You can use any command-line tool that can generate images - the choice is entirely up to you and your specific needs. 56 | 57 | ```bash 58 | # For QR codes 59 | brew install qrencode # macOS 60 | apt-get install qrencode # Ubuntu/Debian 61 | 62 | # For code syntax highlighting 63 | cargo install silicon 64 | 65 | # For Mermaid diagrams 66 | npm install -g @mermaid-js/mermaid-cli 67 | 68 | # For text-to-image (ImageMagick) 69 | brew install imagemagick # macOS 70 | apt-get install imagemagick # Ubuntu/Debian 71 | ``` 72 | 73 | ## Installation 74 | 75 |
76 | Click to expand installation methods 77 | 78 | ```console 79 | # Install via Homebrew (macOS) 80 | % brew install songmu/tap/laminate 81 | 82 | # Install the latest version. (Install it into ./bin/ by default). 83 | % curl -sfL https://raw.githubusercontent.com/Songmu/laminate/main/install.sh | sh -s 84 | 85 | # Specify installation directory ($(go env GOPATH)/bin/) and version. 86 | % curl -sfL https://raw.githubusercontent.com/Songmu/laminate/main/install.sh | sh -s -- -b $(go env GOPATH)/bin [vX.Y.Z] 87 | 88 | # In alpine linux (as it does not come with curl by default) 89 | % wget -O - -q https://raw.githubusercontent.com/Songmu/laminate/main/install.sh | sh -s [vX.Y.Z] 90 | 91 | # go install 92 | % go install github.com/Songmu/laminate/cmd/laminate@latest 93 | ``` 94 | 95 |
96 | 97 | ## Configuration 98 | 99 | Create a configuration file at `~/.config/laminate/config.yaml` (or `$XDG_CONFIG_HOME/laminate/config.yaml`): 100 | 101 | ```yaml 102 | cache: 1h 103 | commands: 104 | - lang: qr 105 | run: 'qrencode -o "{{output}}" -t png "{{input}}"' 106 | ext: png 107 | - lang: mermaid 108 | run: 'mmdc -i - -o "{{output}}" --quiet' 109 | ext: png 110 | - lang: '{go,rust,python,java,javascript,typescript}' 111 | run: 'silicon -l "{{lang}}" -o "{{output}}"' 112 | ext: png 113 | - lang: '*' 114 | run: ['convert', '-background', 'white', '-fill', 'black', 'label:{{input}}', '{{output}}'] 115 | ``` 116 | 117 | ### Configuration Schema 118 | 119 | - **`cache`**: Cache duration (e.g., `1h`, `30m`, `15s`). Omit to disable caching. 120 | - **`commands`**: Array of command configurations. 121 | - **`lang`**: Language pattern (supports glob patterns and brace expansion) 122 | - **`run`**: Command to execute (string or array format) 123 | - **`ext`**: Output file extension (default: `png`) 124 | - **`shell`**: Shell to use for string commands (default: `bash` or `sh`) 125 | 126 | ### Template Variables 127 | 128 | You can use these variables in your commands as needed. The presence or absence of `{{input}}` and `{{output}}` determines how laminate handles I/O with the external command. 129 | 130 | - **`{{input}}`**: Input text from stdin 131 | - Present: Input passed as command-line argument 132 | - Absent: Input passed via stdin to the command 133 | - **`{{output}}`**: Output file path with extension from `ext` field (default: `png`) 134 | - Present: Command writes to this file, laminate reads it 135 | - Absent: Command writes to stdout, laminate captures it 136 | - **`{{lang}}`**: The language parameter specified by user 137 | 138 | **I/O Behavior Examples:** 139 | 140 | | Variables Used | Example Command | How it works | 141 | |---------------|-----------------|--------------| 142 | | Both | `qrencode -o "{{output}}" "{{input}}"` | Input as arg, output to file | 143 | | Output only | `mmdc -i - -o "{{output}}"` | Input via stdin, output to file | 144 | | Input only | `convert label:"{{input}}" png:-` | Input as arg, output to stdout | 145 | | Neither | `some-converter` | Input via stdin, output to stdout | 146 | 147 | ### Language Matching 148 | 149 | Commands are matched against the specified language in **first-match-wins** order from top to bottom in the configuration file. The matching process: 150 | 151 | 1. **Sequential matching**: Each command's `lang` pattern is tested in the order they appear in the config 152 | 2. **First match wins**: The first command whose `lang` pattern matches the specified language is used 153 | 3. **Pattern types**: Supports exact matches, glob patterns, and brace expansion 154 | - Exact: `go`, `python`, `rust` 155 | - Brace expansion: `{go,rust,python}`, `{js,ts}` 156 | - Glob patterns: `py*`, `*script`, `*` 157 | 4. **Fallback**: Typically a wildcard pattern `*` is placed last to catch unmatched languages 158 | 159 | **Example matching order:** 160 | ```yaml 161 | commands: 162 | - lang: go # 1st: Matches "go" exactly 163 | - lang: '{py,python}' # 2nd: Matches "py" or "python" 164 | - lang: 'js*' # 3rd: Matches "js", "json", "jsx", etc. 165 | - lang: '*' # 4th: Matches any remaining language 166 | ``` 167 | 168 | For language `python`: matches the 2nd command (`{py,python}`) and stops there. 169 | 170 | > [!TIP] 171 | > Put more specific patterns at the top and general patterns (like `*`) at the bottom to ensure proper matching priority. 172 | 173 | ## Environment Variables 174 | 175 | - `CODEBLOCK_LANG`: Language specification via environment variable (automatically set by [k1LoW/deck](https://github.com/k1LoW/deck)) 176 | 177 | ## Cache Management 178 | 179 | Cache files are stored in `${XDG_CACHE_HOME:-~/.cache}/laminate/cache/` and keyed by input content + language + format. 180 | 181 | ```yaml 182 | # Set cache duration 183 | cache: 2h 184 | 185 | # Disable caching (omit cache field) 186 | # cache: 0s 187 | ``` 188 | 189 | You can clear the cache by deleting the cache directory. 190 | 191 | ## Usage Examples 192 | 193 | ### Template Variable Behaviors 194 | 195 | #### Commands with `{{input}}` and `{{output}}` 196 | ```yaml 197 | # Input passed as argument, output to file 198 | - lang: qr 199 | run: 'qrencode -o "{{output}}" -t png "{{input}}"' 200 | ext: png 201 | ``` 202 | ```bash 203 | echo "https://example.com" | laminate --lang qr > qr.png 204 | # Executes: qrencode -o "/tmp/laminate123.png" -t png "https://example.com" 205 | ``` 206 | 207 | #### Commands with `{{output}}` only (stdin input) 208 | ```yaml 209 | # Input via stdin, output to file 210 | - lang: mermaid 211 | run: 'mmdc -i - -o "{{output}}" --quiet' 212 | ext: png 213 | ``` 214 | ```bash 215 | echo "graph TD; A-->B" | laminate --lang mermaid > diagram.png 216 | # Executes: mmdc -i - -o "/tmp/laminate456.png" --quiet 217 | # (with "graph TD; A-->B" passed via stdin) 218 | ``` 219 | 220 | #### Commands without `{{output}}` (stdout output) 221 | ```yaml 222 | # Input as argument, output via stdout 223 | - lang: text 224 | run: 'convert -background white -fill black label:"{{input}}" png:-' 225 | ``` 226 | ```bash 227 | echo "Hello World" | laminate --lang text > text.png 228 | # Executes: convert -background white -fill black label:"Hello World" png:- 229 | # (image data read from command's stdout) 230 | ``` 231 | 232 | #### Commands without both variables (stdin to stdout) 233 | ```yaml 234 | # Input via stdin, output via stdout 235 | - lang: simple 236 | run: 'some-image-converter' 237 | ``` 238 | ```bash 239 | echo "input text" | laminate --lang simple > output.png 240 | # Executes: some-image-converter 241 | # (with "input text" passed via stdin, image read from stdout) 242 | ``` 243 | 244 | ### Real-world Examples 245 | 246 | #### QR Code Generation 247 | ```bash 248 | echo "https://example.com" | laminate --lang qr > qr.png 249 | ``` 250 | 251 | #### Code Syntax Highlighting 252 | ```bash 253 | # Using --lang flag (highest priority) 254 | cat main.go | laminate --lang go > code.png 255 | 256 | # Using environment variable 257 | export CODEBLOCK_LANG=python 258 | cat script.py | laminate > highlighted.png 259 | 260 | # Empty language (uses first matching pattern) 261 | cat README.md | laminate --lang "" > readme.png 262 | ``` 263 | 264 | > [!NOTE] 265 | > Priority: `--lang` flag > `CODEBLOCK_LANG` environment variable > empty string 266 | 267 | #### Mermaid Diagrams 268 | ```bash 269 | cat << EOF | laminate --lang mermaid > diagram.png 270 | graph TD 271 | A[Start] --> B[Process] 272 | B --> C[End] 273 | EOF 274 | ``` 275 | 276 | ## Author 277 | 278 | [Songmu](https://github.com/Songmu) 279 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | # Code generated by godzil. DO NOT EDIT. 4 | # It is based on the one generated by godownloader. 5 | 6 | usage() { 7 | this=$1 8 | cat </dev/null 126 | } 127 | echoerr() { 128 | echo "$@" 1>&2 129 | } 130 | log_prefix() { 131 | echo "$0" 132 | } 133 | _logp=6 134 | log_set_priority() { 135 | _logp="$1" 136 | } 137 | log_priority() { 138 | if test -z "$1"; then 139 | echo "$_logp" 140 | return 141 | fi 142 | [ "$1" -le "$_logp" ] 143 | } 144 | log_tag() { 145 | case $1 in 146 | 0) echo "emerg" ;; 147 | 1) echo "alert" ;; 148 | 2) echo "crit" ;; 149 | 3) echo "err" ;; 150 | 4) echo "warning" ;; 151 | 5) echo "notice" ;; 152 | 6) echo "info" ;; 153 | 7) echo "debug" ;; 154 | *) echo "$1" ;; 155 | esac 156 | } 157 | log_debug() { 158 | log_priority 7 || return 0 159 | echoerr "$(log_prefix)" "$(log_tag 7)" "$@" 160 | } 161 | log_info() { 162 | log_priority 6 || return 0 163 | echoerr "$(log_prefix)" "$(log_tag 6)" "$@" 164 | } 165 | log_err() { 166 | log_priority 3 || return 0 167 | echoerr "$(log_prefix)" "$(log_tag 3)" "$@" 168 | } 169 | log_crit() { 170 | log_priority 2 || return 0 171 | echoerr "$(log_prefix)" "$(log_tag 2)" "$@" 172 | } 173 | uname_os() { 174 | os=$(uname -s | tr '[:upper:]' '[:lower:]') 175 | case "$os" in 176 | cygwin_nt*) os="windows" ;; 177 | mingw*) os="windows" ;; 178 | msys_nt*) os="windows" ;; 179 | esac 180 | echo "$os" 181 | } 182 | uname_arch() { 183 | arch=$(uname -m) 184 | case $arch in 185 | x86_64) arch="amd64" ;; 186 | x86) arch="386" ;; 187 | i686) arch="386" ;; 188 | i386) arch="386" ;; 189 | aarch64) arch="arm64" ;; 190 | armv5*) arch="armv5" ;; 191 | armv6*) arch="armv6" ;; 192 | armv7*) arch="armv7" ;; 193 | esac 194 | echo ${arch} 195 | } 196 | uname_os_check() { 197 | os=$(uname_os) 198 | case "$os" in 199 | darwin) return 0 ;; 200 | dragonfly) return 0 ;; 201 | freebsd) return 0 ;; 202 | linux) return 0 ;; 203 | android) return 0 ;; 204 | nacl) return 0 ;; 205 | netbsd) return 0 ;; 206 | openbsd) return 0 ;; 207 | plan9) return 0 ;; 208 | solaris) return 0 ;; 209 | windows) return 0 ;; 210 | esac 211 | log_crit "uname_os_check '$(uname -s)' got converted to '$os' which is not a GOOS value. Please file bug at https://github.com/client9/shlib" 212 | return 1 213 | } 214 | uname_arch_check() { 215 | arch=$(uname_arch) 216 | case "$arch" in 217 | 386) return 0 ;; 218 | amd64) return 0 ;; 219 | arm64) return 0 ;; 220 | armv5) return 0 ;; 221 | armv6) return 0 ;; 222 | armv7) return 0 ;; 223 | ppc64) return 0 ;; 224 | ppc64le) return 0 ;; 225 | mips) return 0 ;; 226 | mipsle) return 0 ;; 227 | mips64) return 0 ;; 228 | mips64le) return 0 ;; 229 | s390x) return 0 ;; 230 | amd64p32) return 0 ;; 231 | esac 232 | log_crit "uname_arch_check '$(uname -m)' got converted to '$arch' which is not a GOARCH value. Please file bug report at https://github.com/client9/shlib" 233 | return 1 234 | } 235 | untar() { 236 | tarball=$1 237 | case "${tarball}" in 238 | *.tar.gz | *.tgz) tar --no-same-owner -xzf "${tarball}" ;; 239 | *.tar) tar --no-same-owner -xf "${tarball}" ;; 240 | *.zip) unzip "${tarball}" ;; 241 | *) 242 | log_err "untar unknown archive format for ${tarball}" 243 | return 1 244 | ;; 245 | esac 246 | } 247 | http_download_curl() { 248 | local_file=$1 249 | source_url=$2 250 | header=$3 251 | if [ -z "$header" ]; then 252 | code=$(curl -w '%{http_code}' -sL -o "$local_file" "$source_url") 253 | else 254 | code=$(curl -w '%{http_code}' -sL -H "$header" -o "$local_file" "$source_url") 255 | fi 256 | if [ "$code" != "200" ]; then 257 | log_debug "http_download_curl received HTTP status $code" 258 | return 1 259 | fi 260 | return 0 261 | } 262 | http_download_wget() { 263 | local_file=$1 264 | source_url=$2 265 | header=$3 266 | if [ -z "$header" ]; then 267 | wget -q -O "$local_file" "$source_url" 268 | else 269 | wget -q --header "$header" -O "$local_file" "$source_url" 270 | fi 271 | } 272 | http_download() { 273 | log_debug "http_download $2" 274 | if is_command curl; then 275 | http_download_curl "$@" 276 | return 277 | elif is_command wget; then 278 | http_download_wget "$@" 279 | return 280 | fi 281 | log_crit "http_download unable to find wget or curl" 282 | return 1 283 | } 284 | http_copy() { 285 | tmp=$(mktemp) 286 | http_download "${tmp}" "$1" "$2" || return 1 287 | body=$(cat "$tmp") 288 | rm -f "${tmp}" 289 | echo "$body" 290 | } 291 | github_release() { 292 | owner_repo=$1 293 | version=$2 294 | test -z "$version" && version="latest" 295 | giturl="https://github.com/${owner_repo}/releases/${version}" 296 | json=$(http_copy "$giturl" "Accept:application/json") 297 | test -z "$json" && return 1 298 | version=$(echo "$json" | tr -s '\n' ' ' | sed 's/.*"tag_name":"//' | sed 's/".*//') 299 | test -z "$version" && return 1 300 | echo "$version" 301 | } 302 | hash_sha256() { 303 | TARGET=${1:-/dev/stdin} 304 | if is_command gsha256sum; then 305 | hash=$(gsha256sum "$TARGET") || return 1 306 | echo "$hash" | cut -d ' ' -f 1 307 | elif is_command sha256sum; then 308 | hash=$(sha256sum "$TARGET") || return 1 309 | echo "$hash" | cut -d ' ' -f 1 310 | elif is_command shasum; then 311 | hash=$(shasum -a 256 "$TARGET" 2>/dev/null) || return 1 312 | echo "$hash" | cut -d ' ' -f 1 313 | elif is_command openssl; then 314 | hash=$(openssl -dst openssl dgst -sha256 "$TARGET") || return 1 315 | echo "$hash" | cut -d ' ' -f a 316 | else 317 | log_crit "hash_sha256 unable to find command to compute sha-256 hash" 318 | return 1 319 | fi 320 | } 321 | hash_sha256_verify() { 322 | TARGET=$1 323 | checksums=$2 324 | if [ -z "$checksums" ]; then 325 | log_err "hash_sha256_verify checksum file not specified in arg2" 326 | return 1 327 | fi 328 | BASENAME=${TARGET##*/} 329 | want=$(grep "${BASENAME}" "${checksums}" 2>/dev/null | tr '\t' ' ' | cut -d ' ' -f 1) 330 | if [ -z "$want" ]; then 331 | log_err "hash_sha256_verify unable to find checksum for '${TARGET}' in '${checksums}'" 332 | return 1 333 | fi 334 | got=$(hash_sha256 "$TARGET") 335 | if [ "$want" != "$got" ]; then 336 | log_err "hash_sha256_verify checksum for '$TARGET' did not verify ${want} vs $got" 337 | return 1 338 | fi 339 | } 340 | cat /dev/null <