summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorclsr <clsr@clsr.net>2017-08-18 14:08:04 +0200
committerclsr <clsr@clsr.net>2017-08-18 14:08:04 +0200
commit1c15fe67c72b4591feaceeffec0951e34a6c2e46 (patch)
treec22393533916300e73799b9fe630c392a48a1c6b
downloadcn-http-1c15fe67c72b4591feaceeffec0951e34a6c2e46.tar.gz
cn-http-1c15fe67c72b4591feaceeffec0951e34a6c2e46.zip
Initial commitv0.1.0
-rw-r--r--.gitignore2
-rw-r--r--cn-http.service24
-rw-r--r--cnhttp.go645
-rw-r--r--static/hlraw.js39
-rw-r--r--static/script.js59
-rw-r--r--static/style.css167
-rw-r--r--templates/content.html97
-rw-r--r--templates/page.html112
8 files changed, 1145 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..62a5f44
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+/cn-http
+/static/highlight
diff --git a/cn-http.service b/cn-http.service
new file mode 100644
index 0000000..debeeb5
--- /dev/null
+++ b/cn-http.service
@@ -0,0 +1,24 @@
+[Unit]
+Description=CNP-HTTP and CNM-HTML translating reverse proxy
+After=network.target
+
+[Service]
+Type=simple
+ExecStart=/usr/local/bin/cn-http -listen localhost:9000 -cnp-host contnet.org
+PrivateTmp=yes
+ProtectSystem=strict
+ProtectHome=yes
+InaccessibleDirectories=/home
+ReadOnlyDirectories=/
+CapabilityBoundingSet=
+LimitFSIZE=0
+DeviceAllow=/dev/null rw
+MemoryDenyWriteExecute=yes
+User=nobody
+Group=nogroup
+WorkingDirectory=/var/contnet/cn-http
+Restart=always
+RestartSec=10s
+
+[Install]
+WantedBy=multi-user.target
diff --git a/cnhttp.go b/cnhttp.go
new file mode 100644
index 0000000..42f723d
--- /dev/null
+++ b/cnhttp.go
@@ -0,0 +1,645 @@
+package main
+
+import (
+ "bytes"
+ "errors"
+ "flag"
+ "html/template"
+ "io"
+ "log"
+ "net"
+ "net/http"
+ "net/url"
+ "path"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/kballard/go-shellquote"
+
+ "contnet.org/lib/cnm-go"
+ "contnet.org/lib/cnm-go/cnmfmt"
+ "contnet.org/lib/cnp-go"
+)
+
+var (
+ listen = flag.String("listen", "localhost:8080", "address for HTTP server to listen on")
+ cnphost = flag.String("cnp-host", "", "the CNP host to proxy (disables browser mode)")
+ nohighlight = flag.Bool("no-highlight", false, "do not include highlight.js scripts and stylesheets")
+ nostatic = flag.Bool("no-static", false, "do not serve static files on /static")
+
+ templates = template.Must(template.New("").Funcs(map[string]interface{}{
+ "inc": func(s string) string { return "" },
+ "dec": func() string { return "" },
+ "depth": func() int { return 0 },
+ "sanchor": func() string { return "" },
+ "lanchor": func() string { return "" },
+ "anchor": func() string { return "" },
+ "lang": func(s string) string {
+ return strings.Map(func(r rune) rune {
+ if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
+ return r
+ }
+ return '_'
+ }, s)
+ },
+ "href": func(s string) template.HTMLAttr {
+ return "href=\"" + template.HTMLAttr(template.HTMLEscapeString(s)) + "\""
+ },
+ "cnmfmt": (func(cnmfmt.Text) template.HTML)(nil),
+ "tourl": (func(string) string)(nil),
+ }).ParseGlob("templates/*.html"))
+)
+
+const (
+ anchorCNPSchema = "cnp://"
+ anchorHTTPSchema = "http://"
+ anchorPrefix = `<a href="`
+ anchorSuffix = `">`
+)
+
+var (
+ anchorTemplate = template.Must(template.New("").Parse(anchorPrefix + "{{.}}" + anchorSuffix))
+)
+
+func escapeURL(urlStr string) string {
+ isCNP := len(urlStr) >= len(anchorCNPSchema) && strings.EqualFold(urlStr[:len(anchorCNPSchema)], anchorCNPSchema)
+ if isCNP {
+ urlStr = anchorHTTPSchema + urlStr[len(anchorCNPSchema):]
+ }
+ var buf bytes.Buffer
+ anchorTemplate.Execute(&buf, urlStr)
+ urlStr = buf.String()
+ urlStr = urlStr[len(anchorPrefix) : len(urlStr)-len(anchorSuffix)]
+ if isCNP {
+ urlStr = anchorCNPSchema + urlStr[len(anchorHTTPSchema):]
+ }
+ return urlStr
+}
+
+type server struct {
+ host string
+ highlighting bool
+}
+
+func newServer(host string, highlighting bool) *server {
+ return &server{host, highlighting}
+}
+
+func (srv *server) handleError(w http.ResponseWriter, err error) {
+ code := http.StatusInternalServerError
+ switch err.(type) {
+ case cnp.ErrorDenied:
+ code = http.StatusForbidden
+ case cnp.ErrorInvalid:
+ code = http.StatusInternalServerError
+ case cnp.ErrorNotFound:
+ code = http.StatusNotFound
+ case cnp.ErrorNotSupported:
+ code = http.StatusInternalServerError
+ case cnp.ErrorRejected:
+ code = http.StatusUnprocessableEntity
+ case cnp.ErrorServerError:
+ code = http.StatusBadGateway
+ case cnp.ErrorSyntax:
+ code = http.StatusInternalServerError
+ case cnp.ErrorTooLarge:
+ code = http.StatusRequestEntityTooLarge
+ case cnp.ErrorVersion:
+ code = http.StatusInternalServerError
+ }
+ log.Printf("error: %T %v", err, err)
+ http.Error(w, http.StatusText(code), code)
+}
+
+func (srv *server) handleIndex(w http.ResponseWriter, r *http.Request) {
+ req, _ := cnp.NewRequest("", "/", nil)
+ resp, _ := cnp.NewResponse(cnp.IntentOK, []byte{})
+ srv.cnpCNMToHTML(w, r, req, resp)
+}
+
+func (srv *server) handleCNP(w http.ResponseWriter, r *http.Request, path string) {
+ host := srv.host
+ if host == "" {
+ ss := strings.SplitN(strings.TrimLeft(path, "/"), "/", 2)
+ if len(ss) != 2 {
+ if len(ss[0]) > 0 {
+ http.Redirect(w, r, path+"/", http.StatusFound)
+ return
+ }
+ srv.handleIndex(w, r)
+ return
+ }
+ host = ss[0]
+ path = "/" + ss[1]
+ }
+
+ req, err := cnp.NewRequest(host, path, nil)
+ if err != nil {
+ srv.handleError(w, err)
+ return
+ }
+
+ if ims := r.Header.Get("If-Modified-Since"); ims != "" {
+ var t time.Time
+ t, err = http.ParseTime(ims)
+ if err == nil && !t.IsZero() {
+ req.SetIfModified(t)
+ }
+ }
+
+ resp, err := cnp.Send(req)
+ intent := "n/a"
+ if resp != nil {
+ intent = resp.Intent()
+ }
+ log.Printf("req: %q -> %q", req.Intent(), intent)
+ if err != nil {
+ if e, ok := err.(*net.OpError); ok && e.Op == "dial" {
+ err = cnp.NewError(cnp.ReasonServerError)
+ }
+ srv.handleError(w, err)
+ return
+ }
+
+ r.ParseForm()
+ _, preq := r.Form["req"]
+ _, phdr := r.Form["hdr"]
+ _, presp := r.Form["resp"]
+ _, praw := r.Form["raw"]
+
+ if preq || phdr || presp {
+ srv.cnpParamsToHTTP(w, resp)
+ w.Header().Set("Content-Type", "text/plain")
+ w.Header().Del("Content-Length")
+
+ if preq {
+ req.Write(w)
+ }
+ if presp {
+ resp.Write(w)
+ } else if phdr {
+ resp.Header.Write(w)
+ }
+ } else {
+ srv.cnpToWeb(w, r, req, resp, praw)
+ }
+}
+
+func (srv *server) cnpToWeb(w http.ResponseWriter, r *http.Request, req *cnp.Request, resp *cnp.Response, raw bool) {
+ if err := resp.Validate(); err != nil {
+ srv.handleError(w, err)
+ return
+ }
+
+ switch resp.ResponseIntent() {
+ case cnp.IntentOK:
+ srv.cnpParamsToHTTP(w, resp)
+ if !raw && resp.Type() == "text/cnm" {
+ srv.cnpCNMToHTML(w, r, req, resp)
+ } else {
+ io.Copy(w, resp.Body)
+ }
+ case cnp.IntentNotModified:
+ w.WriteHeader(http.StatusNotModified)
+ case cnp.IntentRedirect:
+ host, pth, _ := resp.Location() // already validated
+ loc, err := srv.cnpRedirectToHTTP(req, host, pth)
+ if err != nil {
+ srv.handleError(w, err)
+ return
+ }
+ log.Println("redirecting to", loc)
+ http.Redirect(w, r, loc, http.StatusFound)
+ case cnp.IntentError:
+ srv.handleError(w, cnp.NewError(resp.Reason()))
+ default:
+ srv.handleError(w, errors.New("unknown CNP response intent: "+resp.ResponseIntent()))
+ }
+}
+
+func (srv *server) cnpRedirectToHTTP(req *cnp.Request, host, pth string) (string, error) {
+ var loc string
+ if host == "" || host == "." || host == srv.host {
+ loc = pth
+ if host == "." {
+ loc = path.Join(path.Dir(req.Path()), loc)
+ }
+ if srv.host == "" {
+ loc = path.Join("/", req.Host(), loc)
+ }
+ } else {
+ rr, err := cnp.NewRequest(host, pth, nil)
+ if err != nil {
+ return "", err
+ }
+ if srv.host == "" {
+ loc = path.Join("?", rr.Host(), rr.Path())
+ } else {
+ loc = rr.URL().String()
+ }
+ }
+ if strings.HasSuffix(pth, "/") {
+ loc = loc + "/"
+ }
+ return loc, nil
+}
+
+func (srv *server) cnpParamsToHTTP(w http.ResponseWriter, resp *cnp.Response) {
+ if l := resp.Length(); l > 0 {
+ w.Header().Set("Content-Length", strconv.FormatInt(l, 10))
+ }
+ if t := resp.Type(); t != "" {
+ w.Header().Set("Content-Type", t)
+ }
+ if m := resp.Modified(); !m.IsZero() {
+ w.Header().Set("Last-Modified", m.Format(http.TimeFormat))
+ }
+ if t := resp.Time(); !t.IsZero() {
+ w.Header().Set("Date", t.Format(http.TimeFormat))
+ }
+ if n := resp.Name(); n != "" {
+ w.Header().Set("Content-Disposition", "inline; filename=\""+strings.Map(func(r rune) rune {
+ if r < ' ' || r == ' ' || r == '\'' { // filter out nulls, control codes, newlines and quotes
+ return -1
+ }
+ return r
+ }, n)+"\"")
+ }
+}
+
+type cnmPage struct {
+ URL string
+ Req string
+ Resp string
+ Doc *cnm.Document
+ Site []site
+ Netcat string
+ Toc tocSection
+ Highlight bool
+ Browser bool
+ depth int
+}
+
+type tocSection struct {
+ Title string
+ Children []tocSection
+}
+
+type site struct {
+ Path string
+ Name string
+ Children []site
+}
+
+func genToc(b cnm.Block) []tocSection {
+ var res []tocSection
+ switch v := b.(type) {
+ case *cnm.SectionBlock:
+ if t := v.Title(); t != "" {
+ var ch []tocSection
+ for _, c := range v.Children() {
+ ch = append(ch, genToc(c)...)
+ }
+ res = append(res, tocSection{
+ Title: v.Title(),
+ Children: ch,
+ })
+ } else {
+ for _, bl := range v.Children() {
+ res = append(res, genToc(bl)...)
+ }
+ }
+ case *cnm.TableBlock:
+ for _, bl := range v.Rows() {
+ res = append(res, genToc(bl)...)
+ }
+ case *cnm.HeaderBlock:
+ for _, bl := range v.Children() {
+ res = append(res, genToc(bl)...)
+ }
+ case *cnm.RowBlock:
+ for _, bl := range v.Children() {
+ res = append(res, genToc(bl)...)
+ }
+ case *cnm.ContentBlock:
+ if v == nil {
+ break
+ }
+ for _, bl := range v.Children() {
+ res = append(res, genToc(bl)...)
+ }
+ case *cnm.ListBlock:
+ for _, bl := range v.Children() {
+ res = append(res, genToc(bl)...)
+ }
+ }
+ return res
+}
+
+func cnmSite(prefix string, s cnm.Site) (st site) {
+ if ss := strings.Split(path.Clean(s.Path), "/"); len(ss) > 1 {
+ s.Path = strings.Join(ss[1:], "/")
+ return site{
+ Path: path.Join(prefix, ss[0]),
+ Name: ss[0],
+ Children: []site{
+ cnmSite(path.Join(prefix, ss[0]), s),
+ },
+ }
+ }
+
+ st.Path = path.Join(prefix, s.Path)
+ if s.Name == "" {
+ st.Name = s.Path
+ } else {
+ st.Name = s.Name
+ }
+ if len(s.Children) > 0 {
+ chs := make(map[string]site)
+ for _, ch := range s.Children {
+ chs[ch.Name] = cnmSite(st.Path, ch)
+ }
+ keys := make([]string, len(chs))
+ i := 0
+ for k := range chs {
+ keys[i] = k
+ i++
+ }
+ sort.Slice(keys, func(i, j int) bool {
+ a, b := chs[keys[i]], chs[keys[j]]
+ da, db := strings.HasSuffix(a.Name, "/"), strings.HasSuffix(b.Name, "/")
+ if da && !db {
+ return true
+ }
+ if !da && db {
+ return false
+ }
+ return a.Name < b.Name
+ })
+ st.Children = make([]site, len(keys))
+ for i := range keys {
+ st.Children[i] = chs[keys[i]]
+ }
+ }
+ return
+}
+
+func (srv *server) cnpCNMToHTML(w http.ResponseWriter, r *http.Request, req *cnp.Request, resp *cnp.Response) {
+ doc, err := cnm.ParseDocument(resp.Body)
+ if err != nil {
+ srv.handleError(w, err)
+ return
+ }
+
+ w.Header().Set("Content-Type", "text/html")
+ w.Header().Del("Content-Length")
+
+ var breq, bresp, buf bytes.Buffer
+ req.Header.Write(&breq)
+ resp.Header.Write(&bresp)
+ var st []site
+ if len(doc.Site.Children) > 0 {
+ st = []site{
+ site{"/", "/", cnmSite("/", doc.Site).Children},
+ }
+ }
+
+ u := req.URL()
+ port := u.Port()
+ if port == "" {
+ port = strconv.Itoa(cnp.DefaultPort)
+ }
+ hdrs := req.Header.String()
+ hdrs = hdrs[:len(hdrs)-1]
+ host, _, _ := net.SplitHostPort(r.Host)
+ if host == "" && r.Host != "" {
+ host = r.Host
+ }
+ if host == "" {
+ host = r.URL.Hostname()
+ }
+ if host == "" {
+ host = req.Host()
+ }
+ sections := []string{}
+ sanchors := map[string]bool{}
+ lanchors := map[string]bool{}
+ anchor := func() template.URL {
+ var secs []string
+ for _, s := range sections {
+ if s = url.PathEscape(s); s != "" {
+ secs = append(secs, s)
+ }
+ }
+ return template.URL(strings.Join(secs, "/"))
+ }
+ err = templates.Funcs(map[string]interface{}{
+ "inc": func(s string) string { sections = append(sections, s); return "" },
+ "dec": func() string { sections = sections[:len(sections)-1]; return "" },
+ "depth": func() int {
+ d := len(sections) + 1
+ if d > 6 {
+ return 6
+ }
+ return d
+ },
+ "anchor": anchor,
+ "sanchor": func() template.URL {
+ s := url.PathEscape(sections[len(sections)-1])
+ if sanchors[s] {
+ return ""
+ }
+ sanchors[s] = true
+ return template.URL(s)
+ },
+ "lanchor": func() template.URL {
+ s := string(anchor())
+ if lanchors[s] {
+ return ""
+ }
+ lanchors[s] = true
+ return template.URL(s)
+ },
+ "cnmfmt": func(txt cnmfmt.Text) template.HTML {
+ return srv.fmtToHTML(req, txt)
+ },
+ "tourl": func(s string) string {
+ u, _ := srv.linkToURL(req, s)
+ return u
+ },
+ }).ExecuteTemplate(&buf, "page.html", cnmPage{
+ URL: u.String(),
+ Req: breq.String(),
+ Resp: bresp.String(),
+ Doc: doc,
+ Netcat: shellquote.Join("echo", hdrs) + " | " + shellquote.Join("nc", host, port),
+ Site: st,
+ Toc: tocSection{Children: genToc(doc.Content)},
+ Highlight: srv.highlighting,
+ Browser: srv.host == "",
+ })
+ if err != nil {
+ srv.handleError(w, err)
+ return
+ }
+
+ io.Copy(w, &buf)
+}
+
+func (srv *server) fmtToHTML(req *cnp.Request, text cnmfmt.Text) template.HTML {
+ if len(text.Spans) == 0 {
+ return ""
+ }
+
+ var last cnmfmt.Span
+ var buf bytes.Buffer
+ hadText := true
+
+ spans := make([]cnmfmt.Span, len(text.Spans)+1)
+ copy(spans, text.Spans)
+ spans[len(spans)-1] = cnmfmt.Span{} // end all formats
+
+ var tags []string
+ for _, span := range spans {
+ if last.Format.Link != span.Format.Link {
+ if !hadText && last.Format.Link != "" { // no text in link
+ template.HTMLEscape(&buf, []byte(last.Format.Link))
+ }
+ hadText = false
+ }
+ srv.handleTags(req, &buf, &tags, last.Format, span.Format)
+ if span.Text != "" {
+ hadText = true
+ template.HTMLEscape(&buf, []byte(span.Text))
+ }
+ last = span
+ }
+
+ return template.HTML("<p>" + buf.String() + "</p>")
+}
+
+func (srv *server) handleTags(req *cnp.Request, buf *bytes.Buffer, tags *[]string, old, new cnmfmt.Format) {
+ if old == new {
+ return
+ }
+
+ close := map[string]bool{
+ "b": old.Bold && !new.Bold,
+ "i": old.Italic && !new.Italic,
+ "u": old.Underline && !new.Underline,
+ "code": old.Monospace && !new.Monospace,
+ "a": old.Link != "" && old.Link != new.Link,
+ }
+
+ open := map[string]bool{
+ "b": !old.Bold && new.Bold,
+ "i": !old.Italic && new.Italic,
+ "u": !old.Underline && new.Underline,
+ "code": !old.Monospace && new.Monospace,
+ "a": new.Link != "" && old.Link != new.Link,
+ }
+
+ t := *tags
+
+ pop := len(t)
+ for i := len(t) - 1; i >= 0; i-- {
+ if close[t[i]] {
+ pop = i
+ }
+ }
+ for i := len(t) - 1; i >= pop; i-- {
+ if !close[t[i]] {
+ open[t[i]] = true
+ }
+ buf.WriteString("</" + t[i] + ">")
+ }
+ *tags = t[:pop]
+
+ tagPush(buf, tags, open, "b", "<b>")
+ tagPush(buf, tags, open, "i", "<i>")
+ tagPush(buf, tags, open, "u", "<u>")
+ tagPush(buf, tags, open, "code", "<code>")
+
+ link, extern := srv.linkToURL(req, new.Link)
+ tagPush(buf, tags, open, "a", "<a"+extern+" href=\""+escapeURL(link)+"\">")
+}
+
+func (srv *server) linkToURL(req *cnp.Request, link string) (urlStr, extern string) {
+ urlStr = "#ZcnpfmtZ"
+ host := srv.host
+ if host == "" {
+ host = req.Host()
+ }
+ if u, err := url.Parse(link); err == nil {
+ if u.Scheme == "cnp" {
+ var lhost, lpath string
+ lhost = u.Host
+ lpath = u.Path
+ if u.Host == srv.host {
+ lhost = ""
+ } else {
+ extern = " class=\"cnp-external cnp-external-cnp\""
+ }
+ urlStr = path.Join("/", lhost, lpath)
+ if strings.HasSuffix(link, "/") {
+ urlStr += "/"
+ }
+ if u.Fragment != "" {
+ urlStr += "#" + u.Fragment
+ }
+ } else if u.Scheme != "" {
+ extern = " class=\"cnp-external cnp-external-" + template.HTMLEscapeString(u.Scheme) + "\""
+ urlStr = u.String()
+ } else {
+ urlStr = u.Path
+ if srv.host == "" {
+ if !strings.HasPrefix(urlStr, "/") {
+ urlStr = req.Path() + "/" + urlStr
+ }
+ urlStr = "/" + host + cnp.Clean("/"+urlStr)
+ }
+ if u.Fragment != "" {
+ urlStr += "#" + u.Fragment
+ }
+ }
+ }
+ return
+}
+
+func tagPush(buf *bytes.Buffer, tags *[]string, ok map[string]bool, name, tag string) {
+ if ok[name] {
+ buf.WriteString(tag)
+ *tags = append(*tags, name)
+ }
+}
+
+func (srv *server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ log.Printf("access: %s %s %q", r.RemoteAddr, r.Method, r.URL)
+ if r.Method == http.MethodPost {
+ u := r.FormValue("url")
+ req, err := cnp.NewRequestURL(u, nil)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusUnprocessableEntity)
+ return
+ }
+ http.Redirect(w, r, path.Join("/", req.Host(), req.Path()), http.StatusFound)
+ } else {
+ srv.handleCNP(w, r, r.URL.Path)
+ }
+}
+
+func main() {
+ flag.Parse()
+
+ srv := newServer(*cnphost, !*nohighlight)
+
+ if !*nostatic {
+ http.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
+ }
+ http.Handle("/", srv)
+
+ log.Printf("listening on %s", *listen)
+ panic(http.ListenAndServe(*listen, nil))
+}
diff --git a/static/hlraw.js b/static/hlraw.js
new file mode 100644
index 0000000..8d57bb4
--- /dev/null
+++ b/static/hlraw.js
@@ -0,0 +1,39 @@
+(function() {
+ 'use strict';
+
+ var highlight = function(block, lang) {
+ var langs = lang.split('_');
+ if (langs.length > 1) {
+ lang = langs[langs.length-1];
+ }
+ if (!lang || typeof hljs.getLanguage(lang) === 'undefined') {
+ return;
+ }
+ var hl = hljs.highlight(lang, block.textContent, true);
+ block.innerHTML = hl.value;
+ };
+
+ var highlightAll = function() {
+ var rawBlocks = document.querySelectorAll('pre.cnm-raw code');
+ for (var i=0; i<rawBlocks.length; i++) {
+ var code = rawBlocks[i];
+ var classes = code.className.split();
+ var lang = '';
+ for (var j=0; j<classes.length; j++) {
+ if (classes[j].startsWith('cnm-raw-')) {
+ lang = classes[j].slice('cnm-raw-'.length);
+ break;
+ }
+ }
+ if (lang) {
+ try {
+ highlight(code, lang);
+ } catch (e) {
+ console.error(e);
+ }
+ }
+ }
+ };
+
+ highlightAll();
+})();
diff --git a/static/script.js b/static/script.js
new file mode 100644
index 0000000..36e2b18
--- /dev/null
+++ b/static/script.js
@@ -0,0 +1,59 @@
+(function() {
+ 'use strict';
+
+ var init = function() {
+ var toggle = document.createElement('button');
+
+ var clickToggle = function() {
+ toggle.open = !toggle.open;
+ var open = toggle.open ? 'open' : '';
+ var secs = document.querySelectorAll('section>details');
+ for (var i=0; i<secs.length; i++) {
+ secs[i].open = open;
+ }
+ toggle.textContent = (toggle.open ? 'Collapse' : 'Expand') + ' all sections';
+ };
+
+ toggle.addEventListener('click', clickToggle);
+
+ var hashchange = function() {
+ var selected = [];
+ if (location.hash != '' && location.hash != '#') {
+ var h = location.hash.slice(1);
+ var el = document.getElementById(h);
+ while (el) {
+ if (el.tagName == 'DETAILS') {
+ selected.push(el);
+ }
+ el = el.parentNode;
+ }
+ }
+
+ if (selected.length > 0) {
+ toggle.open = true;
+ clickToggle();
+ for (var i=0; i<selected.length; i++) {
+ selected[i].open = 'open';
+ }
+ selected[0].scrollIntoView();
+ } else {
+ toggle.open = false;
+ clickToggle();
+ }
+ };
+ window.addEventListener('hashchange', hashchange);
+
+ hashchange();
+ if (document.querySelectorAll('main section').length > 0) {
+ var main = document.querySelector('main');
+ main.insertBefore(toggle, main.firstChild);
+ }
+
+ };
+
+ if (document.readyState !== 'loading') {
+ init();
+ } else {
+ document.addEventListener('DOMContentLoaded', init);
+ }
+})();
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..752d538
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,167 @@
+html {
+ height: 100%;
+ font-family: sans-serif;
+ color: black;
+ background-color: white;
+}
+
+body {
+ margin: 1em auto;
+ max-width: 50em;
+ padding: 0 0.5em;
+}
+
+section {
+ margin: 0.25em;
+}
+
+section h1, section h2, section h3,
+section h4, section h5, section h6 {
+ padding: 0;
+ margin: 0;
+ display: inline-block;
+}
+
+.sec-link {
+ display: none;
+}
+
+:hover>*>.sec-link {
+ display: inline;
+}
+
+a.cnp-external:after {
+ text-decoration: none;
+ color: gray;
+ display: inline-block;
+ font-size: 0.8em;
+ content: '\01f517';
+}
+
+a.cnp-external-cnp:after {
+ content: '[cnp]';
+}
+
+a.cnp-external-http:after {
+ content: '[http]';
+}
+
+a.cnp-external-https:after {
+ content: '[https]';
+}
+
+main {
+ border: 1px dashed #aaa;
+ tab-size: 4;
+ -moz-tab-size: 4; /* Pale Moon and older FF need prefix */
+}
+
+main, section {
+ padding: 0.75em;
+}
+
+main:hover, section:hover {
+}
+
+pre, code {
+ font-family: monospace, monospace;
+ font-size: 1em;
+}
+
+pre {
+ margin: 0;
+ display: inline-block;
+ white-space: pre-wrap;
+}
+
+code {
+ background-color: #f8f8f8;
+ border: 1px solid #ccc;
+}
+
+pre.cnm-raw {
+ display: block;
+}
+
+pre>code {
+ background-color: #fbfbfb;
+ display: block;
+ border: 1px solid black;
+ width: auto;
+ padding: 0.5em;
+ margin-top: 0.5em;
+ margin-bottom: 0.5em;
+}
+
+pre>code:hover {
+ background-color: #f4f4f4;
+}
+
+p, figcaption, pre {
+ margin-bottom: 1em;
+}
+
+figure {
+ border: 1px solid #aaa;
+ display: inline-block;
+ padding: 0 0.5em;
+ margin: 0 auto;
+}
+
+summary {
+ cursor: pointer;
+}
+
+details>summary>* {
+ text-decoration: underline;
+}
+
+details[open]>summary>* {
+ text-decoration: none;
+}
+
+header {
+}
+
+footer {
+ padding-top: 0.5em;
+ font-size: 0.8em;
+}
+
+footer details {
+ display: inline-block;
+}
+
+table {
+ margin-top: 0.5em;
+ margin-bottom: 0.5em;
+ border-collapse: collapse;
+}
+
+li p, li pre, li code, table p, table pre, table code {
+ padding: 0;
+ margin: 0;
+}
+
+table, th, td {
+ border: 1px solid black;
+}
+
+img {
+ padding-top: 0.5em;
+ max-width: 100%;
+}
+
+#browser {
+ width: 100%;
+ border: 1px solid black;
+ padding: 1em;
+ display: flex;
+}
+#browser input[type=text] {
+ flex: 1;
+}
+#browser input[type=submit] {
+ border: 1px solid black;
+ margin-left: 0.5em;
+}
diff --git a/templates/content.html b/templates/content.html
new file mode 100644
index 0000000..73f18c7
--- /dev/null
+++ b/templates/content.html
@@ -0,0 +1,97 @@
+{{- if eq .Name "section" "content" -}}
+{{- if .Title}}
+<section class="cnm-section"><details open>
+ {{- inc .Title -}}
+ <summary>
+ {{- with .Title -}}
+ {{- if $l := lanchor -}}
+ <h{{depth}}>{{.}}<a class="sec-link" id="/{{$l}}" href="#/{{$l}}">ΒΆ</a>
+ {{- if $s := sanchor}}{{if ne $s $l}}<a id="#{{$s}}"></a>{{end}}{{end -}}
+ </h{{depth}}>
+ {{- end -}}
+ {{- end -}}
+ </summary>
+ {{- range .Children -}}
+ {{- template "content.html" . -}}
+ {{- end -}}
+ {{- dec -}}
+</details></section>
+{{else -}}
+{{- range .Children -}}
+ {{- template "content.html" . -}}
+{{- end -}}
+{{- end -}}
+{{- end -}}
+
+{{- if eq .Name "list" -}}
+{{if .Ordered}}<ol{{else}}<ul{{end}} class="cnm-list">
+ {{- range .Children -}}
+ {{- if eq .Name "list" -}}
+ {{- template "content.html" . -}}
+ {{- else -}}
+ <li>{{- template "content.html" . -}}</li>
+ {{- end -}}
+ {{- end -}}
+{{- if .Ordered}}</ol>{{else}}</ul>{{end -}}
+{{- end -}}
+
+{{- if eq .Name "table" -}}
+<table class="cnm-table">
+ {{- range .Rows -}}
+ {{- template "content.html" . -}}
+ {{- end -}}
+</table>
+{{- end -}}
+
+{{- if eq .Name "header" -}}
+<tr class="cnm-header">
+ {{- range .Children -}}
+ <th>{{template "content.html" .}}</th>
+ {{- end -}}
+</tr>
+{{- end -}}
+
+{{- if eq .Name "row" -}}
+<tr class="cnm-row">
+ {{- range .Children -}}
+ <td>{{template "content.html" .}}</td>
+ {{- end -}}
+</tr>
+{{- end -}}
+
+{{- if eq .Name "embed" -}}
+<figure class="cnm-embed">
+ {{- if eq .Type "image/png" "image/jpeg" "image/webp" "image/*" -}}
+ <a href="{{tourl .URL}}"><img src="{{tourl .URL}}" alt="{{with .Description}}{{.}}{{else}}embedded {{.Type}}{{end}}" {{with .Description}}title="{{.}}" {{end}}/></a>
+ {{- end -}}
+ {{- if eq .Type "video/mp4" "video/webm" "video/*" -}}
+ <video src="{{tourl .URL}}" controls{{with .Description}} title="{{.}}"{{end}}>
+ Video content: <a href="{{tourl .URL}}">{{.URL}}</a>
+ </video>
+ {{- end -}}
+ {{- if not (eq .Type "image/png" "image/jpeg" "image/webp" "image/*" "video/mp4" "video/webm" "video/*") -}}
+ <p>
+ Embedded <code>{{.Type}}</code> content: <a href="{{tourl .URL}}"{{with .Description}} title="{{.}}"{{end}}>{{.URL}}</a>
+ </p>
+ {{- end -}}
+ {{- with .Description}}<figcaption>{{.}}</figcaption>{{end -}}
+</figure>
+{{- end -}}
+
+{{- if eq .Name "text" -}}
+ {{- if eq .Format "" "plain"}}{{range .Contents.Paragraphs -}}
+<p class="cnm-text">{{.}}</p>
+ {{- end}}{{end -}}
+ {{- if eq .Format "fmt" -}}
+ {{- range .Contents.Paragraphs -}}
+ {{- cnmfmt . -}}
+ {{- end -}}
+ {{- end -}}
+ {{- if not (eq .Format "" "plain" "fmt") -}}
+<pre class="cnm-text cnm-text-pre">{{.Contents.Text}}</pre>
+ {{- end -}}
+{{- end -}}
+
+{{- if eq .Name "raw" -}}
+<pre class="cnm-raw"><code class="cnm-raw-{{lang .Syntax}}">{{.Contents}}</code></pre>
+{{- end -}}
diff --git a/templates/page.html b/templates/page.html
new file mode 100644
index 0000000..0f89e2d
--- /dev/null
+++ b/templates/page.html
@@ -0,0 +1,112 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>{{with .Doc.Title}}{{.}}{{else}}&nbsp;{{end}}</title>
+ <link rel="stylesheet" href="/static/style.css" />
+ <style>main p,main summary,h1,h2,h3,h4,h5,h6{white-space: pre-wrap;}</style>
+ <script async src="/static/script.js"></script>
+ {{if .Highlight}}<link rel="stylesheet" href="/static/highlight/style.css" />{{end}}
+ </head>
+ <body>
+ <header>
+ {{if .Browser -}}
+ <form id="browser" action="/" method="post">
+ <input type="text" name="url" placeholder="cnp://example.com/" value="{{if ne .URL "cnp:///"}}{{.URL}}{{end}}" />
+ <input type="submit" value="Go" />
+ </form>
+ {{- end}}
+
+ {{with .Doc.Title}}<h1>{{.}}</h1>{{end -}}
+
+ {{with .Doc.Links -}}
+ <nav>
+ <details open>
+ <summary><b>Links</b></summary>
+ <ul>
+ {{- range .}}
+ <li><a href="{{tourl .URL}}"{{with .Description}} title="{{.}}"{{end}}>{{with .Name}}{{.}}{{else}}{{.URL}}{{end}}</a></li>
+ {{- end}}
+ </ul>
+ </details>
+ </nav>
+ {{- end -}}
+
+ {{with .Site -}}
+ <nav>
+ <details>
+ <summary><b>Sitemap</b></summary>
+ {{- block "site" . -}}
+ <ul>
+ {{- range . -}}
+ <li>
+ <a href="{{tourl .Path}}">{{.Name}}</a>
+ {{- template "site" .Children -}}
+ </li>
+ {{- end -}}
+ </ul>
+ {{- end -}}
+ </details>
+ </nav>
+ {{- end -}}
+
+ {{if .Toc.Children -}}
+ <nav>
+ <details>
+ <summary><b>Table of Contents</b></summary>
+ <ul>
+ {{- range .Toc.Children -}}
+ {{- block "toc" . -}}
+ {{- inc .Title -}}
+ <li>
+ <a href="#/{{anchor}}">{{.Title}}</a>
+ {{- with .Children -}}
+ <ul>
+ {{- range . -}}
+ {{- template "toc" . -}}
+ {{- end -}}
+ </ul>
+ {{- end -}}
+ </li>
+ {{- dec -}}
+ {{- end -}}
+ {{- end -}}
+ </ul>
+ </details>
+ </nav>
+ {{- end}}
+ </header>
+
+ <main>
+ {{- with .Doc.Content}}
+ {{- range .Children}}
+ {{- template "content.html" .}}
+ {{- end}}{{end}}
+ </main>
+
+ <footer>
+ <p>This is a <a href="https://contnet.org/">ContNet</a> <code>text/cnm</code> page retrieved over CNP from <a {{href .URL}}>{{.URL}}</a>
+ -
+ <a href="?req&amp;resp">See request/response</a>
+ -
+ <a href="?raw">Raw CNM document</a>
+ </p>
+ <details>
+ <summary>CNP request header (<a href="?req">try</a>)</summary>
+ <pre><code class="lang-{{lang "text/cnp"}}">{{.Req}}</code></pre>
+ </details>
+ <details>
+ <summary>CNP response header (<a href="?hdr">try</a>)</summary>
+ <pre><code class="lang-{{lang "text/cnp"}}">{{.Resp}}</code></pre>
+ </details>
+ <details>
+ <summary>Try with <a href="https://en.wikipedia.org/wiki/Netcat">Netcat</a></summary>
+ <pre><code class="lang-{{lang "application/x-sh"}}">{{.Netcat}}</code></pre>
+ </details>
+ </footer>
+
+ {{if .Highlight}}<script src="/static/highlight/highlight.pack.js"></script>
+ <script src="/static/hlraw.js"></script>{{end}}
+ </body>
+</html>