From 491fdd5fda6640fb22ed5faf160b8fdaf11b51e7 Mon Sep 17 00:00:00 2001 From: hakki Date: Tue, 31 Mar 2026 22:22:44 +0800 Subject: [PATCH] feat: add from-readme command for README-driven GIF generation --- README.md | 57 +++++++ fromreadme.go | 291 ++++++++++++++++++++++++++++++++++ fromreadme_test.go | 384 +++++++++++++++++++++++++++++++++++++++++++++ main.go | 1 + 4 files changed, 733 insertions(+) create mode 100644 fromreadme.go create mode 100644 fromreadme_test.go diff --git a/README.md b/README.md index 8e3384ae..ab7f9702 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,63 @@ settings or modify actions. Then, you can generate the GIF: vhs cassette.tape ``` +## From README + +VHS can automatically generate a GIF by running shell commands found in a +Markdown document such as your project's `README.md`. + +```bash +vhs from-readme README.md --output demo.gif +``` + +If no file is given, VHS searches the current directory for a README file +automatically. + +Use `--section` to scope extraction to a specific heading: + +```bash +vhs from-readme README.md --section "Tutorial" --output tutorial.gif +``` + +Use `--command` to only record lines that start with a particular CLI tool +(handles `sudo`/`npx`/`env` prefixes transparently): + +```bash +vhs from-readme README.md --command myapp --output myapp.gif +``` + +Use `--dry-run` to preview the generated tape without recording: + +```bash +vhs from-readme README.md --dry-run +``` + +Save the generated tape to a file for further editing: + +```bash +vhs from-readme README.md --tape-out demo.tape --dry-run +``` + +
+All flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--section STRING` | | Only extract commands under this heading (case-insensitive) | +| `--command STRING` | | Only include lines whose first token matches this CLI name | +| `-o, --output STRING` | `vhs.gif` | Output GIF file path | +| `--pause DURATION` | `2s` | Sleep between commands | +| `--typing-speed DURATION` | `75ms` | Per-keystroke typing delay | +| `--dry-run` | | Print the generated tape without recording | +| `--tape-out STRING` | | Also save the generated tape to this file | +| `--font-size INT` | `15` | Terminal font size | +| `--width INT` | `1600` | Terminal width in pixels | +| `--height INT` | `900` | Terminal height in pixels | +| `--wait-timeout DURATION` | `2m` | Max time to wait for shell prompt after each command (increase for long-running commands) | +| `--wait-pattern REGEX` | `[$#>%] *$` | Regex to detect shell prompt (matches bash, zsh, fish, root) | + +
+ ## Publish Tapes VHS allows you to publish your GIFs to our servers for easy sharing with your diff --git a/fromreadme.go b/fromreadme.go new file mode 100644 index 00000000..c44d052d --- /dev/null +++ b/fromreadme.go @@ -0,0 +1,291 @@ +package main + +import ( + "errors" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" +) + +// fromReadmeOptions holds the configuration for the from-readme command. +type fromReadmeOptions struct { + section string + command string + output string + pause time.Duration + typingSpeed time.Duration + dryRun bool + tapeOut string + fontSize int + width int + height int + waitTimeout time.Duration + waitPattern string +} + +// defaultFromReadmeWaitPattern matches the end of common bash/zsh/fish/root prompts. +// VHS's built-in default (">$") only matches fish; this covers "$", "#", ">", "%". +const defaultFromReadmeWaitPattern = `[$#>%] *$` + +var fromReadmeOpts fromReadmeOptions + +var fromReadmeCmd = &cobra.Command{ + Use: "from-readme [file]", + Short: "Generate a GIF by running shell commands from a Markdown document", + SilenceUsage: true, + Args: cobra.MaximumNArgs(1), + RunE: runFromReadme, +} + +func init() { + fromReadmeCmd.Flags().StringVar(&fromReadmeOpts.section, "section", "", "only extract commands under this heading (case-insensitive)") + fromReadmeCmd.Flags().StringVar(&fromReadmeOpts.command, "command", "", "only include lines whose first token matches this CLI name") + fromReadmeCmd.Flags().StringVarP(&fromReadmeOpts.output, "output", "o", "vhs.gif", "output GIF file path") + fromReadmeCmd.Flags().DurationVar(&fromReadmeOpts.pause, "pause", 2*time.Second, "sleep duration between commands") + fromReadmeCmd.Flags().DurationVar(&fromReadmeOpts.typingSpeed, "typing-speed", 75*time.Millisecond, "per-keystroke typing delay") + fromReadmeCmd.Flags().BoolVar(&fromReadmeOpts.dryRun, "dry-run", false, "print the generated tape without recording") + fromReadmeCmd.Flags().StringVar(&fromReadmeOpts.tapeOut, "tape-out", "", "also save the generated tape to this file") + fromReadmeCmd.Flags().IntVar(&fromReadmeOpts.fontSize, "font-size", 15, "terminal font size") + fromReadmeCmd.Flags().IntVar(&fromReadmeOpts.width, "width", 1600, "terminal width in pixels") + fromReadmeCmd.Flags().IntVar(&fromReadmeOpts.height, "height", 900, "terminal height in pixels") + fromReadmeCmd.Flags().DurationVar(&fromReadmeOpts.waitTimeout, "wait-timeout", 2*time.Minute, "max time to wait for shell prompt after each command (increase for long-running commands)") + fromReadmeCmd.Flags().StringVar(&fromReadmeOpts.waitPattern, "wait-pattern", defaultFromReadmeWaitPattern, "regex to detect shell prompt (used in Wait commands)") +} + +func runFromReadme(cmd *cobra.Command, args []string) error { + var mdFile string + if len(args) > 0 { + mdFile = args[0] + } else { + var err error + mdFile, err = findREADME(".") + if err != nil { + return err + } + log.Println(GrayStyle.Render("File: " + mdFile)) + } + + content, err := os.ReadFile(mdFile) + if err != nil { + return fmt.Errorf("reading %s: %w", mdFile, err) + } + + commands, err := extractShellCommands(string(content), fromReadmeOpts.section) + if err != nil { + return err + } + + if fromReadmeOpts.command != "" { + commands = filterByCommand(commands, fromReadmeOpts.command) + } + + if len(commands) == 0 { + return errors.New("no shell commands found") + } + + tape := buildFromReadmeTape(commands, fromReadmeOpts) + + if fromReadmeOpts.tapeOut != "" { + if err := os.WriteFile(fromReadmeOpts.tapeOut, []byte(tape), 0o600); err != nil { + return fmt.Errorf("writing tape file: %w", err) + } + log.Println(GrayStyle.Render("Tape: " + fromReadmeOpts.tapeOut)) + } + + if fromReadmeOpts.dryRun { + fmt.Fprint(cmd.OutOrStdout(), tape) + return nil + } + + if err := ensureDependencies(); err != nil { + return err + } + + out := cmd.OutOrStdout() + if quietFlag { + out = os.Stderr + } + + errs := Evaluate(cmd.Context(), tape, out) + if len(errs) > 0 { + printErrors(os.Stderr, tape, errs) + return errors.New("recording failed") + } + + return nil +} + +// findREADME searches dir for a README file, returning the first one found. +func findREADME(dir string) (string, error) { + candidates := []string{"README.md", "Readme.md", "readme.md", "README"} + for _, name := range candidates { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err == nil { + return p, nil + } + } + return "", fmt.Errorf("no README found in %s", dir) +} + +// shellLanguages is the set of fenced code block language tags treated as shell. +var shellLanguages = map[string]bool{ + "sh": true, + "bash": true, + "zsh": true, + "fish": true, + "shell": true, + "powershell": true, + "pwsh": true, + "cmd": true, +} + +// extractShellCommands parses Markdown content and returns shell command lines +// found in fenced code blocks. If section is non-empty, only blocks under the +// matching heading are included (case-insensitive match). The section ends when +// a heading at the same or higher level is encountered. +func extractShellCommands(content, section string) ([]string, error) { + lines := strings.Split(content, "\n") + + inSection := section == "" + sectionLevel := 0 + inCodeBlock := false + isShellBlock := false + + var commands []string + + for _, line := range lines { + // Detect fenced code block boundaries (``` or ~~~). + if isFence(line) { + if !inCodeBlock { + lang := strings.ToLower(strings.TrimSpace(strings.TrimLeft(line, "`~"))) + inCodeBlock = true + isShellBlock = shellLanguages[lang] && inSection + } else { + inCodeBlock = false + isShellBlock = false + } + continue + } + + // While inside a code block, collect non-empty, non-comment lines. + if inCodeBlock { + if isShellBlock && line != "" && !strings.HasPrefix(line, "#") { + commands = append(commands, line) + } + continue + } + + // Handle headings for section filtering. + if strings.HasPrefix(line, "#") && section != "" { + level := headingLevel(line) + text := strings.ToLower(strings.TrimSpace(strings.TrimLeft(line, "# "))) + + switch { + case strings.Contains(text, strings.ToLower(section)): + inSection = true + sectionLevel = level + case inSection && level <= sectionLevel: + inSection = false + } + } + } + + return commands, nil +} + +// isFence returns true if line is a fenced code block delimiter (``` or ~~~). +func isFence(line string) bool { + return strings.HasPrefix(line, "```") || strings.HasPrefix(line, "~~~") +} + +// headingLevel returns the ATX heading level (number of leading '#') of line. +func headingLevel(line string) int { + level := 0 + for _, ch := range line { + if ch == '#' { + level++ + } else { + break + } + } + return level +} + +// prefixTokens are command-line wrappers that precede the actual tool name. +var prefixTokens = map[string]bool{ + "sudo": true, + "npx": true, + "env": true, + "time": true, +} + +// filterByCommand returns only command lines whose effective first token +// (after stripping prefix wrappers like sudo/npx) matches command. +// If command is empty, all lines are returned unchanged. +func filterByCommand(commands []string, command string) []string { + if command == "" { + return commands + } + var out []string + for _, line := range commands { + tokens := strings.Fields(line) + for len(tokens) > 0 && prefixTokens[tokens[0]] { + tokens = tokens[1:] + } + if len(tokens) == 0 { + continue + } + first := filepath.Base(tokens[0]) + if first == command { + out = append(out, line) + } + } + return out +} + +// tapeDuration formats a time.Duration as the smallest clean VHS tape unit +// (e.g. 2m, 30s, 500ms) without trailing zero components that the parser rejects. +func tapeDuration(d time.Duration) string { + if d == 0 { + return "0s" + } + if d%time.Minute == 0 { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d%time.Second == 0 { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + return fmt.Sprintf("%dms", d.Milliseconds()) +} + +// buildFromReadmeTape generates a VHS tape string from the given shell commands. +func buildFromReadmeTape(commands []string, opts fromReadmeOptions) string { + var b strings.Builder + + fmt.Fprintf(&b, "Output %s\n\n", opts.output) + fmt.Fprintf(&b, "Set FontSize %d\n", opts.fontSize) + fmt.Fprintf(&b, "Set Width %d\n", opts.width) + fmt.Fprintf(&b, "Set Height %d\n", opts.height) + fmt.Fprintf(&b, "Set TypingSpeed %s\n", tapeDuration(opts.typingSpeed)) + fmt.Fprintf(&b, "Set WaitTimeout %s\n", tapeDuration(opts.waitTimeout)) + if opts.waitPattern != "" { + fmt.Fprintf(&b, "Set WaitPattern /%s/\n", opts.waitPattern) + } + b.WriteRune('\n') + + for _, cmd := range commands { + fmt.Fprintf(&b, "Type %s\n", quote(cmd)) + b.WriteString("Sleep 500ms\n") + b.WriteString("Enter\n") + b.WriteString("Wait\n") + fmt.Fprintf(&b, "Sleep %s\n", tapeDuration(opts.pause)) + b.WriteRune('\n') + } + + return b.String() +} diff --git a/fromreadme_test.go b/fromreadme_test.go new file mode 100644 index 00000000..34899882 --- /dev/null +++ b/fromreadme_test.go @@ -0,0 +1,384 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestFindREADME(t *testing.T) { + t.Run("finds README.md", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "README.md") + requireNoErr(t, os.WriteFile(path, []byte("# Hello"), 0o600)) + + got, err := findREADME(dir) + requireNoErr(t, err) + if got != path { + t.Errorf("want %q, got %q", path, got) + } + }) + + t.Run("finds README without extension", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "README") + requireNoErr(t, os.WriteFile(path, []byte("# Hello"), 0o600)) + + got, err := findREADME(dir) + requireNoErr(t, err) + if got != path { + t.Errorf("want %q, got %q", path, got) + } + }) + + t.Run("prefers README.md over README", func(t *testing.T) { + dir := t.TempDir() + requireNoErr(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("# A"), 0o600)) + requireNoErr(t, os.WriteFile(filepath.Join(dir, "README"), []byte("# B"), 0o600)) + + got, err := findREADME(dir) + requireNoErr(t, err) + want := filepath.Join(dir, "README.md") + if got != want { + t.Errorf("want %q, got %q", want, got) + } + }) + + t.Run("error when no README found", func(t *testing.T) { + dir := t.TempDir() + _, err := findREADME(dir) + requireErr(t, err) + }) +} + +func TestExtractShellCommands(t *testing.T) { + tests := []struct { + name string + content string + want []string + }{ + { + name: "extracts from bash block", + content: "```bash\necho hello\n```", + want: []string{"echo hello"}, + }, + { + name: "extracts from sh block", + content: "```sh\nls -la\n```", + want: []string{"ls -la"}, + }, + { + name: "extracts from zsh block", + content: "```zsh\npwd\n```", + want: []string{"pwd"}, + }, + { + name: "extracts from shell block", + content: "```shell\ndate\n```", + want: []string{"date"}, + }, + { + name: "skips non-shell code blocks", + content: "```go\nfmt.Println(\"hi\")\n```\n```bash\necho hi\n```", + want: []string{"echo hi"}, + }, + { + name: "skips comment lines inside code blocks", + content: "```bash\n# This is a comment\necho hello\n```", + want: []string{"echo hello"}, + }, + { + name: "skips empty lines inside code blocks", + content: "```bash\n\necho hello\n\n```", + want: []string{"echo hello"}, + }, + { + name: "returns nil when no shell blocks", + content: "```python\nprint('hi')\n```", + want: nil, + }, + { + name: "extracts from multiple blocks", + content: "```bash\necho first\n```\n\nSome text.\n\n```sh\necho second\n```", + want: []string{"echo first", "echo second"}, + }, + { + name: "extracts multiple lines from one block", + content: "```bash\necho one\necho two\necho three\n```", + want: []string{"echo one", "echo two", "echo three"}, + }, + { + name: "handles tilde fences", + content: "~~~bash\necho hello\n~~~", + want: []string{"echo hello"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := extractShellCommands(tc.content, "") + requireNoErr(t, err) + if !stringSliceEqual(got, tc.want) { + t.Errorf("want %v, got %v", tc.want, got) + } + }) + } +} + +func TestExtractShellCommandsWithSection(t *testing.T) { + tests := []struct { + name string + content string + section string + want []string + }{ + { + name: "extracts only from matching section", + content: `## Usage + +` + "```bash\necho usage\n```" + ` + +## Other + +` + "```bash\necho other\n```", + section: "Usage", + want: []string{"echo usage"}, + }, + { + name: "section match is case-insensitive", + content: `## INSTALLATION + +` + "```bash\necho install\n```", + section: "installation", + want: []string{"echo install"}, + }, + { + name: "section ends at same-level heading", + content: `## Usage + +` + "```bash\necho usage\n```" + ` + +## Install + +` + "```bash\necho install\n```", + section: "Usage", + want: []string{"echo usage"}, + }, + { + name: "section ends at higher-level heading", + content: `### Usage + +` + "```bash\necho usage\n```" + ` + +## Top Level + +` + "```bash\necho top\n```", + section: "Usage", + want: []string{"echo usage"}, + }, + { + name: "subsection included within parent section", + content: `## Usage + +` + "```bash\necho usage\n```" + ` + +### Advanced + +` + "```bash\necho advanced\n```" + ` + +## Other + +` + "```bash\necho other\n```", + section: "Usage", + want: []string{"echo usage", "echo advanced"}, + }, + { + name: "no match returns nil", + content: `## Usage + +` + "```bash\necho usage\n```", + section: "Install", + want: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := extractShellCommands(tc.content, tc.section) + requireNoErr(t, err) + if !stringSliceEqual(got, tc.want) { + t.Errorf("want %v, got %v", tc.want, got) + } + }) + } +} + +func TestFilterByCommand(t *testing.T) { + tests := []struct { + name string + commands []string + command string + want []string + }{ + { + name: "exact first token match", + commands: []string{"myapp run", "other run"}, + command: "myapp", + want: []string{"myapp run"}, + }, + { + name: "match after sudo prefix", + commands: []string{"sudo myapp install", "echo hi"}, + command: "myapp", + want: []string{"sudo myapp install"}, + }, + { + name: "match after npx prefix", + commands: []string{"npx myapp build"}, + command: "myapp", + want: []string{"npx myapp build"}, + }, + { + name: "match after env prefix", + commands: []string{"env myapp run"}, + command: "myapp", + want: []string{"env myapp run"}, + }, + { + name: "no match returns nil", + commands: []string{"echo hello", "ls -la"}, + command: "myapp", + want: nil, + }, + { + name: "empty command returns all lines", + commands: []string{"echo hello", "ls -la"}, + command: "", + want: []string{"echo hello", "ls -la"}, + }, + { + name: "basename matching for path-prefixed commands", + commands: []string{"/usr/local/bin/myapp run"}, + command: "myapp", + want: []string{"/usr/local/bin/myapp run"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := filterByCommand(tc.commands, tc.command) + if !stringSliceEqual(got, tc.want) { + t.Errorf("want %v, got %v", tc.want, got) + } + }) + } +} + +func TestTapeDuration(t *testing.T) { + tests := []struct { + input time.Duration + want string + }{ + {0, "0s"}, + {500 * time.Millisecond, "500ms"}, + {75 * time.Millisecond, "75ms"}, + {1 * time.Second, "1s"}, + {30 * time.Second, "30s"}, + {1 * time.Minute, "1m"}, + {2 * time.Minute, "2m"}, + {90 * time.Second, "90s"}, // 1m30s — not a whole minute, falls to seconds + {1500 * time.Millisecond, "1500ms"}, // not a whole second + } + for _, tc := range tests { + got := tapeDuration(tc.input) + if got != tc.want { + t.Errorf("tapeDuration(%v): want %q, got %q", tc.input, tc.want, got) + } + } +} + +func TestBuildFromReadmeTape(t *testing.T) { + opts := fromReadmeOptions{ + output: "out.gif", + fontSize: 15, + width: 1600, + height: 900, + typingSpeed: 75 * time.Millisecond, + pause: 2 * time.Second, + waitTimeout: 2 * time.Minute, + waitPattern: defaultFromReadmeWaitPattern, + } + + t.Run("header contains settings", func(t *testing.T) { + tape := buildFromReadmeTape([]string{"echo hi"}, opts) + for _, want := range []string{ + "Output out.gif", + "Set FontSize 15", + "Set Width 1600", + "Set Height 900", + "Set TypingSpeed 75ms", + "Set WaitTimeout 2m", + "Set WaitPattern", + } { + if !strings.Contains(tape, want) { + t.Errorf("tape missing %q\ntape:\n%s", want, tape) + } + } + }) + + t.Run("command produces Type/Sleep/Enter/Wait/Sleep sequence", func(t *testing.T) { + tape := buildFromReadmeTape([]string{"echo hello"}, opts) + for _, want := range []string{ + `Type "echo hello"`, + "Sleep 500ms", + "Enter", + "Wait", + "Sleep 2s", + } { + if !strings.Contains(tape, want) { + t.Errorf("tape missing %q\ntape:\n%s", want, tape) + } + } + }) + + t.Run("command with double quotes uses single-quote wrapping", func(t *testing.T) { + tape := buildFromReadmeTape([]string{`echo "hello"`}, opts) + if !strings.Contains(tape, `Type 'echo "hello"'`) { + t.Errorf("expected single-quoted Type, got:\n%s", tape) + } + }) + + t.Run("multiple commands all appear", func(t *testing.T) { + tape := buildFromReadmeTape([]string{"echo one", "echo two", "echo three"}, opts) + for _, cmd := range []string{"echo one", "echo two", "echo three"} { + if !strings.Contains(tape, cmd) { + t.Errorf("tape missing command %q\ntape:\n%s", cmd, tape) + } + } + }) + + t.Run("custom pause appears in tape", func(t *testing.T) { + custom := opts + custom.pause = 3 * time.Second + tape := buildFromReadmeTape([]string{"echo hi"}, custom) + if !strings.Contains(tape, "Sleep 3s") { + t.Errorf("expected Sleep 3s in tape:\n%s", tape) + } + }) +} + +// stringSliceEqual compares two string slices for equality, treating nil and +// empty slice as equal. +func stringSliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/main.go b/main.go index 7df07d73..f3a2ac8d 100644 --- a/main.go +++ b/main.go @@ -273,6 +273,7 @@ func init() { manCmd, serveCmd, publishCmd, + fromReadmeCmd, ) rootCmd.CompletionOptions.HiddenDefaultCmd = true