Hugo 和 Gemini

2021-12-05

我觉得 Gimini 是为终端用户而生的协议。

如果您是 GUI 用户,大概会对 Gemini 协议无感。 但是如果平时经常用 CLI ,大概会觉得 Gemini 是一个简洁优雅的协议。

Gemini 协议简单,语法比较像 Markdown 的缘故,像是 Hugo 这样用 Markdown 写作的博客平台,可以比较容易的生成 gemini 站点所需的文件。

本文参考 Gemini and HugoUsing Hugo to Launch a Gemini Capsule ,在 hermit 主题的基础上修改。但其他主题也很容易效仿,也可以不修改 Hugo 的主题,只针对单个站点修改。

由于此文援引 Parisian 的 Gemini and Hugo ,需要与其协议兼容,故遵守其 BY-NC-SA 3.0 协议,而不是底部脚注的 CC BY-NC 4.0 协议,在此说明。

此教程只适用于服务器搭建的 Hugo 博客站点,不适用于使用 Github Page 等服务搭建的博客站点

修改 config 文件

先添加 gmi 模板文件的支持,Hugo 的开发者已经想到了会有这样的需求。可以用不同的文件后缀,来区分不同的协议模板文件。

除了已定义好的 AMP ,CSV,JSON 等各种格式,也可以在 config 中自定义其他的格式。

下面是定义使用 gmi 作为模板文件后缀,并添加订阅链接的配置文件,以 yaml 格式为例,添加到原有配置文件结尾即可。

mediaTypes:
  text/gemini:
    suffixes:
      - "gmi"
  application/atom:
    suffixes:
      - "xml"

outputFormats:
  GEMINI:
    name: "GEMINI"
    isPlainText: true
    isHTML: false
    mediaType: "text/gemini"
    protocol: "gemini://"
    permalinkable: true
    # path: "gemini/"
    path: ""
  ATOM:
    name: "ATOM"
    isPlainText: false
    isHTML: true
    protocol: "gemini://"
    path: "/gemini/"
    permalinkable: true
    mediaType: "application/atom"
    baseName: "atom"
outputs:
  home:
    - "HTML"
    - "RSS"
    - "ATOM"
    - "GEMINI"
  page:
    - "HTML"
    - "GEMINI"
  section:
    - "HTML"
    - "GEMINI"
    - "RSS"

配置文件的详细意义,可以参考 官方文档

此配置有别于 Using Hugo to Launch a Gemini Capsule ,没有将 gemini 生成内容单独放一个子文件夹,以便更好的利用 Hugo 的 section 功能。

配置模板文件

模板文件皆在 layouts 文件夹下。可以是主题包内的 layouts 文件夹,如不愿意动主题包,也可以放在博客主目录的 layouts 文件夹中。

index.gmi

首先写首页

# {{ .Site.Title }}

## Subcription  
=> gemini/atom.xml Gemini Atom Feed

## Pages
{{ range .Site.Menus.main }}
=> {{ .URL| }} - {{ .Name }}
{{- if .Params.Subtitle }}  {{ .Params.Subtitle }}{{- end}}
{{- end}}

## Social
{{ range .Site.Params.social }}
=> {{ .url | safeURL }} {{ .name | humanize }}
{{ end }}

=> {{ .Site.BaseURL }}{{ replace .RelPermalink "index.gmi" "" }} View {{ .Site.Title }} on the WWW

配置参考 Hermit 主题,如下。

menu:
  main:
    - name: Posts
      url: posts/
    - name: Now
      url: now/
    - name: About
      url: about/
    - name: Links
      url: links/
params:
  social:
    - name: email
      url: 'mailto://i@lin.moe'
    - name: telegram
      url: 'https://t.me/LindsayZhou'
    - name: gitea
      url: 'https://git.lin.moe/Lindsay'

_default/list.gmi

# Posts
  
{{- range .Pages.GroupByDate "2006-01" }}  
  
### {{ .Key }}
{{- range .Pages }}
=> {{.Permalink}} {{.Title}}
{{- end }}{{- end }}

目录页面以年月分隔,给出文件跳转地址和链接。

_default/single.gmi

