From 7e1ecc4df44d20d2c9de1c8885ddc2c188062ef0 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Wed, 3 Jul 2024 12:36:05 +0200 Subject: Initial implementation of the gemtext template extension --- README.md | 28 +++++ cmd/mediocre-caddy/main.go | 1 + example/Caddyfile | 29 ++++- example/static/bamboo.css | 1 + example/static/cheatsheet.gmi | 62 ++++++++++ example/tpl/render_gemtext.html | 15 +++ go.mod | 6 +- http/handlers/templates/functions/functions.go | 3 + http/handlers/templates/functions/gemtext.go | 153 +++++++++++++++++++++++++ plugins.go | 7 ++ 10 files changed, 297 insertions(+), 8 deletions(-) create mode 100644 example/static/bamboo.css create mode 100644 example/static/cheatsheet.gmi create mode 100644 example/tpl/render_gemtext.html create mode 100644 http/handlers/templates/functions/functions.go create mode 100644 http/handlers/templates/functions/gemtext.go create mode 100644 plugins.go 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 }} + + + + TODO Title + + + + {{ gemtext (include $filePath) }} + + 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("
\n")
+				pft = true
+			} else {
+				write("
\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("\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("

%s

\n", urlStr, label) + + case strings.HasPrefix(line, "###"): + write("

%s

\n", sanitizeText(line[3:])) + + case strings.HasPrefix(line, "##"): + write("

%s

\n", sanitizeText(line[2:])) + + case strings.HasPrefix(line, "#"): + write("

%s

\n", sanitizeText(line[1:])) + + case strings.HasPrefix(line, ">"): + write("
%s
\n", sanitizeText(line[1:])) + + default: + line = strings.TrimSpace(line) + write("

%s

\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" +) -- cgit v1.2.3