website: Make ttyshots look like transcripts.

This further simplifies the op structure - only code input and tmux commands are
necessary now; waiting for prompts is now implicit.
This commit is contained in:
Qi Xiao 2022-08-09 13:43:21 +01:00
parent 07791a4631
commit 82a72e31e3
23 changed files with 167 additions and 133 deletions

View File

@ -31,7 +31,7 @@ clean:
ifdef TTYSHOT
%.ttyshot.html: %.ttyshot tools/ttyshot.bin
tools/ttyshot.bin -o $@ $<
tools/ttyshot.bin $(if $(findstring verbose,$(TTYSHOT)),-v,) -o $@ $<
else
%.ttyshot.html:
@: ttyshot generation disabled by default

View File

@ -37,6 +37,35 @@ Building the docset requires the following additional dependencies:
To build the docset, run `make docset`. The generated docset is in
`Elvish.docset`.
# Transcripts
Documents can contain **transcripts** of Elvish sessions, identified by the
language tag `elvish-transcript`. A simple example:
````markdown
```elvish-transcript
~> echo foo |
str:to-upper (one)
▶ FOO
```
````
When the website is built, the toolchain will highlight the
`echo foo | str:to-upper (one)` part correctly as Elvish code.
To be exact, the toolchain uses the following heuristic to determine the range
of Elvish code:
- It looks for what looks like a prompt, which starts with either `~` or `/`,
ends with `>` and a space, with no spaces in between.
- It then extends the range downwards, as long as the line starts with N
whitespaces, where N is the length of the prompt (including the trailing
space).
As long as you use Elvish's default prompt, you should be able to rely on this
heuristic.
# Ttyshots
Some of the pages include "ttyshots" that show the content of Elvish sessions.
@ -50,24 +79,23 @@ built `elvish` in `PATH`. Windows is not supported.
## Instruction syntax
Each line in a ttyshot instruction file is one of the following:
Ttyshot instruction files look like Elvish transcripts, with the following
differences:
- `#prompt` instructs waiting for a new shell prompt.
- It should not contain the output of commands. Anything that is not part of
an input at a prompt causes a parse error.
- `#`_`command`_, where `command` is a string that does **not** start with a
space and is not `prompt`, is a command sent to `tmux`. The most useful one
(and only one being used now) is `send-keys`.
- If the Elvish code starts with `#` followed immediately by a letter, it is
treated instead as a command to sent to `tmux`.
- Anything else is treated as text that should be sent directly to the Elvish
prompt.
The most useful one (and only one being used now) is `send-keys`.
For example, the following instructions runs `cd /tmp`, waits for the next
prompt, and sends Ctrl-N to trigger navigation mode:
For example, the following instructions runs `cd /tmp`, and sends Ctrl-N to
trigger navigation mode at the next prompt:
```
cd /tmp
#prompt
#send-keys ctrl-L
~> cd /tmp
~> #send-keys ctrl-L
```
## Generating ttyshots
@ -77,9 +105,10 @@ the repository, and the `Makefile` rule to generate them is disabled by default.
This is because the process to generate ttyshots is relatively slow and may have
network dependencies.
To turn on ttyshot generation, pass `TTYSHOT=1` to `make`. For example, to
generate a single ttyshot, run `make TTYSHOT=1 foo.ttyshot.html`. To build the
website with ttyshot generation enabled, run `make TTYSHOT=1`.
To turn on ttyshot generation, pass `TTYSHOT=1` to `make` (where `1` can be
replaced by any non-empty string). For example, to generate a single ttyshot,
run `make TTYSHOT=1 foo.ttyshot.html`. To build the website with ttyshot
generation enabled, run `make TTYSHOT=1`.
The first time you generate ttyshots, `make` will build the `ttyshot` tool, and
regenerate all ttyshots. Subsequent runs will only regenerate ttyshots whose

View File

