@ 261101.7

261026.5 · e0e318febdcbbba34e53f28d52928a8ba9a2206e

JSON files have a gron view

A code page for a .json file offers gron at the left end of the view row. It shows one line per node as a JS assignment, eg json.tags[0] = "a";, so each value reads and searches with its full path. The conversion runs in-process, scans once and builds no tree. Keys keep input order and values show as written. A file that is not valid JSON shows the flat code under a line naming where parsing stopped.

reading declarations

README.md

@@ -108,7 +108,7 @@ In a large repo git can take a second for a history, because it reads every comm  Open pages reload when what they show changes. A doc takes its new version in place, without a reload, and each block that changed fills with the highlight colour and fades over three seconds, so the eye finds the change after a save. A code page reloads when its file is written, a listing when a name in it changes, any page in a repo when its HEAD moves, and the index when a tree joins or leaves. A page whose file or tree is gone waits two seconds for it to return, then reloads to say so. A page outlives its server. After three seconds without it, a line over the page says the server is gone and since when, and the page keeps trying each second. When a new server answers, eg after `make run` or `--restart`, the page takes its version from it and a line says new server, fading over three seconds. A restart takes about 60 ms, so its new server answers before the gone line shows. Each page holds a server-sent events stream while its tab is visible; a hidden tab lets go, since a browser opens at most six connections to one host, and checks again when shown. The server watches the directory of each open page and its repo's git directory through inotify, since editors save by renaming a new file over the old one, and polls every second where inotify fails. -Docs follow CommonMark with GitHub's extensions, rendered by goldmark: tables, strikethrough, task lists, bare URLs as links, footnotes and definition lists. Wiki links work as relative links do: `[[name]]` opens `name.md` in the doc's directory, `[[sub/name]]` opens `sub/name.md`, and a name with an extension, eg `[[main.go]]`, keeps it. `[[name|label]]` shows the label and `[[name#heading]]` opens at the heading. Lines join into a paragraph, and a blank line starts the next. A line that starts with a dash begins a list even right after text. Straight quotes and dashes turn typographic. A fenced block marked `dot` or `graphviz` becomes a diagram, drawn by Graphviz's `dot` when it is installed and kept by the hash of its source; without `dot`, or when `dot` rejects the graph, the block shows as code under a line that says why. `dot` may not read image files the graph names. Code in a fenced block with a known language is highlighted by chroma, as are code pages, whose language follows the file name. Go is lexed by Go's own scanner from the standard library instead, about 150 times faster, with the same colours. Python, JavaScript and shell have lexers of www's own, which follow chroma's rules and give every byte the class chroma gives it; chroma took 180 ms to lex argparse.py, 2,633 lines, and they take 2 ms. A whole page of it renders in about 5 ms, and jquery.js, 10,907 lines, in 28 ms instead of 400. Other languages go through chroma, at about 70 µs a line. The server keeps each coloured page by its content's hash, within 16 MB, so a later view takes a few milliseconds. A code page's views, flat, scopes and tree, and raw sit in a row of their own above the code's top right corner and follow the page down as it scrolls. Each keeps its place; the view shown is plain text, not a link. Add `?scopes` to a code page, or follow scopes, and it draws each block of code that spans lines as a sunken box behind its text, nested as deep as the code goes, with deeper boxes fainter. A block is a bracket pair whose ends sit on different lines, or in Python the lines indented under a line that ends with a colon. Brackets and strings within a line, around something, are smaller boxes inside it, down to the innermost, much as vim's `ci(` and `ci"` see them; a string that spans lines is a block. Fills alternate inward between a light shade and the page's colour, so depth shows without the page darkening. The boxes lie on a layer of their own, so no character moves and copied text is unchanged. A box is a link to its lines. The box under the pointer is tinted, and a click marks it and the boxes in it and puts its lines in the address, eg `#L482-L520`; a click on the marked box clears both. When the file is committed and unchanged since, the address also names that commit's version, eg `@261024.22/server.go?scopes#L3-L26`, so the link keeps pointing at the same lines after the file changes. A live reload goes back to the current file. In the same corner, flat goes back to the plain page. Add `?tree`, or follow tree, for the syntax tree: one row per named node, with its field in its parent, its kind, its text on one line and a link to its lines, folded so the file's top-level declarations show first. A helper parses it with tree-sitter: `www-syntax`, which `make syntax` builds beside www and which needs a C compiler. It knows Go, Python, JavaScript, TypeScript, Rust, C, C++, shell, Java, C#, JSON, HTML, CSS and Ruby. Without the helper, or for another language, the page says so above the plain code. A first view of a 1,000-line file takes about 60 ms; the server keeps the tree, so a later view takes a few. Every gap between blocks is the same, whether the block is a paragraph, list, table or code. Headings sit close to their text.+Docs follow CommonMark with GitHub's extensions, rendered by goldmark: tables, strikethrough, task lists, bare URLs as links, footnotes and definition lists. Wiki links work as relative links do: `[[name]]` opens `name.md` in the doc's directory, `[[sub/name]]` opens `sub/name.md`, and a name with an extension, eg `[[main.go]]`, keeps it. `[[name|label]]` shows the label and `[[name#heading]]` opens at the heading. Lines join into a paragraph, and a blank line starts the next. A line that starts with a dash begins a list even right after text. Straight quotes and dashes turn typographic. A fenced block marked `dot` or `graphviz` becomes a diagram, drawn by Graphviz's `dot` when it is installed and kept by the hash of its source; without `dot`, or when `dot` rejects the graph, the block shows as code under a line that says why. `dot` may not read image files the graph names. Code in a fenced block with a known language is highlighted by chroma, as are code pages, whose language follows the file name. Go is lexed by Go's own scanner from the standard library instead, about 150 times faster, with the same colours. Python, JavaScript and shell have lexers of www's own, which follow chroma's rules and give every byte the class chroma gives it; chroma took 180 ms to lex argparse.py, 2,633 lines, and they take 2 ms. A whole page of it renders in about 5 ms, and jquery.js, 10,907 lines, in 28 ms instead of 400. Other languages go through chroma, at about 70 µs a line. The server keeps each coloured page by its content's hash, within 16 MB, so a later view takes a few milliseconds. A code page's views, flat, scopes and tree, and raw sit in a row of their own above the code's top right corner and follow the page down as it scrolls. Each keeps its place; the view shown is plain text, not a link. Add `?scopes` to a code page, or follow scopes, and it draws each block of code that spans lines as a sunken box behind its text, nested as deep as the code goes, with deeper boxes fainter. A block is a bracket pair whose ends sit on different lines, or in Python the lines indented under a line that ends with a colon. Brackets and strings within a line, around something, are smaller boxes inside it, down to the innermost, much as vim's `ci(` and `ci"` see them; a string that spans lines is a block. Fills alternate inward between a light shade and the page's colour, so depth shows without the page darkening. The boxes lie on a layer of their own, so no character moves and copied text is unchanged. A box is a link to its lines. The box under the pointer is tinted, and a click marks it and the boxes in it and puts its lines in the address, eg `#L482-L520`; a click on the marked box clears both. When the file is committed and unchanged since, the address also names that commit's version, eg `@261024.22/server.go?scopes#L3-L26`, so the link keeps pointing at the same lines after the file changes. A live reload goes back to the current file. In the same corner, flat goes back to the plain page. Add `?tree`, or follow tree, for the syntax tree: one row per named node, with its field in its parent, its kind, its text on one line and a link to its lines, folded so the file's top-level declarations show first. A helper parses it with tree-sitter: `www-syntax`, which `make syntax` builds beside www and which needs a C compiler. It knows Go, Python, JavaScript, TypeScript, Rust, C, C++, shell, Java, C#, JSON, HTML, CSS and Ruby. Without the helper, or for another language, the page says so above the plain code. A first view of a 1,000-line file takes about 60 ms; the server keeps the tree, so a later view takes a few. A JSON file has one more view, gron, at the left end of that row. `?gron` shows the file as one line per node, each a JavaScript assignment such as `json.tags[0] = "a";`, so every value reads and searches with its full path. Keys keep the file's order and values show as written. A file that is not valid JSON shows the plain code under a line that names where parsing stopped. Every gap between blocks is the same, whether the block is a paragraph, list, table or code. Headings sit close to their text.  www refuses to serve the home directory and every directory above it, such as `/home` and `/`. A tree exposes everything below it, and home holds `~/.ssh`, `~/.gnupg` and every token file. Symlinks inside the tree are followed, absolute ones included. A link that leads out of the tree is followed only into a directory that holds a file named `.www-follow`, or below one, and only when other users could read the target. A file needs the others-read bit and a directory others-read and others-execute, checked from the marker's directory down. So nobody reads through www what `cat` would not show them.

