elvish/website/cmd/ttyshot/interp.go
Qi Xiao 1412473b4a website: Revamp homepage, site-wide styles and ttyshots.
- Homepage revamp:

  - Rewrite the homepage in a linear format of sections, with every other
    section styled with full-width gray background.

  - The first section is a short intro, with big buttons for "download" and
    "learn".

  - Create a new "case studies" page to house the explainers for the examples.

- Site-wide styles:

  - Restyle code blocks and ttyshots, using a more subtle gray border box
    instead of gray background.

  - Support headers for code blocks and ttyshots, in a style that vaguely
    resembles title bars of desktop windows. In Markdown sources, additional
    content after the language tag becomes the header.

  - Reorganize the global stylesheet and template, including using 2 spaces for
    indentation and putting dark mode color rules next to the light mode rules.

  - Change debug hotkey to toggle dark mode to Shift-D.

- Ttyshots:

  - Handle trailing spaces in more places in the ttyshot tool - there seems to
    be more of them in the latest tmux version.

  - Base the ttyshot tool's parser on pkg/transcript.

  - Support per-ttyshot cols and rows.

  - Rename x.elvts and x.ttyshot.html to x-ttyshot.elvts and x-ttyshot.html.

  - Change the syntax from "@ttyshot filename" to code block with a special
   "ttyshot" language tag to work better with the header syntax.
2024-02-26 18:23:16 +00:00

333 lines
8.5 KiB
Go