@ -107,7 +107,7 @@ func createTtyshot(homePath string, script []op, saveRaw string) ([]byte, error)
if err != nil {
return nil, err
}
executeScript(script, ctrl, homePath)
finalEmptyPrompt := executeScript(script, ctrl, homePath)
log.Println("executed script, waiting for tmux to exit")
// Drain outputs from the terminal. This is needed so that tmux can exit
@ -129,11 +129,9 @@ func createTtyshot(homePath string, script []op, saveRaw string) ([]byte, error)
segments := strings.Split(ttyshot, cutMarker+"\n")
ttyshot = segments[len(segments)-1]
// Strip all the prompt markers, and the content after the last prompt
// marker if the last instruction was #prompt (in which case the content
// will just be an empty prompt).
// Strip all the prompt markers and the final empty prompt.
segments = strings.Split(ttyshot, promptMarker+"\n")
if len(script) > 0 && script[len(script)-1].typ == opPrompt {
if finalEmptyPrompt {
segments = segments[:len(segments)-1]
}
ttyshot = strings.Join(segments, "")
@ -186,39 +184,58 @@ func spawnElvish(homePath string, tty *os.File) (<-chan error, error) {
return doneCh, nil
}
func executeScript(script []op, ctrl *os.File, homePath string) {
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)
switch op.typ {
case opText:
text := op.val.(string)
ctrl.WriteString(text)
ctrl.WriteString("\r")
case opPrompt:
err := waitForOutput(ctrl, promptMarker,
func(bs []byte) bool { return bytes.HasSuffix(bs, []byte(promptMarker)) })
if err != nil {
// TODO: Handle the error properly
panic(err)
if op.codeLines != nil {
for i, line := range op.codeLines {
if i > 0 {
// Use Alt-Enter to avoid committing the code
ctrl.WriteString("\033\r")
}
ctrl.WriteString(line)
}
case opTmux:
ctrl.WriteString("\r")
} else {
tmuxSock := filepath.Join(homePath, ".tmp", "tmux.sock")
tmuxCmd := exec.Command("tmux",
append([]string{"-S", tmuxSock}, op.val.([]string)...)...)
append([]string{"-S", tmuxSock}, op.tmuxCommand...)...)
tmuxCmd.Env = []string{}
err := tmuxCmd.Run()
if err != nil {
// TODO: Handle the error properly
panic(err)
}
default:
panic("unhandled op")
}
}
if len(script) > 0 && script[len(script)-1].codeLines != nil {
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 {

View File

@ -53,7 +53,10 @@ func run(args []string) error {
if err != nil {
return err
}
spec := parseSpec(string(content))
spec, err := parseSpec(string(content))
if err != nil {
return err
}
homePath, err := setupHome()
if err != nil {

View File

@ -3,48 +3,48 @@
package main
import (
"fmt"
"regexp"
"strings"
)
type opType int
type op struct {
codeLines []string
tmuxCommand []string
}
// Operations for driving a demo ttyshot.
const (
opText opType = iota // send the provided text, optionally followed by Enter
opPrompt // wait for prompt marker
opTmux // run tmux command
var (
ps1Pattern = regexp.MustCompile(`^[~/][^ ]*> `)
tmuxPattern = regexp.MustCompile(`^#[a-z]`)
)
type op struct {
typ opType
val any
}
func parseSpec(content string) []op {
func parseSpec(content string) ([]op, error) {
lines := strings.Split(content, "\n")
ops := make([]op, 1, len(lines)+2)
ops[0] = op{opPrompt, nil}
for _, line := range lines {
if len(line) == 0 {
continue // ignore empty lines
var ops []op
for i := 0; i < len(lines); i++ {
line := lines[i]
if line == "" {
continue
}
var newOp op
if line == "#prompt" {
newOp = op{opPrompt, nil}
} else if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "# ") {
newOp = op{opTmux, strings.Fields(line[1:])}
} else {
newOp = op{opText, line}
ps1 := ps1Pattern.FindString(line)
if ps1 == "" {
return nil, fmt.Errorf("invalid line %v", i+1)
}
ops = append(ops, newOp)
content := line[len(ps1):]
if tmuxPattern.MatchString(content) {
ops = append(ops, op{tmuxCommand: strings.Fields(content[1:])})
continue
}
codeLines := []string{content}
ps2 := strings.Repeat(" ", len(ps1))
for i++; i < len(lines) && strings.HasPrefix(lines[i], ps2); i++ {
codeLines = append(codeLines, lines[i][len(ps2):])
}
i--
ops = append(ops, op{codeLines: codeLines})
}
if len(ops) > 0 && ops[len(ops)-1].typ == opText {
// The termination of the ttyshot process relies on the editor to be
// active, so add an implicit #prompt.
ops = append(ops, op{opPrompt, nil})
}
return ops
return ops, nil
}

View File

@ -1,13 +1,11 @@
if $true { echo good } else { echo bad }
#prompt
for x [lorem ipsum] {
echo $x.pdf
}
#prompt
try {
fail 'bad error'
} catch e {
echo error $e
} else {
echo ok
}
~> if $true { echo good } else { echo bad }
~> for x [lorem ipsum] {
echo $x.pdf
}
~> try {
fail 'bad error'
} catch e {
echo error $e
} else {
echo ok
}

View File

@ -1 +1 @@
#send-keys C-R Up Up
~> #send-keys C-R Up Up

View File

@ -1 +1 @@
#send-keys C-L
~> #send-keys C-L

View File

@ -1,3 +1,2 @@
cd elvish; echo '[CUT]'
#prompt
#send-keys C-N
~> cd elvish; echo '[CUT]'
~> #send-keys C-N

View File

@ -1,4 +1,4 @@
curl -sL api.github.com/repos/elves/elvish/issues |
all (from-json) |
each {|x| echo (exact-num $x[number]): $x[title] } |
head -n 10
~> curl -sL api.github.com/repos/elves/elvish/issues |
all (from-json) |
each {|x| echo (exact-num $x[number]): $x[title] } |
head -n 10

View File

@ -1,5 +1,3 @@
-randseed 1; echo '[CUT]'
#prompt
randint 1 7
#prompt
#send-keys Up
~> -randseed 1; echo '[CUT]'
~> randint 1 7
~> #send-keys Up

View File

@ -1,7 +1,4 @@
-randseed 2; echo '[CUT]'
#prompt
randint 1 7
#prompt
# more commands ...
#prompt
#send-keys ra Up
~> -randseed 2; echo '[CUT]'
~> randint 1 7
~> # more commands ...
~> #send-keys ra Up

View File

@ -1,3 +1,2 @@
cd elvish
#prompt
#send-keys echo Space Tab .md
~> cd elvish
~> #send-keys echo Space Tab .md

View File

@ -1,3 +1,2 @@
cd elvish
#prompt
#send-keys echo Space Tab
~> cd elvish
~> #send-keys echo Space Tab

View File

@ -1 +1 @@
#send-keys C-R
~> #send-keys C-R

View File

@ -1 +1 @@
#send-keys echo Up Up Up
~> #send-keys echo Up Up Up

View File

@ -1 +1 @@
#send-keys Up
~> #send-keys Up

View File

@ -1,3 +1,2 @@
echo abc def
#prompt
#send-keys vim Space M-,
~> echo abc def
~> #send-keys vim Space M-,

View File

@ -1 +1 @@
#send-keys C-L local
~> #send-keys C-L local

View File

@ -1 +1 @@
#send-keys C-L
~> #send-keys C-L

View File

@ -1,3 +1,2 @@
cd elvish; echo '[CUT]'
#prompt
#send-keys C-N
~> cd elvish; echo '[CUT]'
~> #send-keys C-N

View File

@ -1,9 +1,7 @@
set edit:rprompt = (constantly ^
(styled (whoami)✸(hostname) inverse))
#prompt
set edit:prompt = {
tilde-abbr $pwd
styled '❱ ' bright-red
}
#prompt
#send-keys # Space Fancy Space unicode Space prompts!
~> set edit:rprompt = (constantly ^
(styled (whoami)✸(hostname) inverse))
~> set edit:prompt = {
tilde-abbr $pwd
styled '❱ ' bright-red
}
~> #send-keys # Space Fancy Space unicode Space prompts!

View File

@ -1,3 +1,2 @@
cd elvish; echo '[CUT]'
#prompt
#send-keys vim Space Tab
~> cd elvish; echo '[CUT]'
~> #send-keys vim Space Tab