assets/style.css

@@ -466,6 +466,8 @@ div.view { position: sticky; top: var(--search-h, 0); z-index: 1; text-align: ri div.view a, div.view span { padding: 0 0.4em; margin-left: 0.4em; color: var(--muted); text-decoration-color: var(--rule); } div.view span { color: inherit; } div.view a:hover { text-decoration-color: currentColor; }+/* A JSON file's gron view starts at the row's left edge. */+div.view .left { float: left; margin-left: 0; padding-left: 0; } /* Browsers round each row's height, so a box over hundreds of rows can    end a pixel or two past its last line; the scrolling div around the pre    clips that rather than grow a scroll bar. */

gron.go

new file mode 100644@@ -0,0 +1,331 @@+package main++import (+	"bytes"+	"encoding/json"+	"fmt"+	"path"+	"strconv"+	"strings"+	"unicode"+	"unicode/utf8"++	"github.com/alecthomas/chroma/v2/lexers"+)++// isJSON says whether a code page offers the gron view.+func isJSON(name string) bool {+	return strings.EqualFold(path.Ext(name), ".json")+}++// gronLexer colours the gron view: its lines are JavaScript.+var gronLexer = lexers.Get("javascript")++// gron writes each node of a JSON document on its own line as a JS+// assignment, json.a.b[0] = "x";, so the page reads and searches by path.+// It scans the input once and builds no tree. Keys keep their input order+// and values print as written. Malformed input returns an error that names+// its line and column.+func gron(b []byte) (out []byte, err error) {+	g := &gronner{buf: b, path: []byte("json")}+	defer func() {+		if r := recover(); r != nil {+			e, ok := r.(gronError)+			if !ok {+				panic(r)+			}+			out, err = nil, e+		}+	}()+	g.ws()+	if g.pos == len(b) {+		g.fail("no value")+	}+	g.value()+	g.ws()+	if g.pos != len(b) {+		g.fail("data after the value")+	}+	return g.out.Bytes(), nil+}++type gronError struct {+	line, col int+	msg       string+}++func (e gronError) Error() string {+	return fmt.Sprintf("line %d, column %d: %s", e.line, e.col, e.msg)+}++// gronMaxDepth bounds nesting, so a hostile file cannot exhaust the stack.+const gronMaxDepth = 10000++type gronner struct {+	buf   []byte+	pos   int+	depth int+	path  []byte+	out   bytes.Buffer+}++func (g *gronner) fail(msg string) {+	line, col := 1, 1+	for _, c := range g.buf[:min(g.pos, len(g.buf))] {+		if c == '\n' {+			line, col = line+1, 1+		} else {+			col+++		}+	}+	panic(gronError{line, col, msg})+}++func (g *gronner) ws() {+	for g.pos < len(g.buf) {+		switch g.buf[g.pos] {+		case ' ', '\t', '\n', '\r':+			g.pos+++		default:+			return+		}+	}+}++func (g *gronner) peek() byte {+	if g.pos < len(g.buf) {+		return g.buf[g.pos]+	}+	return 0+}++func (g *gronner) emit(v []byte) {+	g.out.Write(g.path)+	g.out.WriteString(" = ")+	g.out.Write(v)+	g.out.WriteString(";\n")+}++func (g *gronner) value() {+	g.ws()+	start := g.pos+	switch c := g.peek(); {+	case c == '{':+		g.object()+	case c == '[':+		g.array()+	case c == '"':+		g.str()+		g.emit(g.buf[start:g.pos])+	case c == '-' || isDigit(c):+		g.number()+		g.emit(g.buf[start:g.pos])+	case c == 't':+		g.literal("true")+	case c == 'f':+		g.literal("false")+	case c == 'n':+		g.literal("null")+	case g.pos >= len(g.buf):+		g.fail("unexpected end")+	default:+		g.fail(fmt.Sprintf("unexpected %q", c))+	}+}++func (g *gronner) enter() {+	g.depth+++	if g.depth > gronMaxDepth {+		g.fail(fmt.Sprintf("nested deeper than %d", gronMaxDepth))+	}+	g.pos+++	g.ws()+}++func (g *gronner) object() {+	g.emit([]byte("{}"))+	g.enter()+	defer func() { g.depth-- }()+	if g.peek() == '}' {+		g.pos+++		return+	}+	for {+		g.ws()+		if g.peek() != '"' {+			g.fail("expected a string key")+		}+		start := g.pos+		esc := g.str()+		n := len(g.path)+		g.path = appendKey(g.path, g.buf[start:g.pos], esc)+		g.ws()+		if g.peek() != ':' {+			g.fail("expected ':'")+		}+		g.pos+++		g.value()+		g.path = g.path[:n]+		g.ws()+		switch g.peek() {+		case ',':+			g.pos+++		case '}':+			g.pos+++			return+		default:+			g.fail("expected ',' or '}'")+		}+	}+}++func (g *gronner) array() {+	g.emit([]byte("[]"))+	g.enter()+	defer func() { g.depth-- }()+	if g.peek() == ']' {+		g.pos+++		return+	}+	for i := 0; ; i++ {+		n := len(g.path)+		g.path = append(g.path, '[')+		g.path = strconv.AppendInt(g.path, int64(i), 10)+		g.path = append(g.path, ']')+		g.value()+		g.path = g.path[:n]+		g.ws()+		switch g.peek() {+		case ',':+			g.pos+++		case ']':+			g.pos+++			return+		default:+			g.fail("expected ',' or ']'")+		}+	}+}++// str scans a string and reports whether it holds escapes.+func (g *gronner) str() (esc bool) {+	g.pos+++	for g.pos < len(g.buf) {+		c := g.buf[g.pos]+		switch {+		case c == '"':+			g.pos+++			return esc+		case c == '\\':+			esc = true+			g.pos+++			switch g.peek() {+			case '"', '\\', '/', 'b', 'f', 'n', 'r', 't':+			case 'u':+				for range 4 {+					g.pos+++					if !isHex(g.peek()) {+						g.fail(`bad \u escape`)+					}+				}+			default:+				g.fail("bad escape")+			}+		case c < 0x20:+			g.fail("control character in a string")+		}+		g.pos+++	}+	g.fail("unterminated string")+	return+}++func (g *gronner) digits() {+	if !isDigit(g.peek()) {+		g.fail("expected a digit")+	}+	for isDigit(g.peek()) {+		g.pos+++	}+}++func (g *gronner) number() {+	if g.peek() == '-' {+		g.pos+++	}+	if g.peek() == '0' {+		g.pos+++	} else {+		g.digits()+	}+	if g.peek() == '.' {+		g.pos+++		g.digits()+	}+	if c := g.peek(); c == 'e' || c == 'E' {+		g.pos+++		if c := g.peek(); c == '+' || c == '-' {+			g.pos+++		}+		g.digits()+	}+}++func (g *gronner) literal(s string) {+	if !bytes.HasPrefix(g.buf[g.pos:], []byte(s)) {+		g.fail("bad literal")+	}+	g.pos += len(s)+	g.emit([]byte(s))+}++func isHex(c byte) bool {+	return isDigit(c) || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'+}++// appendKey adds a key to the path: .name when the key is a JS identifier,+// otherwise ["key"] with the key as written.+func appendKey(path, quoted []byte, esc bool) []byte {+	name := quoted[1 : len(quoted)-1]+	if esc {+		var s string+		if json.Unmarshal(quoted, &s) == nil {+			name = []byte(s)+		}+	}+	if utf8.Valid(name) && isIdent(name) {+		path = append(path, '.')+		return append(path, name...)+	}+	path = append(path, '[')+	path = append(path, quoted...)+	return append(path, ']')+}++// isIdent follows gron's rule: a JS identifier that is not a reserved word.+func isIdent(b []byte) bool {+	if len(b) == 0 || gronReserved[string(b)] {+		return false+	}+	for i, r := range string(b) {+		if r == '$' || r == '_' || unicode.In(r, unicode.Lu, unicode.Ll, unicode.Lm, unicode.Lo, unicode.Nl) {+			continue+		}+		if i > 0 && unicode.In(r, unicode.Mn, unicode.Mc, unicode.Nd, unicode.Pc) {+			continue+		}+		return false+	}+	return true+}++var gronReserved = map[string]bool{+	"break": true, "case": true, "catch": true, "class": true, "const": true,+	"continue": true, "debugger": true, "default": true, "delete": true,+	"do": true, "else": true, "export": true, "extends": true, "false": true,+	"finally": true, "for": true, "function": true, "if": true, "import": true,+	"in": true, "instanceof": true, "new": true, "null": true, "return": true,+	"super": true, "switch": true, "this": true, "throw": true, "true": true,+	"try": true, "typeof": true, "var": true, "void": true, "while": true,+	"with": true, "yield": true,+}

gron_test.go

new file mode 100644@@ -0,0 +1,81 @@+package main++import (+	"os"+	"path/filepath"+	"strings"+	"testing"+)++func TestGron(t *testing.T) {+	in := `{"name":"Anna","n":-1.5e3,"ok":true,"x":null,"tags":["a",[1,{}]],"meta":{},+"createdAt":"2026","Content-Type":"x","class":1,"$v":2,"åke":"ö","escA":"v","1st":0}`+	want := `json = {};+json.name = "Anna";+json.n = -1.5e3;+json.ok = true;+json.x = null;+json.tags = [];+json.tags[0] = "a";+json.tags[1] = [];+json.tags[1][0] = 1;+json.tags[1][1] = {};+json.meta = {};+json.createdAt = "2026";+json["Content-Type"] = "x";+json["class"] = 1;+json.$v = 2;+json.åke = "ö";+json.escA = "v";+json["1st"] = 0;+`+	out, err := gron([]byte(in))+	if err != nil {+		t.Fatal(err)+	}+	if string(out) != want {+		t.Errorf("got\n%s\nwant\n%s", out, want)+	}+}++func TestGronRejectsBadJSON(t *testing.T) {+	for in, want := range map[string]string{+		"":                                  "line 1, column 1: no value",+		"{\n\"a\" 1}":                       "line 2, column 5: expected ':'",+		`[1 2]`:                             "line 1, column 4: expected ',' or ']'",+		`"a\x"`:                             "line 1, column 4: bad escape",+		`01`:                                "line 1, column 2: data after the value",+		`tru`:                               "line 1, column 1: bad literal",+		`{"a":1,}`:                          "line 1, column 8: expected a string key",+		strings.Repeat("[", gronMaxDepth+1): "nested deeper than",+	} {+		_, err := gron([]byte(in))+		if err == nil || !strings.Contains(err.Error(), want) {+			t.Errorf("%.20q: error %v, want %q", in, err, want)+		}+	}+}++func TestGronView(t *testing.T) {+	s, _, dir := serveTree(t)+	os.WriteFile(filepath.Join(dir, "a.json"), []byte(`{"a":[1]}`), 0o644)+	os.WriteFile(filepath.Join(dir, "bad.json"), []byte(`{"a":}`), 0o644)++	if body := get(s.handler(), "/a.json").Body.String(); !strings.Contains(body, `<a class="left" href="?gron">gron</a>`) {+		t.Errorf("a JSON page has no gron link: %s", body)+	}+	body := get(s.handler(), "/a.json?gron").Body.String()+	if !strings.Contains(body, `<span class="left">gron</span>`) || !strings.Contains(body, `<span id="L3">`) ||+		!strings.Contains(body, `<a href="?">flat</a>`) {+		t.Errorf("no gron view with three numbered lines: %s", body)+	}+	if body := get(s.handler(), "/bad.json?gron").Body.String(); !strings.Contains(body, "not valid JSON: line 1, column 6") ||+		!strings.Contains(body, `<span>flat</span>`) {+		t.Errorf("bad JSON does not say why and show flat: %s", body)+	}+	for _, p := range []string{"/a/b.go", "/a/b.go?gron"} {+		if body := get(s.handler(), p).Body.String(); strings.Contains(body, "gron") {+			t.Errorf("%s offers gron: %s", p, body)+		}+	}+}

server.go

@@ -838,8 +838,9 @@ func (t *tree) missing(name string) func(string) bool { }  // serveCode shows a text file as a highlighted code page with numbered,-// linkable lines; ?scopes draws each block of code as a box, and ?tree-// shows the syntax tree. Binary files go raw.+// linkable lines; ?scopes draws each block of code as a box, ?tree+// shows the syntax tree, and ?gron shows a JSON file as one line per node.+// Binary files go raw. func (t *tree) serveCode(w http.ResponseWriter, r *http.Request, name string, info fs.FileInfo) { 	b, err := fs.ReadFile(t.fsys, name) 	if err != nil {@@ -857,10 +858,19 @@ func (t *tree) serveCode(w http.ResponseWriter, r *http.Request, name string, in 	// page down. A tree that cannot be had says why above the flat code. 	q := r.URL.Query() 	_, scoped := q["scopes"]+	if _, ok := q["gron"]; ok && isJSON(name) {+		out, err := gron(b)+		if err == nil {+			body.WriteString(`<div class="code">` + codeViews(name, "gron") + highlightPage(gronLexer, out, false) + `</div>`)+			t.render(w, r, http.StatusOK, page{Title: name, Crumbs: t.crumbs(name), Kind: "code", Body: template.HTML(body.String())})+			return+		}+		body.WriteString("<p class=\"own warn\">No gron view, since this is not valid JSON: " + template.HTMLEscapeString(err.Error()) + "</p>\n")+	} 	if _, ok := q["tree"]; ok { 		tree, err := syntaxPage(r.Context(), name, pageHref(r.URL.Path), b) 		if err == nil {-			body.WriteString(`<div class="code">` + codeViews("tree") + tree + `</div>`)+			body.WriteString(`<div class="code">` + codeViews(name, "tree") + tree + `</div>`) 			t.render(w, r, http.StatusOK, page{Title: name, Crumbs: t.crumbs(name), Kind: "tree", Body: template.HTML(body.String())}) 			return 		}@@ -871,7 +881,7 @@ func (t *tree) serveCode(w http.ResponseWriter, r *http.Request, name string, in 	if scoped { 		view = "scopes" 	}-	body.WriteString(`<div class="code">` + codeViews(view))+	body.WriteString(`<div class="code">` + codeViews(name, view)) 	body.WriteString(highlightPage(codeLexer(name), b, scoped)) 	body.WriteString(`</div>`) 	t.render(w, r, http.StatusOK, page{Title: name, Crumbs: t.crumbs(name), Kind: "code", Body: template.HTML(body.String())})

syntax.go

@@ -191,19 +191,26 @@ func excerpt(lines [][]byte, n synNode) string { }  // codeViews are the links over a code page's top right corner: every-// view, the one shown as plain text, then the raw file.-func codeViews(shown string) string {+// view, the one shown as plain text, then the raw file. A JSON file also+// has the gron view, at the row's left end.+func codeViews(name, shown string) string { 	var b strings.Builder 	b.WriteString(`<div class="view">`) 	// Every view keeps its place, so a click never leaves another link 	// under the pointer; the one shown is plain text.-	for _, v := range []struct{ name, href string }{{"flat", "?"}, {"scopes", "?scopes"}, {"tree", "?tree"}} {-		if v.name == shown {-			b.WriteString(`<span>` + v.name + `</span>`)+	view := func(name, href, class string) {+		if name == shown {+			b.WriteString(`<span` + class + `>` + name + `</span>`) 		} else {-			b.WriteString(`<a href="` + v.href + `">` + v.name + `</a>`)+			b.WriteString(`<a` + class + ` href="` + href + `">` + name + `</a>`) 		} 	}+	if isJSON(name) {+		view("gron", "?gron", ` class="left"`)+	}+	for _, v := range []struct{ name, href string }{{"flat", "?"}, {"scopes", "?scopes"}, {"tree", "?tree"}} {+		view(v.name, v.href, "")+	} 	b.WriteString(`<a href="?raw">raw</a></div>`) 	return b.String() }