Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions printer/doctype_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package printer

import (
"bytes"
"reflect"
"strings"
"testing"

"golang.org/x/net/html"
)

func documentTypeAttributes(t *testing.T, text string) []html.Attribute {
t.Helper()
doc, err := html.Parse(strings.NewReader(text))
if err != nil {
t.Fatal(err)
}
for n := doc.FirstChild; n != nil; n = n.NextSibling {
if n.Type == html.DoctypeNode {
return n.Attr
}
}
t.Fatal("doctype missing")
return nil
}

func TestPrettyHTMLPreservesDoctypeIdentifiers(t *testing.T) {
for _, source := range []string{
`<!DOCTYPE html><p>Body</p>`,
`<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><p>Body</p>`,
`<!DOCTYPE html SYSTEM "about:legacy-compat"><p>Body</p>`,
} {
var output bytes.Buffer
if _, err := PrettyHTML(&output, strings.NewReader(source), false); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(documentTypeAttributes(t, source), documentTypeAttributes(t, output.String())) {
t.Fatalf("doctype identifiers lost: %s", output.String())
}
}
}
25 changes: 25 additions & 0 deletions printer/fragment_detection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package printer

import (
"bytes"
"strings"
"testing"
)

func TestPrettyHTMLIgnoresTagNamesInCommentsAndRawText(t *testing.T) {
for _, source := range []string{
`<!-- <html><head><body> --><p>Fragment</p>`,
`<script>const template = "<html><head><body>";</script>`,
`<htmlish>Fragment</htmlish>`,
} {
var output bytes.Buffer
if _, err := PrettyHTML(&output, strings.NewReader(source), false); err != nil {
t.Fatal(err)
}
for _, wrapper := range []string{"</html>", "</head>", "</body>"} {
if strings.Contains(output.String(), wrapper) {
t.Fatalf("inserted wrapper %s: %s", wrapper, output.String())
}
}
}
}
76 changes: 50 additions & 26 deletions printer/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,26 @@ func PrettyHTML(w io.Writer, r io.Reader, enabled bool) (int64, error) {
return 0, err
}

contentStr := string(data)
hasHtml := strings.Contains(strings.ToLower(contentStr), "<html")
hasBody := strings.Contains(strings.ToLower(contentStr), "<body")
hasHead := strings.Contains(strings.ToLower(contentStr), "<head")
var hasHtml, hasBody, hasHead bool
tokenizer := html.NewTokenizer(bytes.NewReader(data))
for {
kind := tokenizer.Next()
if kind == html.ErrorToken {
break
}
if kind != html.StartTagToken && kind != html.SelfClosingTagToken {
continue
}
name, _ := tokenizer.TagName()
switch string(name) {
case "html":
hasHtml = true
case "body":
hasBody = true
case "head":
hasHead = true
}
}

