├── 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 |
3 |
4 | TOP 5 |
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 | 10 | {{end}} -------------------------------------------------------------------------------- /assets/theme/labelGroup.gtpl: -------------------------------------------------------------------------------- 1 | {{define "LabelGroupTemplate"}} 2 | {{ if . }} 3 | 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 | 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 | <![CDATA[{{ $descussoin.Title }}]]> 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 | 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 | 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 | 18 | 19 |
  • 20 | {{end}} -------------------------------------------------------------------------------- /assets/theme/header.gtpl: -------------------------------------------------------------------------------- 1 | {{define "HeaderTemplate"}} 2 |
    3 | 20 |
    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 |
    4 | 5 | 6 |
    7 |
    8 |
    9 | {{ .Author.ShowName }} 10 | 11 |
    12 | 回复 13 |
    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 |
    78 | 79 | {{ .Viewer.ShowName }} 80 | 发布在{{ .Data.Category.Name }} 81 | 于 83 | {{ if .Data.UpvoteCount }} 84 | 85 | {{ template "VoteSVGTemplate" 22 }}{{ .Data.UpvoteCount }} 86 | {{ end }} 87 |
    88 | 89 |
    90 |
    91 | {{ .Data.BodyHTML }} 92 |
    93 | 101 | 114 | {{ template "TopComponentTemplate" }} 115 |
    116 |
    117 | 118 |
    119 |
    120 | {{ if .Data.Comments }} 121 | 128 | {{ end }} 129 | 前往 GitHub Discussion 评论 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 | --------------------------------------------------------------------------------