├── assets
├── theme
│ ├── colorStyle.gtpl
│ ├── top.component.gtpl
│ ├── categoryItem.gtpl
│ ├── labelItem.gtpl
│ ├── discussionGroup2.gtpl
│ ├── labelGroup.gtpl
│ ├── head.gtpl
│ ├── categoryGroup.gtpl
│ ├── vote.svg.gtpl
│ ├── rss.gtpl
│ ├── category.gtpl
│ ├── footer.gtpl
│ ├── 404.gtpl
│ ├── label.gtpl
│ ├── discussionGroup.gtpl
│ ├── discussionItem.gtpl
│ ├── header.gtpl
│ ├── index.gtpl
│ ├── archive.gtpl
│ ├── labels.gtpl
│ ├── categories.gtpl
│ ├── about.gtpl
│ ├── commentItem.gtpl
│ ├── main.css
│ └── post.gtpl
├── embed.go
├── global.gtpl
├── debug.tmpl.gtpl
└── js-render-loader.gtpl
├── .goreleaser.yaml
├── go.mod
├── file.go
├── .gitignore
├── README.md
├── LICENSE
├── .github
└── workflows
│ ├── release.yml
│ └── noll.yml
├── newsite.go
├── export.go
├── debug.go
├── main_test.go
├── main.go
├── scheme.go
├── github.go
└── render.go
/assets/theme/colorStyle.gtpl:
--------------------------------------------------------------------------------
1 | {{define "ColorStyleTemplate"}}
2 | style="color: #{{ . }};"
3 | {{end}}
--------------------------------------------------------------------------------
/assets/embed.go:
--------------------------------------------------------------------------------
1 | package assets
2 |
3 | import "embed"
4 |
5 | //go:embed *
6 | // Dir is assets dir embed fs
7 | var Dir embed.FS
8 |
--------------------------------------------------------------------------------
/assets/theme/top.component.gtpl:
--------------------------------------------------------------------------------
1 | {{define "TopComponentTemplate"}}
2 |
6 | {{end}}
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
1 | project_name: noll
2 | builds:
3 | - env: [CGO_ENABLED=0]
4 | goos:
5 | - linux
6 | - windows
7 | - darwin
8 | goarch:
9 | - amd64
10 | - arm64
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module io.github.nollgo
2 |
3 | go 1.16
4 |
5 | require (
6 | github.com/excing/goflag v1.0.1
7 | github.com/fsnotify/fsnotify v1.6.0
8 | github.com/gosimple/slug v1.13.1
9 | github.com/lxzan/gws v1.4.1
10 | )
11 |
--------------------------------------------------------------------------------
/assets/theme/categoryItem.gtpl:
--------------------------------------------------------------------------------
1 | {{define "CategoryItemTemplate"}}
2 |
3 | {{ .EmojiHTML }} {{ .Name }}
4 | {{ if .Discussions }}
5 | ({{ .Discussions.TotalCount }})
6 | {{ end }}
7 |
8 | {{end}}
--------------------------------------------------------------------------------
/assets/theme/labelItem.gtpl:
--------------------------------------------------------------------------------
1 | {{define "LabelItemTemplate"}}
2 |
3 | #{{ .Name }}
4 | {{ if .Discussions }}
5 | ({{ .Discussions.TotalCount }})
6 | {{ end }}
7 |
8 | {{end}}
--------------------------------------------------------------------------------
/assets/theme/discussionGroup2.gtpl:
--------------------------------------------------------------------------------
1 | {{define "DiscussionGroup2Template"}}
2 |
3 |
4 | {{ range $i, $discussion := .Nodes }}
5 | {{ if lt $i 7 }}
6 | {{ template "DiscussionItemTemplate" $discussion }}
7 | {{ end }}
8 | {{ end }}
9 |
10 | {{end}}
--------------------------------------------------------------------------------
/assets/theme/labelGroup.gtpl:
--------------------------------------------------------------------------------
1 | {{define "LabelGroupTemplate"}}
2 | {{ if . }}
3 |
4 | {{ range $i, $label := .Nodes }}
5 | {{ if $label.Discussions.TotalCount }}
6 | {{ template "LabelItemTemplate" $label }}
7 | {{ end }}
8 | {{ end }}
9 |
10 | {{ end }}
11 | {{end}}
--------------------------------------------------------------------------------
/assets/theme/head.gtpl:
--------------------------------------------------------------------------------
1 | {{define "HeadTemplate"}}
2 |
3 |
4 |
5 |
6 |
7 | {{end}}
--------------------------------------------------------------------------------
/assets/theme/categoryGroup.gtpl:
--------------------------------------------------------------------------------
1 | {{define "CategoryGroupTemplate"}}
2 | {{ if . }}
3 |
4 | {{ range $i, $category := .Nodes }}
5 | {{ if $category.Discussions.TotalCount }}
6 | {{ template "CategoryItemTemplate" $category }}
7 | {{ end }}
8 | {{ end }}
9 |
10 | {{ end }}
11 | {{end}}
--------------------------------------------------------------------------------
/file.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | )
7 |
8 | // MkdirFileFolderIfNotExists 创建文件所在的目录,如果这个目录不存在
9 | func MkdirFileFolderIfNotExists(path string) error {
10 | pathDir := filepath.Dir(path)
11 | if _, err := os.Stat(pathDir); os.IsNotExist(err) {
12 | return os.MkdirAll(pathDir, os.ModePerm)
13 | }
14 | return nil
15 | }
16 |
--------------------------------------------------------------------------------
/assets/theme/vote.svg.gtpl:
--------------------------------------------------------------------------------
1 | {{define "VoteSVGTemplate"}}
2 |
3 |
5 |
6 |
7 | {{end}}
--------------------------------------------------------------------------------
/assets/theme/rss.gtpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ range $descussoin := .Data.Nodes }}
5 | -
6 |
7 |
8 |
9 | {{ url $descussoin }}
10 |
11 |
12 |
13 |
14 | {{ end }}
15 |
16 |
17 |
--------------------------------------------------------------------------------
/assets/global.gtpl:
--------------------------------------------------------------------------------
1 |
6 | {{ if .GamID }}
7 |
8 |
9 |
16 | {{ end }}
--------------------------------------------------------------------------------
/assets/theme/category.gtpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ template "HeadTemplate" .Viewer }}
6 | {{ .Data.Name }} Category —— {{ .Viewer.ShowName }}'s Blog
7 |
8 |
9 |
10 | {{ template "HeaderTemplate" . }}
11 | {{ .Data.Name }}
12 | {{ .Data.Description }}
13 | {{ template "DiscussionGroupTemplate" .Data.Discussions }}
14 | {{ template "footerTemplate" .Viewer }}
15 |
16 |
17 |
--------------------------------------------------------------------------------
/assets/theme/footer.gtpl:
--------------------------------------------------------------------------------
1 | {{define "footerTemplate"}}
2 |
3 | {{ template "TopComponentTemplate" }}
4 |
7 |
11 |
12 | {{end}}
--------------------------------------------------------------------------------
/assets/theme/404.gtpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ template "HeadTemplate" .Viewer }}
6 | 404 Not Found —— {{ .Viewer.ShowName }}'s Blog
7 |
8 |
9 |
10 | {{ template "HeaderTemplate" . }}
11 |
12 | 404 Not Found
13 |
14 |
15 | {{ template "footerTemplate" .Viewer }}
16 |
17 |
18 |
--------------------------------------------------------------------------------
/assets/theme/label.gtpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ template "HeadTemplate" .Viewer }}
6 | {{ .Data.Name }} Label —— {{ .Viewer.ShowName }}'s Blog
7 |
8 |
9 |
10 | {{ template "HeaderTemplate" . }}
11 |
12 |
#{{ .Data.Name }}
13 | {{ .Data.Description }}
14 | {{ template "DiscussionGroupTemplate" .Data.Discussions }}
15 |
16 | {{ template "footerTemplate" .Viewer }}
17 |
18 |
19 |
--------------------------------------------------------------------------------
/assets/theme/discussionGroup.gtpl:
--------------------------------------------------------------------------------
1 | {{define "DiscussionGroupTemplate"}}
2 |
3 | {{ $time := time }}
4 | {{ range $i, $discussion := .Nodes }}
5 | {{ if ism $time $discussion.CreatedAt }}
6 | {{ else }}
7 |
8 | {{ $discussion.CreatedAt.Format "01 / 2006" }}
9 |
10 | {{ $time = $discussion.CreatedAt }}
11 | {{ end }}
12 | {{ template "DiscussionItemTemplate" $discussion }}
13 | {{ end }}
14 |
15 | {{end}}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | .idea
3 | justfile
4 | pages
5 | themes
6 | export
7 | *.exe
8 | *.exe~
9 | *.dll
10 | *.so
11 | *.dylib
12 |
13 | # Test binary, built with `go test -c`
14 | *.test
15 |
16 | # Output of the go coverage tool, specifically when used with LiteIDE
17 | *.out
18 | go.sum
19 |
20 | # vscode
21 | .vscode
22 | __debug_bin
23 |
24 | # debugger for chrome
25 | debug.log
26 |
27 | # facebook/ent
28 | ent/*
29 | !ent/schema
30 | !ent/generate.go
31 |
32 | # upx build file
33 | *.upx
34 |
35 | # project
36 | config*.json
37 | test.db
38 | curl.sh
39 | screenshots/
40 |
41 | #本地项目测试文件,不包含 golang test
42 | *test.*
43 | !*test.go
--------------------------------------------------------------------------------
/assets/theme/discussionItem.gtpl:
--------------------------------------------------------------------------------
1 | {{define "DiscussionItemTemplate"}}
2 |
3 |
4 | {{ .Title }}
5 |
6 |
8 | {{ if .ReactionGroups }}
9 | {{ range $reaction := .ReactionGroups }}
10 | {{ if $reaction.Reactors.TotalCount }}
11 |
12 | {{ $reaction.Reactors.TotalCount }}
13 |
14 | {{ end }}
15 | {{ end }}
16 | {{ end }}
17 |
18 |
19 |
20 | {{end}}
--------------------------------------------------------------------------------
/assets/theme/header.gtpl:
--------------------------------------------------------------------------------
1 | {{define "HeaderTemplate"}}
2 |
21 | {{end}}
--------------------------------------------------------------------------------
/assets/theme/index.gtpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ template "HeadTemplate" .Viewer }}
6 | {{ .Viewer.ShowName }}'s Blog
7 |
8 |
9 |
10 | {{ if .Data }}
11 | {{ template "HeaderTemplate" . }}
12 |
13 |
14 |
近期文章
15 | {{ template "DiscussionGroup2Template" .Data }}
16 |
21 |
22 |
23 |
分类
24 | {{ template "CategoryGroupTemplate" .Categories }}
25 |
26 |
27 |
标签
28 | {{ template "LabelGroupTemplate" .Labels }}
29 |
30 |
31 | {{ end }}
32 | {{ template "footerTemplate" .Viewer }}
33 |
34 |
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 一个把你的 GitHub Discussions 构建为 Pages 的静态站点生成器,由 [excing](https://github.com/excing) 使用 [Golang](https://go.dev) 开发。
2 |
3 | # 概述
4 |
5 | Noll 是一个用 Go 编写的静态 HTML 和 CSS 网站生成器。它是专为 GitHub 平台而设计。Noll 获取用户的 Discussions 和指定的模板,并将它们呈现为一个完整的 HTML 网站。
6 |
7 | Noll 适用于任何类型的网站,包括博客、问答和文档。使用 Noll 可以轻松构建一个专业的静态网站,将您的项目展示给更广泛的受众。
8 |
9 | # 特色
10 |
11 | - 支持在线编辑
12 | - 支持文章分类
13 | - 文章和评论处于同一个 Thread
14 | - 支持 GitHub 的反应(Rection)
15 | - 支持将私有仓库发布为公开仓库的 Pages
16 |
17 | # 快速使用
18 |
19 | 只需 Fork [NollAction](https://github.com/NollGo/NollAction) 项目,并开启 Discussions 和 Pages 功能,即可立即开始体验 Noll,有关详细说明,请查看[此文章](https://nollgo.github.io/Noll/post/29.html)。
20 |
21 | # Noll 文档
22 |
23 | Noll 文档位于
24 |
25 | > 由 Noll 构建并由 NollAction 发布
26 |
27 | # 欢迎
28 |
29 | 我们欢迎任何形式的贡献,包括文档、教程、博客文章、错误报告、问题、功能请求、功能实现、拉取请求、帮助管理问题等。
30 |
31 | 由于目前仅有一位开发人员在维护和开发 Noll,项目更新速度可能会有所放缓,请您理解。我们会尽力为您提供更好的服务和支持。
--------------------------------------------------------------------------------
/assets/theme/archive.gtpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ template "HeadTemplate" .Viewer }}
6 | 归档 —— {{ .Viewer.ShowName }}'s Blog
7 |
8 |
9 |
10 | {{ if .Data }}
11 | {{ template "HeaderTemplate" . }}
12 |
13 |
归档
14 | {{ template "DiscussionGroupTemplate" .Data }}
15 |
16 | {{ if .Data.PageInfo.HasPrevPage }}
17 |
18 | 上一页
19 |
20 | {{ end }}
21 | {{ if .Data.PageInfo.HasNextPage }}
22 |
23 | 下一页
24 |
25 | {{ end }}
26 |
27 |
28 | {{ end }}
29 | {{ template "footerTemplate" .Viewer }}
30 |
31 |
32 |
--------------------------------------------------------------------------------
/assets/theme/labels.gtpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ template "HeadTemplate" .Viewer }}
6 | 标签 —— {{ .Viewer.ShowName }}'s Blog
7 |
8 |
9 |
10 | {{ template "HeaderTemplate" . }}
11 | {{ if .Labels.TotalCount }}
12 |
13 | {{ range $label := .Labels.Nodes }}
14 | {{ if $label.Discussions.TotalCount }}
15 |
16 |
#{{ $label.Name }} ({{ $label.Discussions.TotalCount }})
17 | {{ template "DiscussionGroup2Template" $label.Discussions }}
18 |
23 |
24 | {{ end }}
25 | {{ end }}
26 |
27 | {{ else }}
28 | 这里还没有标签
29 | {{ end }}
30 | {{ template "footerTemplate" .Viewer }}
31 |
32 |
33 |
--------------------------------------------------------------------------------
/assets/theme/categories.gtpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ template "HeadTemplate" .Viewer }}
6 | 分类 —— {{ .Viewer.ShowName }}'s Blog
7 |
8 |
9 |
10 | {{ template "HeaderTemplate" . }}
11 | {{ if .Categories.TotalCount }}
12 |
13 | {{ range $category := .Categories.Nodes }}
14 | {{ if $category.Discussions.TotalCount }}
15 |
16 |
{{ $category.EmojiHTML }} {{ $category.Name }} ({{ $category.Discussions.TotalCount }})
17 | {{ template "DiscussionGroup2Template" $category.Discussions }}
18 |
23 |
24 | {{ end }}
25 | {{ end }}
26 |
27 | {{ else }}
28 | 这里还没有分类
29 | {{ end }}
30 | {{ template "footerTemplate" .Viewer }}
31 |
32 |
33 |
--------------------------------------------------------------------------------
/assets/theme/about.gtpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | {{ template "HeadTemplate" .Viewer }}
6 | Aoubt —— {{ .Viewer.ShowName }}'s Blog
7 |
8 |
9 |
10 | {{ template "HeaderTemplate" . }}
11 |
12 | About {{ .Viewer.ShowName }}
13 | {{ if .Viewer.Bio }}
14 | {{ .Viewer.Bio }}
15 | {{ end }}
16 | {{ if .Viewer.Company }}
17 | 🏢 {{ .Viewer.Company }}
18 | {{ end }}
19 | {{ if .Viewer.Location }}
20 | 🌍 {{ .Viewer.Location }}
21 | {{ end }}
22 | {{ if .Viewer.Email }}
23 | 📧 {{ .Viewer.Email }}
24 | {{ end }}
25 | 😺 {{ .Viewer.GitHubURL }}
26 | {{ if .Viewer.Twitter }}
27 | 🕊️
28 | https://twitter.com/{{ .Viewer.Twitter }}
29 | {{ end }}
30 |
31 | {{ template "footerTemplate" .Viewer }}
32 |
33 |
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Three Tenth Team/十分之三团队
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/assets/theme/commentItem.gtpl:
--------------------------------------------------------------------------------
1 | {{define "CommentItemTemplate"}}
2 |
3 |
7 |
8 |
14 |
{{ .BodyHTML }}
15 |
16 |
17 | {{ template "VoteSVGTemplate" 22 }} {{ .UpvoteCount }}
18 |
19 | {{ range $reaction := .ReactionGroups }}
20 | {{ if $reaction.Reactors.TotalCount }}
21 |
22 | {{ $reaction.Reactors.TotalCount }}
23 |
24 | {{ end }}
25 | {{ end }}
26 |
27 |
28 |
29 | {{end}}
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Build & Deploy package
2 |
3 | on:
4 | push:
5 | # run only against tags
6 | tags:
7 | - '*'
8 |
9 | permissions:
10 | contents: write
11 | # packages: write
12 | # issues: write
13 |
14 | jobs:
15 | goreleaser:
16 | runs-on: ubuntu-latest
17 | steps:
18 | - uses: actions/checkout@v3
19 | with:
20 | fetch-depth: 0
21 | - run: git fetch --force --tags
22 | - name: Get service dependencies
23 | run: |
24 | go get -v -u -d ./...
25 | if [ -f Gopkg.toml ]; then
26 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
27 | dep ensure
28 | fi
29 | - uses: actions/setup-go@v3
30 | with:
31 | go-version: '>=1.20.1'
32 | cache: true
33 | - name: Validates GO releaser config
34 | uses: docker://goreleaser/goreleaser:latest
35 | with:
36 | args: check
37 |
38 | - name: Create release on GitHub
39 | uses: docker://goreleaser/goreleaser:latest
40 | with:
41 | version: latest
42 | args: release --clean
43 | env:
44 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
--------------------------------------------------------------------------------
/newsite.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "io"
5 | "io.github.nollgo/assets"
6 | "io/fs"
7 | "os"
8 | "path/filepath"
9 | )
10 |
11 | const themePath = "theme"
12 |
13 | func newSite(path string) error {
14 | if path == "" {
15 | path = "."
16 | }
17 |
18 | dir, err := assets.Dir.ReadDir(themePath)
19 | if err != nil {
20 | return err
21 | }
22 |
23 | // create dir
24 | _ = os.MkdirAll(path, 0755)
25 |
26 | if err = write(path, themePath, dir); err != nil {
27 | return err
28 | }
29 | return nil
30 | }
31 |
32 | func write(path string, parent string, entries []fs.DirEntry) error {
33 | for _, entry := range entries {
34 | entryPath := filepath.Join(path, entry.Name())
35 | if entry.IsDir() {
36 | if err := os.Mkdir(entryPath, 0755); err != nil {
37 | return err
38 | }
39 | dir, err := assets.Dir.ReadDir(filepath.Join(parent, entry.Name()))
40 | if err != nil {
41 | return err
42 | }
43 | if err = write(entryPath, filepath.Join(parent, entry.Name()), dir); err != nil {
44 | return err
45 | }
46 | } else {
47 | f, err := assets.Dir.Open(filepath.Join(parent, entry.Name()))
48 | if err != nil {
49 | return err
50 | }
51 | bytes, err := io.ReadAll(f)
52 | if err != nil {
53 | return err
54 | }
55 |
56 | if err = os.WriteFile(entryPath, bytes, 0644); err != nil {
57 | return err
58 | }
59 | }
60 | }
61 | return nil
62 | }
63 |
--------------------------------------------------------------------------------
/export.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "path/filepath"
7 | "text/template"
8 | )
9 |
10 | const exportTemplate = `---
11 | title: {{ .Title }}
12 | createAt: {{ .CreatedAt }}
13 | updateAt: {{ .UpdatedAt }}
14 | tags: {{ .LabelsString }}
15 | categories: {{ .Category }}
16 | ---
17 |
18 | {{ .Body }}
19 | `
20 |
21 | func export(config Config) (err error) {
22 | var data *GithubData
23 | if data, err = getRepository(config.Owner, config.Name, config.Token); err != nil {
24 | return err
25 | }
26 |
27 | discussions := data.Repository.Discussions
28 | if discussions == nil || discussions.TotalCount == 0 {
29 | return fmt.Errorf("no discussions")
30 | }
31 |
32 | discussionsMap := groupByCategory(discussions.Nodes)
33 | tmpl, err := parseTemplate(exportTemplate)
34 | if err != nil {
35 | return err
36 | }
37 | for category, discussions := range discussionsMap {
38 | _ = os.MkdirAll(filepath.Join(config.Export, category), os.ModePerm)
39 | for _, discussion := range discussions {
40 | if err = exportDiscussion(config.Export, discussion, tmpl); err != nil {
41 | return err
42 | }
43 | }
44 | }
45 | return err
46 | }
47 |
48 | func exportDiscussion(dir string, discussion *Discussion, tmpl *template.Template) error {
49 | filePath := filepath.Join(dir, discussion.Category.Name, fmt.Sprintf("%v.md", discussion.Number))
50 | file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY, os.ModePerm)
51 | if err != nil {
52 | return err
53 | }
54 |
55 | err = tmpl.Execute(file, map[string]interface{}{
56 | "Title": discussion.Title,
57 | "CreatedAt": discussion.CreatedAt.Format("2006-01-02 15:04:05"),
58 | "UpdatedAt": discussion.UpdatedAt.Format("2006-01-02 15:04:05"),
59 | "LabelsString": discussion.Labels.String(),
60 | "Labels": discussion.Labels,
61 | "Category": discussion.Category.Name,
62 | "Body": discussion.Body,
63 | })
64 | if err != nil {
65 | return err
66 | }
67 |
68 | return nil
69 | }
70 |
71 | func groupByCategory(discussions []*Discussion) map[string][]*Discussion {
72 | group := make(map[string][]*Discussion)
73 | for i := range discussions {
74 | discussion := discussions[i]
75 | group[discussion.Category.Name] = append(group[discussion.Category.Name], discussion)
76 | }
77 | return group
78 | }
79 |
80 | func parseTemplate(tmp string) (*template.Template, error) {
81 | return template.New("export").Parse(tmp)
82 | }
83 |
--------------------------------------------------------------------------------
/assets/theme/main.css:
--------------------------------------------------------------------------------
1 | html {
2 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
3 | }
4 |
5 | body {
6 | max-width : 720px;
7 | margin : auto;
8 | padding : 8px;
9 | font-size : 1.2rem;
10 | word-spacing: 0.1rem;
11 | }
12 |
13 | footer {
14 | text-align: center;
15 | font-size : 1rem;
16 | color : #bcbcbc;
17 | margin : 40px auto 10px auto;
18 | }
19 |
20 | footer a {
21 | color: #a1a1a1;
22 | }
23 |
24 | ul.ul {
25 | padding: 0px;
26 | }
27 |
28 | ul.ul li.li {
29 | display: inline-block;
30 | }
31 |
32 | a {
33 | color : inherit;
34 | text-decoration : none;
35 | transition : all 0.2s ease-in-out;
36 | background-color: transparent;
37 | border-radius : 5px;
38 | padding : 5px 10px;
39 | display : inline-block;
40 | }
41 |
42 | a[href]:hover {
43 | background-color: #eee;
44 | }
45 |
46 | a[href]:active {
47 | background-color: #ddd;
48 | }
49 |
50 | a.text {
51 | border-radius: 0;
52 | padding : 0;
53 | }
54 |
55 | a.text[href]:hover,
56 | a.text[href]:active {
57 | background-color: transparent;
58 | color : black;
59 | }
60 |
61 | .margin-right-5px-without-last:not(:last-of-type) {
62 | margin-right: 5px;
63 | }
64 |
65 | .column {
66 | display : flex;
67 | flex-wrap: wrap;
68 | }
69 |
70 | .flex-fill {
71 | flex: 1 1 auto;
72 | }
73 |
74 | .clearfix::before {
75 | content: "";
76 | display: table;
77 | clear : both;
78 | }
79 |
80 | .THUMBS_UP::before {
81 | content: "👍";
82 | }
83 |
84 | .THUMBS_DOWN::before {
85 | content: "👎";
86 | }
87 |
88 | .LAUGH::before {
89 | content: "😄";
90 | }
91 |
92 | .HOORAY::before {
93 | content: "🎉";
94 | }
95 |
96 | .CONFUSED::before {
97 | content: "😕";
98 | }
99 |
100 | .HEART::before {
101 | content: "❤️";
102 | }
103 |
104 | .ROCKET::before {
105 | content: "🚀";
106 | }
107 |
108 | .EYES::before {
109 | content: "👀";
110 | }
111 |
112 | .COMMENT::before {
113 | content: "💬";
114 | }
115 |
116 | .SMILING::before {
117 | content: "🙂";
118 | }
119 |
120 | /* .THUMBS_UP,
121 | .THUMBS_DOWN,
122 | .LAUGH,
123 | .HOORAY,
124 | .CONFUSED,
125 | .HEART,
126 | .ROCKET,
127 | .EYES,
128 | .COMMENT {
129 | font-size: 1rem;
130 | } */
--------------------------------------------------------------------------------
/.github/workflows/noll.yml:
--------------------------------------------------------------------------------
1 | name: Deploy site to Pages
2 |
3 | on:
4 | discussion:
5 | types: [created, edited, deleted]
6 |
7 | # Allows you to run this workflow manually from the Actions tab
8 | workflow_dispatch:
9 |
10 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
11 | permissions:
12 | contents: read
13 | pages: write
14 | id-token: write
15 |
16 | # Allow one concurrent deployment
17 | concurrency:
18 | group: "pages"
19 | cancel-in-progress: true
20 |
21 | jobs:
22 | # Build job
23 | build:
24 | runs-on: ubuntu-latest
25 | steps:
26 | - name: Checkout
27 | uses: actions/checkout@v3
28 | - name: Set up Go
29 | uses: actions/setup-go@v5 # 使用最新版 v5
30 | with:
31 | go-version: '1.16' # 指定精确版本(支持语义化版本,如 '1.21.x')
32 | - name: Verify Go
33 | run: |
34 | go version
35 | go env GOROOT # 检查安装路径
36 | - name: Install Noll
37 | run: |
38 | git clone https://github.com/NollGo/Noll
39 | cd Noll
40 | go get -v -d ./...
41 | if [ -f Gopkg.toml ]; then
42 | DEP_RELEASE_TAG=v1.16 && curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
43 | dep ensure
44 | fi
45 | ls -a -lh
46 | echo "GO GET SUCCESSED"
47 | cat go.mod
48 | cat go.sum
49 | go build --ldflags="-w -s"
50 | ls -a -lh
51 | chmod 775 ./io.github.nollgo
52 | echo "NEXT"
53 | - name: Build site
54 | run: |
55 | repository=${{ github.repository }}
56 | repositoryName=$(basename "$repository")
57 | pagesPath="./"
58 | baseURL="/$repositoryName"
59 | gamID="${{ vars.GAMID }}"
60 | echo "${{ github.repository_owner }}, $repositoryName, ${{secrets.GITHUB_TOKEN}}, $pagesPath, $baseURL, $gamID,"
61 | ./Noll/io.github.nollgo -owner ${{ github.repository_owner }} -name $repositoryName -token ${{secrets.GITHUB_TOKEN}} -pages $pagesPath -baseURL $baseURL -gamID $gamID
62 | rm -rf ./Noll
63 | ls -a -lh
64 | - name: Upload artifact
65 | uses: actions/upload-pages-artifact@v3
66 | with:
67 | # Upload entire repository
68 | path: './'
69 |
70 | # Deployment job
71 | deploy:
72 | environment:
73 | name: github-pages
74 | url: ${{ steps.deployment.outputs.page_url }}
75 | runs-on: ubuntu-latest
76 | needs: build
77 | steps:
78 | - name: Deploy to GitHub Pages
79 | id: deployment
80 | uses: actions/deploy-pages@v4
81 |
--------------------------------------------------------------------------------
/debug.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "os"
7 | "path/filepath"
8 |
9 | "github.com/fsnotify/fsnotify"
10 | "github.com/lxzan/gws"
11 | )
12 |
13 | var upgrader = func(event gws.Event) *gws.Upgrader {
14 | return gws.NewUpgrader(event, &gws.ServerOption{
15 | CompressEnabled: true,
16 | CheckUtf8Enabled: true,
17 | ReadMaxPayloadSize: 32 * 1024 * 1024,
18 | WriteMaxPayloadSize: 32 * 1024 * 1024,
19 | })
20 | }
21 |
22 | func debugWs(config Config, _render func() error) http.Handler {
23 | websocket := &DebugWs{}
24 |
25 | // watch file change config.ThemeDir
26 | dirList := collDir(config.ThemeDir)
27 | pathChan, err := watch(_render, websocket)
28 | if err != nil {
29 | panic(err)
30 | }
31 |
32 | for i := range dirList {
33 | pathChan <- dirList[i]
34 | }
35 |
36 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37 | socket, err := upgrader(websocket).Accept(w, r)
38 | if err != nil {
39 | return
40 | }
41 |
42 | websocket.socket = socket
43 | go socket.Listen()
44 | })
45 | }
46 |
47 | func watch(_render func() error, websocket *DebugWs) (chan string, error) {
48 | watcher, err := fsnotify.NewWatcher()
49 | if err != nil {
50 | return nil, err
51 | }
52 |
53 | go func() {
54 | for {
55 | select {
56 | case event := <-watcher.Events:
57 |
58 | if event.Has(fsnotify.Write) {
59 | if err := _render(); err != nil {
60 | fmt.Println("error:", err)
61 | }
62 |
63 | if websocket.socket != nil {
64 | _ = websocket.socket.WriteString("reload")
65 | }
66 | }
67 | case err := <-watcher.Errors:
68 | fmt.Println("error:", err)
69 | }
70 | }
71 | }()
72 |
73 | pathChan := make(chan string)
74 | go func() {
75 | for {
76 | select {
77 | case path := <-pathChan:
78 | if err := watcher.Add(path); err == nil {
79 | fmt.Println("Start watch file change", path)
80 | } else {
81 | panic(err)
82 | }
83 | }
84 | }
85 | }()
86 |
87 | return pathChan, nil
88 | }
89 |
90 | // collect all dir
91 | func collDir(path string) []string {
92 | if path == "" {
93 | return []string{}
94 | }
95 |
96 | var dirs []string
97 |
98 | if err := filepath.Walk(path, func(path string, info os.FileInfo, err error) error {
99 | if info.IsDir() {
100 | dirs = append(dirs, path)
101 | }
102 | return nil
103 | }); err != nil {
104 | return []string{}
105 | }
106 |
107 | return dirs
108 | }
109 |
110 | // DebugWs is 调试 websocket event
111 | type DebugWs struct {
112 | socket *gws.Conn
113 | }
114 |
115 | // OnOpen is websocket 建立连接事件
116 | func (d DebugWs) OnOpen(socket *gws.Conn) {
117 | }
118 |
119 | // OnError is websocket 错误事件
120 | // IO错误, 协议错误, 压缩解压错误...
121 | func (d DebugWs) OnError(socket *gws.Conn, err error) {
122 | }
123 |
124 | // OnClose is websocket 关闭事件
125 | // 另一端发送了关闭帧
126 | func (d DebugWs) OnClose(socket *gws.Conn, code uint16, reason []byte) {
127 | }
128 |
129 | // OnPing is websocket 心跳探测事件
130 | func (d DebugWs) OnPing(socket *gws.Conn, payload []byte) {
131 | }
132 |
133 | // OnPong is websocket 心跳响应事件
134 | func (d DebugWs) OnPong(socket *gws.Conn, payload []byte) {
135 | }
136 |
137 | // OnMessage is websocket 消息事件
138 | // 如果开启了AsyncReadEnabled, 可以在一个连接里面并行处理多个请求
139 | func (d DebugWs) OnMessage(socket *gws.Conn, message *gws.Message) {
140 | }
141 |
--------------------------------------------------------------------------------
/assets/debug.tmpl.gtpl:
--------------------------------------------------------------------------------
1 |
52 |
--------------------------------------------------------------------------------
/assets/theme/post.gtpl:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ $githubURL := .Data.GitHubURL }}
5 |
6 |
7 | {{ template "HeadTemplate" .Viewer }}
8 | {{ .Data.Title }}
9 |
10 |
71 |
72 |
73 |
74 | {{ template "HeaderTemplate" . }}
75 |
76 |
{{ .Data.Title }}
77 |
88 |
89 |
90 |
91 | {{ .Data.BodyHTML }}
92 |
93 |
94 | {{ template "CategoryItemTemplate" .Data.Category }}
95 | {{ if .Data.Labels }}
96 | {{ range $i, $label := .Data.Labels.Nodes }}
97 | {{ template "LabelItemTemplate" $label }}
98 | {{ end }}
99 | {{ end }}
100 |
101 |
102 |
103 |
104 |
105 | {{ range $reaction := .Data.ReactionGroups }}
106 | {{ if $reaction.Reactors.TotalCount }}
107 |
108 |
109 | {{ $reaction.Reactors.TotalCount }}
110 |
111 | {{ end }}
112 | {{ end }}
113 |
114 | {{ template "TopComponentTemplate" }}
115 |
120 | {{ if .Data.Comments }}
121 |
122 | {{ range $comment := .Data.Comments.Nodes }}
123 |
126 | {{ end }}
127 |
128 | {{ end }}
129 |
130 | {{ template "footerTemplate" .Viewer }}
131 |
132 |
133 |
--------------------------------------------------------------------------------
/main_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "strings"
7 | "testing"
8 | "time"
9 |
10 | "github.com/gosimple/slug"
11 | )
12 |
13 | func TestURLSlug(t *testing.T) {
14 | text := slug.Make("Hellö Wörld хелло ворлд")
15 | fmt.Println(text) // Will print: "hello-world-khello-vorld"
16 |
17 | text2 := slug.Make("o(* ̄▽ ̄*)ブ")
18 | fmt.Println(text2) // Will print: "o-v-bu"
19 |
20 | text3 := slug.Make("💡 (_ _)。゜zzZ")
21 | fmt.Println(text3) // Will print: "zzz"
22 |
23 | text4 := slug.Make("( ´・・)ノ(._.`)")
24 | fmt.Println(text4) // Will print: "no"
25 |
26 | text5 := slug.Make("ヾ(⌐■_■)ノ♪")
27 | fmt.Println(text5) // Will print: "no"
28 |
29 | someText := slug.Make("影師")
30 | fmt.Println(someText) // Will print: "ying-shi"
31 |
32 | enText := slug.MakeLang("This & that", "en")
33 | fmt.Println(enText) // Will print: "this-and-that"
34 |
35 | deText := slug.MakeLang("Diese & Dass", "de")
36 | fmt.Println(deText) // Will print: "diese-und-dass"
37 |
38 | slug.Lowercase = false // Keep uppercase characters
39 | deUppercaseText := slug.MakeLang("Diese & Dass", "fa")
40 | fmt.Println(deUppercaseText) // Will print: "Diese-und-Dass"
41 |
42 | slug.CustomSub = map[string]string{
43 | "water": "sand",
44 | }
45 | textSub := slug.Make("water is hot")
46 | fmt.Println(textSub) // Will print: "sand-is-hot"
47 | }
48 |
49 | func TestStringContains(t *testing.T) {
50 | if strings.Contains(`{
51 | repository(owner: "excing", name: "find-roots-of-word") {
52 | discussionCategories(first: 10) {
53 | nodes {
54 | id
55 | name
56 | emoji
57 | description
58 | }
59 | totalCount
60 | }
61 | }
62 | viewer {
63 | login
64 | }
65 | }`, `first: 11`) {
66 | t.Log()
67 | } else {
68 | t.Fail()
69 | }
70 | }
71 |
72 | func TestTimeConvert(t *testing.T) {
73 | timeStr := ""
74 | time, err := time.Parse("2006-01-02 15:04:05", timeStr)
75 | if err != nil {
76 | // Result: failed
77 | t.Fatal(err)
78 | }
79 | t.Log(time.String())
80 | }
81 |
82 | func TestQueryf(t *testing.T) {
83 | query := queryf(`{
84 | repository(owner: "excing", name: "find-roots-of-word") {
85 | discussionCategories(first: 10) {
86 | nodes {
87 | id
88 | name
89 | emoji
90 | description
91 | }
92 | totalCount
93 | }
94 | }
95 | viewer {
96 | login
97 | }
98 | }`)
99 | fmt.Println(query)
100 | // {"query": "query { repository(owner: \"excing\", name: \"find-roots-of-word\") { discussionCategories(first: 10) { nodes { id name emoji description } totalCount } } viewer { login } }" }
101 | }
102 |
103 | func TestRender(t *testing.T) {
104 | err := render(
105 | &RenderSite{"/", ""},
106 | testRepository(),
107 | "assets/theme",
108 | true,
109 | func(s string, b []byte) error {
110 | fmt.Println(s)
111 | _, err := os.Stdout.Write(b)
112 | return err
113 | },
114 | )
115 | if err != nil {
116 | t.Fatal(err)
117 | }
118 | }
119 |
120 | func TestOsStat(t *testing.T) {
121 | if _, err := os.Stat(""); os.IsNotExist(err) {
122 | t.Fatal(err)
123 | } else {
124 | t.Log("PASS")
125 | }
126 | }
127 |
128 | func TestGetEmoji4GEmoji(t *testing.T) {
129 | gemojiFormat := `
`
130 | gemoji := `📣 `
131 | t.Log(getGemoji(fmt.Sprintf(gemojiFormat, gemoji)) == gemoji)
132 | }
133 |
134 | func TestPref(t *testing.T) {
135 | str := "example.txt"
136 | suffix := "txt"
137 | t.Log(strings.HasSuffix(str, suffix))
138 | }
139 |
140 | func TestNewSite(t *testing.T) {
141 | fmt.Println(newSite("./test"))
142 | }
143 |
144 | func testRepository() *GithubData {
145 | labels := &LabelPage{}
146 | labels.Nodes = append(labels.Nodes, &Label{Name: "bug", Discussions: &DiscussionPage{}})
147 | labels.TotalCount = len(labels.Nodes)
148 |
149 | categories := &CategoryPage{}
150 | categories.Nodes = append(categories.Nodes, &Category{Name: "Announcements", Discussions: &DiscussionPage{}})
151 | categories.Nodes = append(categories.Nodes, &Category{Name: "General", Discussions: &DiscussionPage{}})
152 | categories.Nodes = append(categories.Nodes, &Category{Name: "Ideas", Discussions: &DiscussionPage{}})
153 | categories.Nodes = append(categories.Nodes, &Category{Name: "Polls", Discussions: &DiscussionPage{}})
154 | categories.Nodes = append(categories.Nodes, &Category{Name: "Q&A", Discussions: &DiscussionPage{}})
155 | categories.TotalCount = len(categories.Nodes)
156 |
157 | discussions := &DiscussionPage{}
158 | discussions.Nodes = append(discussions.Nodes, &Discussion{Title: "关于模板版本的一些思考", GitHubURL: "https://github.com/ThreeTenth/GitHub-Discussions-to-Blog/discussions/8", Category: &Category{Name: "Ideas"}, Comments: &CommentPage{}})
159 | discussions.TotalCount = len(discussions.Nodes)
160 |
161 | return &GithubData{
162 | &Repository{Labels: labels, Categories: categories, Discussions: discussions},
163 | &User{Login: "excing"},
164 | &Organization{},
165 | }
166 | }
167 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 |
10 | "github.com/excing/goflag"
11 | )
12 |
13 | // Config is gd2b config
14 | type Config struct {
15 | Owner string `flag:"Github repository owner"`
16 | Name string `flag:"Github repository name"`
17 | Token string `flag:"Github authorization token (see https://docs.github.com/zh/graphql/guides/forming-calls-with-graphql)"`
18 | Pages string `flag:"Your github pages repository name, If None, defaults to the repository where the discussion resides"`
19 | Debug bool `flag:"Debug mode if true"`
20 | DebugMod string `flag:"Debug mode: auto, manual. Automatic debugging mode and manual debugging mode"`
21 | BaseURL string `flag:"Web site base url"`
22 | GamID string `flag:"Google Analytics Measurement id, Defaults to empty to not load the Google Analytics script"`
23 | ThemeDir string `flag:"Filesystem path to themes directory, Defaults to embed assets/theme"`
24 | NewSite bool `flag:"Generate theme, Defaults to false"`
25 | Export string `flag:"Export all Discussions to markdown, Value is the export directory"`
26 | }
27 |
28 | func main() {
29 | var config Config
30 | goflag.Var(&config)
31 | goflag.Parse("config", "Configuration file path.")
32 |
33 | if config.NewSite {
34 | if err := newSite(config.ThemeDir); err != nil {
35 | panic(err)
36 | }
37 | fmt.Println("New site success")
38 | return
39 | }
40 | if config.Export != "" {
41 | if err := export(config); err != nil {
42 | panic(err)
43 | }
44 | fmt.Println("Export success")
45 | return
46 | }
47 |
48 | fmt.Println("Start build noll siteweb")
49 |
50 | if config.Pages == "" {
51 | config.Pages = config.Name
52 | }
53 | if config.ThemeDir == "" {
54 | config.ThemeDir = "assets/theme"
55 | }
56 |
57 | pageDomain := fmt.Sprintf("%v.github.io", config.Owner)
58 | config.BaseURL = UnixPath(strings.ReplaceAll(config.BaseURL, pageDomain, "/"))
59 |
60 | var err error
61 | if _, err = os.Stat(config.Pages); os.IsNotExist(err) {
62 | os.MkdirAll(config.Pages, os.ModePerm)
63 | }
64 |
65 | var githubData *GithubData
66 |
67 | _getGithubData := func() error {
68 | githubData, err = getRepository(config.Owner, config.Name, config.Token)
69 | return err
70 | }
71 |
72 | _render := func() error {
73 | return render(
74 | &RenderSite{
75 | BaseURL: config.BaseURL,
76 | GamID: config.GamID,
77 | },
78 | githubData, config.ThemeDir,
79 | config.Debug,
80 | func(s string, b []byte) error {
81 | fname := strings.ReplaceAll(s, ".gtpl", ".html")
82 | htmlPath := filepath.Join(config.Pages, fname)
83 | MkdirFileFolderIfNotExists(htmlPath)
84 | if config.Debug {
85 | fmt.Println(s, string(b), "\n=========================================")
86 | }
87 | return os.WriteFile(htmlPath, b, os.ModePerm)
88 | })
89 | }
90 | if err = _getGithubData(); err != nil {
91 | panic(err)
92 | }
93 | if err = _render(); err != nil {
94 | panic(err)
95 | }
96 |
97 | fmt.Println("Build noll siteweb finished")
98 |
99 | if config.Debug {
100 | port := ":20000"
101 | fs := &DirWithError{
102 | FS: http.Dir(config.Pages),
103 | Status: map[int]string{http.StatusNotFound: "404.html"},
104 | }
105 | fmt.Println("Start noll debug mode in http://localhost" + port)
106 |
107 | if config.DebugMod == "auto" {
108 | http.Handle("/ws", debugWs(config, _render))
109 | }
110 | http.Handle("/", http.StripPrefix("/", http.FileServer(fs)))
111 | // 重新编译渲染接口
112 | // 调试使用
113 | http.Handle("/build", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
114 | query := r.URL.Query()
115 | mode := query.Get("mode")
116 | switch mode {
117 | case "full":
118 | // 全量更新:
119 | // 删除本地所有文件,
120 | // 然后从网络上获取最新数据,
121 | // 再重新生成所有文件。
122 | case "increase":
123 | // 增量更新:
124 | // 从网络上获取最新数据,
125 | // 并检测本地数据是否需要更新,
126 | // 如果需要,则更新,否则跳过,此操作由渲染引擎处理。
127 | //
128 | // 增量更新和全量更新在流程,仅是否有删除本地所有文件的区别。
129 | if err = _getGithubData(); err != nil {
130 | w.WriteHeader(http.StatusInternalServerError)
131 | w.Write([]byte(err.Error()))
132 | return
133 | }
134 | }
135 | if err = _render(); err != nil {
136 | w.WriteHeader(http.StatusInternalServerError)
137 | w.Write([]byte(err.Error()))
138 | } else {
139 | w.WriteHeader(http.StatusOK)
140 | w.Write([]byte("Build successed!"))
141 | }
142 | }))
143 | err = http.ListenAndServe(port, nil)
144 | if err != nil {
145 | panic(err)
146 | }
147 | }
148 | }
149 |
150 | // DirWithError 带有错误状态页面的 http 文件系统
151 | type DirWithError struct {
152 | FS http.FileSystem
153 | Status map[int]string
154 | }
155 |
156 | // Open 返回指定名称(路径)的文件
157 | func (d *DirWithError) Open(name string) (http.File, error) {
158 | f, err := d.FS.Open(name)
159 | if err != nil {
160 | if os.IsNotExist(err) {
161 | _404, ok := d.Status[http.StatusNotFound]
162 | if ok {
163 | return d.FS.Open(_404)
164 | }
165 | } else if os.IsPermission(err) {
166 | _403, ok := d.Status[http.StatusForbidden]
167 | if ok {
168 | return d.FS.Open(_403)
169 | }
170 | } else {
171 | // Default:
172 | _500, ok := d.Status[http.StatusInternalServerError]
173 | if ok {
174 | return d.FS.Open(_500)
175 | }
176 | }
177 | return f, err
178 | }
179 |
180 | return f, nil
181 | }
182 |
--------------------------------------------------------------------------------
/scheme.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "regexp"
5 | "strings"
6 | "time"
7 | )
8 |
9 | // Body is Github GraphQL api response body
10 | type Body struct {
11 | Data *GithubData `json:"data"`
12 | }
13 |
14 | // GithubData is Github GraphQL api data
15 | type GithubData struct {
16 | Repository *Repository `json:"repository"`
17 | Viewer *User `json:"user"`
18 | Organization *Organization `json:"organization"`
19 | }
20 |
21 | // PageInfo is Github GraphQL api page data info
22 | type PageInfo struct {
23 | HasNextPage bool `json:"hasNextPage"`
24 | EndCursor string `json:"endCursor"`
25 | HasPrevPage bool `json:"-"`
26 | StartCursor string `json:"-"`
27 | }
28 |
29 | // Repository is Github repository scheme
30 | type Repository struct {
31 | Name string `json:"name"`
32 | URL string `json:"url"`
33 | Labels *LabelPage `json:"labels"`
34 | Categories *CategoryPage `json:"discussionCategories"`
35 | Discussions *DiscussionPage `json:"discussions"`
36 | Discussion *Discussion `json:"discussion"`
37 | }
38 |
39 | // CategoryPage is Github discussion category page scheme
40 | type CategoryPage struct {
41 | TotalCount int `json:"totalCount"`
42 | Nodes []*Category `json:"nodes"`
43 | }
44 |
45 | // Category is Github discussion category scheme
46 | type Category struct {
47 | Emoji string `json:"emoji"`
48 | EmojiHTML string `json:"emojiHTML"`
49 | Name string `json:"name"`
50 | Description string `json:"description"`
51 | CreatedAt time.Time `json:"createdAt"`
52 | UpdatedAt time.Time `json:"updatedAt"`
53 | Discussions *DiscussionPage `json:"-"`
54 | }
55 |
56 | // InvaildFileNameRegex 无效的文件名字符正则表达式
57 | var InvaildFileNameRegex = regexp.MustCompile(`[<>:"/\\|?*\x00-\x1f]`)
58 |
59 | // Slug 返回分类的合法的 url 名称,将其中无效的文件名替换为 '-'
60 | func (c *Category) Slug() string {
61 | return InvaildFileNameRegex.ReplaceAllString(c.Name, "-")
62 | }
63 |
64 | // LabelPage is Github discussion label page scheme
65 | type LabelPage struct {
66 | TotalCount int `json:"totalCount"`
67 | Nodes []*Label `json:"nodes"`
68 | }
69 |
70 | // Label is Github label(discussion and issue) scheme
71 | type Label struct {
72 | Color string `json:"color"`
73 | Name string `json:"name"`
74 | Description string `json:"description"`
75 | CreatedAt time.Time `json:"createdAt"`
76 | UpdatedAt time.Time `json:"updatedAt"`
77 | Discussions *DiscussionPage `json:"-"`
78 | }
79 |
80 | // Slug 返回标签的合法的 url 名称,将其中无效的文件名替换为 '-'
81 | func (l *Label) Slug() string {
82 | return InvaildFileNameRegex.ReplaceAllString(l.Name, "-")
83 | }
84 |
85 | // String 返回标签的名称列表
86 | func (p LabelPage) String() string {
87 | var labels []string
88 | for _, label := range p.Nodes {
89 | labels = append(labels, label.Name)
90 | }
91 | return "[" + strings.Join(labels, ", ") + "]"
92 | }
93 |
94 | // DiscussionPage is Github Discussion page scheme
95 | type DiscussionPage struct {
96 | TotalCount int `json:"totalCount"`
97 | Nodes []*Discussion `json:"nodes"`
98 | PageInfo *PageInfo `json:"pageInfo"`
99 | }
100 |
101 | // Discussion is Github Discussion scheme
102 | type Discussion struct {
103 | Number int `json:"number"`
104 | Title string `json:"title"`
105 | Body string `json:"body"`
106 | BodyHTML string `json:"bodyHTML"`
107 | Locked bool `json:"locked"`
108 | UpvoteCount int `json:"upvoteCount"`
109 | GitHubURL string `json:"url"`
110 | CreatedAt time.Time `json:"createdAt"`
111 | UpdatedAt time.Time `json:"updatedAt"`
112 | Author *User `json:"author"`
113 | Category *Category `json:"category"`
114 | Labels *LabelPage `json:"labels"`
115 | Comments *CommentPage `json:"comments"`
116 | ReactionGroups []*ReactionGroup `json:"reactionGroups"`
117 | }
118 |
119 | // CommentPage is Github Discussion Comment page scheme
120 | type CommentPage struct {
121 | TotalCount int `json:"totalCount"`
122 | Nodes []*Comment `json:"nodes"`
123 | PageInfo *PageInfo `json:"pageInfo"`
124 | }
125 |
126 | // Comment is Github Discussion comment scheme
127 | type Comment struct {
128 | Body string `json:"body"`
129 | BodyHTML string `json:"bodyHTML"`
130 | UpvoteCount int `json:"upvoteCount"`
131 | GitHubURL string `json:"url"`
132 | AuthorAssociation string `json:"authorAssociation"`
133 | CreatedAt time.Time `json:"createdAt"`
134 | UpdatedAt time.Time `json:"updatedAt"`
135 | Author *User `json:"author"`
136 | ReactionGroups []*ReactionGroup `json:"reactionGroups"`
137 | }
138 |
139 | // ReactionGroup is Github Discussion Reaction group scheme
140 | type ReactionGroup struct {
141 | Content string `json:"content"`
142 | Reactors *ReactionPage `json:"reactors"`
143 | }
144 |
145 | // ReactionPage is Github Discussion Reaction page scheme
146 | type ReactionPage struct {
147 | TotalCount int `json:"totalCount"`
148 | }
149 |
150 | // User is Github user scheme
151 | type User struct {
152 | Login string `json:"login"`
153 | AvatarURL string `json:"avatarUrl"`
154 | GitHubURL string `json:"url"`
155 | Bio string `json:"bio"`
156 | Email string `json:"email"`
157 | Company string `json:"company"`
158 | Location string `json:"location"`
159 | Name string `json:"name"`
160 | Twitter string `json:"twitterUsername"`
161 | }
162 |
163 | // ShowName 返回该用户的对外显示的名字
164 | func (u *User) ShowName() string {
165 | if u.Name != "" {
166 | return u.Name
167 | }
168 | return u.Login
169 | }
170 |
171 | // Organization is Github organization scheme
172 | type Organization struct {
173 | Login string `json:"login"`
174 | AvatarURL string `json:"avatarUrl"`
175 | GitHubURL string `json:"url"`
176 | Bio string `json:"description"`
177 | Email string `json:"email"`
178 | Location string `json:"location"`
179 | Name string `json:"name"`
180 | Twitter string `json:"twitterUsername"`
181 | }
182 |
--------------------------------------------------------------------------------
/github.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io/ioutil"
7 | "net/http"
8 | "regexp"
9 | "strings"
10 | )
11 |
12 | func getGemoji(gemoji string) string {
13 | fmt.Printf("Start parse gemoji %v \n", gemoji)
14 | gEmojiRegex := `(.*)
`
15 | regex := regexp.MustCompile(gEmojiRegex)
16 | result := regex.FindStringSubmatch(gemoji)
17 | if len(result) == 0 {
18 | return ""
19 | }
20 | return result[0]
21 | }
22 |
23 | func getRepository(owner, name, token string) (*GithubData, error) {
24 | fmt.Printf("Start get %v/%v repository\n", owner, name)
25 |
26 | viewer, err := getViewer(owner, token)
27 | if err != nil {
28 | return nil, err
29 | }
30 | // 标签集合
31 | lables, err := getLabels(owner, name, token)
32 | if err != nil {
33 | return nil, err
34 | }
35 | for _, lable := range lables.Nodes {
36 | lable.Discussions = &DiscussionPage{}
37 | }
38 |
39 | // 分类集合
40 | categories, err := getCategories(owner, name, token)
41 | if err != nil {
42 | return nil, err
43 | }
44 | for _, category := range categories.Nodes {
45 | category.EmojiHTML = getGemoji(category.EmojiHTML)
46 | category.Discussions = &DiscussionPage{}
47 | }
48 |
49 | // 讨论集合
50 | hasNextPage := true
51 | endCursor := ""
52 | discussions := &DiscussionPage{}
53 | for hasNextPage {
54 | // 获取所有的讨论
55 | discussionPage, err := getDiscussionPage(owner, name, token, endCursor)
56 | if err != nil {
57 | return nil, err
58 | }
59 |
60 | for _, discussion := range discussionPage.Nodes {
61 | // 获取所有的评论
62 | hasNextCommentPage := true
63 | endCommentCursor := ""
64 | discussion.Comments = &CommentPage{}
65 | for hasNextCommentPage {
66 | commentPage, err := getCommentPage(owner, name, token, discussion.Number, endCommentCursor)
67 | if err != nil {
68 | return nil, err
69 | }
70 |
71 | if 0 < commentPage.TotalCount {
72 | discussion.Comments.Nodes = append(discussion.Comments.Nodes, commentPage.Nodes...)
73 | discussion.Comments.PageInfo = commentPage.PageInfo
74 | }
75 |
76 | // 是否有下一页评论
77 | hasNextCommentPage = commentPage.PageInfo.HasNextPage
78 | endCommentCursor = commentPage.PageInfo.EndCursor
79 | }
80 | discussion.Comments.TotalCount = len(discussion.Comments.Nodes)
81 |
82 | // 获取分类的文章列表
83 | discussion.Category.EmojiHTML = getGemoji(discussion.Category.EmojiHTML)
84 | for _, category := range categories.Nodes {
85 | if category.Name == discussion.Category.Name {
86 | category.Discussions.Nodes = append(category.Discussions.Nodes, discussion)
87 | category.Discussions.TotalCount++
88 | }
89 | }
90 |
91 | // 获取标签的文章列表
92 | for _, discussLabel := range discussion.Labels.Nodes {
93 | for _, label := range lables.Nodes {
94 | if discussLabel.Name == label.Name {
95 | label.Discussions.Nodes = append(label.Discussions.Nodes, discussion)
96 | label.Discussions.TotalCount++
97 | }
98 | }
99 | }
100 | }
101 |
102 | if 0 < discussionPage.TotalCount {
103 | discussions.Nodes = append(discussions.Nodes, discussionPage.Nodes...)
104 | discussions.PageInfo = discussionPage.PageInfo
105 | }
106 |
107 | // 是否有下一页
108 | hasNextPage = discussionPage.PageInfo.HasNextPage
109 | endCursor = discussionPage.PageInfo.EndCursor
110 | }
111 | discussions.TotalCount = len(discussions.Nodes)
112 |
113 | return &GithubData{
114 | Viewer: viewer,
115 | Repository: &Repository{
116 | Name: name,
117 | URL: fmt.Sprintf("https://github.com/%v/%v", owner, name),
118 | Labels: lables,
119 | Categories: categories,
120 | Discussions: discussions,
121 | },
122 | }, nil
123 | }
124 |
125 | func getDiscussionPage(owner, name, token string, afterCursor string) (*DiscussionPage, error) {
126 | queryFormat := `{
127 | repository(owner: "%v", name: "%v") {
128 | discussions(first: 10, %v) {
129 | totalCount
130 | nodes {
131 | number
132 | title
133 | body
134 | bodyHTML
135 | upvoteCount
136 | locked
137 | createdAt
138 | updatedAt
139 | url
140 | author {
141 | login
142 | avatarUrl
143 | url
144 | }
145 | category {
146 | emoji
147 | emojiHTML
148 | name
149 | }
150 | labels(first: 10) {
151 | totalCount
152 | nodes {
153 | color
154 | name
155 | }
156 | }
157 | reactionGroups {
158 | content
159 | reactors(first: 1) {
160 | totalCount
161 | }
162 | }
163 | }
164 | pageInfo {
165 | hasNextPage
166 | endCursor
167 | }
168 | }
169 | }
170 | }`
171 | var result Body
172 | if err := query(fmt.Sprintf(queryFormat, owner, name, afterQuery(afterCursor)), token, &result); err != nil {
173 | return nil, err
174 | }
175 | return result.Data.Repository.Discussions, nil
176 | }
177 |
178 | func getCommentPage(owner, name, token string, discussionNumber int, afterCursor string) (*CommentPage, error) {
179 | queryFormat := `{
180 | repository(owner: "%v", name: "%v") {
181 | discussion(number: %v) {
182 | comments(first: 100, %v) {
183 | totalCount
184 | nodes {
185 | body
186 | bodyHTML
187 | createdAt
188 | author {
189 | avatarUrl
190 | login
191 | url
192 | }
193 | authorAssociation
194 | updatedAt
195 | upvoteCount
196 | url
197 | reactionGroups {
198 | content
199 | reactors(first: 1) {
200 | totalCount
201 | }
202 | }
203 | }
204 | pageInfo {
205 | hasNextPage
206 | endCursor
207 | }
208 | }
209 | }
210 | }
211 | }`
212 | var result Body
213 | if err := query(fmt.Sprintf(queryFormat, owner, name, discussionNumber, afterQuery(afterCursor)), token, &result); err != nil {
214 | return nil, err
215 | }
216 | return result.Data.Repository.Discussion.Comments, nil
217 | }
218 |
219 | func getCategories(owner, name, token string) (*CategoryPage, error) {
220 | queryFormat := `{
221 | repository(owner: "%v", name: "%v") {
222 | discussionCategories(first: 100) {
223 | nodes {
224 | name
225 | slug
226 | emoji
227 | emojiHTML
228 | description
229 | }
230 | totalCount
231 | }
232 | }
233 | }`
234 | var result Body
235 | if err := query(fmt.Sprintf(queryFormat, owner, name), token, &result); err != nil {
236 | return nil, err
237 | }
238 | return result.Data.Repository.Categories, nil
239 | }
240 |
241 | func getLabels(owner, name, token string) (*LabelPage, error) {
242 | queryFormat := `{
243 | repository(owner: "%v", name: "%v") {
244 | labels(first: 100) {
245 | totalCount
246 | nodes {
247 | color
248 | name
249 | description
250 | createdAt
251 | updatedAt
252 | }
253 | }
254 | }
255 | }`
256 | var result Body
257 | if err := query(fmt.Sprintf(queryFormat, owner, name), token, &result); err != nil {
258 | return nil, err
259 | }
260 | return result.Data.Repository.Labels, nil
261 | }
262 |
263 | func getViewer(owner, token string) (*User, error) {
264 | // 查询该账号是否为一名用户。
265 | // 需要 `read:user`, `read:mail` 权限。
266 | queryFormat := `{
267 | user(login: "%v") {
268 | login
269 | url
270 | avatarUrl
271 | bio
272 | email
273 | company
274 | location
275 | name
276 | twitterUsername
277 | }
278 | }`
279 | var result Body
280 | err := query(fmt.Sprintf(queryFormat, owner), token, &result)
281 | if result.Data.Viewer != nil {
282 | return result.Data.Viewer, nil
283 | }
284 | // 如果该账号不是一名用户,则查询是否为一个组织。
285 | // 需要 `read:org` 权限。
286 | queryFormat = `{
287 | organization(login: "%v") {
288 | email
289 | location
290 | login
291 | name
292 | description
293 | url
294 | avatarUrl
295 | twitterUsername
296 | }
297 | }`
298 | err1 := query(fmt.Sprintf(queryFormat, owner), token, &result)
299 | if result.Data.Organization != nil {
300 | return &User{
301 | Login: result.Data.Organization.Login,
302 | Name: result.Data.Organization.Name,
303 | Email: result.Data.Organization.Email,
304 | Location: result.Data.Organization.Location,
305 | AvatarURL: result.Data.Organization.AvatarURL,
306 | Bio: result.Data.Organization.Bio,
307 | GitHubURL: result.Data.Organization.GitHubURL,
308 | Twitter: result.Data.Organization.Twitter,
309 | }, nil
310 | }
311 |
312 | if err == nil && err1 == nil {
313 | // 如果没有异常,也没有返回任何账号信息,则表示该账号不存在。
314 | return nil, fmt.Errorf("This account does not exist")
315 | }
316 |
317 | return nil, fmt.Errorf("%v\n%v", err, err1)
318 | }
319 |
320 | func query(body string, token string, result *Body) error {
321 | req, err := http.NewRequest("POST", "https://api.github.com/graphql", strings.NewReader(queryf(body)))
322 | if err != nil {
323 | return err
324 | }
325 | req.Header.Set("Authorization", "bearer "+token)
326 |
327 | response, err := http.DefaultClient.Do(req)
328 | if err != nil {
329 | return err
330 | }
331 | defer response.Body.Close()
332 |
333 | resBodyBytes, err := ioutil.ReadAll(response.Body)
334 |
335 | if http.StatusOK != response.StatusCode {
336 | return fmt.Errorf("GraphQL query failed: %v\n%v", response.Status, string(resBodyBytes))
337 | }
338 |
339 | if err = json.Unmarshal(resBodyBytes, &result); err != nil {
340 | return err
341 | }
342 |
343 | if result.Data == nil {
344 | return fmt.Errorf("GraphQL query error: %v", string(resBodyBytes))
345 | }
346 |
347 | return nil
348 | }
349 |
350 | // queryf 参数的值来源 https://docs.github.com/zh/graphql/overview/explorer
351 | func queryf(query string) string {
352 | query = strings.ReplaceAll(query, "\n", "")
353 | query = strings.ReplaceAll(query, "\t", " ")
354 | query = strings.ReplaceAll(query, `"`, `\"`)
355 | fields := strings.FieldsFunc(query, func(c rune) bool {
356 | return c == ' '
357 | })
358 | return fmt.Sprintf(`{"query": "query %v" }`, strings.Join(fields, " "))
359 | }
360 |
361 | func afterQuery(afterCursor string) string {
362 | after := ""
363 | if afterCursor != "" {
364 | after = fmt.Sprintf(`after: "%v"`, afterCursor)
365 | }
366 | return after
367 | }
368 |
--------------------------------------------------------------------------------
/assets/js-render-loader.gtpl:
--------------------------------------------------------------------------------
1 | {{ if .HasMermaid }}
2 |
18 |
19 | {{ end }}
20 | {{ if .HasMathjax }}
21 |
29 |
30 | {{ end }}
31 | {{ if .HasGeojson }}
32 |
35 |
38 |
39 |
123 | {{ end }}
124 | {{ if .HasSTL3D }}
125 |
126 |
127 |
135 |
136 |
312 | {{ end }}
--------------------------------------------------------------------------------
/render.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "embed"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 | "text/template"
10 | "time"
11 |
12 | "io.github.nollgo/assets"
13 | )
14 |
15 | // RenderData 渲染模板的结构体
16 | type RenderData struct {
17 | Site *RenderSite
18 | Viewer *User
19 | Labels *LabelPage
20 | Categories *CategoryPage
21 | Data interface{}
22 | }
23 |
24 | // RenderSite 渲染的站点信息
25 | type RenderSite struct {
26 | BaseURL string
27 | GamID string
28 | }
29 |
30 | // JsRenderLoader js 渲染加载器
31 | // 包含数学公式、图表、地图和三维模型
32 | type JsRenderLoader struct {
33 | HTML string
34 | HasMermaid bool
35 | HasMathjax bool
36 | HasGeojson bool
37 | HasSTL3D bool
38 | }
39 |
40 | // Has 返回 Html 中是否包含需要 js 渲染的内容
41 | func (l *JsRenderLoader) Has() bool {
42 | if strings.Contains(l.HTML, `data-type="geojsin"`) || strings.Contains(l.HTML, `data-type="topojson"`) {
43 | l.HasGeojson = true
44 | }
45 | if strings.Contains(l.HTML, ``) {
46 | l.HasMathjax = true
47 | }
48 | if strings.Contains(l.HTML, `data-type="mermaid"`) {
49 | l.HasMermaid = true
50 | }
51 | if strings.Contains(l.HTML, `data-type="stl"`) {
52 | l.HasSTL3D = true
53 | }
54 | return l.HasGeojson || l.HasMathjax || l.HasMermaid || l.HasSTL3D
55 | }
56 |
57 | // WriterFunc 向指定文件写入内容
58 | type WriterFunc func(string, []byte) error
59 |
60 | // StringWriter 可以写入到字符串的 Writer
61 | type StringWriter struct {
62 | Data []byte
63 | }
64 |
65 | // Reset 重置资源
66 | func (w *StringWriter) Reset() *StringWriter {
67 | w.Data = make([]byte, 0)
68 | return w
69 | }
70 |
71 | // Write 向字符串中写入
72 | func (w *StringWriter) Write(p []byte) (n int, err error) {
73 | w.Data = append(w.Data, p...)
74 | return len(p), nil
75 | }
76 |
77 | func (w *StringWriter) String() string {
78 | return string(w.Data)
79 | }
80 |
81 | // FileReader 是文件读取接口
82 | type FileReader interface {
83 | ReadDir(name string) ([]os.DirEntry, error)
84 | ReadFile(name string) ([]byte, error)
85 | }
86 |
87 | // LocalFileReader 本地文件读取器
88 | type LocalFileReader struct {
89 | DirPath string
90 | }
91 |
92 | // ReadDir 读取本地文件夹
93 | func (r *LocalFileReader) ReadDir(name string) ([]os.DirEntry, error) {
94 | return os.ReadDir(filepath.Join(r.DirPath, name))
95 | }
96 |
97 | // ReadFile 读取本地文件,并返回文件内容
98 | func (r *LocalFileReader) ReadFile(name string) ([]byte, error) {
99 | return os.ReadFile(filepath.Join(r.DirPath, name))
100 | }
101 |
102 | // EmbedFileReader embed 文件读取器
103 | type EmbedFileReader struct {
104 | DirEmbed embed.FS
105 | DirPath string
106 | }
107 |
108 | // ReadDir 读取 embed 打包里的文件夹
109 | func (r *EmbedFileReader) ReadDir(name string) ([]os.DirEntry, error) {
110 | return r.DirEmbed.ReadDir(UnixPath(filepath.Join(r.DirPath, name)))
111 | }
112 |
113 | // ReadFile 读取 embed 文件,并返回文件内容
114 | func (r *EmbedFileReader) ReadFile(name string) ([]byte, error) {
115 | return r.DirEmbed.ReadFile(UnixPath(filepath.Join(r.DirPath, name)))
116 | }
117 |
118 | // UnixPath 返回当前目录下 name 文件的 unix 路径。
119 | // embed 路径,即 Linux 路径,Windows 的 `\` 路径 embed 不支持,
120 | // 所以需要对其进行替换。
121 | func UnixPath(path string) string {
122 | return strings.ReplaceAll(filepath.Clean(path), `\`, "/")
123 | }
124 |
125 | func render(site *RenderSite, data *GithubData, themeTmplDir string, debug bool, writer WriterFunc) error {
126 | // 1. 获取全局资源(assets 文件夹)文件
127 | readGlobalFile := func(name string) ([]byte, error) {
128 | var fname = filepath.Join("assets", name)
129 | if _, err := os.Stat(fname); err != nil {
130 | return assets.Dir.ReadFile(UnixPath(name))
131 | }
132 | return os.ReadFile(fname)
133 | }
134 |
135 | readGlobalGtpl := func(name string) (*template.Template, error) {
136 | bs, err := readGlobalFile(name)
137 | if err != nil {
138 | return nil, err
139 | }
140 | return template.New(name).Parse(string(bs))
141 | }
142 |
143 | var r FileReader
144 | if _, err := os.Stat(themeTmplDir); os.IsNotExist(err) {
145 | r = &EmbedFileReader{assets.Dir, "theme"}
146 | } else {
147 | r = &LocalFileReader{themeTmplDir}
148 | }
149 |
150 | // 2. 获取主题模板
151 | templateFuncMap := template.FuncMap{
152 | "time": func() time.Time { return time.Time{} },
153 | "isd": func(d1, d2 time.Time) bool {
154 | return d1.Year() == d2.Year() && d1.YearDay() == d2.YearDay()
155 | },
156 | "ism": func(d1, d2 time.Time) bool {
157 | return d1.Year() == d2.Year() && d1.Month() == d2.Month()
158 | },
159 | "isy": func(d1, d2 time.Time) bool {
160 | return d1.Year() == d2.Year()
161 | },
162 | "url": func(obj interface{}) string {
163 | if path, ok := obj.(string); ok {
164 | switch path {
165 | case "Index":
166 | path = "/"
167 | case "Archive":
168 | path = "archive/1.html"
169 | case "Categories":
170 | path = "categories.html"
171 | case "Labels":
172 | path = "labels.html"
173 | case "About":
174 | path = "about.html"
175 | case "RSS":
176 | path = "rss.xml"
177 | case "NewPost":
178 | return fmt.Sprintf("%v/discussions/new/choose", data.Repository.URL)
179 | }
180 | return UnixPath(filepath.Join(site.BaseURL, path))
181 | }
182 | if label, ok := obj.(*Label); ok {
183 | return UnixPath(filepath.Join(site.BaseURL, "label", fmt.Sprintf("%v.html", label.Slug())))
184 | }
185 | if category, ok := obj.(*Category); ok {
186 | return UnixPath(filepath.Join(site.BaseURL, "category", fmt.Sprintf("%v.html", category.Slug())))
187 | }
188 | if discussion, ok := obj.(*Discussion); ok {
189 | return UnixPath(filepath.Join(site.BaseURL, "post", fmt.Sprintf("%v.html", discussion.Number)))
190 | }
191 | return site.BaseURL
192 | },
193 | // 带有页号的链接
194 | "url2": func(obj interface{}, number interface{}) string {
195 | if _, ok := obj.(*LabelPage); ok {
196 | // 标签文章列表分页
197 | return UnixPath(filepath.Join(site.BaseURL, "label", fmt.Sprintf("%v.html", number)))
198 | }
199 | if _, ok := obj.(*CategoryPage); ok {
200 | // 类别文章列表分页
201 | return UnixPath(filepath.Join(site.BaseURL, "category", fmt.Sprintf("%v.html", number)))
202 | }
203 | if _, ok := obj.(*DiscussionPage); ok {
204 | // 归档文章列表分页
205 | return UnixPath(filepath.Join(site.BaseURL, "archive", fmt.Sprintf("%v.html", number)))
206 | }
207 | return site.BaseURL
208 | },
209 | // 根据分类或标签获取文章列表 "#标签" 或 "分类"
210 | "discus": func(names ...string) *DiscussionPage {
211 | categoryNames := make([]string, 0)
212 | labelNames := make([]string, 0)
213 | for _, name := range names {
214 | if name[0:1] == "#" {
215 | labelNames = append(labelNames, name[1:])
216 | } else {
217 | categoryNames = append(categoryNames, name)
218 | }
219 | }
220 | page := &DiscussionPage{
221 | Nodes: []*Discussion{},
222 | TotalCount: 0,
223 | }
224 |
225 | if len(categoryNames) > 0 {
226 | for i := range categoryNames {
227 | page.Nodes = append(page.Nodes, getDiscussionByCategory(categoryNames[i], data)...)
228 | }
229 | }
230 |
231 | if len(labelNames) > 0 {
232 | for i := range labelNames {
233 | page.Nodes = append(page.Nodes, getDiscussionByLabel(labelNames[i], data)...)
234 | }
235 | }
236 |
237 | page.Nodes = deduplication(page.Nodes)
238 | page.TotalCount = len(page.Nodes)
239 | return page
240 | },
241 | }
242 | themeTemplate, err := readTemplates(
243 | template.New("__nollTemplate__").Funcs(templateFuncMap), r, ".")
244 | if err != nil {
245 | return err
246 | }
247 |
248 | jsRenderTemplate, err := readGlobalGtpl("js-render-loader.gtpl")
249 | if err != nil {
250 | return err
251 | }
252 |
253 | // 3. 拷贝无需渲染的主题文件到目标文件夹
254 | if err = copyNonRenderFiles(r, "", writer); err != nil {
255 | return err
256 | }
257 |
258 | // 4. 渲染模板
259 | htmlPages := make(map[string]string)
260 | stringWriter := &StringWriter{}
261 | indexTemplate := themeTemplate.Lookup("index.gtpl")
262 | _data := &RenderData{
263 | Site: site,
264 | Viewer: data.Viewer,
265 | Labels: data.Repository.Labels,
266 | Categories: data.Repository.Categories,
267 | }
268 | _data.Data = data.Repository.Discussions
269 | if err = indexTemplate.Execute(stringWriter.Reset(), _data); err != nil {
270 | return err
271 | }
272 | htmlPages[indexTemplate.Name()] = stringWriter.String()
273 |
274 | notFoundTemplate := themeTemplate.Lookup("404.gtpl")
275 | if err = notFoundTemplate.Execute(stringWriter.Reset(), _data); err != nil {
276 | return err
277 | }
278 | htmlPages[notFoundTemplate.Name()] = stringWriter.String()
279 |
280 | categoriesTemplate := themeTemplate.Lookup("categories.gtpl")
281 | if err = categoriesTemplate.Execute(stringWriter.Reset(), _data); err != nil {
282 | return err
283 | }
284 | htmlPages[categoriesTemplate.Name()] = stringWriter.String()
285 |
286 | labelsTemplate := themeTemplate.Lookup("labels.gtpl")
287 | if err = labelsTemplate.Execute(stringWriter.Reset(), _data); err != nil {
288 | return err
289 | }
290 | htmlPages[labelsTemplate.Name()] = stringWriter.String()
291 |
292 | aboutTemplate := themeTemplate.Lookup("about.gtpl")
293 | if err = aboutTemplate.Execute(stringWriter.Reset(), _data); err != nil {
294 | return err
295 | }
296 | htmlPages[aboutTemplate.Name()] = stringWriter.String()
297 |
298 | categoryTemplate := themeTemplate.Lookup("category.gtpl")
299 | for _, category := range data.Repository.Categories.Nodes {
300 | _data.Data = category
301 | if err = categoryTemplate.Execute(stringWriter.Reset(), _data); err != nil {
302 | return err
303 | }
304 | htmlPages[fmt.Sprintf(`category/%v.gtpl`, category.Slug())] = stringWriter.String()
305 | }
306 |
307 | labelTemplate := themeTemplate.Lookup("label.gtpl")
308 | for _, label := range data.Repository.Labels.Nodes {
309 | _data.Data = label
310 | if err = labelTemplate.Execute(stringWriter.Reset(), _data); err != nil {
311 | return err
312 | }
313 | htmlPages[fmt.Sprintf(`label/%v.gtpl`, label.Slug())] = stringWriter.String()
314 | }
315 |
316 | postTemplate := themeTemplate.Lookup("post.gtpl")
317 | for _, discussion := range data.Repository.Discussions.Nodes {
318 | _data.Data = discussion
319 | if err = postTemplate.Execute(stringWriter.Reset(), _data); err != nil {
320 | return err
321 | }
322 | jrl := &JsRenderLoader{HTML: stringWriter.String()}
323 | if jrl.Has() {
324 | jsRenderTemplate.Execute(stringWriter, jrl)
325 | }
326 | htmlPages[fmt.Sprintf(`post/%v.gtpl`, discussion.Number)] = stringWriter.String()
327 | }
328 |
329 | archiveTemplate := themeTemplate.Lookup("archive.gtpl")
330 | totalCount := data.Repository.Discussions.TotalCount
331 | pageIndex := 1 // 编号从 1 开始
332 | pageSize := 30
333 | pageCount := totalCount / pageSize
334 | if totalCount%pageSize > 0 {
335 | pageCount++
336 | }
337 | for start := 0; start < totalCount; {
338 | end := start + pageSize
339 | if end > totalCount {
340 | end = totalCount
341 | }
342 | nodes := data.Repository.Discussions.Nodes[start:end]
343 | _pageInfo := &PageInfo{end < totalCount, fmt.Sprintf("%v", pageIndex+1), 0 < start, fmt.Sprintf("%v", pageIndex-1)}
344 | _data.Data = &DiscussionPage{end - start, nodes, _pageInfo}
345 | if err = archiveTemplate.Execute(stringWriter.Reset(), _data); err != nil {
346 | return err
347 | }
348 | htmlPages[fmt.Sprintf("archive/%v.gtpl", pageIndex)] = stringWriter.String()
349 | pageIndex++
350 | start = end
351 | }
352 |
353 | globalTemplate, err := readGlobalGtpl("global.gtpl")
354 | if err != nil {
355 | return err
356 | }
357 | globalTemplate.Execute(stringWriter.Reset(), &site)
358 | globalHTML := stringWriter.String()
359 |
360 | // 5. 全局渲染,比如调试模式
361 | bs, err := readGlobalFile("debug.tmpl.gtpl")
362 | for name, page := range htmlPages {
363 | // 6. 输出到目标文件夹
364 | pageHTML := page + "\n\n" + globalHTML
365 | if debug {
366 | if err = writer(name, []byte(pageHTML+"\n\n"+string(bs))); err != nil {
367 | return err
368 | }
369 | } else {
370 | if err = writer(name, []byte(pageHTML)); err != nil {
371 | return err
372 | }
373 | }
374 | }
375 |
376 | rssTemplate := themeTemplate.Lookup("rss.gtpl")
377 | _data.Data = data.Repository.Discussions
378 | if err = rssTemplate.Execute(stringWriter.Reset(), _data); err != nil {
379 | return err
380 | }
381 | if err = writer("rss.xml", stringWriter.Data); err != nil {
382 | return err
383 | }
384 |
385 | return nil
386 | }
387 |
388 | func copyNonRenderFiles(r FileReader, name string, writer WriterFunc) error {
389 | entities, err := r.ReadDir(name)
390 | if err != nil {
391 | return err
392 | }
393 | for _, entity := range entities {
394 | fname := filepath.Join(name, entity.Name())
395 | if entity.IsDir() {
396 | err = copyNonRenderFiles(r, fname, writer)
397 | if err != nil {
398 | return err
399 | }
400 | } else if !strings.HasSuffix(fname, ".gtpl") {
401 | bs, err := r.ReadFile(fname)
402 | if err != nil {
403 | return err
404 | }
405 | err = writer(fname, bs)
406 | if err != nil {
407 | return err
408 | }
409 | }
410 | }
411 | return nil
412 | }
413 |
414 | // Support syntax highlighting for Go Template files: *.go.txt, *.go.tpl, *.go.tmpl, *.gtpl.
415 | func readTemplates(rootTmpl *template.Template, r FileReader, name string) (*template.Template, error) {
416 | dirEntries, err := r.ReadDir(name)
417 | if err != nil {
418 | return nil, err
419 | }
420 | for _, entity := range dirEntries {
421 | fname := filepath.Join(name, entity.Name())
422 | if entity.IsDir() {
423 | if _, err = readTemplates(rootTmpl, r, fname); err != nil {
424 | return nil, err
425 | }
426 | } else if strings.HasSuffix(fname, ".gtpl") {
427 | bs, err := r.ReadFile(fname)
428 | if err != nil {
429 | return nil, err
430 | }
431 | // 可能会覆盖同名的模板
432 | _, err = rootTmpl.New(fname).Parse(string(bs))
433 | if err != nil {
434 | return nil, err
435 | }
436 | }
437 | }
438 | return rootTmpl, nil
439 | }
440 |
441 | // 获取分类下的文章列表
442 | func getDiscussionByCategory(category string, data *GithubData) []*Discussion {
443 | dis := make([]*Discussion, 0)
444 |
445 | discussions := data.Repository.Discussions.Nodes
446 | for i := range discussions {
447 | discussion := discussions[i]
448 | if discussion.Category.Name == category {
449 | dis = append(dis, discussion)
450 | }
451 | }
452 |
453 | return dis
454 | }
455 |
456 | // 获取标签下的文章列表
457 | func getDiscussionByLabel(label string, data *GithubData) []*Discussion {
458 | dis := make([]*Discussion, 0)
459 |
460 | discussions := data.Repository.Discussions.Nodes
461 | for i := range discussions {
462 | discussion := discussions[i]
463 | for j := range discussion.Labels.Nodes {
464 | if discussion.Labels.Nodes[j].Name == label {
465 | dis = append(dis, discussion)
466 | break
467 | }
468 | }
469 | }
470 |
471 | return dis
472 | }
473 |
474 | func deduplication(dis []*Discussion) []*Discussion {
475 | m := make(map[int]struct{})
476 | res := make([]*Discussion, 0)
477 | for _, d := range dis {
478 | if _, ok := m[d.Number]; !ok {
479 | m[d.Number] = struct{}{}
480 | res = append(res, d)
481 | }
482 | }
483 | return res
484 | }
485 |
--------------------------------------------------------------------------------