go.talks/pkg/present: include line numbers in output HTML

Also refactor HTML code generation to be line and template based.

R=golang-dev, dvyukov, r
CC=golang-dev
https://golang.org/cl/10539043
diff --git a/pkg/present/code.go b/pkg/present/code.go
index 367e4b2..4d62d78 100644
--- a/pkg/present/code.go
+++ b/pkg/present/code.go
@@ -5,13 +5,14 @@
 package present
 
 import (
+	"bufio"
+	"bytes"
 	"fmt"
 	"html/template"
 	"path/filepath"
 	"regexp"
 	"strconv"
 	"strings"
-	"unicode"
 )
 
 // Is the playground available?
@@ -37,8 +38,11 @@
 // The input line is a .code or .play entry with a file name and an optional HLfoo marker on the end.
 // Anything between the file and HL (if any) is an address expression, which we treat as a string here.
 // We pick off the HL first, for easy parsing.
-var highlightRE = regexp.MustCompile(`\s+HL([a-zA-Z0-9_]+)?$`)
-var codeRE = regexp.MustCompile(`\.(code|play)\s+([^\s]+)(\s+)?(.*)?$`)
+var (
+	highlightRE = regexp.MustCompile(`\s+HL([a-zA-Z0-9_]+)?$`)
+	hlCommentRE = regexp.MustCompile(`(.+) // HL(.*)$`)
+	codeRE      = regexp.MustCompile(`\.(code|play)\s+([^\s]+)(\s+)?(.*)?$`)
+)
 
 func parseCode(ctx *Context, sourceFile string, sourceLine int, cmd string) (Elem, error) {
 	cmd = strings.TrimSpace(cmd)
@@ -85,44 +89,91 @@
 			hi++
 		}
 	}
-	text := string(textBytes[lo:hi])
 
-	// Clear ommitted lines.
-	text = skipOMIT(text)
+	lines := codeLines(textBytes, lo, hi)
 
-	// Replace tabs by spaces, which work better in HTML.
-	text = strings.Replace(text, "\t", "    ", -1)
+	for i, line := range lines {
+		// Replace tabs by spaces, which work better in HTML.
+		line.L = strings.Replace(line.L, "\t", "    ", -1)
 
-	// Clear trailing newlines.
-	text = strings.TrimRight(text, "\n")
+		// Highlight lines that end with "// HL[highlight]"
+		// and strip the magic comment.
+		if m := hlCommentRE.FindStringSubmatch(line.L); m != nil {
+			line.L = m[1]
+			line.HL = m[2] == highlight
+		}
 
-	// Escape the program text for HTML.
-	text = template.HTMLEscapeString(text)
+		lines[i] = line
+	}
 
-	// Highlight and span-wrap lines.
-	text = "<pre>" + highlightLines(text, highlight) + "</pre>"
+	data := &codeTemplateData{Lines: lines}
 
 	// Include before and after in a hidden span for playground code.
 	if play {
-		text = hide(skipOMIT(string(textBytes[:lo]))) +
-			text + hide(skipOMIT(string(textBytes[hi:])))
+		data.Prefix = textBytes[:lo]
+		data.Suffix = textBytes[hi:]
 	}
 
-	// Include the command as a comment.
-	text = fmt.Sprintf("<!--{{%s}}\n-->%s", cmd, text)
-
-	return Code{Text: template.HTML(text), Play: play}, nil
+	var buf bytes.Buffer
+	if err := codeTemplate.Execute(&buf, data); err != nil {
+		return nil, err
+	}
+	return Code{Text: template.HTML(buf.String()), Play: play}, nil
 }
 
-// skipOMIT turns text into a string, dropping lines ending with OMIT.
-func skipOMIT(text string) string {
-	lines := strings.SplitAfter(text, "\n")
-	for k := range lines {
-		if strings.HasSuffix(lines[k], "OMIT\n") {
-			lines[k] = ""
+type codeTemplateData struct {
+	Lines          []codeLine
+	Prefix, Suffix []byte
+}
+
+var leadingSpaceRE = regexp.MustCompile(`^[ \t]*`)
+
+var codeTemplate = template.Must(template.New("code").Funcs(template.FuncMap{
+	"trimSpace":    strings.TrimSpace,
+	"leadingSpace": leadingSpaceRE.FindString,
+}).Parse(codeTemplateHTML))
+
+const codeTemplateHTML = `
+{{with .Prefix}}<pre style="display: none"><span>{{printf "%s" .}}</span></pre>{{end}}
+
+<pre>{{range .Lines}}<span num="{{.N}}">{{/*
+	*/}}{{if .HL}}{{leadingSpace .L}}<b>{{trimSpace .L}}</b>{{/*
+	*/}}{{else}}{{.L}}{{end}}{{/*
+*/}}</span>
+{{end}}</pre>
+
+{{with .Suffix}}<pre style="display: none"><span>{{printf "%s" .}}</span></pre>{{end}}
+`
+
+// codeLine represents a line of code extracted from a source file.
+type codeLine struct {
+	L  string // The line of code.
+	N  int    // The line number from the source file.
+	HL bool   // Whether the line should be highlighted.
+}
+
+// codeLines takes a source file and returns the lines that
+// span the byte range specified by start and end.
+// It discards lines that end in "OMIT".
+func codeLines(src []byte, start, end int) (lines []codeLine) {
+	startLine := 1
+	for i, b := range src {
+		if i == start {
+			break
+		}
+		if b == '\n' {
+			startLine++
 		}
 	}
-	return strings.Join(lines, "")
+	s := bufio.NewScanner(bytes.NewReader(src[start:end]))
+	for n := startLine; s.Scan(); n++ {
+		l := s.Text()
+		if strings.HasSuffix(l, "OMIT") {
+			continue
+		}
+		lines = append(lines, codeLine{L: l, N: n})
+	}
+	return
 }
 
 func parseArgs(name string, line int, args []string) (res []interface{}, err error) {
@@ -256,35 +307,3 @@
 	}
 	return 0, fmt.Errorf("unrecognized pattern: %q", pattern)
 }
-
-var hlRE = regexp.MustCompile(`(.+) // HL(.*)$`)
-
-// highlightLines emboldens lines that end with "// HL" and
-// wraps any other lines in span tags.
-func highlightLines(text, label string) string {
-	lines := strings.Split(text, "\n")
-	for i, line := range lines {
-		m := hlRE.FindStringSubmatch(line)
-		if m == nil {
-			continue
-		}
-		line := m[1]
-		if m[2] != "" && m[2] != label {
-			lines[i] = line
-			continue
-		}
-		space := ""
-		if j := strings.IndexFunc(line, func(r rune) bool {
-			return !unicode.IsSpace(r)
-		}); j > 0 {
-			space = line[:j]
-			line = line[j:]
-		}
-		lines[i] = space + "<b>" + line + "</b>"
-	}
-	return strings.Join(lines, "\n")
-}
-
-func hide(text string) string {
-	return fmt.Sprintf(`<pre style="display: none">%s</pre>`, template.HTMLEscapeString(text))
-}
diff --git a/present/js/play.js b/present/js/play.js
index c815ae1..a30ca74 100644
--- a/present/js/play.js
+++ b/present/js/play.js
@@ -16,7 +16,7 @@
     var s = "";
     for (var i = 0; i < node.childNodes.length; i++) {
       var n = node.childNodes[i];
-      if (n.nodeType === 1 && n.tagName === "PRE") {
+      if (n.nodeType === 1 && n.tagName === "SPAN" && n.className != "number") {
         var innerText = n.innerText === undefined ? "textContent" : "innerText";
         s += n[innerText] + "\n";
         continue;