cw := &countingWriter{w: w}
if err := format(cw, doc, 0, enabled, hasHtml, hasBody, hasHead); err != nil {
Expand Down Expand Up @@ -102,28 +118,18 @@ func format(cw *countingWriter, n *html.Node, depth int, enabled bool, hasHtml,
return nil
}

// Format script and style block content as raw but indented text block
// Raw-text content can contain significant whitespace and template literals.
if n.Data == "script" || n.Data == "style" {
start := renderStartTag(n, enabled)
if _, err := fmt.Fprint(cw, strings.Repeat(" ", depth)+start+"\n"); err != nil {
if _, err := fmt.Fprint(cw, strings.Repeat(" ", depth)+renderStartTag(n, enabled)); err != nil {
return err
}
if n.FirstChild != nil {
lines := strings.Split(n.FirstChild.Data, "\n")
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed != "" {
if _, err := fmt.Fprint(cw, strings.Repeat(" ", depth+1)+trimmed+"\n"); err != nil {
return err
}
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
if _, err := fmt.Fprint(cw, c.Data); err != nil {
return err
}
}
end := renderEndTag(n, enabled)
if _, err := fmt.Fprint(cw, strings.Repeat(" ", depth)+end+"\n"); err != nil {
return err
}
return nil
_, err := fmt.Fprint(cw, renderEndTag(n, enabled)+"\n")
return err
}

// Handle empty elements
Expand All @@ -141,7 +147,7 @@ func format(cw *countingWriter, n *html.Node, depth int, enabled bool, hasHtml,
}

// Format simple inline elements (and their inline children) on a single line
if hasOnlyInlineChildren(n) {
if n.Data == "pre" || hasOnlyInlineChildren(n) {
var sb strings.Builder
sb.WriteString(renderStartTag(n, enabled))
if err := formatInline(&sb, n, enabled); err != nil {
Expand Down Expand Up @@ -179,10 +185,19 @@ func format(cw *countingWriter, n *html.Node, depth int, enabled bool, hasHtml,
}

func formatInline(sb *strings.Builder, n *html.Node, enabled bool) error {
// HTML parsing removes a first newline inside pre/textarea. Restore that
// sentinel when the parsed text itself starts with a newline.
if (n.Data == "pre" || n.Data == "textarea") && n.FirstChild != nil && n.FirstChild.Type == html.TextNode && strings.HasPrefix(n.FirstChild.Data, "\n") {
sb.WriteString("\n")
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
switch c.Type {
case html.TextNode:
sb.WriteString(html.EscapeString(c.Data))
if n.Data == "script" || n.Data == "style" {
sb.WriteString(c.Data)
} else {
sb.WriteString(html.EscapeString(c.Data))
}
case html.CommentNode:
sb.WriteString(renderComment(c, enabled))
case html.ElementNode:
Expand Down Expand Up @@ -237,7 +252,7 @@ func renderStartTag(n *html.Node, enabled bool) string {
sb.WriteString(color.Key(enabled, n.Data))
for _, attr := range n.Attr {
sb.WriteString(" ")
sb.WriteString(color.Number(enabled, attr.Key))
sb.WriteString(color.Number(enabled, qualifiedAttributeName(attr)))
sb.WriteString(color.Border(enabled, "="))
sb.WriteString(color.String(enabled, `"`+html.EscapeString(attr.Val)+`"`))
}
Expand All @@ -259,7 +274,7 @@ func renderVoidTag(n *html.Node, enabled bool) string {
sb.WriteString(color.Key(enabled, n.Data))
for _, attr := range n.Attr {
sb.WriteString(" ")
sb.WriteString(color.Number(enabled, attr.Key))
sb.WriteString(color.Number(enabled, qualifiedAttributeName(attr)))
sb.WriteString(color.Border(enabled, "="))
sb.WriteString(color.String(enabled, `"`+html.EscapeString(attr.Val)+`"`))
}
Expand All @@ -272,5 +287,14 @@ func renderComment(n *html.Node, enabled bool) string {
}

func renderDoctype(n *html.Node, enabled bool) string {
return color.Wrap(enabled, color.Bold+color.Magenta, "<!DOCTYPE "+n.Data+">")
var serialized strings.Builder
_ = html.Render(&serialized, n) // strings.Builder cannot fail to write.
return color.Wrap(enabled, color.Bold+color.Magenta, serialized.String())
}

func qualifiedAttributeName(attr html.Attribute) string {
if attr.Namespace != "" {
return attr.Namespace + ":" + attr.Key
}
return attr.Key
}
27 changes: 27 additions & 0 deletions printer/html_namespace_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package printer

import (
"bytes"
"golang.org/x/net/html"
"strings"
"testing"
)

func TestPrettyHTMLPreservesNamespacedAttributes(t *testing.T) {
input := `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><use xlink:href="#shape" xml:lang="en"></use></svg>`
var out bytes.Buffer
if _, err := PrettyHTML(&out, strings.NewReader(input), false); err != nil {
t.Fatal(err)
}
for _, attribute := range []string{`xmlns:xlink="http://www.w3.org/1999/xlink"`, `xlink:href="#shape"`, `xml:lang="en"`} {
if !strings.Contains(out.String(), attribute) {
t.Errorf("missing qualified attribute %s in %s", attribute, out.String())
}
}
}
func TestRenderVoidTagPreservesAttributeNamespace(t *testing.T) {
node := &html.Node{Type: html.ElementNode, Data: "br", Attr: []html.Attribute{{Namespace: "xml", Key: "lang", Val: "en"}}}
if got := renderVoidTag(node, false); got != `<br xml:lang="en" />` {
t.Fatalf("void tag=%q", got)
}
}
51 changes: 51 additions & 0 deletions printer/html_raw_text_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package printer

import (
"bytes"
"golang.org/x/net/html"
"strings"
"testing"
)

func rawTextElements(t *testing.T, input string) []string {
t.Helper()
doc, err := html.Parse(strings.NewReader(input))
if err != nil {
t.Fatal(err)
}
var values []string
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style") {
var s strings.Builder
for c := n.FirstChild; c != nil; c = c.NextSibling {
s.WriteString(c.Data)
}
values = append(values, s.String())
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(doc)
return values
}
func TestPrettyHTMLPreservesRawText(t *testing.T) {
for _, input := range []string{"<script>const value = `\n first\n\n second \n`;</script>", "<style>\n a::after { content: 'a & b'; }\n\n</style>", `<div><script>if (a < b && c > d) run();</script></div>`} {
t.Run(input, func(t *testing.T) {
var out bytes.Buffer
if _, err := PrettyHTML(&out, strings.NewReader(input), false); err != nil {
t.Fatal(err)
}
want, got := rawTextElements(t, input), rawTextElements(t, out.String())
if len(want) != len(got) {
t.Fatalf("elements: %v vs %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("raw text changed: got %q, want %q", got[i], want[i])
}
}
})
}
}
Loading
Loading