# {{ .Title }}{{ $scratch := newScratch }}
{{ $content := .RawContent -}}
{{ $content := $content | replaceRE `#### ` "### " -}}
{{ $content := $content | replaceRE `\n- (.+?)` "\n* $1" -}}
{{ $content := $content | replaceRE `\n(\d+). (.+?)` "\n* $2" -}}
{{ $content := $content | replaceRE `\[\^(.+?)\]:?` "" -}}
{{ $content := $content | replaceRE `<br/??>` "\n" -}}
{{ $content := $content | replaceRE `<a .*href="(.+?)".*>(.+?)</a>` "[$2]($1)" -}}
{{ $content := $content | replaceRE `\sgemini://(\S*)` " [gemini://$1](gemini://$1)" -}}
{{ $content := $content | replaceRE `{{ < audio "(.+?)" >}}` "=> https://brainbaking.com/$1 Embedded Audio link - $1" -}}
{{ $content := $content | replaceRE `{{ < video "(.+?)" >}}` "=> https://brainbaking.com/$1 Embedded Video link - $1" -}}
{{ $content := $content | replaceRE `{{ < youtube (.+?) >}}` "=> https://www.youtube.com/watch?v=$1 YouTube Video link to $1" -}}
{{ $content := $content | replaceRE `{{ < vimeo (.+?) >}}` "=> https://vimeo.com/$1 Vimeo Video link to $1" -}}
{{ $content := $content | replaceRE "([^`])<.*?>([^`])" "$1$2" -}}
{{ $content := $content | replaceRE `\n\n!\[.*\]\((.+?) \"(.+?)\"\)` "\n\n=> $1 Image: $2" -}}
{{ $content := $content | replaceRE `\n\n!\[.*]\((.+?)\)` "\n\n=> $1 Embedded Image: $1" -}}
{{ $content := $content | replaceRE `&emsp;` "  " -}}
{{ $tmpcontent := $content | replaceRE "(?ms:^```.*?```$)" "" -}}
{{ $links := findRE `\n=> ` $tmpcontent }}{{ $scratch.Set "ref" (add (len $links) 1) }}
{{ $refs := findRE `\[.+?\]\(.+?\)` $tmpcontent }}
{{ $scratch.Set "content" $content }}{{ range $refs }}{{ $ref := $scratch.Get "ref" }}{{ $contentInLoop := $scratch.Get "content" }}{{$linkmark := . |replaceRE `\[(.+?)\]\((.+?)\)` "[$1]-($2)" }}{{ $url := (printf "%s #%d" $linkmark $ref) }}{{ $contentInLoop := replace $contentInLoop . $url 1 -}}{{ $scratch.Set "content" $contentInLoop }}{{ $scratch.Set "ref" (add $ref 1) }}{{ end }}{{ $content := $scratch.Get "content" | replaceRE `\[(.+?)\]-\((.+?)\) #(\d+)` "$1 [#$3]" -}}

{{ $content | safeHTML }}
---
Written by {{ .Site.Author.name }} on {{ .Lastmod.Format (.Site.Params.dateFormat | default "2 January 2006") }}.

## References
{{ $scratch.Set "ref" (add (len $links) 1) }}{{ range $refs }}{{ $ref := $scratch.Get "ref" }}{{ $url := (printf "%s #%d" . $ref) }}{{ $base_url := $url | replaceRE `\[.+?\]\((.+?)\) #\d+` "$1" | absURL }}
=> {{ $base_url }} {{ $url | replaceRE `\[(.+?)\]\((.+?)\) #(\d+)` "$1 ($2)" -}}
{{ $scratch.Set "ref" (add $ref 1) }}{{ end}}
{{ $related := first 3 (where (where .Site.RegularPages.ByDate.Reverse ".Params.tags" "intersect" .Params.tags) "Permalink" "!=" .Permalink) }}
{{ if $related }}
## Related articles
{{ range $related }}
=> {{ .RelPermalink | absURL |replaceRE `https?://(.+?)` "gemini://$1" }} {{ .Title }}{{ if .Params.Subtitle }}: {{ .Params.Subtitle }}{{ end }}{{ end }}
{{ end }}
---

=> / Back to the Index
=> {{ .Site.BaseURL }}{{ replace .RelPermalink "index.gmi" "" }} View this article on the WWW

