aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBrian Picciano <me@mediocregopher.com>2024-07-03 12:36:05 +0200
committerBrian Picciano <me@mediocregopher.com>2024-07-03 12:36:05 +0200
commit7e1ecc4df44d20d2c9de1c8885ddc2c188062ef0 (patch)
treeac31b787dbaf6c3c702b508e9529d62ceb221923
parent1a6d506a525e32bc374f89377e46a775c6737cf0 (diff)
Initial implementation of the gemtext template extension
-rw-r--r--README.md28
-rw-r--r--cmd/mediocre-caddy/main.go1
-rw-r--r--example/Caddyfile29
-rw-r--r--example/static/bamboo.css1
-rw-r--r--example/static/cheatsheet.gmi62
-rw-r--r--example/tpl/render_gemtext.html15
-rw-r--r--go.mod6
-rw-r--r--http/handlers/templates/functions/functions.go3
-rw-r--r--http/handlers/templates/functions/gemtext.go153
-rw-r--r--plugins.go7
10 files changed, 297 insertions, 8 deletions
diff --git a/README.md b/README.md
index 52f3019..2a146ba 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,34 @@
TODO proper introduction
+## Build
+
+TODO
+
+## Plugins
+
+The following plugins are implemented in this module.
+
+### http.handlers.templates.functions.gemtext
+
+This extension to `templates` allows for rendering a [gemtext][gemtext] string
+as a roughly equivalent set of HTML tags. It is similar to the [markdown template
+function][mdfunc] in its usage. It can be enabled by being included in the
+`templates.extensions` set.
+
+```
+templates {
+ extensions {
+ gemtext
+ }
+}
+```
+
+See the `template.localhost` virtual host in `example/Caddyfile` for an example
+of using the `gemtext` template to render a gemtext file within an HTML file.
+
+[gemtext]: https://geminiprotocol.net/docs/gemtext.gmi
+
## Development
A nix-based development environment is provided with the correct versions of all
diff --git a/cmd/mediocre-caddy/main.go b/cmd/mediocre-caddy/main.go
index 48fa149..3f19e84 100644
--- a/cmd/mediocre-caddy/main.go
+++ b/cmd/mediocre-caddy/main.go
@@ -32,6 +32,7 @@ import (
caddycmd "github.com/caddyserver/caddy/v2/cmd"
// plug in Caddy modules here
+ _ "dev.mediocregopher.com/mediocre-caddy-plugins.git"
_ "github.com/caddyserver/caddy/v2/modules/standard"
)
diff --git a/example/Caddyfile b/example/Caddyfile
index 8f08483..aa12136 100644
--- a/example/Caddyfile
+++ b/example/Caddyfile
@@ -1,12 +1,29 @@
{
debug
admin off
- log {
- level debug
- }
+ auto_https off
+ http_port 8000
}
-http://localhost:8000 {
- root * ./examples/static
- file_server
+http://template.localhost {
+ root example/static
+
+ # If the path exists in the static directory then serve it directly
+ @static file {path} {path}/
+ route @static {
+ file_server
+ }
+
+ # Otherwise send it through the template, which will look for a matching gmi
+ # file to render.
+ route {
+ rewrite * /tpl/render_gemtext.html
+ root example
+ templates {
+ extensions {
+ gemtext
+ }
+ }
+ file_server
+ }
}
diff --git a/example/static/bamboo.css b/example/static/bamboo.css
new file mode 100644
index 0000000..cee0aba
--- /dev/null
+++ b/example/static/bamboo.css
@@ -0,0 +1 @@
+:root{--b-font-main: system-ui, sans-serif;--b-font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--b-txt: #2e3440;--b-bg-1: #fff;--b-bg-2: #eceff4;--b-line: #eceff4;--b-link: #bf616a;--b-btn-bg: #242933;--b-btn-txt: #fff;--b-focus: #88c0d0}@media(prefers-color-scheme: dark){:root{--b-txt: #eceff4;--b-bg-1: #2e3440;--b-bg-2: #3b4252;--b-line: #3b4252}}*,::before,::after{box-sizing:border-box}html:focus-within{scroll-behavior:smooth}body{max-width:70ch;padding:0 1rem;margin:auto;background:var(--b-bg-1);font-family:var(--b-font-main);text-rendering:optimizeSpeed;line-height:1.5;color:var(--b-txt);-moz-tab-size:4;tab-size:4;word-break:break-word;overflow-wrap:break-word;-webkit-tap-highlight-color:rgba(0,0,0,0);-webkit-text-size-adjust:100%}h1,h2,h3,h4,h5,h6,p,ul,ol,dl,dd,details,blockquote,pre,figure,table,address,hr,fieldset,iframe,audio,video{margin:0 0 1.5rem}h1,h2,h3,h4,h5,h6{line-height:1.25;margin-top:2rem;text-wrap:balance}h1{font-size:2rem}h2{font-size:1.5rem}h3{font-size:1.25rem}h4{font-size:1rem}h5{font-size:.875rem}h6{font-size:.75rem}a{color:var(--b-link);text-decoration:none}a:hover{text-decoration:underline}img,video,svg{max-width:100%;height:auto}embed,iframe,object{max-width:100%}iframe{border-style:none}abbr[title]{text-decoration:underline dotted}b,strong{font-weight:700}blockquote{margin-left:0;margin-trim:block;padding:.5rem 0 .5rem 1.5rem;border-left:.25rem solid var(--b-txt)}small{font-size:.875rem}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-0.25em}sup{top:-0.5em}hr{height:0;border:0;border-bottom:1px solid var(--b-line)}pre,code,kbd,samp,tt,var{background:var(--b-bg-2);border-radius:.25rem;padding:.125rem .25rem;font-family:var(--b-font-mono);font-size:.875em}pre{padding:1rem;overflow:auto;white-space:pre}pre code{padding:0}details{display:block;padding:.5rem 1rem;background:var(--b-bg-2);border:1px solid var(--b-line);border-radius:.25rem;margin-trim:block}details[open]>summary{margin-bottom:1.5rem}summary{display:list-item;cursor:pointer;font-weight:bold}summary:focus{box-shadow:none}table{border-collapse:collapse;width:100%;text-indent:0}table caption{margin-bottom:.5rem}tr{border-bottom:1px solid var(--b-line)}td,th{padding:.5rem 0 .5rem 1rem;word-break:normal}td:first-child,th:first-child{padding-left:0}th{text-align:left}ul,ol,dd{padding-left:2rem}li>ul,li>ol{margin-bottom:0}fieldset{padding:.5rem .75rem;border:1px solid var(--b-line);border-radius:.25rem}legend{padding:0 .25rem}label{cursor:pointer;display:block;margin-bottom:.25rem}button,input,select,textarea{margin:0;padding:.5rem .75rem;max-width:100%;background:var(--b-bg-2);border:0;border-radius:.25rem;font:inherit;line-height:1.125;color:var(--b-txt)}button,select{text-transform:none}select,[type=date],[type=datetime-local],[type=datetime],[type=email],[type=month],[type=number],[type=password],[type=search],[type=tel],[type=text],[type=time],[type=url],[type=week]{width:100%}[type=image],[type=checkbox],[type=radio]{cursor:pointer}[type=color]{min-height:2.125rem}select:not([multiple]):not([size]){padding-right:1.5rem;background-repeat:no-repeat;background-position:right .5rem center;-moz-appearance:none;-webkit-appearance:none;appearance:none}textarea{width:100%;resize:vertical}textarea:not([rows]){height:8rem}button{touch-action:manipulation}button,[type=button],[type=submit],[type=reset]{-webkit-appearance:button;display:inline-block;text-align:center;white-space:nowrap;background:var(--b-btn-bg);color:var(--b-btn-txt);border:0;cursor:pointer;transition:opacity .25s}button:hover,[type=button]:hover,[type=submit]:hover,[type=reset]:hover{opacity:.75}button[disabled],[type=button][disabled],[type=submit][disabled],[type=reset][disabled]{opacity:.5}progress{vertical-align:baseline}[type=search]{-webkit-appearance:none;outline-offset:-2px}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}::-webkit-input-placeholder{color:inherit;opacity:.5}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}::-moz-focus-inner{border-style:none;padding:0}:-moz-focusring{outline:1px dotted ButtonText}:-moz-ui-invalid{box-shadow:none}[aria-busy=true]{cursor:progress}[aria-disabled=true],[disabled]{cursor:not-allowed}:focus,details:focus-within{outline:none;box-shadow:0 0 0 2px var(--b-focus)}@media(prefers-reduced-motion: reduce){html:focus-within{scroll-behavior:auto}*,::before,::after{animation-delay:-1ms !important;animation-duration:1ms !important;animation-iteration-count:1 !important;background-attachment:initial !important;scroll-behavior:auto !important;transition-delay:0 !important;transition-duration:0 !important}}select:not([multiple]):not([size]){background-image:url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%232e3440'%3E%3Cpath d='M5 6l5 5 5-5 2 1-7 7-7-7 2-1z'/%3E%3C/svg%3E")}@media(prefers-color-scheme: dark){select:not([multiple]):not([size]){background-image:url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23eceff4'%3E%3Cpath d='M5 6l5 5 5-5 2 1-7 7-7-7 2-1z'/%3E%3C/svg%3E")}}
diff --git a/example/static/cheatsheet.gmi b/example/static/cheatsheet.gmi
new file mode 100644
index 0000000..e7c9286
--- /dev/null
+++ b/example/static/cheatsheet.gmi
@@ -0,0 +1,62 @@
+# Gemtext cheatsheet
+
+This is a quick "cheatsheet" intended for people who haven't been writing Gemtext for long or who need their memory refreshed after a break. If you're completely new to Gemtext and you need things explained in a more detail, you should check out the full length introduction:
+
+=> gemini://geminiprotocol.net/docs/gemtext.gmi A quick introduction to "gemtext" markup
+
+## Text
+
+Here's the basics of how text works in Gemtext:
+
+* Long lines get wrapped by the client to fit the screen
+* Short lines *don't* get joined together
+* Write paragraphs as single long lines
+* Blank lines are rendered verbatim
+
+## Links
+
+At the bare minimum, a link line consists of just the characters `=>` and a URL. Here's a link to this page:
+
+```
+=> gemini://geminiprotocol.net/docs/cheatsheet.gmi
+```
+
+But you can include labels with links, and probably should most of the time. Labels are separated from the URL by one or more spaces or tabs:
+
+```
+=> gemini://geminiprotocol.net/docs/cheatsheet.gmi Gemtext cheatsheet
+```
+
+## Headings
+
+You get three levels of heading:
+
+```
+# Heading
+
+## Sub-heading
+
+### Sub-subheading
+```
+
+## Lists
+
+You get one kind of list and you can't nest them:
+
+```
+* Mercury
+* Gemini
+* Apollo
+```
+
+## Quotes
+
+Here's a quote from Maciej Cegłowski:
+
+```
+> I contend that text-based websites should not exceed in size the major works of Russian literature.
+```
+
+## Pre-fromatted text
+
+Lines which start with ``` will cause clients to toggle in and out of ordinary rendering mode and preformatted mode. In preformatted mode, Gemtext syntax is ignored so links etc. will not be rendered, and text will appear in a monospace font.
diff --git a/example/tpl/render_gemtext.html b/example/tpl/render_gemtext.html
new file mode 100644
index 0000000..0cfdec6
--- /dev/null
+++ b/example/tpl/render_gemtext.html
@@ -0,0 +1,15 @@
+{{ $pathSplit := splitList "/" .OriginalReq.URL.Path }}
+{{ $base := last $pathSplit | default "index.html" }}
+{{ $newBase := trimSuffix (ext $base) $base | printf "%s.gmi" }}
+{{ $filePath := append (initial $pathSplit) $newBase | join "/" | printf "static%s" }}
+{{ if not (fileExists $filePath) }}{{ httpError 404 }}{{ end }}
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>TODO Title</title>
+ <link rel="stylesheet" type="text/css" href="/bamboo.css" />
+ </head>
+ <body>
+ {{ gemtext (include $filePath) }}
+ </body>
+</html>
diff --git a/go.mod b/go.mod
index f1fbfb0..f1e58f9 100644
--- a/go.mod
+++ b/go.mod
@@ -2,7 +2,10 @@ module dev.mediocregopher.com/mediocre-caddy-plugins.git
go 1.22.3
-require github.com/caddyserver/caddy/v2 v2.8.4
+require (
+ github.com/caddyserver/caddy/v2 v2.8.4
+ go.uber.org/zap v1.27.0
+)
require (
filippo.io/edwards25519 v1.1.0 // indirect
@@ -124,7 +127,6 @@ require (
go.uber.org/automaxprocs v1.5.3 // indirect
go.uber.org/mock v0.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- go.uber.org/zap v1.27.0 // indirect
go.uber.org/zap/exp v0.2.0 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
diff --git a/http/handlers/templates/functions/functions.go b/http/handlers/templates/functions/functions.go
new file mode 100644
index 0000000..dd2101c
--- /dev/null
+++ b/http/handlers/templates/functions/functions.go
@@ -0,0 +1,3 @@
+// Package functions makes available extra functions within templates being
+// processed by the `http.handlers.templates` directive.
+package functions
diff --git a/http/handlers/templates/functions/gemtext.go b/http/handlers/templates/functions/gemtext.go
new file mode 100644
index 0000000..5419290
--- /dev/null
+++ b/http/handlers/templates/functions/gemtext.go
@@ -0,0 +1,153 @@
+package functions
+
+import (
+ "bufio"
+ "bytes"
+ "errors"
+ "fmt"
+ "html"
+ "io"
+ "strings"
+ "text/template"
+
+ "github.com/caddyserver/caddy/v2"
+ "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
+ "github.com/caddyserver/caddy/v2/caddyconfig/httpcaddyfile"
+ "github.com/caddyserver/caddy/v2/modules/caddyhttp/templates"
+)
+
+func init() {
+ caddy.RegisterModule(Gemtext{})
+ httpcaddyfile.RegisterDirective(
+ "gemtext",
+ func(h httpcaddyfile.Helper) ([]httpcaddyfile.ConfigValue, error) {
+ var f Gemtext
+ err := f.UnmarshalCaddyfile(h.Dispenser)
+ return []httpcaddyfile.ConfigValue{{
+ Class: "template_function", Value: f,
+ }}, err
+ },
+ )
+}
+
+type Gemtext struct{}
+
+var _ templates.CustomFunctions = (*Gemtext)(nil)
+
+func (f *Gemtext) CustomTemplateFunctions() template.FuncMap {
+ return template.FuncMap{
+ "gemtext": f.funcGemtext,
+ }
+}
+
+func (Gemtext) CaddyModule() caddy.ModuleInfo {
+ return caddy.ModuleInfo{
+ ID: "http.handlers.templates.functions.gemtext",
+ New: func() caddy.Module { return new(Gemtext) },
+ }
+}
+
+func sanitizeText(str string) string {
+ return html.EscapeString(strings.TrimSpace(str))
+}
+
+func (*Gemtext) funcGemtext(input any) (string, error) {
+ var (
+ r = bufio.NewReader(strings.NewReader(caddy.ToString(input)))
+ w = new(bytes.Buffer)
+ pft, list bool
+ writeErr error
+ )
+
+ write := func(fmtStr string, args ...any) {
+ if writeErr != nil {
+ return
+ }
+ fmt.Fprintf(w, fmtStr, args...)
+ }
+
+loop:
+ for {
+ if writeErr != nil {
+ return "", fmt.Errorf("writing line: %w", writeErr)
+ }
+
+ line, err := r.ReadString('\n')
+
+ switch {
+ case errors.Is(err, io.EOF):
+ break loop
+
+ case err != nil:
+ return "", fmt.Errorf("reading next line: %w", err)
+
+ case strings.HasPrefix(line, "```"):
+ if !pft {
+ write("<pre>\n")
+ pft = true
+ } else {
+ write("</pre>\n")
+ pft = false
+ }
+ continue
+
+ case pft:
+ write(line)
+ continue
+
+ case len(strings.TrimSpace(line)) == 0:
+ continue
+ }
+
+ // list case is special, because it requires a prefix and suffix tag
+ if strings.HasPrefix(line, "*") {
+ if !list {
+ write("<ul>\n")
+ }
+ write("<li>%s</li>\n", sanitizeText(line[1:]))
+ list = true
+ continue
+ } else if list {
+ write("</ul>\n")
+ list = false
+ }
+
+ switch {
+ case strings.HasPrefix(line, "=>"):
+ // TODO convert gemini:// links ?
+ var (
+ line = strings.TrimSpace(line[2:])
+ urlStr = line
+ label = urlStr
+ )
+ if i := strings.IndexAny(urlStr, " \t"); i > -1 {
+ urlStr, label = urlStr[:i], sanitizeText(urlStr[i:])
+ }
+ write("<p><a href=\"%s\">%s</a></p>\n", urlStr, label)
+
+ case strings.HasPrefix(line, "###"):
+ write("<h3>%s</h3>\n", sanitizeText(line[3:]))
+
+ case strings.HasPrefix(line, "##"):
+ write("<h2>%s</h2>\n", sanitizeText(line[2:]))
+
+ case strings.HasPrefix(line, "#"):
+ write("<h1>%s</h1>\n", sanitizeText(line[1:]))
+
+ case strings.HasPrefix(line, ">"):
+ write("<blockquote>%s</blockquote>\n", sanitizeText(line[1:]))
+
+ default:
+ line = strings.TrimSpace(line)
+ write("<p>%s</p>\n", line)
+ }
+ }
+
+ return w.String(), nil
+}
+
+// UnmarshalCaddyfile implements caddyfile.Unmarshaler.
+func (*Gemtext) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
+ d.Next() // consume directive name
+ return nil
+}
diff --git a/plugins.go b/plugins.go
new file mode 100644
index 0000000..7a98baa
--- /dev/null
+++ b/plugins.go
@@ -0,0 +1,7 @@
+// Package mediocrecaddyplugins is an index package which automatically imports
+// and registers all plugins defined in this module.
+package mediocrecaddyplugins
+
+import (
+ _ "dev.mediocregopher.com/mediocre-caddy-plugins.git/http/handlers/templates/functions"
+)