//go:build unix
package main
import (
"bytes"
_ "embed"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
"github.com/creack/pty"
"src.elv.sh/pkg/sys/eunix"
"src.elv.sh/pkg/ui"
)
const (
cutMarker = "[CUT]"
promptMarker = "[PROMPT]"
)
// "tmux capture-pane" can save superfluous trailing spaces, so when removing
// these patterns we need to account for that.
var (
cutPattern = regexp.MustCompile(regexp.QuoteMeta(cutMarker) + " *\n")
promptPattern = regexp.MustCompile(regexp.QuoteMeta(promptMarker) + " *\n")
)
//go:embed rc.elv
var rcElv string
// Creates a temporary home directory for running tmux and elvish in. The caller
// is responsible for removing the directory.
func setupHome() (string, error) {
homePath, err := os.MkdirTemp("", "ttyshot-*")
if err != nil {
return "", fmt.Errorf("create temp home: %w", err)
}
// The temporary directory may include symlinks in the path. Expand them so
// that commands like tilde-abbr behaves as expected.
resolvedHomePath, err := filepath.EvalSymlinks(homePath)
if err != nil {
return homePath, fmt.Errorf("resolve symlinks in homePath: %w", err)
}
homePath = resolvedHomePath
err = ApplyDir(Dir{
// Directories to be used in navigation mode.
"bash": Dir{},
"elvish": Dir{
"1.0-release.md": "1.0 has not been released yet.",
"CONTRIBUTING.md": "",
"Dockerfile": "",
"LICENSE": "",
"Makefile": "",
"PACKAGING.md": "",
"README.md": "",
"SECURITY.md": "",
"cmd": Dir{},
"go.mod": "",
"go.sum": "",
"pkg": Dir{},
"syntaxes": Dir{},
"tools": Dir{},
"vscode": Dir{},
"website": Dir{},
},
"zsh": Dir{},
// Will keep tmux and elvish's sockets, and raw output of capture-pane
".tmp": Dir{},
".config": Dir{
"elvish": Dir{
"rc.elv": rcElv,
},
},
}, homePath)
return homePath, err
}
func createTtyshot(homePath string, script *script, saveRaw string) ([]byte, error) {
ctrl, tty, err := pty.Open()
if err != nil {
return nil, err
}
defer ctrl.Close()
defer tty.Close()
winsize := pty.Winsize{Rows: script.rows, Cols: script.cols}
pty.Setsize(ctrl, &winsize)
rawPath := filepath.Join(homePath, ".tmp", "ttyshot.raw")
if saveRaw != "" {
saveRaw, err := filepath.Abs(saveRaw)
if err != nil {
return nil, fmt.Errorf("resolve path to raw dump file: %w", err)
}
os.Symlink(saveRaw, rawPath)
}
doneCh, err := spawnElvish(homePath, tty)
if err != nil {
return nil, err
}
finalEmptyPrompt := executeScript(script.ops, ctrl, homePath)
log.Println("executed script, waiting for tmux to exit")
// Drain outputs from the terminal. This is needed so that tmux can exit
// properly without blocking on flushing outputs.
go io.Copy(io.Discard, ctrl)
err = <-doneCh
if err != nil {
return nil, err
}
rawBytes, err := os.ReadFile(rawPath)
if err != nil {
return nil, err
}
ttyshot := string(rawBytes)
// Remove all content before the last cutMarker.
segments := cutPattern.Split(ttyshot, -1)
ttyshot = segments[len(segments)-1]
// Strip all the prompt markers and the final empty prompt.
segments = promptPattern.Split(ttyshot, -1)
if finalEmptyPrompt {
segments = segments[:len(segments)-1]
}
ttyshot = strings.Join(segments, "")
ttyshot = strings.TrimRight(ttyshot, "\n")
return []byte(sgrTextToHTML(ttyshot) + "\n"), nil
}
func spawnElvish(homePath string, tty *os.File) (<-chan error, error) {
elvishPath, err := exec.LookPath("elvish")
if err != nil {
return nil, fmt.Errorf("find elvish: %w", err)
}
tmuxPath, err := exec.LookPath("tmux")
if err != nil {
return nil, fmt.Errorf("find tmux: %w", err)
}
tmuxSock := filepath.Join(homePath, ".tmp", "tmux.sock")
elvSock := filepath.Join(homePath, ".tmp", "elv.sock")
// Start tmux and have it start a hermetic Elvish session.
tmuxCmd := exec.Cmd{
Path: tmuxPath,
Args: []string{
tmuxPath,
"-S", tmuxSock, "-f", "/dev/null", "-u", "-T", "256,RGB",
"new-session", elvishPath, "-sock", elvSock},
Dir: homePath,
Env: []string{
"HOME=" + homePath,
"PATH=" + os.Getenv("PATH"),
// The actual value doesn't matter here, as long as it can be looked
// up in terminfo. We rely on the -T flag above to force tmux to
// support certain terminal features.
"TERM=xterm",
},
Stdin: tty,
Stdout: tty,
Stderr: tty,
}
log.Println("started tmux, socket", tmuxSock)
doneCh := make(chan error, 1)
go func() {
doneCh <- tmuxCmd.Run()
log.Println("tmux exited")
}()
return doneCh, nil
}
func executeScript(script []op, ctrl *os.File, homePath string) (finalEmptyPrompt bool) {
for _, op := range script {
log.Println("waiting for prompt")
err := waitForPrompt(ctrl)
if err != nil {
// TODO: Handle the error properly
panic(err)
}
log.Println("executing", op)
if op.isTmux {
tmuxSock := filepath.Join(homePath, ".tmp", "tmux.sock")
tmuxCmd := exec.Command("tmux",
append([]string{"-S", tmuxSock}, strings.Fields(op.code)...)...)
tmuxCmd.Env = []string{}
err := tmuxCmd.Run()
if err != nil {
// TODO: Handle the error properly
panic(err)
}
} else {
for i, line := range strings.Split(op.code, "\n") {
if i > 0 {
// Use Alt-Enter to avoid committing the code
ctrl.WriteString("\033\r")
}
ctrl.WriteString(line)
}
ctrl.WriteString("\r")
}
}
if len(script) > 0 && !script[len(script)-1].isTmux {
log.Println("waiting for final empty prompt")
finalEmptyPrompt = true
err := waitForPrompt(ctrl)
if err != nil {
// TODO: Handle the error properly
panic(err)
}
}
log.Println("sending Alt-q")
// Alt-q is bound to a function that captures the content of the pane and
// exits
ctrl.Write([]byte{'\033', 'q'})
return finalEmptyPrompt
}
func waitForPrompt(f *os.File) error {
return waitForOutput(f, promptMarker,
func(bs []byte) bool { return bytes.HasSuffix(bs, []byte(promptMarker)) })
}
func waitForOutput(f *os.File, expected string, matcher func([]byte) bool) error {
var buf bytes.Buffer
// It shouldn't take more than a couple of seconds to see the expected
// output, so use a timeout an order of magnitude longer to allow for
// overloaded systems.
deadline := time.Now().Add(30 * time.Second)
for {
budget := time.Until(deadline)
if budget <= 0 {
break
}
ready, err := eunix.WaitForRead(budget, f)
if err != nil {
if err == syscall.EINTR {
continue
}
return fmt.Errorf("waiting for tmux output: %w", err)
}
if !ready[0] {
break
}
_, err = io.CopyN(&buf, f, 1)
if err != nil {
return fmt.Errorf("reading tmux output: %w", err)
}
if matcher(buf.Bytes()) {
return nil
}
}
return fmt.Errorf("timed out waiting for %s in tmux output; output so far: %q", expected, buf)
}
// We use this instead of html.EscapeString, since the latter also escapes ' and
// " unnecessarily.
var htmlEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
func sgrTextToHTML(ttyshot string) string {
t := ui.ParseSGREscapedText(ttyshot)
var sb strings.Builder
for i, line := range t.SplitByRune('\n') {
if i > 0 {
sb.WriteRune('\n')
}
for j, seg := range line {
style := seg.Style
var classes []string
if style.Inverse {
// The inverse attribute means that the foreground and
// background colors should be swapped, which cannot be
// expressed in pure CSS. To work around this, this code swaps
// the foreground and background colors, and uses two special
// CSS classes to indicate that the foreground/background should
// take the inverse of the default color.
style.Inverse = false
style.Fg, style.Bg = style.Bg, style.Fg
if style.Fg == nil {
classes = append(classes, "sgr-7fg")
}
if style.Bg == nil {
classes = append(classes, "sgr-7bg")
}
}
for _, c := range style.SGRValues() {
classes = append(classes, "sgr-"+c)
}
text := seg.Text
// We pass -N to tmux capture-pane in order to correctly preserve
// trailing spaces that have background colors. However, this
// preserves unstyled trailing spaces too, which makes the ttyshot
// harder to copy-paste, so strip it.
if len(classes) == 0 && j == len(line)-1 {
text = strings.TrimRight(text, " ")
}
if text == "" {
continue
}
escapedText := htmlEscaper.Replace(text)
if len(classes) == 0 {
sb.WriteString(escapedText)
} else {
fmt.Fprintf(&sb, `<span class="%s">%s</span>`, strings.Join(classes, " "), escapedText)
}
}
}
return sb.String()
}