// The weave command is a simple preprocessor for markdown files. // It builds a table of contents and processes %include directives. // // Example usage: // // $ go run internal/cmd/weave go-types.md > README.md // // The weave command copies lines of the input file to standard output, with two // exceptions: // // If a line begins with "%toc", it is replaced with a table of contents // consisting of links to the top two levels of headers ("#" and "##"). // // If a line begins with "%include FILENAME TAG", it is replaced with the lines // of the file between lines containing "!+TAG" and "!-TAG". TAG can be omitted, // in which case the delimiters are simply "!+" and "!-". // // Before the included lines, a line of the form // // // go get PACKAGE // // is output, where PACKAGE is constructed from the module path, the // base name of the current directory, and the directory of FILENAME. // This caption can be suppressed by putting "-" as the final word of the %include line. package main import ( "bufio" "bytes" "fmt" "log" "os" "path/filepath" "regexp" "strings" ) func main() { log.SetFlags(0) log.SetPrefix("weave: ") if len(os.Args) != 2 { log.Fatal("usage: weave input.md\n") } f, err := os.Open(os.Args[1]) if err != nil { log.Fatal(err) } defer f.Close() wd, err := os.Getwd() if err != nil { log.Fatal(err) } curDir := filepath.Base(wd) fmt.Println("") // Pass 1: extract table of contents. var toc []string in := bufio.NewScanner(f) for in.Scan() { line := in.Text() if line == "" || (line[0] != '#' && line[0] != '%') { continue } line = strings.TrimSpace(line) if line == "%toc" { toc = nil } else if strings.HasPrefix(line, "# ") || strings.HasPrefix(line, "## ") { words := strings.Fields(line) depth := len(words[0]) words = words[1:] text := strings.Join(words, " ") for i := range words { words[i] = strings.ToLower(words[i]) } line = fmt.Sprintf("%s1. [%s](#%s)", strings.Repeat("\t", depth-1), text, strings.Join(words, "-")) toc = append(toc, line) } } if in.Err() != nil { log.Fatal(in.Err()) } // Pass 2. if _, err := f.Seek(0, os.SEEK_SET); err != nil { log.Fatalf("can't rewind input: %v", err) } in = bufio.NewScanner(f) for in.Scan() { line := in.Text() switch { case strings.HasPrefix(line, "%toc"): // ToC for _, h := range toc { fmt.Println(h) } case strings.HasPrefix(line, "%include"): words := strings.Fields(line) if len(words) < 2 { log.Fatal(line) } filename := words[1] // Show caption unless '-' follows. if len(words) < 4 || words[3] != "-" { fmt.Printf(" // go get golang.org/x/example/%s/%s\n\n", curDir, filepath.Dir(filename)) } section := "" if len(words) > 2 { section = words[2] } s, err := include(filename, section) if err != nil { log.Fatal(err) } fmt.Println("```") fmt.Println(cleanListing(s)) // TODO(adonovan): escape /^```/ in s fmt.Println("```") default: fmt.Println(line) } } if in.Err() != nil { log.Fatal(in.Err()) } } // include processes an included file, and returns the included text. // Only lines between those matching !+tag and !-tag will be returned. // This is true even if tag=="". func include(file, tag string) (string, error) { f, err := os.Open(file) if err != nil { return "", err } defer f.Close() startre, err := regexp.Compile("!\\+" + tag + "$") if err != nil { return "", err } endre, err := regexp.Compile("!\\-" + tag + "$") if err != nil { return "", err } var text bytes.Buffer in := bufio.NewScanner(f) var on bool for in.Scan() { line := in.Text() switch { case startre.MatchString(line): on = true case endre.MatchString(line): on = false case on: text.WriteByte('\t') text.WriteString(line) text.WriteByte('\n') } } if in.Err() != nil { return "", in.Err() } if text.Len() == 0 { return "", fmt.Errorf("no lines of %s matched tag %q", file, tag) } return text.String(), nil } func isBlank(line string) bool { return strings.TrimSpace(line) == "" } func indented(line string) bool { return strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") } // cleanListing removes entirely blank leading and trailing lines from // text, and removes n leading tabs. func cleanListing(text string) string { lines := strings.Split(text, "\n") // remove minimum number of leading tabs from all non-blank lines tabs := 999 for i, line := range lines { if strings.TrimSpace(line) == "" { lines[i] = "" } else { if n := leadingTabs(line); n < tabs { tabs = n } } } for i, line := range lines { if line != "" { line := line[tabs:] lines[i] = line // remove leading tabs } } // remove leading blank lines for len(lines) > 0 && lines[0] == "" { lines = lines[1:] } // remove trailing blank lines for len(lines) > 0 && lines[len(lines)-1] == "" { lines = lines[:len(lines)-1] } return strings.Join(lines, "\n") } func leadingTabs(s string) int { var i int for i = 0; i < len(s); i++ { if s[i] != '\t' { break } } return i }