├── .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 | [](https://crates.io/crates/zine)
8 | 
9 | [](./LICENSE)
10 | [](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#" "#,
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("", "/hello.js"; "script")]
134 | #[test_case(" ", "/hello.mp3"; "audio")]
135 | #[test_case(" ", "/hello.mp4"; "video")]
136 | #[test_case("", "/hello.html"; "iframe")]
137 | fn test_rewrite_html_base_url(html: &str, path: &str) {
138 | assert_eq!(
139 | String::from_utf8_lossy(
140 | &rewrite_html_base_url(
141 | html.replace("{}", path).as_bytes(),
142 | Some(SITE_URL),
143 | Some(CDN_URL)
144 | )
145 | .unwrap()
146 | ),
147 | html.replace("{}", &format!("{}{}", SITE_URL, path))
148 | );
149 | }
150 |
151 | #[test_case(" ", "/"; "a1")]
152 | #[test_case(" ", "/hello"; "a2")]
153 | #[test_case(" ", "/hello/world"; "a3")]
154 | #[test_case(" ", "/hello.css"; "link")]
155 | #[test_case(" ", "/hello.png"; "img")]
156 | #[test_case("
70 |
71 | {% if live_reload -%}
72 |
92 | {% endif -%}
93 |