├── .github ├── FUNDING.yml └── workflows │ ├── build.yaml │ ├── fmt.yaml │ └── release.yaml ├── .gitignore ├── .gitmodules ├── Cargo.toml ├── LICENSE ├── README.md ├── locales ├── en.ftl └── zh.ftl ├── src ├── cmd │ ├── mod.rs │ └── new.rs ├── code_blocks │ ├── author.rs │ ├── inline_link.rs │ └── mod.rs ├── data.rs ├── engine.rs ├── entity │ ├── article.rs │ ├── author.rs │ ├── issue.rs │ ├── list.rs │ ├── mod.rs │ ├── page.rs │ ├── site.rs │ ├── theme.rs │ ├── topic.rs │ └── zine.rs ├── error.rs ├── feed.rs ├── html.rs ├── i18n.rs ├── locales.rs ├── main.rs └── markdown.rs ├── static ├── edit.svg ├── i18n.svg ├── medium-zoom.min.js ├── zine-placeholder.svg ├── zine.css ├── zine.js └── zine.png ├── tailwind.config.js ├── tailwindcss.html ├── templates ├── _article_ref.jinja ├── _macros.jinja ├── _meta.jinja ├── article.jinja ├── author-list.jinja ├── author.jinja ├── base.jinja ├── feed.jinja ├── index.jinja ├── issue.jinja ├── page.jinja ├── sitemap.jinja ├── topic-list.jinja └── topic.jinja ├── zine-entry.css └── zine.svg /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Folyd] 4 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | ref: 'master' 16 | submodules: 'recursive' 17 | - name: Install Rust toolchain 18 | run: | 19 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 20 | - name: Build 21 | run: | 22 | export PATH="$HOME/.cargo/bin:$PATH" 23 | cargo build --release 24 | - name: Run clippy 25 | run: | 26 | export PATH="$HOME/.cargo/bin:$PATH" 27 | cargo clippy --release --no-deps 28 | - name: Run test 29 | run: | 30 | export PATH="$HOME/.cargo/bin:$PATH" 31 | cargo test --release 32 | - name: Publish test 33 | run: | 34 | export PATH="$HOME/.cargo/bin:$PATH" 35 | cargo publish --dry-run 36 | - name: Install zine and build docs 37 | run: | 38 | export PATH="$HOME/.cargo/bin:$PATH" 39 | cargo install --path . 40 | # Test build docs 41 | cd docs; zine build 42 | # Test new empty project then build 43 | zine new test; cd test; zine build 44 | -------------------------------------------------------------------------------- /.github/workflows/fmt.yaml: -------------------------------------------------------------------------------- 1 | name: Format check 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | cargo_fmt_check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Install Rust toolchain 15 | run: | 16 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y 17 | - name: Run format check 18 | run: | 19 | export PATH="$HOME/.cargo/bin:$PATH" 20 | cargo fmt -- --check -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.* 7 | 8 | env: 9 | CARGO_INCREMENTAL: 0 10 | CARGO_TERM_COLOR: always 11 | RUSTFLAGS: -D warnings 12 | RUST_BACKTRACE: 1 13 | 14 | defaults: 15 | run: 16 | shell: bash 17 | 18 | jobs: 19 | upload-assets: 20 | name: ${{ matrix.target }} 21 | strategy: 22 | matrix: 23 | include: 24 | - target: x86_64-unknown-linux-gnu 25 | tap: true 26 | - target: x86_64-apple-darwin 27 | os: macos-latest 28 | tap: true 29 | - target: x86_64-pc-windows-msvc 30 | os: windows-latest 31 | tap: false 32 | - target: x86_64-unknown-linux-musl 33 | tap: false 34 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 35 | steps: 36 | - uses: actions/checkout@v2 37 | - uses: dtolnay/rust-toolchain@stable 38 | - uses: taiki-e/upload-rust-binary-action@v1 39 | with: 40 | bin: zine 41 | target: ${{ matrix.target }} 42 | tar: all 43 | zip: windows 44 | features: openssl-vendored 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.TOKEN }} 47 | CARGO_PROFILE_RELEASE_LTO: true 48 | - name: Get the version 49 | id: get_version 50 | run: echo ::set-output name=VERSION::$(echo $GITHUB_REF | cut -d / -f 3) 51 | - name: Update zine homebrew formula 52 | if: ${{ matrix.tap == true }} 53 | run: | 54 | curl -X POST -H "Accept: application/vnd.github.v3+json" \ 55 | -H "Authorization: token ${{ secrets.TOKEN }}" \ 56 | -d '{"event_type":"version-updated","client_payload":{"version":"${{ steps.get_version.outputs.VERSION }}"}}' \ 57 | https://api.github.com/repos/zineland/homebrew-tap/dispatches 58 | cargo-publish: 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v2 62 | - uses: dtolnay/rust-toolchain@stable 63 | - run: cargo package 64 | - uses: taiki-e/create-gh-release-action@v1 65 | with: 66 | # changelog: CHANGELOG.md 67 | title: $version 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.TOKEN }} 70 | - run: cargo publish 71 | env: 72 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 6 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 7 | Cargo.lock 8 | 9 | # These are backup files generated by rustfmt 10 | **/*.rs.bk 11 | 12 | 13 | # Added by cargo 14 | 15 | /target 16 | 17 | build -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "docs"] 2 | path = docs 3 | url = git@github.com:zineland/zineland.github.io.git 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "zine" 3 | version = "0.16.0" 4 | description = "A simple and opinionated tool to build your own magazine." 5 | authors = ["Folyd"] 6 | homepage = "https://github.com/zineland/zine" 7 | repository = "https://github.com/zineland/zine" 8 | license = "Apache-2.0" 9 | edition = "2021" 10 | exclude = ["tailwind.config.js", "tailwindcss.html", "zine-entry.css"] 11 | readme = "README.md" 12 | 13 | [features] 14 | # Enable vendored openssl to help building in cross-rs environment. 15 | # See https://github.com/cross-rs/cross/pull/322 16 | openssl-vendored = ["genkit/openssl-vendored"] 17 | 18 | [dependencies] 19 | anyhow = "1.0" 20 | async-trait = "0.1.68" 21 | clap = { version = "4", features = ["cargo"] } 22 | fluent = "0.16" 23 | # genkit = { path = "../genkit" } 24 | genkit = "0.3.1" 25 | http = "0.2" 26 | include_dir = "0.7" 27 | intl-memoizer = "0.5" 28 | lol_html = "1.0" 29 | minijinja = { version = "1", features = ["loader"] } 30 | once_cell = "1" 31 | parking_lot = "0.12" 32 | promptly = "0.3" 33 | rayon = "1.6" 34 | serde = { version = "1", features = ["derive"] } 35 | serde_json = "1" 36 | thiserror = "1" 37 | time = { version = "0.3", features = ["serde"] } 38 | tokio = { version = "1.26", features = ["rt-multi-thread", "macros"] } 39 | toml = "0.7" 40 | walkdir = "2" 41 | 42 | [dev-dependencies] 43 | anyhow = { version = "1.0", features = ["backtrace"] } 44 | parking_lot = { version = "0.12", features = ["deadlock_detection"] } 45 | test-case = "2" 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # zine 6 | 7 | [![Crates.io](https://img.shields.io/crates/v/zine.svg)](https://crates.io/crates/zine) 8 | ![Crates.io](https://img.shields.io/crates/d/zine) 9 | [![license-apache](https://img.shields.io/badge/license-Apache-yellow.svg)](./LICENSE) 10 | [![dependency status](https://deps.rs/crate/zine/latest/status.svg)](https://deps.rs/crate/zine) 11 | 12 | Zine - a simple and opinionated tool to build your own magazine. 13 | 14 | https://zineland.github.io 15 | 16 | - Mobile-first. 17 | - Intuitive and elegant magazine design. 18 | - Best reading experiences. 19 | - Theme customizable, extend friendly. 20 | - RSS Feed supported. 21 | - Open Graph Protocol supported. 22 | - Article topic supported. 23 | - I18n and l10n supported. 24 | - Build into a static website, hosting anywhere. 25 | 26 | ## Installation 27 | 28 | `cargo install zine` 29 | 30 | or `brew install zineland/tap/zine` 31 | 32 | or `brew tap zineland/tap`, then `brew install zine` 33 | 34 | ## Get Started 35 | 36 | Run `zine new your-zine-site`, you'll get following directory: 37 | 38 | ``` 39 | $ tree your-zine-site 40 | your-zine-site 41 | ├── content # The content directory your issues located 42 | │ └── issue-1 # The first issue directory 43 | │ ├── 1-first.md # The first markdown article in this issue 44 | │ └── zine.toml # The issue Zine config file 45 | └── zine.toml # The root Zine config file of this project 46 | 47 | 2 directories, 3 files 48 | ``` 49 | 50 | Run `zine serve` to preview your zine site on your local computer: 51 | 52 | ``` 53 | $ cd your-zine-site 54 | 55 | $ zine serve 56 | 57 | ███████╗██╗███╗ ██╗███████╗ 58 | ╚══███╔╝██║████╗ ██║██╔════╝ 59 | ███╔╝ ██║██╔██╗ ██║█████╗ 60 | ███╔╝ ██║██║╚██╗██║██╔══╝ 61 | ███████╗██║██║ ╚████║███████╗ 62 | ╚══════╝╚═╝╚═╝ ╚═══╝╚══════╝ 63 | 64 | listening on http://127.0.0.1:3000 65 | ``` 66 | 67 | Run `zine build` to build your zine site into a static website: 68 | 69 | ``` 70 | $ cd your-zine-site 71 | 72 | $ zine build 73 | Build success! The build directory is `build`. 74 | ``` 75 | 76 | ## Some cool magazines powered by Zine 77 | 78 | - [https://zineland.github.io](https://zineland.github.io) The zine documentation is built by zine itself. 79 | - [https://rustmagazine.org](https://rustmagazine.org) The Rust Magazine. 80 | - [https://2d2d.io](https://2d2d.io) 81 | - [https://o11y.cn](https://o11y.cn) 82 | - [https://thewhitepaper.github.io](https://thewhitepaper.github.io) 83 | 84 | ## Docmentations 85 | 86 | - [Getting started](https://zineland.github.io/getting-started) 87 | - [Customization](https://zineland.github.io/customization) 88 | - [Code blocks](https://zineland.github.io/code-blocks) 89 | - [Advanced](https://zineland.github.io/advanced) 90 | 91 | ## TODO 92 | 93 | - [x] Support RSS Feed 94 | - [x] Support render OGP meta 95 | - [x] Support l10n 96 | - [x] Support sitemap.xml 97 | - [x] Support code syntax highlight 98 | - [x] Support table of content 99 | - [x] Support i18n 100 | - [x] `zine serve` support live reload 101 | - [x] Support article topic 102 | 103 | ## License 104 | 105 | This project is licensed under the [Apache-2.0 license](./LICENSE). 106 | -------------------------------------------------------------------------------- /locales/en.ftl: -------------------------------------------------------------------------------- 1 | 2 | view-more = View more 3 | 4 | article-number = No. { $number } 5 | 6 | previous = Previous: 7 | 8 | next = Next: 9 | 10 | author-article-title = Published { $number } article(s) 11 | 12 | author-list = Authors list 13 | 14 | author-team-list = Teams 15 | 16 | article-count = { $number } articles 17 | 18 | editor = Editor 19 | 20 | topic-article-title = { $number } article(s) 21 | 22 | topic-list = Topic list 23 | -------------------------------------------------------------------------------- /locales/zh.ftl: -------------------------------------------------------------------------------- 1 | 2 | view-more = 查看更多 3 | 4 | article-number = 第 { $number } 篇 5 | 6 | previous = 上一篇: 7 | 8 | next = 下一篇: 9 | 10 | author-article-title = 已发布 { $number } 篇文章 11 | 12 | author-list = 作者列表 13 | 14 | author-team-list = 团队账号 15 | 16 | article-count = { $number } 篇文章 17 | 18 | editor = 责任编辑 19 | 20 | topic-article-title = { $number } 篇文章 21 | 22 | topic-list = 话题列表 -------------------------------------------------------------------------------- /src/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod new; 2 | 3 | pub use new::NewCmd; 4 | -------------------------------------------------------------------------------- /src/cmd/new.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, env, fs, io::Write, path::PathBuf}; 2 | 3 | use anyhow::{Context as _, Ok, Result}; 4 | use clap::{Arg, ArgAction, Command}; 5 | use genkit::{helpers, Cmd}; 6 | use minijinja::render; 7 | use promptly::prompt_default; 8 | use time::OffsetDateTime; 9 | 10 | use crate::{entity::Zine, ZINE_FILE}; 11 | 12 | static TEMPLATE_PROJECT_FILE: &str = r#" 13 | [site] 14 | url = "http://localhost" 15 | name = "{{ name }}" 16 | description = "" 17 | 18 | [authors] 19 | {% if author -%} 20 | {{ author | lower }} = { name = "{{ author }}" } 21 | {% endif -%} 22 | "#; 23 | 24 | static TEMPLATE_ISSUE_FILE: &str = r#" 25 | slug = "{{ slug }}" 26 | number = {{ number }} 27 | title = "{{ title }}" 28 | 29 | [[article]] 30 | file = "1-first.md" 31 | title = "First article" 32 | author = "{{ author | lower }}" 33 | cover = "" 34 | pub_date = "{{ pub_date }}" 35 | publish = true 36 | featured = true 37 | "#; 38 | 39 | static TEMPLATE_ARTICLE: &str = r#" 40 | 41 | [[article]] 42 | file = "{{ file }}" 43 | title = "{{ title }}" 44 | author = "{{ author | lower }}" 45 | cover = "" 46 | pub_date = "{{ pub_date }}" 47 | publish = true 48 | featured = true 49 | "#; 50 | 51 | pub struct NewCmd; 52 | 53 | #[async_trait::async_trait] 54 | impl Cmd for NewCmd { 55 | fn on_init(&self) -> clap::Command { 56 | Command::new("new") 57 | .args([ 58 | Arg::new("name").help("Name of the project").required(false), 59 | Arg::new("issue") 60 | .long("issue") 61 | .short('i') 62 | .action(ArgAction::SetTrue) 63 | .help("New issue."), 64 | Arg::new("article") 65 | .long("article") 66 | .short('a') 67 | .action(ArgAction::SetTrue) 68 | .conflicts_with("issue") 69 | .help("New article."), 70 | ]) 71 | .about("New a Zine project, issue or article") 72 | } 73 | 74 | async fn on_execute(&self, arg_matches: &clap::ArgMatches) -> anyhow::Result<()> { 75 | let issue = arg_matches.get_flag("issue"); 76 | let article = arg_matches.get_flag("article"); 77 | if issue { 78 | new_zine_issue()?; 79 | } else if article { 80 | new_article()?; 81 | } else { 82 | new_zine_project(arg_matches.get_one("name").cloned())? 83 | } 84 | 85 | Ok(()) 86 | } 87 | } 88 | 89 | struct ZineScaffold { 90 | source: PathBuf, 91 | author: String, 92 | issue_dir: Cow<'static, str>, 93 | issue_number: usize, 94 | issue_title: Cow<'static, str>, 95 | } 96 | 97 | impl ZineScaffold { 98 | fn create_project(&self, name: &str) -> Result<()> { 99 | // Generate project zine.toml 100 | fs::write( 101 | self.source.join(ZINE_FILE), 102 | render!(TEMPLATE_PROJECT_FILE, name, author => self.author), 103 | )?; 104 | 105 | // Create issue dir and issue zine.toml 106 | self.create_issue()?; 107 | Ok(()) 108 | } 109 | 110 | // Create issue dir and issue zine.toml 111 | fn create_issue(&self) -> Result<()> { 112 | let issue_dir = self 113 | .source 114 | .join(crate::ZINE_CONTENT_DIR) 115 | .join(self.issue_dir.as_ref()); 116 | fs::create_dir_all(&issue_dir)?; 117 | 118 | fs::write( 119 | issue_dir.join(ZINE_FILE), 120 | render!( 121 | TEMPLATE_ISSUE_FILE, 122 | slug => self.issue_dir, 123 | number => self.issue_number, 124 | title => self.issue_title, 125 | pub_date => helpers::format_date(&OffsetDateTime::now_utc().date()), 126 | author => self.author 127 | ), 128 | )?; 129 | 130 | // Create first article 131 | fs::write(issue_dir.join("1-first.md"), "Hello Zine")?; 132 | Ok(()) 133 | } 134 | } 135 | 136 | pub fn new_zine_project(name: Option) -> Result<()> { 137 | let source = if let Some(name) = name.as_ref() { 138 | env::current_dir()?.join(name) 139 | } else { 140 | env::current_dir()? 141 | }; 142 | if !source.exists() { 143 | fs::create_dir_all(&source)?; 144 | } 145 | 146 | let author = git_user_name(); 147 | let scaffold = ZineScaffold { 148 | source, 149 | author, 150 | issue_dir: "issue-1".into(), 151 | issue_number: 1, 152 | issue_title: "Issue 1".into(), 153 | }; 154 | 155 | scaffold.create_project(&name.unwrap_or_default())?; 156 | println!( 157 | r#" 158 | Created sucessfully! 159 | 160 | To start your magazine, run: 161 | $ zine serve --open 162 | 163 | Or to build your magazine, run: 164 | $ zine build 165 | "# 166 | ); 167 | Ok(()) 168 | } 169 | 170 | fn load_zine_project() -> Result<(PathBuf, Zine)> { 171 | // Use zine.toml to find root path 172 | let (source, mut zine) = crate::locate_root_zine_folder(env::current_dir()?)? 173 | .with_context(|| "Failed to find the root zine.toml file".to_string())?; 174 | zine.parse_issue_from_dir(&source)?; 175 | Ok((source, zine)) 176 | } 177 | 178 | pub fn new_zine_issue() -> Result<()> { 179 | let (source, zine) = load_zine_project()?; 180 | let next_issue_number = zine.issues.len() + 1; 181 | let issue_dir = prompt_default( 182 | "What is your issue directory name?", 183 | format!("issue-{next_issue_number}"), 184 | )?; 185 | let issue_number = prompt_default("What is your issue number?", next_issue_number)?; 186 | let issue_title = prompt_default( 187 | "What is your issue title?", 188 | format!("Issue {next_issue_number}"), 189 | )?; 190 | 191 | let author = git_user_name(); 192 | let scaffold = ZineScaffold { 193 | source, 194 | author, 195 | issue_dir: issue_dir.into(), 196 | issue_number, 197 | issue_title: issue_title.into(), 198 | }; 199 | scaffold.create_issue()?; 200 | Ok(()) 201 | } 202 | 203 | pub fn new_article() -> Result<()> { 204 | let (source, zine) = load_zine_project()?; 205 | let latest_issue_number = zine.issues.len(); 206 | let issue_number = prompt_default( 207 | "Which Issue do you want create a new article?", 208 | latest_issue_number, 209 | )?; 210 | if let Some(issue) = zine.get_issue_by_number(issue_number as u32) { 211 | let article_file = prompt_default( 212 | "What is your article file name?", 213 | "new-article.md".to_owned(), 214 | )?; 215 | let title = prompt_default("What is your article title?", "New Article".to_owned())?; 216 | let author = git_user_name(); 217 | 218 | let issue_dir = source.join(crate::ZINE_CONTENT_DIR).join(&issue.dir); 219 | // Write article file 220 | fs::write(issue_dir.join(&article_file), "Hello Zine")?; 221 | 222 | // Append article to issue zine.toml 223 | let article_content = render!( 224 | TEMPLATE_ARTICLE, 225 | title, 226 | author, 227 | file => article_file, 228 | pub_date => helpers::format_date(&OffsetDateTime::now_utc().date()), 229 | ); 230 | let mut issue_file = fs::OpenOptions::new() 231 | .append(true) 232 | .open(issue_dir.join(ZINE_FILE))?; 233 | issue_file.write_all(article_content.as_bytes())?; 234 | } else { 235 | println!("Issue {} not found", issue_number); 236 | } 237 | 238 | Ok(()) 239 | } 240 | 241 | fn git_user_name() -> String { 242 | helpers::run_command("git", &["config", "user.name"]) 243 | .ok() 244 | .unwrap_or_default() 245 | .replace(' ', "_") 246 | } 247 | -------------------------------------------------------------------------------- /src/code_blocks/author.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | 3 | use genkit::CodeBlock; 4 | 5 | use crate::entity::Author; 6 | 7 | /// The author code is designed to render the avatar-name link on the markdown page. 8 | /// 9 | /// The syntax is very simple, just write like this `@author_id`. 10 | /// If the `author_id` is declared in the `[authors]` table of the root `zine.toml`, 11 | /// it will render the UI as expected, otherwise it fallback into the raw code UI. 12 | pub struct AuthorCode<'a>(pub &'a Author); 13 | 14 | impl<'a> CodeBlock for AuthorCode<'a> { 15 | fn render(&self) -> anyhow::Result { 16 | let mut html = String::new(); 17 | 18 | let author = self.0; 19 | writeln!( 20 | &mut html, 21 | r#""#, 22 | author.id, 23 | )?; 24 | if let Some(avatar) = author.avatar.as_ref() { 25 | writeln!( 26 | &mut html, 27 | r#"avatar"#, 28 | avatar, 29 | )?; 30 | } 31 | writeln!( 32 | &mut html, 33 | r#"{}"#, 34 | author.name.as_ref().unwrap() 35 | )?; 36 | writeln!(&mut html, r#""#)?; 37 | Ok(html) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/code_blocks/inline_link.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Write; 2 | 3 | use anyhow::Ok; 4 | 5 | use super::CodeBlock; 6 | 7 | pub struct InlineLink<'a> { 8 | title: &'a str, 9 | url: &'a str, 10 | image: Option<&'a String>, 11 | } 12 | 13 | impl<'a> InlineLink<'a> { 14 | pub fn new(title: &'a str, url: &'a str, image: Option<&'a String>) -> Self { 15 | Self { title, url, image } 16 | } 17 | } 18 | 19 | impl<'a> CodeBlock for InlineLink<'a> { 20 | fn render(&self) -> anyhow::Result { 21 | let mut html = String::new(); 22 | writeln!( 23 | &mut html, 24 | r###" 29 | {title} 30 | "###, 31 | url = self.url, 32 | title = self.title, 33 | image = self.image.unwrap_or(&String::new()) 34 | )?; 35 | Ok(html) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/code_blocks/mod.rs: -------------------------------------------------------------------------------- 1 | mod author; 2 | mod inline_link; 3 | 4 | use genkit::CodeBlock; 5 | 6 | pub use author::AuthorCode; 7 | pub use inline_link::InlineLink; 8 | -------------------------------------------------------------------------------- /src/data.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::OnceCell; 2 | use parking_lot::{RwLock, RwLockReadGuard, RwLockWriteGuard}; 3 | 4 | use crate::entity::{Author, MetaArticle, Site, Theme}; 5 | 6 | static ZINE_DATA: OnceCell> = OnceCell::new(); 7 | 8 | pub fn load() { 9 | ZINE_DATA.get_or_init(|| RwLock::new(ZineData::default())); 10 | } 11 | 12 | pub fn read() -> RwLockReadGuard<'static, ZineData> { 13 | ZINE_DATA.get().unwrap().read() 14 | } 15 | 16 | pub fn write() -> RwLockWriteGuard<'static, ZineData> { 17 | ZINE_DATA.get().unwrap().write() 18 | } 19 | 20 | #[derive(Debug, Default)] 21 | pub struct ZineData { 22 | authors: Vec, 23 | // Issue slug and article pair list. 24 | articles: Vec<(String, MetaArticle)>, 25 | // The topic name list. 26 | topics: Vec, 27 | site: Site, 28 | theme: Theme, 29 | } 30 | 31 | impl ZineData { 32 | pub fn set_authors(&mut self, authors: Vec) -> &mut Self { 33 | self.authors = authors; 34 | self 35 | } 36 | 37 | pub fn set_topics(&mut self, topics: Vec) -> &mut Self { 38 | self.topics = topics; 39 | self 40 | } 41 | 42 | pub fn set_articles(&mut self, articles: Vec<(String, MetaArticle)>) -> &mut Self { 43 | self.articles = articles; 44 | self 45 | } 46 | 47 | pub fn set_site(&mut self, site: Site) -> &mut Self { 48 | self.site = site; 49 | self 50 | } 51 | 52 | pub fn set_theme(&mut self, theme: Theme) -> &mut Self { 53 | self.theme = theme; 54 | self 55 | } 56 | 57 | pub fn get_authors(&self) -> Vec<&Author> { 58 | self.authors.iter().by_ref().collect() 59 | } 60 | 61 | pub fn get_author_by_id(&self, author_id: &str) -> Option<&Author> { 62 | self.authors 63 | .iter() 64 | .find(|author| author.id.eq_ignore_ascii_case(author_id)) 65 | } 66 | 67 | pub fn get_article_by_path(&self, article_path: &str) -> Option { 68 | self.articles 69 | .iter() 70 | .find_map(|(issue_slug, article)| { 71 | if article.path.as_deref() == Some(article_path) 72 | || format!("/{}/{}", issue_slug, article.slug) == article_path 73 | { 74 | Some(article) 75 | } else { 76 | None 77 | } 78 | }) 79 | .cloned() 80 | } 81 | 82 | pub fn get_site(&self) -> &Site { 83 | &self.site 84 | } 85 | 86 | pub fn get_theme(&self) -> &Theme { 87 | &self.theme 88 | } 89 | 90 | pub fn is_valid_topic(&self, topic: &str) -> bool { 91 | self.topics.iter().any(|t| t.eq_ignore_ascii_case(topic)) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/engine.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, env, fs, path::Path}; 2 | 3 | use crate::{data, html::rewrite_html_base_url, locales::FluentLoader, Zine}; 4 | use genkit::{current_mode, helpers::copy_dir, Context, Entity, Generator, Mode}; 5 | 6 | use anyhow::{Context as _, Result}; 7 | use http::Uri; 8 | use minijinja::{context, value::Value as JinjaValue, Environment, Error as JinjaError, ErrorKind}; 9 | use once_cell::sync::OnceCell; 10 | use parking_lot::RwLock; 11 | use serde::Serialize; 12 | use serde_json::Value; 13 | 14 | pub fn render( 15 | env: &Environment, 16 | template: &str, 17 | context: Context, 18 | dest: impl AsRef, 19 | ) -> Result<()> { 20 | let mut buf = vec![]; 21 | let dest = dest.as_ref().join("index.html"); 22 | if let Some(parent_dir) = dest.parent() { 23 | if !parent_dir.exists() { 24 | fs::create_dir_all(parent_dir)?; 25 | } 26 | } 27 | 28 | let site = context.get("site").cloned(); 29 | env.get_template(template)? 30 | .render_to_write(context.into_json(), &mut buf)?; 31 | 32 | // Rewrite some site url and cdn links if and only if: 33 | // 1. in build run mode 34 | // 2. site url has a path 35 | if matches!(current_mode(), Mode::Build) { 36 | let mut site_url: Option<&str> = None; 37 | let mut cdn_url: Option<&str> = None; 38 | 39 | if let Some(Value::String(url)) = site.as_ref().and_then(|site| site.get("cdn")) { 40 | let _ = url.parse::().expect("Invalid cdn url."); 41 | cdn_url = Some(url); 42 | } 43 | if let Some(Value::String(url)) = site.as_ref().and_then(|site| site.get("url")) { 44 | let uri = url.parse::().expect("Invalid site url."); 45 | // We don't need to rewrite links if the site url has a root path. 46 | if uri.path() != "/" { 47 | site_url = Some(url); 48 | } 49 | } 50 | 51 | let html = rewrite_html_base_url(&buf, site_url, cdn_url)?; 52 | fs::write(dest, html)?; 53 | return Ok(()); 54 | } 55 | 56 | fs::write(dest, buf)?; 57 | Ok(()) 58 | } 59 | 60 | // Render Atom feed 61 | fn render_atom_feed( 62 | env: &Environment, 63 | context: impl Serialize, 64 | dest: impl AsRef, 65 | ) -> Result<()> { 66 | let dest = dest.as_ref().join("feed.xml"); 67 | let template = env.get_template("feed.jinja")?; 68 | 69 | let mut buf = vec![]; 70 | 71 | template 72 | .render_to_write(context, &mut buf) 73 | .expect("Render feed.jinja failed."); 74 | fs::write(dest, buf).expect("Write feed.xml failed"); 75 | Ok(()) 76 | } 77 | 78 | // Render sitemap.xml 79 | fn render_sitemap( 80 | env: &Environment, 81 | context: impl Serialize, 82 | dest: impl AsRef, 83 | ) -> Result<()> { 84 | let dest = dest.as_ref().join("sitemap.xml"); 85 | let template = env.get_template("sitemap.jinja")?; 86 | let mut buf = vec![]; 87 | template 88 | .render_to_write(context, &mut buf) 89 | .expect("Render sitemap.jinja failed."); 90 | fs::write(dest, buf).expect("Write sitemap.xml failed"); 91 | Ok(()) 92 | } 93 | 94 | pub struct ZineGenerator; 95 | 96 | impl Generator for ZineGenerator { 97 | type Entity = Zine; 98 | 99 | fn on_load(&self, source: &std::path::Path) -> Result { 100 | data::load(); 101 | let (_source, zine) = crate::locate_root_zine_folder(std::fs::canonicalize(source)?)? 102 | .with_context(|| "Failed to find the root zine.toml file".to_string())?; 103 | Ok(zine) 104 | } 105 | 106 | fn on_reload(&self, source: &std::path::Path) -> Result { 107 | Zine::parse_from_toml(source) 108 | } 109 | 110 | fn get_markdown_config(&self, zine: &Self::Entity) -> Option { 111 | Some(zine.markdown_config.clone()) 112 | } 113 | 114 | fn on_extend_environment<'a>( 115 | &self, 116 | source: &std::path::Path, 117 | mut env: minijinja::Environment<'a>, 118 | zine: &'a Self::Entity, 119 | ) -> minijinja::Environment<'a> { 120 | #[cfg(debug_assertions)] 121 | env.set_loader(minijinja::path_loader("templates")); 122 | 123 | env.add_global("site", JinjaValue::from_serializable(&zine.site)); 124 | env.add_global("theme", JinjaValue::from_serializable(&zine.theme)); 125 | env.add_global( 126 | "zine_version", 127 | option_env!("CARGO_PKG_VERSION").unwrap_or("(Unknown Cargo package version)"), 128 | ); 129 | env.add_global( 130 | "live_reload", 131 | matches!(genkit::current_mode(), genkit::Mode::Serve), 132 | ); 133 | 134 | #[cfg(not(debug_assertions))] 135 | { 136 | let templates = [ 137 | ( 138 | "_article_ref.jinja", 139 | include_str!("../templates/_article_ref.jinja"), 140 | ), 141 | ("_macros.jinja", include_str!("../templates/_macros.jinja")), 142 | ("_meta.jinja", include_str!("../templates/_meta.jinja")), 143 | ("base.jinja", include_str!("../templates/base.jinja")), 144 | ("index.jinja", include_str!("../templates/index.jinja")), 145 | ("issue.jinja", include_str!("../templates/issue.jinja")), 146 | ("article.jinja", include_str!("../templates/article.jinja")), 147 | ("author.jinja", include_str!("../templates/author.jinja")), 148 | ( 149 | "author-list.jinja", 150 | include_str!("../templates/author-list.jinja"), 151 | ), 152 | ("topic.jinja", include_str!("../templates/topic.jinja")), 153 | ( 154 | "topic-list.jinja", 155 | include_str!("../templates/topic-list.jinja"), 156 | ), 157 | ("page.jinja", include_str!("../templates/page.jinja")), 158 | ("feed.jinja", include_str!("../templates/feed.jinja")), 159 | ("sitemap.jinja", include_str!("../templates/sitemap.jinja")), 160 | ]; 161 | for (name, template) in templates { 162 | env.add_template(name, template).unwrap(); 163 | } 164 | } 165 | 166 | // Dynamically add templates. 167 | if let Some(head_template) = &zine.theme.head_template { 168 | env.add_template("head_template.jinja", head_template) 169 | .expect("Cannot add head_template"); 170 | } 171 | if let Some(footer_template) = &zine.theme.footer_template { 172 | env.add_template("footer_template.jinja", footer_template) 173 | .expect("Cannot add footer_template"); 174 | } 175 | if let Some(article_extend_template) = &zine.theme.article_extend_template { 176 | env.add_template("article_extend_template.jinja", article_extend_template) 177 | .expect("Cannot add article_extend_template"); 178 | } 179 | 180 | env.add_function("load_json", load_json); 181 | env.add_function("get_entity", get_entity); 182 | env.add_function("get_author", get_author_function); 183 | let fluent_loader = FluentLoader::new(source, &zine.site.locale); 184 | env.add_function("fluent", move |key: &str, number: Option| -> String { 185 | fluent_loader.format(key, number) 186 | }); 187 | env 188 | } 189 | 190 | fn on_render( 191 | &self, 192 | env: &Environment, 193 | context: Context, 194 | zine: &Self::Entity, 195 | source: &Path, 196 | dest: &Path, 197 | ) -> Result<()> { 198 | zine.render(env, context, dest)?; 199 | render_atom_feed( 200 | env, 201 | context! { 202 | site => &zine.site, 203 | entries => &zine.latest_feed_entries(20), 204 | generator_version => env!("CARGO_PKG_VERSION"), 205 | }, 206 | dest, 207 | )?; 208 | render_sitemap( 209 | env, 210 | context! { 211 | site => &zine.site, 212 | entries => &zine.sitemap_entries(), 213 | }, 214 | dest, 215 | )?; 216 | 217 | copy_static_assets(source, dest)?; 218 | Ok(()) 219 | } 220 | } 221 | 222 | fn get_author_function(id: &str) -> JinjaValue { 223 | let data = data::read(); 224 | let author = data.get_author_by_id(id); 225 | JinjaValue::from_serializable(&author) 226 | } 227 | 228 | fn get_entity(name: &str) -> Result { 229 | match name { 230 | "authors" => { 231 | let data = data::read(); 232 | Ok(JinjaValue::from_serializable(&data.get_authors())) 233 | } 234 | _ => Err(JinjaError::new( 235 | ErrorKind::NonKey, 236 | format!("invalid entity name `{name}` for `get_entity` function."), 237 | )), 238 | } 239 | } 240 | 241 | static DATA_JSON: OnceCell>> = OnceCell::new(); 242 | 243 | fn load_json(filename: &str) -> Result { 244 | let data = DATA_JSON.get_or_init(|| RwLock::new(HashMap::new())); 245 | if let Some(value) = { data.read().get(filename).cloned() } { 246 | return Ok(value); 247 | } 248 | 249 | let mut path = env::current_dir().unwrap(); 250 | for segment in filename.split('/') { 251 | if segment.starts_with('.') || segment.contains('\\') { 252 | return Err(JinjaError::new(ErrorKind::InvalidOperation, "bad filename")); 253 | } 254 | path.push(segment); 255 | } 256 | println!("Loading json data from {}", path.display()); 257 | 258 | let contents = fs::read(&path).map_err(|err| { 259 | JinjaError::new(ErrorKind::InvalidOperation, "could not read JSON file").with_source(err) 260 | })?; 261 | let parsed: serde_json::Value = serde_json::from_slice(&contents[..]).map_err(|err| { 262 | JinjaError::new(ErrorKind::InvalidOperation, "invalid JSON").with_source(err) 263 | })?; 264 | let value = JinjaValue::from_serializable(&parsed); 265 | data.write().insert(filename.to_owned(), value.clone()); 266 | Ok(value) 267 | } 268 | 269 | fn copy_static_assets(source: &Path, dest: &Path) -> Result<()> { 270 | let static_dir = source.join("static"); 271 | if static_dir.exists() { 272 | copy_dir(&static_dir, dest)?; 273 | } 274 | 275 | // Copy builtin static files into dest static dir. 276 | let dest_static_dir = dest.join("static"); 277 | #[allow(clippy::needless_borrow)] 278 | fs::create_dir_all(&dest_static_dir)?; 279 | 280 | #[cfg(not(debug_assertions))] 281 | include_dir::include_dir!("static").extract(dest_static_dir)?; 282 | // Alwasy copy static directory in debug mode. 283 | #[cfg(debug_assertions)] 284 | copy_dir(Path::new("static"), dest)?; 285 | 286 | Ok(()) 287 | } 288 | -------------------------------------------------------------------------------- /src/entity/article.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, collections::HashMap, fs, path::Path}; 2 | 3 | use anyhow::{ensure, Context as _, Result}; 4 | use genkit::{current_mode, Mode}; 5 | use genkit::{html::Meta, markdown, Context}; 6 | use minijinja::Environment; 7 | use rayon::prelude::{ParallelBridge, ParallelIterator}; 8 | use serde::{Deserialize, Serialize}; 9 | use time::Date; 10 | 11 | use crate::{data, engine, i18n}; 12 | 13 | use super::{AuthorId, Entity}; 14 | 15 | /// The Meta info of Article. 16 | #[derive(Debug, Clone, Serialize, Deserialize)] 17 | pub struct MetaArticle { 18 | pub file: String, 19 | /// The slug after this artcile rendered. 20 | /// Fallback to file name if no slug specified. 21 | #[serde(default)] 22 | pub slug: String, 23 | /// Absolute path of this article. 24 | /// The field take precedence over `slug` field. 25 | pub path: Option, 26 | pub title: String, 27 | /// The author id of this article. 28 | /// An article can has zero, one or multiple authors. 29 | pub author: Option, 30 | pub cover: Option, 31 | /// The publish date. Format like YYYY-MM-DD. 32 | #[serde(with = "genkit::helpers::serde_date")] 33 | #[serde(default = "MetaArticle::default_pub_date")] 34 | pub pub_date: Date, 35 | } 36 | 37 | #[derive(Clone, Serialize, Deserialize)] 38 | pub struct Article { 39 | #[serde(flatten)] 40 | pub meta: MetaArticle, 41 | /// The article's markdown content. 42 | #[serde(default, skip_serializing)] 43 | pub markdown: String, 44 | /// The optional topics of this article. 45 | #[serde(default)] 46 | #[serde(rename(deserialize = "topic"))] 47 | pub topics: Vec, 48 | /// Whether the article is an featured article. 49 | /// Featured article will display in home page. 50 | #[serde(default, skip_serializing)] 51 | pub featured: bool, 52 | /// Whether publish the article. Publish means generate the article HTML file. 53 | /// This field would be ignored if in `zine serve` mode, that's mean we alwasy 54 | /// generate HTML file in this mode. 55 | #[serde(default)] 56 | publish: bool, 57 | /// The canonical link of this article. 58 | /// See issue: https://github.com/zineland/zine/issues/141 59 | canonical: Option, 60 | #[serde(default, skip_serializing)] 61 | pub i18n: HashMap, 62 | } 63 | 64 | /// The translation info of an article. 65 | #[derive(Serialize)] 66 | struct Translations<'a> { 67 | // The locale name. 68 | name: &'static str, 69 | // Article slug. 70 | slug: &'a String, 71 | // Article path. 72 | path: &'a Option, 73 | } 74 | 75 | impl MetaArticle { 76 | pub(super) fn has_empty_cover(&self) -> bool { 77 | self.cover.is_none() || matches!(self.cover.as_ref(), Some(cover) if cover.is_empty()) 78 | } 79 | 80 | fn default_pub_date() -> Date { 81 | Date::MIN 82 | } 83 | 84 | fn is_default_pub_date(&self) -> bool { 85 | self.pub_date == Date::MIN 86 | } 87 | } 88 | 89 | impl std::fmt::Debug for Article { 90 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 91 | f.debug_struct("Article") 92 | .field("meta", &self.meta) 93 | .field("i18n", &self.i18n) 94 | .field("publish", &self.publish) 95 | .finish() 96 | } 97 | } 98 | 99 | impl Article { 100 | /// Check whether `author` name is the author of this article. 101 | pub fn is_author(&self, author: &str) -> bool { 102 | self.meta 103 | .author 104 | .as_ref() 105 | .map(|inner| inner.is_author(author)) 106 | .unwrap_or_default() 107 | } 108 | 109 | /// Check whether the article need publish. 110 | /// 111 | /// The article need publish in any of two conditions: 112 | /// - the publish property is true 113 | /// - in `zine serve` mode 114 | pub fn need_publish(&self) -> bool { 115 | self.publish || matches!(current_mode(), Mode::Serve) 116 | } 117 | 118 | fn get_translations(&self) -> Vec> { 119 | let mut translations = self 120 | .i18n 121 | .iter() 122 | .map(|(locale, article)| Translations { 123 | name: i18n::get_locale_name(locale) 124 | .unwrap_or_else(|| panic!("Currently, we don't support locale: `{locale}`")), 125 | slug: &article.meta.slug, 126 | path: &article.meta.path, 127 | }) 128 | .collect::>(); 129 | 130 | if !translations.is_empty() { 131 | let zine_data = data::read(); 132 | let site = zine_data.get_site(); 133 | // Add default locale. 134 | translations.push(Translations { 135 | name: i18n::get_locale_name(&site.locale).unwrap_or_else(|| { 136 | panic!("Currently, we don't support locale: `{}`", site.locale) 137 | }), 138 | slug: &self.meta.slug, 139 | path: &self.meta.path, 140 | }); 141 | translations.sort_by_key(|t| t.name); 142 | } 143 | translations 144 | } 145 | 146 | fn parse(&mut self, source: &Path) -> Result<()> { 147 | let file_path = source.join(&self.meta.file); 148 | self.markdown = fs::read_to_string(&file_path).with_context(|| { 149 | format!("Failed to read markdown file of `{}`", file_path.display()) 150 | })?; 151 | 152 | // Fallback to file name if no slug specified. 153 | if self.meta.path.is_none() && self.meta.slug.is_empty() { 154 | self.meta.slug = self.meta.file.replace(".md", "") 155 | } 156 | // Fallback to the default placeholder image if the cover is missing. 157 | if self.meta.has_empty_cover() { 158 | let data = data::read(); 159 | self.meta.cover = data.get_theme().default_cover.clone(); 160 | } 161 | // Ensure the path starts with / if exists. 162 | if matches!(self.meta.path.as_ref(), Some(path) if !path.starts_with('/')) { 163 | self.meta.path = Some(format!("/{}", self.meta.path.take().unwrap_or_default())); 164 | } 165 | Ok(()) 166 | } 167 | 168 | fn render(&self, env: &Environment, mut context: Context, dest: &Path) -> Result<()> { 169 | context.insert( 170 | "meta", 171 | &Meta { 172 | title: Cow::Borrowed(&self.meta.title), 173 | description: Cow::Owned(markdown::extract_description(&self.markdown)), 174 | url: Some( 175 | if let Some(path) = self 176 | .meta 177 | .path 178 | .as_ref() 179 | // Remove the prefix slash 180 | .and_then(|path| path.strip_prefix('/')) 181 | { 182 | Cow::Borrowed(path) 183 | } else { 184 | let issue_slug = context 185 | .get("issue") 186 | .and_then(|issue| issue.get("slug")) 187 | .and_then(|v| v.as_str()) 188 | .unwrap_or_default(); 189 | Cow::Owned(format!("{}/{}", issue_slug, self.meta.slug)) 190 | }, 191 | ), 192 | image: self.meta.cover.as_deref().map(Cow::Borrowed), 193 | }, 194 | ); 195 | context.insert("page_type", "article"); 196 | context.insert("article", &self); 197 | context.insert("canonical_url", &self.canonical); 198 | 199 | let (html, toc) = markdown::render_html_with_toc(&self.markdown); 200 | context.insert("html", &html); 201 | context.insert("toc", &toc); 202 | 203 | if let Some(path) = self.meta.path.as_ref() { 204 | let mut dest = dest.to_path_buf(); 205 | dest.pop(); 206 | engine::render( 207 | env, 208 | "article.jinja", 209 | context, 210 | dest.join(path.trim_start_matches('/')), 211 | ) 212 | } else { 213 | engine::render(env, "article.jinja", context, dest.join(&self.meta.slug)) 214 | } 215 | } 216 | } 217 | 218 | impl Entity for Article { 219 | fn parse(&mut self, source: &Path) -> Result<()> { 220 | Article::parse(self, source)?; 221 | ensure!( 222 | !self.meta.is_default_pub_date(), 223 | "`pub_date` is required for article `{}`", 224 | self.meta.title 225 | ); 226 | { 227 | let zine_data = data::read(); 228 | self.topics.iter().for_each(|topic| { 229 | if !zine_data.is_valid_topic(topic) { 230 | println!( 231 | "Warning: the topic `{}` is invalid, please declare it in the root `zine.toml`", 232 | topic 233 | ) 234 | } 235 | }); 236 | } 237 | 238 | for article in self.i18n.values_mut() { 239 | // Extend topics from the origin article 240 | article.topics = self.topics.clone(); 241 | if article.meta.author.is_none() { 242 | article.meta.author = self.meta.author.clone(); 243 | } 244 | if article.meta.has_empty_cover() { 245 | article.meta.cover = self.meta.cover.clone(); 246 | } 247 | // Fallback to original article date if the `pub_date` is missing 248 | if article.meta.is_default_pub_date() { 249 | article.meta.pub_date = self.meta.pub_date; 250 | } 251 | Article::parse(article, source)?; 252 | } 253 | Ok(()) 254 | } 255 | 256 | fn render(&self, env: &Environment, mut context: Context, dest: &Path) -> Result<()> { 257 | context.insert("i18n", &self.get_translations()); 258 | Article::render(self, env, context.clone(), dest)?; 259 | 260 | self.i18n.values().par_bridge().for_each(|article| { 261 | Article::render(article, env, context.clone(), dest).expect("Failed to render article"); 262 | }); 263 | 264 | Ok(()) 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/entity/author.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, path::Path}; 2 | 3 | use anyhow::Result; 4 | use genkit::{html::Meta, markdown, Context, Entity}; 5 | use minijinja::Environment; 6 | use serde::{de, ser::SerializeSeq, Deserialize, Serialize}; 7 | 8 | use crate::engine; 9 | 10 | /// AuthorId represents a single author or multiple co-authors. 11 | /// Declared in `[[article]]` table. 12 | #[derive(Debug, Clone)] 13 | pub enum AuthorId { 14 | // Single author. 15 | One(String), 16 | // Co-authors. 17 | List(Vec), 18 | } 19 | 20 | /// The author of an article. Declared in the root `zine.toml`'s **[authors]** table. 21 | #[derive(Debug, Clone, Serialize, Deserialize)] 22 | pub struct Author { 23 | /// The author id, which is the key declared in `[authors]` table. 24 | #[serde(skip_deserializing, default)] 25 | pub id: String, 26 | /// The author's name. Will fallback to capitalized id if missing. 27 | pub name: Option, 28 | /// The optional avatar url. Will fallback to default zine logo if missing. 29 | pub avatar: Option, 30 | /// The bio of author (markdown format). 31 | pub bio: Option, 32 | /// Whether the author is an editor. 33 | #[serde(default)] 34 | pub editor: bool, 35 | #[serde(default)] 36 | /// Whether the author is a team account. 37 | pub team: bool, 38 | } 39 | 40 | impl AuthorId { 41 | pub fn is_author(&self, id: &str) -> bool { 42 | match self { 43 | Self::One(author_id) => author_id.eq_ignore_ascii_case(id), 44 | Self::List(authors) => authors 45 | .iter() 46 | .any(|author_id| author_id.eq_ignore_ascii_case(id)), 47 | } 48 | } 49 | } 50 | 51 | impl Entity for Author { 52 | fn render(&self, env: &Environment, mut context: Context, dest: &Path) -> anyhow::Result<()> { 53 | let slug = format!("@{}", self.id.to_lowercase()); 54 | context.insert( 55 | "meta", 56 | &Meta { 57 | title: Cow::Borrowed(self.name.as_deref().unwrap_or(&self.id)), 58 | description: Cow::Owned( 59 | self.bio 60 | .as_ref() 61 | .map(|bio| markdown::extract_description(bio)) 62 | .unwrap_or_default(), 63 | ), 64 | url: Some(Cow::Borrowed(&slug)), 65 | image: None, 66 | }, 67 | ); 68 | context.insert("author", &self); 69 | engine::render(env, "author.jinja", context, dest.join(slug))?; 70 | Ok(()) 71 | } 72 | } 73 | 74 | impl<'de> Deserialize<'de> for AuthorId { 75 | fn deserialize(deserializer: D) -> Result 76 | where 77 | D: serde::Deserializer<'de>, 78 | { 79 | deserializer.deserialize_any(AuthorNameVisitor) 80 | } 81 | } 82 | 83 | impl Serialize for AuthorId { 84 | fn serialize(&self, serializer: S) -> Result 85 | where 86 | S: serde::Serializer, 87 | { 88 | match self { 89 | AuthorId::One(author) => serializer.serialize_str(author), 90 | AuthorId::List(authors) => { 91 | let mut seq = serializer.serialize_seq(Some(authors.len()))?; 92 | for author in authors { 93 | seq.serialize_element(author)?; 94 | } 95 | seq.end() 96 | } 97 | } 98 | } 99 | } 100 | 101 | struct AuthorNameVisitor; 102 | 103 | impl<'de> de::Visitor<'de> for AuthorNameVisitor { 104 | type Value = AuthorId; 105 | 106 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 107 | formatter.write_str("plain string or string list") 108 | } 109 | 110 | fn visit_str(self, v: &str) -> Result 111 | where 112 | E: de::Error, 113 | { 114 | Ok(AuthorId::One(v.to_string())) 115 | } 116 | 117 | fn visit_seq(self, mut seq: A) -> Result 118 | where 119 | A: de::SeqAccess<'de>, 120 | { 121 | let mut authors = Vec::new(); 122 | while let Some(author) = seq.next_element()? { 123 | // Avoid author duplication. 124 | if !authors.contains(&author) { 125 | authors.push(author); 126 | } 127 | } 128 | Ok(AuthorId::List(authors)) 129 | } 130 | } 131 | 132 | #[cfg(test)] 133 | mod tests { 134 | use super::AuthorId; 135 | 136 | #[test] 137 | fn test_author_name() { 138 | assert!(matches!( 139 | serde_json::from_str::("\"Alice\"").unwrap(), 140 | AuthorId::One(name) if name == *"Alice", 141 | )); 142 | assert!(matches!( 143 | serde_json::from_str::("[\"Alice\",\"Bob\"]").unwrap(), 144 | AuthorId::List(names) if names == vec![String::from("Alice"), String::from("Bob")], 145 | )); 146 | assert!(matches!( 147 | serde_json::from_str::("[\"Alice\",\"Bob\", \"Alice\"]").unwrap(), 148 | AuthorId::List(names) if names == vec![String::from("Alice"), String::from("Bob")], 149 | )); 150 | assert!(matches!( 151 | serde_json::from_str::("[]").unwrap(), 152 | AuthorId::List(names) if names.is_empty(), 153 | )); 154 | 155 | let a = AuthorId::One(String::from("John")); 156 | assert!(a.is_author("John")); 157 | assert!(!a.is_author("Alice")); 158 | assert_eq!("\"John\"", serde_json::to_string(&a).unwrap()); 159 | 160 | let a = AuthorId::List(vec![String::from("Alice"), String::from("Bob")]); 161 | assert!(a.is_author("Alice")); 162 | assert!(!a.is_author("John")); 163 | assert_eq!("[\"Alice\",\"Bob\"]", serde_json::to_string(&a).unwrap()); 164 | 165 | let a = AuthorId::List(vec![String::from("Alice"), String::from("Bob")]); 166 | assert!(a.is_author("Alice")); 167 | assert!(!a.is_author("John")); 168 | assert_eq!("[\"Alice\",\"Bob\"]", serde_json::to_string(&a).unwrap()); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/entity/issue.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, fs, path::Path}; 2 | 3 | use anyhow::{Context as _, Result}; 4 | use genkit::{html::Meta, markdown, Context}; 5 | use minijinja::Environment; 6 | use rayon::{ 7 | prelude::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator}, 8 | slice::ParallelSliceMut, 9 | }; 10 | use serde::{Deserialize, Serialize}; 11 | use time::Date; 12 | 13 | use genkit::{current_mode, Mode}; 14 | 15 | use crate::engine; 16 | 17 | use super::{article::Article, Entity}; 18 | 19 | /// The issue entity config. 20 | /// It parsed from issue directory's `zine.toml`. 21 | #[derive(Clone, Serialize, Deserialize)] 22 | pub struct Issue { 23 | /// The slug after this issue rendered. 24 | /// Fallback to issue path name if no slug specified. 25 | #[serde(default)] 26 | pub slug: String, 27 | pub number: u32, 28 | pub title: String, 29 | /// The optional introduction for this issue (parsed from convention intro.md file). 30 | #[serde(skip)] 31 | pub intro: Option, 32 | cover: Option, 33 | /// Default cover for each article in this issue. 34 | /// The global `default_cover` in [theme] section will be overrided. 35 | #[serde(skip_serializing)] 36 | default_cover: Option, 37 | /// The publish date. Format like YYYY-MM-DD. 38 | #[serde(default)] 39 | #[serde(with = "genkit::helpers::serde_date::options")] 40 | pub pub_date: Option, 41 | /// Whether to publish the whole issue. 42 | #[serde(default)] 43 | publish: bool, 44 | /// The path of issue diretory. 45 | #[serde(skip_deserializing)] 46 | pub dir: String, 47 | /// Skip serialize `articles` since a single article page would 48 | /// contain a issue context, the `articles` is useless for the 49 | /// single article page. 50 | #[serde(skip_serializing, default)] 51 | #[serde(rename(deserialize = "article"))] 52 | articles: Vec
, 53 | } 54 | 55 | impl std::fmt::Debug for Issue { 56 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 57 | f.debug_struct("Issue") 58 | .field("slug", &self.slug) 59 | .field("number", &self.number) 60 | .field("title", &self.title) 61 | .field("intro", &self.intro.is_some()) 62 | .field("cover", &self.cover) 63 | .field("dir", &self.dir) 64 | .field("articles", &self.articles) 65 | .finish() 66 | } 67 | } 68 | 69 | impl Issue { 70 | /// Check whether the issue need publish. 71 | /// 72 | /// The issue need publish in any of two conditions: 73 | /// - the publish property is true 74 | /// - in `zine serve` mode 75 | pub fn need_publish(&self) -> bool { 76 | self.publish || matches!(current_mode(), Mode::Serve) 77 | } 78 | 79 | // Get the description of this issue. 80 | // Mainly for html meta description tag. 81 | fn description(&self) -> String { 82 | if let Some(intro) = self.intro.as_ref() { 83 | markdown::extract_description(intro) 84 | } else { 85 | String::default() 86 | } 87 | } 88 | 89 | fn sibling_articles(&self, current: usize) -> (Option<&Article>, Option<&Article>) { 90 | if current == 0 { 91 | return (None, self.articles.get(current + 1)); 92 | } 93 | 94 | ( 95 | self.articles.get(current - 1), 96 | self.articles.get(current + 1), 97 | ) 98 | } 99 | 100 | pub fn featured_articles(&self) -> Vec<&Article> { 101 | self.articles 102 | .iter() 103 | .filter(|article| article.featured && article.need_publish()) 104 | .collect() 105 | } 106 | 107 | /// Get all articles need published. 108 | /// 109 | /// See [`Article::need_publish()`](super::Article::need_publish) 110 | pub fn articles(&self) -> Vec<&Article> { 111 | let issue_need_publish = self.need_publish(); 112 | self.articles 113 | .iter() 114 | .filter(|article| issue_need_publish && article.need_publish()) 115 | .collect() 116 | } 117 | } 118 | 119 | impl Entity for Issue { 120 | fn parse(&mut self, source: &Path) -> Result<()> { 121 | // Fallback to path if no slug specified. 122 | if self.slug.is_empty() { 123 | self.slug = self.dir.clone(); 124 | } 125 | 126 | let dir = source.join(crate::ZINE_CONTENT_DIR).join(&self.dir); 127 | // Parse intro file 128 | let intro_path = dir.join(crate::ZINE_INTRO_FILE); 129 | if intro_path.exists() { 130 | self.intro = 131 | Some(fs::read_to_string(&intro_path).with_context(|| { 132 | format!("Failed to read intro from {}", intro_path.display()) 133 | })?); 134 | } 135 | 136 | // Sort all articles by pub_date. 137 | self.articles 138 | .par_sort_unstable_by_key(|article| article.meta.pub_date); 139 | 140 | if self.default_cover.is_none() 141 | || matches!(self.default_cover.as_ref(), Some(cover) if cover.is_empty()) 142 | { 143 | self.default_cover = self.cover.clone(); 144 | } 145 | 146 | if let Some(default_cover) = self.default_cover.as_deref() { 147 | // Set default cover for articles in this issue if article has no `cover`. 148 | self.articles 149 | .iter_mut() 150 | .filter(|article| article.meta.has_empty_cover()) 151 | .for_each(|article| article.meta.cover = Some(default_cover.to_owned())) 152 | } 153 | 154 | self.articles.parse(&dir)?; 155 | Ok(()) 156 | } 157 | 158 | fn render(&self, env: &Environment, mut context: Context, dest: &Path) -> Result<()> { 159 | if !self.need_publish() { 160 | return Ok(()); 161 | } 162 | 163 | let issue_dir = dest.join(&self.slug); 164 | context.insert("issue", &self); 165 | 166 | let articles = self 167 | .articles 168 | .iter() 169 | // Only render article which need published. 170 | .filter(|article| article.need_publish()) 171 | .collect::>(); 172 | // Render articles with number context. 173 | articles 174 | .par_iter() 175 | .enumerate() 176 | .for_each(|(index, article)| { 177 | let mut context = context.clone(); 178 | context.insert("siblings", &self.sibling_articles(index)); 179 | context.insert("number", &(index + 1)); 180 | 181 | let dest = issue_dir.clone(); 182 | let article = (*article).clone(); 183 | article 184 | .render(env, context, &dest) 185 | .expect("Render article failed."); 186 | }); 187 | 188 | context.insert("articles", &articles); 189 | context.insert( 190 | "meta", 191 | &Meta { 192 | title: Cow::Borrowed(&self.title), 193 | description: Cow::Owned(self.description()), 194 | url: Some(Cow::Borrowed(&self.slug)), 195 | image: self.cover.as_deref().map(Cow::Borrowed), 196 | }, 197 | ); 198 | context.insert("intro", &self.intro); 199 | engine::render(env, "issue.jinja", context, issue_dir)?; 200 | Ok(()) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /src/entity/list.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, path::Path}; 2 | 3 | use crate::engine; 4 | 5 | use super::{Author, Topic}; 6 | use genkit::Entity; 7 | use genkit::{html::Meta, Context}; 8 | use minijinja::context; 9 | use minijinja::Environment; 10 | use serde::Serialize; 11 | 12 | #[derive(Serialize)] 13 | pub struct List<'a, E> { 14 | entities: Vec>, 15 | name: &'static str, 16 | template: &'static str, 17 | fluent_key: &'static str, 18 | } 19 | 20 | /// A [`Entity`] struct with additional `article_count` field. 21 | #[derive(Serialize)] 22 | pub(super) struct EntityExt<'a, E> { 23 | #[serde(flatten)] 24 | entity: &'a E, 25 | // How many articles this entity has. 26 | article_count: usize, 27 | } 28 | 29 | impl<'a> List<'a, Author> { 30 | pub(super) fn author_list() -> Self { 31 | List { 32 | entities: Default::default(), 33 | name: "authors", 34 | template: "author-list.jinja", 35 | fluent_key: "author-list", 36 | } 37 | } 38 | 39 | pub(super) fn push_author(&mut self, author: &'a Author, article_count: usize) { 40 | self.entities.push(EntityExt { 41 | entity: author, 42 | article_count, 43 | }); 44 | } 45 | } 46 | 47 | impl<'a> List<'a, Topic> { 48 | pub(super) fn topic_list() -> Self { 49 | List { 50 | entities: Default::default(), 51 | name: "topics", 52 | template: "topic-list.jinja", 53 | fluent_key: "topic-list", 54 | } 55 | } 56 | 57 | pub(super) fn push_topic(&mut self, topic: &'a Topic, article_count: usize) { 58 | self.entities.push(EntityExt { 59 | entity: topic, 60 | article_count, 61 | }); 62 | } 63 | } 64 | 65 | impl<'a, E: Serialize> Entity for List<'a, E> { 66 | fn render(&self, env: &Environment, mut context: Context, dest: &Path) -> anyhow::Result<()> { 67 | if self.entities.is_empty() { 68 | // Do nothing if the entities is empty. 69 | return Ok(()); 70 | } 71 | 72 | let title = env.render_str( 73 | &format!(r#"{{{{ fluent("{}") }}}}"#, self.fluent_key), 74 | context! {}, 75 | )?; 76 | context.insert( 77 | "meta", 78 | &Meta { 79 | title: Cow::Owned(title), 80 | description: Cow::Owned(String::new()), 81 | url: Some(self.name.into()), 82 | image: None, 83 | }, 84 | ); 85 | context.insert(self.name, &self.entities); 86 | engine::render(env, self.template, context, dest.join(self.name))?; 87 | Ok(()) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/entity/mod.rs: -------------------------------------------------------------------------------- 1 | mod article; 2 | mod author; 3 | mod issue; 4 | mod list; 5 | mod page; 6 | mod site; 7 | mod theme; 8 | mod topic; 9 | mod zine; 10 | 11 | pub use genkit::Entity; 12 | 13 | pub use article::{Article, MetaArticle}; 14 | pub use author::{Author, AuthorId}; 15 | pub use issue::Issue; 16 | pub use list::List; 17 | pub use page::Page; 18 | pub use site::Site; 19 | pub use theme::Theme; 20 | pub use topic::Topic; 21 | pub use zine::Zine; 22 | -------------------------------------------------------------------------------- /src/entity/page.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | borrow::Cow, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use anyhow::Result; 7 | use minijinja::Environment; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | use crate::engine; 11 | use genkit::{html::Meta, markdown, Context}; 12 | 13 | use super::Entity; 14 | 15 | #[derive(Clone, Debug, Serialize, Deserialize)] 16 | pub struct Page { 17 | // The page's markdown content. 18 | pub markdown: String, 19 | // Relative path of page file. 20 | pub file_path: PathBuf, 21 | } 22 | 23 | impl Page { 24 | pub fn slug(&self) -> String { 25 | self.file_path.to_str().unwrap().replace(".md", "") 26 | } 27 | 28 | fn title(&self) -> String { 29 | let prefix = &['#', ' ']; 30 | self.markdown 31 | .lines() 32 | .find_map(|line| { 33 | if line.starts_with(prefix) { 34 | Some(line.trim_start_matches(prefix).to_owned()) 35 | } else { 36 | None 37 | } 38 | }) 39 | .unwrap_or_default() 40 | } 41 | } 42 | 43 | impl Entity for Page { 44 | fn render(&self, env: &Environment, mut context: Context, dest: &Path) -> Result<()> { 45 | context.insert( 46 | "meta", 47 | &Meta { 48 | title: Cow::Borrowed(&self.title()), 49 | description: Cow::Owned(markdown::extract_description(&self.markdown)), 50 | url: Some(Cow::Owned(self.slug())), 51 | image: None, 52 | }, 53 | ); 54 | context.insert("page", &self); 55 | engine::render(env, "page.jinja", context, dest.join(self.slug()))?; 56 | Ok(()) 57 | } 58 | } 59 | 60 | #[cfg(test)] 61 | mod tests { 62 | use std::path::PathBuf; 63 | 64 | use test_case::test_case; 65 | 66 | use super::Page; 67 | 68 | #[test_case(" # Title 69 | aaa"; "case0")] 70 | #[test_case("# Title 71 | aaa"; "case1")] 72 | #[test_case("## Title 73 | aaa"; "case2")] 74 | #[test_case(" 75 | 76 | # Title 77 | aaa"; "case3")] 78 | #[test_case(" 79 | # Title 80 | aaa"; "case4")] 81 | #[test_case(" 82 | # Title 83 | ## Subtitle 84 | aaa"; "case5")] 85 | fn test_parse_page_title(markdown: &str) { 86 | let page = Page { 87 | markdown: markdown.to_owned(), 88 | file_path: PathBuf::new(), 89 | }; 90 | 91 | assert_eq!("Title", page.title()); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/entity/site.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] 4 | pub struct Site { 5 | /// The absolute url of this site. 6 | pub url: String, 7 | pub cdn: Option, 8 | pub name: String, 9 | pub description: Option, 10 | /// The repository edit url of this zine website. 11 | pub edit_url: Option, 12 | /// The OpenGraph social image. 13 | pub social_image: Option, 14 | /// The locale to localize some builtin text. 15 | /// Default to 'en'. 16 | #[serde(default = "default_locale")] 17 | pub locale: String, 18 | #[serde(rename(deserialize = "menu"))] 19 | #[serde(default)] 20 | pub menus: Vec, 21 | } 22 | 23 | #[derive(Clone, Debug, Serialize, Deserialize)] 24 | pub struct Menu { 25 | pub name: String, 26 | pub url: String, 27 | } 28 | 29 | fn default_locale() -> String { 30 | "en".to_owned() 31 | } 32 | -------------------------------------------------------------------------------- /src/entity/theme.rs: -------------------------------------------------------------------------------- 1 | use std::{fs, path::Path}; 2 | 3 | use anyhow::{Context, Result}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use super::Entity; 7 | 8 | #[derive(Clone, Serialize, Deserialize)] 9 | #[serde(rename_all(deserialize = "snake_case"))] 10 | pub struct Theme { 11 | // The primary color. 12 | #[serde(default = "Theme::default_primary_color")] 13 | pub primary_color: String, 14 | // The text main color. 15 | #[serde(default = "Theme::default_main_color")] 16 | pub main_color: String, 17 | // The article's link color. 18 | #[serde(default = "Theme::default_link_color")] 19 | pub link_color: String, 20 | // The background color. 21 | #[serde(default = "Theme::default_secondary_color")] 22 | pub secondary_color: String, 23 | // The background image url. 24 | #[serde(default)] 25 | pub background_image: Option, 26 | // The extra head template path, will be parsed to html. 27 | pub head_template: Option, 28 | // The custom footer template path, will be parsed to html. 29 | pub footer_template: Option, 30 | // The extend template path for article page, will be parsed to html. 31 | // Normally, this template can be a comment widget, such as https://giscus.app. 32 | pub article_extend_template: Option, 33 | #[serde(skip_serializing)] 34 | pub default_cover: Option, 35 | #[serde(skip_serializing)] 36 | pub default_avatar: Option, 37 | } 38 | 39 | impl Default for Theme { 40 | fn default() -> Self { 41 | Self { 42 | primary_color: Self::default_primary_color(), 43 | main_color: Self::default_main_color(), 44 | link_color: Self::default_link_color(), 45 | secondary_color: Self::default_secondary_color(), 46 | background_image: None, 47 | head_template: None, 48 | footer_template: None, 49 | article_extend_template: None, 50 | default_cover: None, 51 | default_avatar: None, 52 | } 53 | } 54 | } 55 | 56 | impl std::fmt::Debug for Theme { 57 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 58 | f.debug_struct("Theme") 59 | .field("primary_color", &self.primary_color) 60 | .field("main_color", &self.main_color) 61 | .field("link_color", &self.link_color) 62 | .field("secondary_color", &self.secondary_color) 63 | .field("background_image", &self.background_image) 64 | .field("head_template", &self.head_template.is_some()) 65 | .field("footer_template", &self.footer_template.is_some()) 66 | .field( 67 | "article_extend_template", 68 | &self.article_extend_template.is_some(), 69 | ) 70 | .field("default_cover", &self.default_cover) 71 | .field("default_avatar", &self.default_avatar) 72 | .finish() 73 | } 74 | } 75 | 76 | impl Theme { 77 | const DEFAULT_PRIMARY_COLOR: &'static str = "#2563eb"; 78 | const DEFAULT_MAIN_COLOR: &'static str = "#ffffff"; 79 | const DEFAULT_LINK_COLOR: &'static str = "#2563eb"; 80 | const DEFAULT_SECONDARY_COLOR: &'static str = "#eff3f7"; 81 | 82 | fn default_primary_color() -> String { 83 | Self::DEFAULT_PRIMARY_COLOR.to_string() 84 | } 85 | 86 | fn default_main_color() -> String { 87 | Self::DEFAULT_MAIN_COLOR.to_string() 88 | } 89 | 90 | fn default_link_color() -> String { 91 | Self::DEFAULT_LINK_COLOR.to_string() 92 | } 93 | 94 | fn default_secondary_color() -> String { 95 | Self::DEFAULT_SECONDARY_COLOR.to_string() 96 | } 97 | } 98 | 99 | impl Entity for Theme { 100 | fn parse(&mut self, source: &Path) -> Result<()> { 101 | if self.default_cover.is_none() { 102 | self.default_cover = Some(String::from("/static/zine-placeholder.svg")); 103 | } 104 | if self.default_avatar.is_none() { 105 | self.default_avatar = Some(String::from("/static/zine.png")); 106 | } 107 | 108 | if let Some(head_template) = self.head_template.as_ref() { 109 | // Read head template from path to html. 110 | self.head_template = Some( 111 | fs::read_to_string(source.join(head_template)).with_context(|| { 112 | format!( 113 | "Failed to parse the head template: `{}`", 114 | source.join(head_template).display(), 115 | ) 116 | })?, 117 | ); 118 | } 119 | if let Some(footer_template) = self.footer_template.as_ref() { 120 | // Read footer template from path to html. 121 | self.footer_template = Some( 122 | fs::read_to_string(source.join(footer_template)).with_context(|| { 123 | format!( 124 | "Failed to parse the footer template: `{}`", 125 | source.join(footer_template).display(), 126 | ) 127 | })?, 128 | ); 129 | } 130 | if let Some(article_extend_template) = self.article_extend_template.as_ref() { 131 | // Read article extend template from path to html. 132 | self.article_extend_template = Some( 133 | fs::read_to_string(source.join(article_extend_template)).with_context(|| { 134 | format!( 135 | "Failed to parse the article extend template: `{}`", 136 | source.join(article_extend_template).display(), 137 | ) 138 | })?, 139 | ); 140 | } 141 | Ok(()) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/entity/topic.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, path::Path}; 2 | 3 | use anyhow::Result; 4 | use minijinja::Environment; 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use crate::engine; 8 | 9 | use super::Entity; 10 | use genkit::{html::Meta, Context}; 11 | 12 | #[derive(Clone, Debug, Serialize, Deserialize)] 13 | pub struct Topic { 14 | #[serde(skip_deserializing, default)] 15 | pub id: String, 16 | name: Option, 17 | description: Option, 18 | } 19 | 20 | impl Entity for Topic { 21 | fn parse(&mut self, _source: &Path) -> Result<()> { 22 | // Fallback to capitalized id if missing. 23 | if self.name.is_none() { 24 | self.name = Some(genkit::helpers::capitalize(&self.id)); 25 | } 26 | Ok(()) 27 | } 28 | 29 | fn render(&self, env: &Environment, mut context: Context, dest: &Path) -> Result<()> { 30 | context.insert( 31 | "meta", 32 | &Meta { 33 | title: Cow::Borrowed(self.name.as_deref().unwrap_or(&self.id)), 34 | description: Cow::Borrowed(self.description.as_deref().unwrap_or("")), 35 | url: Some(format!("/topic/{}", self.id.to_lowercase()).into()), 36 | image: None, 37 | }, 38 | ); 39 | context.insert("topic", &self); 40 | engine::render( 41 | env, 42 | "topic.jinja", 43 | context, 44 | dest.join(self.id.to_lowercase()), 45 | )?; 46 | Ok(()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/entity/zine.rs: -------------------------------------------------------------------------------- 1 | use crate::{data, engine, error::ZineError, feed::FeedEntry}; 2 | use anyhow::{Context as _, Result}; 3 | use genkit::{ 4 | entity::MarkdownConfig, 5 | helpers::{self, capitalize}, 6 | Context, Entity, 7 | }; 8 | use minijinja::{context, Environment}; 9 | use rayon::{ 10 | iter::{IntoParallelRefIterator, ParallelBridge, ParallelExtend, ParallelIterator}, 11 | prelude::IntoParallelRefMutIterator, 12 | slice::ParallelSliceMut, 13 | }; 14 | use serde::{Deserialize, Serialize}; 15 | use std::{ 16 | cmp::Ordering, 17 | collections::BTreeMap, 18 | fs, 19 | path::{Component, Path}, 20 | }; 21 | use walkdir::WalkDir; 22 | 23 | use super::{Author, Issue, List, MetaArticle, Page, Site, Theme, Topic}; 24 | 25 | /// The root zine entity config. 26 | /// 27 | /// It parsed from the root directory's `zine.toml`. 28 | #[derive(Deserialize)] 29 | pub struct Zine { 30 | pub site: Site, 31 | #[serde(default)] 32 | pub theme: Theme, 33 | #[serde(default)] 34 | pub authors: BTreeMap, 35 | #[serde(default)] 36 | #[serde(rename = "issue")] 37 | pub issues: Vec, 38 | #[serde(default)] 39 | pub topics: BTreeMap, 40 | #[serde(skip)] 41 | pub pages: Vec, 42 | #[serde(default)] 43 | #[serde(rename = "markdown")] 44 | pub markdown_config: MarkdownConfig, 45 | } 46 | 47 | impl std::fmt::Debug for Zine { 48 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 49 | f.debug_struct("Zine") 50 | .field("site", &self.site) 51 | .field("theme", &self.theme) 52 | .field("issues", &self.issues) 53 | .finish() 54 | } 55 | } 56 | 57 | // A [`MetaArticle`] and issue info pair. 58 | // Naming is hard, give it a better name? 59 | #[derive(Serialize)] 60 | struct ArticleRef<'a> { 61 | article: &'a MetaArticle, 62 | issue_title: &'a String, 63 | issue_slug: &'a String, 64 | } 65 | 66 | impl Zine { 67 | /// Parse Zine instance from the root zine.toml file. 68 | pub fn parse_from_toml>(source: P) -> Result { 69 | let source = source.as_ref().join(crate::ZINE_FILE); 70 | let content = fs::read_to_string(&source) 71 | .with_context(|| format!("Failed to read `{}`", source.display()))?; 72 | 73 | Ok(toml::from_str::(&content).map_err(|err| { 74 | let value = toml::from_str::(&content) 75 | .unwrap_or_else(|_| panic!("Parse `{}` failed", source.display())); 76 | if value.get("site").is_some() { 77 | ZineError::InvalidRootTomlFile(err) 78 | } else { 79 | ZineError::NotRootTomlFile 80 | } 81 | })?) 82 | } 83 | 84 | /// Parsing issue entities from dir. 85 | pub fn parse_issue_from_dir(&mut self, source: &Path) -> Result<()> { 86 | let content_dir = source.join(crate::ZINE_CONTENT_DIR); 87 | if !content_dir.exists() { 88 | println!( 89 | "`{}` fold not found, creating it...", 90 | crate::ZINE_CONTENT_DIR 91 | ); 92 | fs::create_dir_all(&content_dir)?; 93 | } 94 | 95 | for entry in WalkDir::new(&content_dir).contents_first(true).into_iter() { 96 | let entry = entry?; 97 | if entry.file_name() != crate::ZINE_FILE { 98 | continue; 99 | } 100 | let content = fs::read_to_string(entry.path()).with_context(|| { 101 | format!( 102 | "Failed to parse `zine.toml` of `{}`", 103 | entry.path().display() 104 | ) 105 | })?; 106 | let mut issue = toml::from_str::(&content)?; 107 | let dir = entry 108 | .path() 109 | .components() 110 | .fold(Vec::new(), |mut dir, component| { 111 | let name = component.as_os_str(); 112 | if !dir.is_empty() && name != crate::ZINE_FILE { 113 | dir.push(name.to_string_lossy().to_string()); 114 | return dir; 115 | } 116 | 117 | if matches!(component, Component::Normal(c) if c == crate::ZINE_CONTENT_DIR ) { 118 | // a empty indicator we should start collect the components 119 | dir.push(String::new()); 120 | } 121 | dir 122 | }); 123 | // skip the first empty indicator 124 | issue.dir = dir[1..].join("/"); 125 | self.issues.push(issue); 126 | } 127 | 128 | Ok(()) 129 | } 130 | 131 | pub fn get_issue_by_number(&self, number: u32) -> Option<&Issue> { 132 | self.issues.iter().find(|issue| issue.number == number) 133 | } 134 | 135 | // Get the article metadata list by author id, sorted by descending order of publishing date. 136 | fn get_articles_by_author(&self, author_id: &str) -> Vec { 137 | let mut items = self 138 | .issues 139 | .par_iter() 140 | .flat_map(|issue| { 141 | issue 142 | .articles() 143 | .into_iter() 144 | .flat_map(|article| { 145 | let mut articles = vec![article]; 146 | // including translation articles 147 | articles.extend(article.i18n.values()); 148 | articles 149 | }) 150 | .filter_map(|article| { 151 | if article.is_author(author_id) { 152 | Some(ArticleRef { 153 | article: &article.meta, 154 | issue_title: &issue.title, 155 | issue_slug: &issue.slug, 156 | }) 157 | } else { 158 | None 159 | } 160 | }) 161 | .collect::>() 162 | }) 163 | .collect::>(); 164 | items.par_sort_unstable_by(|a, b| b.article.pub_date.cmp(&a.article.pub_date)); 165 | items 166 | } 167 | 168 | // Get the article meta list by topic id 169 | fn get_articles_by_topic(&self, topic: &str) -> Vec { 170 | let mut items = self 171 | .issues 172 | .par_iter() 173 | .flat_map(|issue| { 174 | issue 175 | .articles() 176 | .iter() 177 | .filter_map(|article| { 178 | if article.topics.iter().any(|t| t == topic) { 179 | Some(ArticleRef { 180 | article: &article.meta, 181 | issue_title: &issue.title, 182 | issue_slug: &issue.slug, 183 | }) 184 | } else { 185 | None 186 | } 187 | }) 188 | .collect::>() 189 | }) 190 | .collect::>(); 191 | items.par_sort_unstable_by(|a, b| b.article.pub_date.cmp(&a.article.pub_date)); 192 | items 193 | } 194 | 195 | // Get author list. 196 | fn authors(&self) -> Vec { 197 | self.authors.values().cloned().collect() 198 | } 199 | 200 | fn all_articles(&self) -> Vec<(String, MetaArticle)> { 201 | self.issues 202 | .par_iter() 203 | .flat_map(|issue| { 204 | issue 205 | .articles() 206 | .iter() 207 | .map(|article| (issue.slug.clone(), article.meta.clone())) 208 | .collect::>() 209 | }) 210 | .collect() 211 | } 212 | 213 | /// Get latest `limit` number of articles in all issues. 214 | /// Sort by date in descending order. 215 | pub fn latest_feed_entries(&self, limit: usize) -> Vec { 216 | let mut entries = self 217 | .issues 218 | .par_iter() 219 | .flat_map(|issue| { 220 | let mut entries = issue 221 | .articles() 222 | .iter() 223 | .map(|article| FeedEntry { 224 | title: &article.meta.title, 225 | url: if let Some(path) = article.meta.path.as_ref() { 226 | format!("{}{}", self.site.url, path) 227 | } else { 228 | format!("{}/{}/{}", self.site.url, issue.slug, article.meta.slug) 229 | }, 230 | content: &article.markdown, 231 | author: &article.meta.author, 232 | date: Some(article.meta.pub_date), 233 | }) 234 | .collect::>(); 235 | 236 | // Add issue intro article into feed 237 | if issue.need_publish() { 238 | if let Some(content) = issue.intro.as_ref() { 239 | entries.push(FeedEntry { 240 | title: &issue.title, 241 | url: format!("{}/{}", self.site.url, issue.slug), 242 | content, 243 | author: &None, 244 | date: issue.pub_date, 245 | }) 246 | } 247 | } 248 | entries 249 | }) 250 | .collect::>(); 251 | 252 | // Sort by date in descending order. 253 | entries.par_sort_unstable_by(|a, b| match (a.date, b.date) { 254 | (Some(a_date), Some(b_date)) => b_date.cmp(&a_date), 255 | _ => Ordering::Equal, 256 | }); 257 | entries.into_iter().take(limit).collect() 258 | } 259 | 260 | /// Get `sitemap.xml` entries. 261 | pub fn sitemap_entries(&self) -> Vec { 262 | let base_url = &self.site.url; 263 | // Sitemap URL must begin with the protocol (such as http) 264 | // and end with a trailing slash. 265 | // https://www.sitemaps.org/protocol.html 266 | let mut entries = vec![format!("{}/", base_url)]; 267 | 268 | // Issues and articles 269 | for issue in &self.issues { 270 | entries.push(format!("{}/{}/", base_url, issue.slug)); 271 | let articles = issue 272 | .articles() 273 | .into_iter() 274 | .par_bridge() 275 | .flat_map(|article| { 276 | let mut articles = vec![article]; 277 | // including translation articles 278 | articles.extend(article.i18n.values()); 279 | articles 280 | }) 281 | .map(|article| { 282 | if let Some(path) = article.meta.path.as_ref() { 283 | format!("{}{}", base_url, path) 284 | } else { 285 | format!("{}/{}/{}", base_url, issue.slug, article.meta.slug) 286 | } 287 | }); 288 | entries.par_extend(articles); 289 | } 290 | 291 | // Authors 292 | entries.push(format!("{}/authors/", base_url)); 293 | entries.par_extend( 294 | self.authors 295 | .par_iter() 296 | .map(|(id, _)| format!("{}/@{}/", base_url, id.to_lowercase())), 297 | ); 298 | 299 | // Topics 300 | if !self.topics.is_empty() { 301 | entries.push(format!("{}/topics/", base_url)); 302 | entries.par_extend( 303 | self.topics 304 | .par_iter() 305 | .map(|(id, _)| format!("{}/topic/{}/", base_url, id.to_lowercase())), 306 | ); 307 | } 308 | 309 | // Pages 310 | entries.par_extend( 311 | self.pages 312 | .par_iter() 313 | .map(|page| format!("{}/{}/", base_url, page.slug())), 314 | ); 315 | entries 316 | } 317 | } 318 | 319 | impl Entity for Zine { 320 | fn parse(&mut self, source: &Path) -> Result<()> { 321 | self.theme.parse(source)?; 322 | 323 | self.topics.par_iter_mut().try_for_each(|(id, topic)| { 324 | topic.id = id.clone(); 325 | topic.parse(source) 326 | })?; 327 | 328 | { 329 | let mut zine_data = data::write(); 330 | zine_data 331 | .set_theme(self.theme.clone()) 332 | .set_site(self.site.clone()) 333 | .set_topics(self.topics.keys().cloned().collect()); 334 | } 335 | 336 | self.parse_issue_from_dir(source)?; 337 | 338 | self.issues.parse(source)?; 339 | // Sort all issues by number. 340 | self.issues.par_sort_unstable_by_key(|s| s.number); 341 | 342 | if self.authors.is_empty() { 343 | println!("Warning: no author specified in [authors] of root `zine.toml`."); 344 | } else { 345 | self.authors.par_iter_mut().try_for_each(|(id, author)| { 346 | author.id = id.clone(); 347 | // Fallback to default zine avatar if neccessary. 348 | if author.avatar.is_none() 349 | || matches!(&author.avatar, Some(avatar) if avatar.is_empty()) 350 | { 351 | author.avatar = self.theme.default_avatar.clone(); 352 | } 353 | 354 | // Fallback to capitalized id if missing. 355 | if author.name.is_none() { 356 | author.name = Some(capitalize(&author.id)); 357 | } 358 | author.parse(source) 359 | })?; 360 | } 361 | 362 | // Parse pages 363 | let page_dir = source.join("pages"); 364 | if page_dir.exists() { 365 | // Parallelize pages dir walk 366 | self.pages = WalkDir::new(&page_dir) 367 | .into_iter() 368 | .par_bridge() 369 | .try_fold_with(vec![], |mut pages, entry| { 370 | let entry = entry?; 371 | let path = entry.path(); 372 | if path.is_file() { 373 | let markdown = fs::read_to_string(path).with_context(|| { 374 | format!("Failed to read markdown file of `{}`", path.display()) 375 | })?; 376 | pages.push(Page { 377 | markdown, 378 | file_path: path.strip_prefix(&page_dir)?.to_owned(), 379 | }); 380 | } 381 | anyhow::Ok(pages) 382 | }) 383 | .try_reduce_with(|mut pages, chuncks| { 384 | pages.par_extend(chuncks); 385 | anyhow::Ok(pages) 386 | }) 387 | .transpose()? 388 | .unwrap_or_default(); 389 | } 390 | Ok(()) 391 | } 392 | 393 | fn render(&self, env: &Environment, mut context: Context, dest: &Path) -> Result<()> { 394 | context.insert("site", &self.site); 395 | 396 | // Render all authors pages. 397 | let authors = self.authors(); 398 | let mut author_list = List::author_list(); 399 | authors.iter().try_for_each(|author| { 400 | let articles = self.get_articles_by_author(&author.id); 401 | author_list.push_author(author, articles.len()); 402 | 403 | let mut context = context.clone(); 404 | context.insert("articles", &articles); 405 | author 406 | .render(env, context, dest) 407 | .expect("Failed to render author page"); 408 | 409 | anyhow::Ok(()) 410 | })?; 411 | // Render author list page. 412 | author_list 413 | .render(env, context.clone(), dest) 414 | .expect("Failed to render author list page"); 415 | 416 | { 417 | let mut zine_data = data::write(); 418 | zine_data 419 | .set_authors(authors) 420 | .set_articles(self.all_articles()); 421 | } 422 | 423 | // Render all issues pages. 424 | self.issues 425 | .render(env, context.clone(), dest) 426 | .expect("Failed to render issues"); 427 | 428 | // Render all topic pages 429 | let topic_dest = dest.join("topic"); 430 | let mut topic_list = List::topic_list(); 431 | self.topics 432 | .values() 433 | .try_for_each(|topic| { 434 | let mut context = context.clone(); 435 | let articles = self.get_articles_by_topic(&topic.id); 436 | topic_list.push_topic(topic, articles.len()); 437 | context.insert("articles", &articles); 438 | topic.render(env, context, &topic_dest) 439 | }) 440 | .expect("Failed to render topic pages"); 441 | // Render topic list page 442 | topic_list 443 | .render(env, context.clone(), dest) 444 | .expect("Failed to render topic list page"); 445 | 446 | // Render other pages. 447 | self.pages 448 | .render(env, context.clone(), dest) 449 | .expect("Failed to render pages"); 450 | 451 | // Render home page. 452 | let issues = self 453 | .issues 454 | .par_iter() 455 | .filter(|issue| issue.need_publish()) 456 | .map(|issue| { 457 | context! { 458 | slug => issue.slug, 459 | title => issue.title, 460 | number => issue.number, 461 | pub_date => issue.pub_date.as_ref().map(helpers::format_date), 462 | articles => issue.featured_articles(), 463 | } 464 | }) 465 | .collect::>(); 466 | context.insert("issues", &issues); 467 | engine::render(env, "index.jinja", context, dest).expect("Failed to render home page"); 468 | Ok(()) 469 | } 470 | } 471 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | pub enum ZineError { 5 | #[error("Invalid format of root `zine.toml`: {0}")] 6 | InvalidRootTomlFile(#[from] toml::de::Error), 7 | #[error("Not a root `zine.toml`, maybe it a `zine.toml` for issue?")] 8 | NotRootTomlFile, 9 | } 10 | -------------------------------------------------------------------------------- /src/feed.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use time::Date; 3 | 4 | use crate::entity::AuthorId; 5 | 6 | #[derive(Serialize)] 7 | pub struct FeedEntry<'a> { 8 | pub title: &'a String, 9 | pub url: String, 10 | pub content: &'a String, 11 | pub author: &'a Option, 12 | #[serde(with = "genkit::helpers::serde_date::options")] 13 | pub date: Option, 14 | } 15 | -------------------------------------------------------------------------------- /src/html.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | 3 | use genkit::helpers; 4 | use lol_html::{element, html_content::Element, HtmlRewriter, Settings}; 5 | 6 | /// Rewrite root path URL in `raw_html` with `site_url` and `cdn_url`. 7 | pub fn rewrite_html_base_url( 8 | raw_html: &[u8], 9 | site_url: Option<&str>, 10 | cdn_url: Option<&str>, 11 | ) -> Result> { 12 | let rewrite_url_in_attr = |el: &mut Element, attr_name: &str| { 13 | if let Some(attr) = el.get_attribute(attr_name) { 14 | let dest_url = 15 | if let (Some(attr), Some(cdn_url)) = (attr.strip_prefix("/static"), cdn_url) { 16 | format!("{}{}", &cdn_url, attr) 17 | } else if let (true, Some(site_url)) = (attr.starts_with('/'), site_url) { 18 | format!("{}{}", &site_url, attr) 19 | } else { 20 | // no need to rewrite 21 | return; 22 | }; 23 | 24 | el.set_attribute(attr_name, &dest_url) 25 | .expect("Set attribute failed"); 26 | } 27 | }; 28 | 29 | let mut html = vec![]; 30 | let mut html_rewriter = HtmlRewriter::new( 31 | Settings { 32 | element_content_handlers: vec![ 33 | element!("a[href], link[rel=stylesheet][href]", |el| { 34 | rewrite_url_in_attr(el, "href"); 35 | Ok(()) 36 | }), 37 | element!( 38 | "script[src], iframe[src], img[src], audio[src], video[src]", 39 | |el| { 40 | rewrite_url_in_attr(el, "src"); 41 | Ok(()) 42 | } 43 | ), 44 | // Rewrite background image url. 45 | element!("body>div.bg-primary.text-main", |el| { 46 | if let Some(style) = el.get_attribute("style") { 47 | let mut pairs = helpers::split_styles(&style); 48 | let backgrond_image_url = match pairs.get("background-image") { 49 | Some(value) if value.starts_with("url('/static") => { 50 | if let Some(cdn_url) = cdn_url { 51 | value.replacen("/static", cdn_url, 1) 52 | } else { 53 | return Ok(()); 54 | } 55 | } 56 | Some(value) if value.starts_with("url('/") => { 57 | if let Some(site_url) = site_url { 58 | value.replacen('/', &format!("{site_url}/"), 1) 59 | } else { 60 | return Ok(()); 61 | } 62 | } 63 | _ => { 64 | // no need to rewrite 65 | return Ok(()); 66 | } 67 | }; 68 | 69 | pairs.insert("background-image", &backgrond_image_url); 70 | let new_style = pairs.into_iter().map(|(k, v)| format!("{k}: {v}")).fold( 71 | String::new(), 72 | |mut acc, pair| { 73 | acc.push_str(&pair); 74 | acc.push(';'); 75 | acc 76 | }, 77 | ); 78 | el.set_attribute("style", &new_style) 79 | .expect("Rewrite background-image failed.") 80 | } 81 | Ok(()) 82 | }), 83 | element!("meta[content]", |el| { 84 | rewrite_url_in_attr(el, "content"); 85 | Ok(()) 86 | }), 87 | ], 88 | ..Default::default() 89 | }, 90 | |c: &[u8]| { 91 | html.extend_from_slice(c); 92 | }, 93 | ); 94 | html_rewriter.write(raw_html)?; 95 | 96 | Ok(html) 97 | } 98 | 99 | #[cfg(test)] 100 | mod tests { 101 | use super::rewrite_html_base_url; 102 | use test_case::test_case; 103 | 104 | const SITE_URL: &str = "https://github.com"; 105 | const CDN_URL: &str = "https://cdn-example.net"; 106 | 107 | #[test_case(r#"
"#)] 108 | fn test_rewrite_background_image_url(html: &str) { 109 | assert_eq!( 110 | String::from_utf8_lossy( 111 | &rewrite_html_base_url(html.as_bytes(), Some(SITE_URL), Some(CDN_URL)).unwrap() 112 | ), 113 | html.replace("/test.png", &format!("{}/test.png", SITE_URL)) 114 | ); 115 | } 116 | 117 | #[test_case(r#"
"#)] 118 | // #[test_case(r#"
"#; "uppercase")] 119 | fn test_rewrite_cdn_background_image_url(html: &str) { 120 | assert_eq!( 121 | String::from_utf8_lossy( 122 | &rewrite_html_base_url(html.as_bytes(), Some(SITE_URL), Some(CDN_URL)).unwrap() 123 | ), 124 | html.replace("/static/test.png", &format!("{}/test.png", CDN_URL)) 125 | ); 126 | } 127 | 128 | #[test_case("
", "/"; "a1")] 129 | #[test_case("", "/hello"; "a2")] 130 | #[test_case("", "/hello/world"; "a3")] 131 | #[test_case("", "/hello.css"; "link")] 132 | #[test_case("", "/hello.png"; "img")] 133 | #[test_case(" 70 | 71 | {% if live_reload -%} 72 | 92 | {% endif -%} 93 | -------------------------------------------------------------------------------- /templates/feed.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{ site.name }} 4 | {{ site.description }} 5 | {{ now() }} 6 | {{ site.url }} 7 | 8 | 9 | zine 10 | 11 | {{ site.name }} 12 | {{ site.url }} 13 | 14 | 15 | {% for entry in entries -%} 16 | 17 | {{ entry.title }} 18 | 19 | {{ entry.date }}T00:00:00+00:00 20 | {{ entry.date }}T00:00:00+00:00 21 | {{ entry.url }} 22 | 23 | 24 | 25 | 26 | 27 | {% if entry.author is sequence -%} 28 | {{ entry.author | join(", ")}} 29 | {% else -%} 30 | {{ entry.author }} 31 | {% endif -%} 32 | 33 | 34 | {% endfor -%} 35 | -------------------------------------------------------------------------------- /templates/index.jinja: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja" -%} 2 | {% import "_macros.jinja" as macros -%} 3 | {% block content -%} 4 | {% for issue in issues | reverse -%} 5 | 41 | {% endfor -%} 42 | {% endblock content -%} -------------------------------------------------------------------------------- /templates/issue.jinja: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja" -%} 2 | {% import "_macros.jinja" as macros -%} 3 | {% block content -%} 4 |
5 |
6 | {{ macros.issue_diamond(title=issue.title, date=issue.pub_date) }} 7 |
8 |
9 | {% if intro -%} 10 | {% if site.edit_url -%} 11 | 13 | edit 14 | 15 | {% endif -%} 16 |
{{ markdown_to_html(intro) | safe }}
17 | {% endif -%} 18 | {% for article in articles -%} 19 |
20 | {% if article.path -%} 21 | 22 | {% else -%} 23 | 24 | {% endif -%} 25 |
26 | 28 | No. {{ loop.index }} 29 | 30 | {{ article.title }} 32 |
33 |
34 |
36 | {{ article.title }} 37 |
38 |
39 |
40 |
41 | {{ article.pub_date }} 42 | {{ macros.author_link(article.author) }} 43 |
44 |
45 | {% if not loop.last -%} 46 |
47 | {% endif -%} 48 | {% endfor -%} 49 |
50 |
51 | {% endblock content -%} 52 | -------------------------------------------------------------------------------- /templates/page.jinja: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja" -%} 2 | {% block content -%} 3 |
4 | {% if site.edit_url -%} 5 | 7 | edit 8 | 9 | {% endif -%} 10 |
11 | {{ markdown_to_html(page.markdown) | safe }} 12 |
13 |
14 | {% endblock content -%} -------------------------------------------------------------------------------- /templates/sitemap.jinja: -------------------------------------------------------------------------------- 1 | 2 | 3 | {%- for entry in entries %} 4 | 5 | {{ entry | safe }} 6 | 7 | {%- endfor %} 8 | -------------------------------------------------------------------------------- /templates/topic-list.jinja: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja" -%} 2 | {% block content -%} 3 | 36 | 37 | {% endblock content -%} -------------------------------------------------------------------------------- /templates/topic.jinja: -------------------------------------------------------------------------------- 1 | {% extends "base.jinja" -%} 2 | {% block content -%} 3 |
4 |
5 | 7 |
Topics
8 |
9 | / {{ topic.name }} 10 |
11 |
12 |
13 |
14 |
15 |
#
16 |
17 | {{ topic.name }} 18 |
19 |
20 | {% if topic.description -%} 21 |
22 | {{ markdown_to_html(topic.description) | safe }} 23 |
24 | {% endif -%} 25 |
26 | {% set article_count = articles | length -%} 27 | {% if article_count > 0 %} 28 |
29 | 30 | {{ fluent("topic-article-title", article_count) }} 31 |
32 | {% include "_article_ref.jinja" -%} 33 | {% endif %} 34 |
35 |
36 | {% endblock content -%} -------------------------------------------------------------------------------- /zine-entry.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .author-code { 6 | padding: 2px; 7 | margin: 0 2px; 8 | @apply inline rounded; 9 | @apply text-gray-500 !important; 10 | } 11 | 12 | .author-code:hover { 13 | @apply bg-gray-200 no-underline !important; 14 | } 15 | 16 | .author-code>img { 17 | margin: 0 !important; 18 | margin-left: 2px !important; 19 | @apply inline w-4 h-4 align-middle rounded-full object-cover; 20 | } 21 | 22 | .author-code>span{ 23 | vertical-align: middle; 24 | } 25 | 26 | .url-preview { 27 | outline: #dee0e3 1px solid; 28 | padding: 1rem 1.25rem; 29 | background-color: #f5f6f7; 30 | } 31 | 32 | .url-preview>div:nth-child(1) { 33 | font-weight: 500; 34 | font-size: 1rem; 35 | } 36 | 37 | .url-preview>div:nth-child(2) { 38 | color: #6b7078; 39 | margin: 0.5rem 0; 40 | font-size: 0.9rem; 41 | } 42 | 43 | .url-preview>a { 44 | font-size: 0.8rem; 45 | overflow: hidden; 46 | display: block; 47 | } 48 | 49 | .url-preview>img { 50 | margin-top: 1rem; 51 | margin-bottom: 0; 52 | } 53 | 54 | .url-preview:hover { 55 | outline: #dee0e3 2px solid; 56 | cursor: pointer; 57 | } 58 | 59 | .prose p>img:hover { 60 | outline: #dee0e3 2px solid; 61 | cursor: zoom-out; 62 | } 63 | 64 | .callout { 65 | padding: 0 20px; 66 | margin: 20px 0; 67 | border: 1px solid transparent; 68 | border-radius: 4px; 69 | } 70 | 71 | .inline-link { 72 | padding: 2px; 73 | @apply inline rounded; 74 | @apply font-bold text-black !important; 75 | @apply underline decoration-primary !important; 76 | } 77 | 78 | .inline-link:hover { 79 | @apply text-link !important; 80 | } 81 | 82 | /* auto center page's h1 heading */ 83 | .zine-page>h1 { 84 | display: flex; 85 | justify-content: center; 86 | } 87 | 88 | .toc-active { 89 | @apply text-main bg-primary !important; 90 | } -------------------------------------------------------------------------------- /zine.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | --------------------------------------------------------------------------------