大部分参考 Parisian 的 Gemini and Hugo ,修改了少量的内容。

如添加 &emsp; 的替换,用作中文的缩进。使用 .Site.Author.name 等变量, 替换原来硬编码的作者名等。

此规则还不太完善,不代表最终版本。

_default/index.atom.xml

{{- $allowedRssSections := (slice "post") -}}
{{- $baseurl := .Site.BaseURL -}}
{{- $pctx := . -}}
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
{{- $pages := slice -}}
{{- if or $.IsHome $.IsSection -}}
{{- $pages = $pctx.RegularPages -}}
{{- else -}}
{{- $pages = $pctx.Pages -}}
{{- end -}}
{{- $limit := .Site.Config.Services.RSS.Limit -}}
{{- if ge $limit 1 -}}
{{- $pages = $pages | first $limit -}}
{{- end -}}
{{- printf "<?xml version=\"1.0\" encoding=\"utf-8\" standalone=\"yes\"?>" | safeHTML }}
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en">
  <title>{{ .Site.Title }}</title>
  {{- $perm := replace .Permalink "/gemini" "" 1 -}}
  {{- $alt := .Site.BaseURL | replaceRE `https?://(.+?)` "gemini://$1" -}}
  {{ printf "<link rel=\"self\" type=\"application/atom+xml\" href=\"%s\"/>" $perm | safeHTML }}
  {{ printf "<link rel=\"alternate\" type=\"text/html\" href=\"%s\"/>" $alt | safeHTML }}
  <updated>{{ .Date.Format "2006-01-02T15:04:05-0700" | safeHTML }}</updated>
  <author>
    <name>{{ .Site.Author.name }}</name>
    <uri>{{ .Site.BaseURL | replaceRE `https?://(.+?)` "gemini://$1" }}</uri>
  </author>
  <id>{{ $perm }}</id>
  {{ range $pages }}
  <entry>
    <title>{{ .Title }}</title>
    {{- $entryperm := .Permalink | replaceRE `https?://(.+?)` "gemini://$1" -}}
    {{ printf "<link rel=\"alternate\" href=\"%s\"/>" $entryperm | safeHTML }}
    <id>{{ $entryperm }}</id>
    <published>{{ .Date.Format "2006-01-02T15:04:05-0700" | safeHTML }}</published>
    <updated>{{ .Lastmod.Format "2006-01-02T15:04:05-0700" | safeHTML }}</updated>
    <summary>{{ if isset .Params "subtitle" }}{{ .Params.subtitle }}{{ else }}{{ .Summary | html }}{{ end }}</summary>
  </entry>
  {{ end }}
</feed>

这部分没有太多好说的,与一般的 atom 模板大致相似。

只是使用正则,将 http 协议头,替换成了 gemini 的协议头。

文件树

最后,涉及到的文件大致如下

.
├── config.yaml
└── layouts
    ├── _default
    │   ├── index.atom.xml
    │   ├── list.gmi
    │   └── single.gmi
    └── index.gmi

运行 hugo 命令,生成静态文件后,可以看到 public 文件夹中生成了 gmi 后缀的文件。

这些文件可以用 agate 之类的服务器提供访问。

您现在可以用 amfora 之类的 Gemini 浏览器,通过 gemini://lin.moe/posts/hugo_and_gemini/ 访问本文

可惜 Hugo 不能提供 server 之类对 gemini 进行 debug 的功能。

除了使用 hugo 本身的功能,也可利用 md2gmi, gmnhg 等工具。笔者暂未尝试,效果未知,故不再此详细说明。

Update

2022-01-18

single.gmi: 修复重复链接匹配混乱和匹配到代码块中链接的问题

如果需要分离 gmi 文件,可以用下面的命令

# 复制 *.gmi 文件和 atom.xml 文件复制到 gemini 文件夹
rsync -zarv --include "*/" --include="*.gmi" --include="atom.xml" --exclude="*" --prune-empty-dirs ./public/* ./gemini 
# 其他文件复制到 www 文件夹
rsync -zarv --include "*/"  --exclude="*.gmi" --exclude="atom.xml" --prune-empty-dirs ./public/* ./www

2022-05-28

放弃了,太麻烦