├── .eslintrc.cjs
├── .github
├── funding.yml
├── issue_template.md
└── pull_request_template.md
├── .gitignore
├── .node-version
├── .vars
├── CODE_OF_CONDUCT.md
├── README-zh-CN.md
├── README.md
├── app
├── components
│ ├── Ackee.tsx
│ ├── BlogPost.tsx
│ ├── ClientOnly.tsx
│ ├── Comments.tsx
│ ├── Container.tsx
│ ├── Footer.tsx
│ ├── Gitalk
│ │ ├── assets
│ │ │ └── icon
│ │ │ │ ├── arrow_down.svg
│ │ │ │ ├── edit.svg
│ │ │ │ ├── github.svg
│ │ │ │ ├── heart.svg
│ │ │ │ ├── heart_on.svg
│ │ │ │ ├── reply.svg
│ │ │ │ └── tip.svg
│ │ ├── component
│ │ │ ├── action.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── button.tsx
│ │ │ ├── comment.tsx
│ │ │ └── svg.tsx
│ │ ├── const.ts
│ │ ├── graphql
│ │ │ └── getComments.ts
│ │ ├── index.tsx
│ │ ├── util.ts
│ │ └── zh-CN.json
│ ├── Header.tsx
│ ├── Pagination.tsx
│ ├── PostActions.module.css
│ ├── PostActions.tsx
│ ├── Scripts.tsx
│ ├── TableOfContent.tsx
│ ├── TagItem.tsx
│ ├── Tags.tsx
│ └── Utterances.tsx
├── entry.client.tsx
├── entry.server.tsx
├── layouts
│ ├── layout.tsx
│ └── search.tsx
├── libs
│ ├── cusdisLang.ts
│ ├── formatDate.ts
│ ├── lang.ts
│ ├── locale.tsx
│ ├── notion.ts
│ ├── notion
│ │ ├── filterPublishedPosts.ts
│ │ ├── getAllPageIds.ts
│ │ ├── getAllPosts.ts
│ │ ├── getAllTagsFromPosts.ts
│ │ ├── getMetadata.ts
│ │ ├── getPageProperties.ts
│ │ ├── getPostBlocks.ts
│ │ └── preview-images.ts
│ ├── rss.ts
│ ├── utils.ts
│ └── withCache.ts
├── root.tsx
├── routes
│ ├── $slug.tsx
│ ├── _index.tsx
│ ├── action.set-theme.ts
│ ├── api.webhook.cusdis.ts
│ ├── atom[.]xml.tsx
│ ├── page.$pageId..tsx
│ └── search.tsx
├── sessions.server.ts
├── styles
│ ├── gitalk.css
│ ├── globals.css
│ ├── notion.css
│ └── prism.css
└── tailwind.css
├── blog.config.ts
├── cjk.ts
├── desktop.png
├── functions
└── [[path]].ts
├── load-context.ts
├── package.json
├── pnpm-lock.yaml
├── postcss.config.js
├── public
├── _headers
├── _routes.json
├── apple-touch-icon.png
├── avatar.jpeg
├── favicon copy.ico
├── favicon.ico
├── favicon.png
├── favicon.svg
├── fonts
│ ├── IBMPlexSansVar-Italic.woff2
│ ├── IBMPlexSansVar-Roman.woff2
│ ├── SourceSerif-Italic.var.woff2
│ └── SourceSerif.var.woff2
└── polyfill.js
├── scripts
└── build.js
├── tailwind.config.ts
├── tsconfig.json
├── vite.config.ts
├── worker-configuration.d.ts
└── wrangler.toml.tpl
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | /**
2 | * This is intended to be a basic starting point for linting in your app.
3 | * It relies on recommended configs out of the box for simplicity, but you can
4 | * and should modify this configuration to best suit your team's needs.
5 | */
6 |
7 | /** @type {import('eslint').Linter.Config} */
8 | module.exports = {
9 | root: true,
10 | parserOptions: {
11 | ecmaVersion: 'latest',
12 | sourceType: 'module',
13 | ecmaFeatures: {
14 | jsx: true,
15 | },
16 | },
17 | env: {
18 | browser: true,
19 | commonjs: true,
20 | es6: true,
21 | },
22 | ignorePatterns: ['!**/.server', '!**/.client'],
23 |
24 | // Base config
25 | extends: ['eslint:recommended'],
26 |
27 | overrides: [
28 | // React
29 | {
30 | files: ['**/*.{js,jsx,ts,tsx}'],
31 | plugins: ['react', 'jsx-a11y'],
32 | extends: [
33 | 'plugin:react/recommended',
34 | 'plugin:react/jsx-runtime',
35 | 'plugin:react-hooks/recommended',
36 | 'plugin:jsx-a11y/recommended',
37 | ],
38 | settings: {
39 | react: {
40 | version: 'detect',
41 | },
42 | formComponents: ['Form'],
43 | linkComponents: [
44 | { name: 'Link', linkAttribute: 'to' },
45 | { name: 'NavLink', linkAttribute: 'to' },
46 | ],
47 | 'import/resolver': {
48 | typescript: {},
49 | },
50 | },
51 | },
52 |
53 | // Typescript
54 | {
55 | files: ['**/*.{ts,tsx}'],
56 | plugins: ['@typescript-eslint', 'import'],
57 | parser: '@typescript-eslint/parser',
58 | settings: {
59 | 'import/internal-regex': '^~/',
60 | 'import/resolver': {
61 | node: {
62 | extensions: ['.ts', '.tsx'],
63 | },
64 | typescript: {
65 | alwaysTryTypes: true,
66 | },
67 | },
68 | },
69 | extends: [
70 | 'plugin:@typescript-eslint/recommended',
71 | 'plugin:import/recommended',
72 | 'plugin:import/typescript',
73 | ],
74 | },
75 |
76 | // Node
77 | {
78 | files: ['.eslintrc.cjs'],
79 | env: {
80 | node: true,
81 | },
82 | },
83 | ],
84 | };
85 |
--------------------------------------------------------------------------------
/.github/funding.yml:
--------------------------------------------------------------------------------
1 | github: [ycjcl868]
2 |
--------------------------------------------------------------------------------
/.github/issue_template.md:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | /.cache
4 | /build
5 | .env
6 | .dev.vars
7 |
8 | .wrangler
9 | ~
10 | .DS_Store
11 | wrangler.toml
12 |
--------------------------------------------------------------------------------
/.node-version:
--------------------------------------------------------------------------------
1 | 20.7.0
2 |
--------------------------------------------------------------------------------
/.vars:
--------------------------------------------------------------------------------
1 | NOTION_PAGE_ID=
2 | NOTION_ACCESS_TOKEN=
3 | CLOUDFLARE_KV_ID=
4 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | - Demonstrating empathy and kindness toward other people
21 | - Being respectful of differing opinions, viewpoints, and experiences
22 | - Giving and gracefully accepting constructive feedback
23 | - Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | - Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | - The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | - Trolling, insulting or derogatory comments, and personal or political attacks
33 | - Public or private harassment
34 | - Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | - Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | i#craigary.net.
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/README-zh-CN.md:
--------------------------------------------------------------------------------
1 | # 📝 Notion Blog - 零成本实时更新的现代化博客系统
2 |
3 | 中文 | [English](./README.md)
4 |
5 | [](https://developers.cloudflare.com/pages/)
6 | [](https://remix.run/)
7 |
8 | 基于 Remix 和 Notion 构建,部署在 Cloudflare Pages ,使用 Edge Function 的无成本、可实时更新的博客站点。
9 |
10 | 
11 |
12 | ## 🔥 特性
13 |
14 | - 直接使用你的 Notion 页面创建博客,博客内容访问实时更新
15 | - 支持智能缓存(`stale-while-revalidate`),确保页面快速加载的同时,自动在后台更新内容
16 | - 使用 [Remix](https://remix.run/) 构建,最新的技术栈
17 | - 已经部署到 Cloudflare Pages,无需费用
18 | - 使用 [TailwindCSS](https://tailwindcss.com/) 设计简洁美观的博客
19 | - 支持分类标签和搜索功能
20 | - 支持 RSS Feed
21 | - 主题 Light/Dark 切换
22 | - [ ] 国际化支持,运行时大语言模型翻译
23 |
24 | ## 🚀 性能
25 |
26 | [PageSpeed Insights](https://pagespeed.web.dev/analysis/https-www-rustc-cloud/1zuls2fmg9?hl=zh-cn&form_factor=desktop)
27 |
28 | 
29 |
30 |
31 | ## 📦 安装
32 |
33 | 使用以下指令将该项目克隆到你本地
34 |
35 | ```
36 | git clone https://github.com/ycjcl868/blog
37 | ```
38 |
39 | 接下来,使用 pnpm 安装依赖
40 |
41 | ```
42 | pnpm i
43 | ```
44 |
45 | ## 生成 Notion 数据库
46 |
47 | 复制这个 [Notion 模板](https://ycjcl868.notion.site/b7e25fb9b29a48269e92e36f65a3ffbb),共享生成连接。
48 |
49 | 
50 |
51 | 链接中对应的 `PAGE_ID` 是:`https://www.notion.so/{workspace_name}/{page_id}`,通常是地址后 32 位数字
52 |
53 | ## 🔨 本地开发
54 |
55 | 新建 `.dev.vars`,配置如下:
56 |
57 | ```bash
58 | NOTION_PAGE_ID=xxxx # Notion 共享 ID
59 | NOTION_ACCESS_TOKEN=secret_xxx # 在这里申请一个 TOKEN:https://developers.notion.com/docs/create-a-notion-integration
60 | ```
61 |
62 | 然后执行启动命令:
63 |
64 | ```
65 | npm run start
66 | ```
67 |
68 | 访问 `localhost:3000` 即可访问
69 |
70 | ## 📝 发表你的想法
71 |
72 | 如果你有任何建议,欢迎提交 issue 或者 pull request。
73 |
74 | ## Star History
75 |
76 | [](https://star-history.com/#ycjcl868/blog&Date)
77 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # 📝 Notion Blog - A Modern Zero-Cost Blogging System with Real-time Updates
2 |
3 | [中文](./README-zh-CN.md) | English
4 |
5 | [](https://developers.cloudflare.com/pages/)
6 | [](https://remix.run/)
7 |
8 | Built with Remix and Notion, deployed on Cloudflare Pages using Edge Functions for a cost-free, real-time updated blog site.
9 |
10 | 
11 |
12 | ## 🔥 Features
13 |
14 | - Create blogs directly from your Notion pages with real-time content updates
15 | - Smart caching ensures fast page loads while auto-updating content in the background
16 | - Built with modern tech stack using [Remix](https://remix.run/)
17 | - Zero-cost deployment on Cloudflare Pages
18 | - Clean and elegant design powered by [TailwindCSS](https://tailwindcss.com/)
19 | - Category tags and search functionality
20 | - RSS Feed support
21 | - Light/Dark theme toggle
22 | - [ ] Internationalization support with runtime translation using large language models
23 |
24 | https://github.com/user-attachments/assets/375b1a6a-c564-4717-838e-3285f0b90541
25 |
26 | ## 🚀 Performance
27 |
28 | [PageSpeed Insights](https://pagespeed.web.dev/analysis/https-www-rustc-cloud/1zuls2fmg9?hl=zh-cn&form_factor=desktop)
29 |
30 | 
31 |
32 | ## 📦 Installation
33 |
34 | Clone the repository to your local machine:
35 |
36 | ```
37 | git clone https://github.com/ycjcl868/blog
38 | ```
39 |
40 | Install dependencies using pnpm:
41 |
42 | ```
43 | pnpm i
44 | ```
45 |
46 | ## Generate Notion Database
47 |
48 | Duplicate this [Notion template](https://ycjcl868.notion.site/b7e25fb9b29a48269e92e36f65a3ffbb) and share the page to generate a public link.
49 |
50 | 
51 |
52 | The PAGE_ID can be found in the URL format: `https://www.notion.so/{workspace_name}/{page_id}`(typically the last 32 characters)
53 |
54 | ## 🔨 Local Development
55 |
56 | Create `.dev.vars` file with the following configuration:
57 |
58 | ```bash
59 | NOTION_PAGE_ID=xxxx # Notion page ID
60 | NOTION_ACCESS_TOKEN=secret_xxx # Create integration token at: https://developers.notion.com/docs/create-a-notion-integration
61 | ```
62 |
63 | Start the development server:
64 |
65 | ```
66 | npm run start
67 | ```
68 |
69 | Access the site at `localhost:3000`
70 |
71 | ## 📝 Contribute
72 |
73 | Feel free to submit an issue or pull request if you have any suggestions.
74 |
75 | ## Star History
76 |
77 | [](https://star-history.com/#ycjcl868/blog&Date)
78 |
--------------------------------------------------------------------------------
/app/components/Ackee.tsx:
--------------------------------------------------------------------------------
1 | import { useLocation } from '@remix-run/react';
2 | import useAckee from 'use-ackee';
3 |
4 | const Ackee = ({ ackeeServerUrl, ackeeDomainId }) => {
5 | const location = useLocation();
6 | useAckee(
7 | location.pathname,
8 | { server: ackeeServerUrl, domainId: ackeeDomainId },
9 | { detailed: false, ignoreLocalhost: true }
10 | );
11 | return null;
12 | };
13 |
14 | export default Ackee;
15 |
--------------------------------------------------------------------------------
/app/components/BlogPost.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { Link } from '@remix-run/react';
3 | import dayjs from 'dayjs';
4 |
5 | const BlogPost = ({ post }) => {
6 | const date = post?.date?.start_date || post.createdTime;
7 | return (
8 |
9 |
10 |
11 |
12 | {post.title}
13 |
14 | {dayjs(date).isValid() ? (
15 |
18 | ) : null}
19 |
20 |
21 |
22 | {post.summary}
23 |
24 |
25 |
26 |
27 | );
28 | };
29 |
30 | export default BlogPost;
31 |
--------------------------------------------------------------------------------
/app/components/ClientOnly.tsx:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useSyncExternalStore } from 'react';
3 |
4 | function subscribe() {
5 | // biome-ignore lint/suspicious/noEmptyBlockStatements: Mock function
6 | return () => {};
7 | }
8 |
9 | /**
10 | * Return a boolean indicating if the JS has been hydrated already.
11 | * When doing Server-Side Rendering, the result will always be false.
12 | * When doing Client-Side Rendering, the result will always be false on the
13 | * first render and true from then on. Even if a new component renders it will
14 | * always start with true.
15 | *
16 | * Example: Disable a button that needs JS to work.
17 | * ```tsx
18 | * let hydrated = useHydrated();
19 | * return (
20 | *
23 | * );
24 | * ```
25 | */
26 | export function useHydrated() {
27 | return useSyncExternalStore(
28 | subscribe,
29 | () => true,
30 | () => false
31 | );
32 | }
33 |
34 | type Props = {
35 | /**
36 | * You are encouraged to add a fallback that is the same dimensions
37 | * as the client rendered children. This will avoid content layout
38 | * shift which is disgusting
39 | */
40 | children(): React.ReactNode;
41 | fallback?: React.ReactNode;
42 | };
43 |
44 | /**
45 | * Render the children only after the JS has loaded client-side. Use an optional
46 | * fallback component if the JS is not yet loaded.
47 | *
48 | * Example: Render a Chart component if JS loads, renders a simple FakeChart
49 | * component server-side or if there is no JS. The FakeChart can have only the
50 | * UI without the behavior or be a loading spinner or skeleton.
51 | * ```tsx
52 | * return (
53 | * }>
54 | * {() => }
55 | *
56 | * );
57 | * ```
58 | */
59 | export function ClientOnly({ children, fallback = null }: Props) {
60 | return useHydrated() ? <>{children()}> : <>{fallback}>;
61 | }
62 |
--------------------------------------------------------------------------------
/app/components/Comments.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { useLocation } from '@remix-run/react';
3 | import { lazy, Suspense, useEffect } from 'react';
4 | import { Theme, useTheme } from 'remix-themes';
5 | import { ClientOnly } from './ClientOnly';
6 |
7 | const GitalkComponent = lazy(() => import('~/components/Gitalk'));
8 | const UtterancesComponent = lazy(() => import('~/components/Utterances'));
9 | const CusdisComponent = lazy(() =>
10 | import('react-cusdis').then((mod) => ({
11 | default: mod.ReactCusdis,
12 | }))
13 | );
14 |
15 | const Comments = ({ frontMatter }) => {
16 | const location = useLocation();
17 | const [theme] = useTheme();
18 |
19 | const cusdisTheme = theme === Theme.DARK ? Theme.DARK : Theme.LIGHT;
20 |
21 | useEffect(() => {
22 | if (typeof window !== 'undefined' && window.CUSDIS) {
23 | window.CUSDIS.setTheme(cusdisTheme);
24 | }
25 | }, [cusdisTheme]);
26 |
27 | return (
28 |
29 | {BLOG.comment && BLOG.comment.provider === 'gitalk' && (
30 |
31 |
45 |
46 | )}
47 | {BLOG.comment && BLOG.comment.provider === 'utterances' && (
48 |
49 |
50 |
51 | )}
52 | {BLOG.comment && BLOG.comment.provider === 'cusdis' && (
53 |
54 | {() => (
55 |
56 |
67 |
68 | )}
69 |
70 | )}
71 |
72 | );
73 | };
74 |
75 | export default Comments;
76 |
--------------------------------------------------------------------------------
/app/components/Container.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { motion } from 'framer-motion';
3 | import { useEffect, useState } from 'react';
4 | import Footer from '~/components/Footer';
5 | import Header from '~/components/Header';
6 |
7 | const Container: React.FC = ({
8 | children,
9 | title,
10 | layout,
11 | fullWidth,
12 | ...customMeta
13 | }) => {
14 | const [isMounted, setIsMounted] = useState(false);
15 | const meta = {
16 | title: title || BLOG.title,
17 | type: 'website',
18 | ...customMeta,
19 | };
20 |
21 | useEffect(() => {
22 | setIsMounted(true);
23 | }, []);
24 |
25 | return (
26 |
27 |
37 |
41 |
46 | {children}
47 |
48 |
49 |
50 |
51 | );
52 | };
53 |
54 | export default Container;
55 |
--------------------------------------------------------------------------------
/app/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { forwardRef } from 'react';
3 |
4 | const Footer = forwardRef(({ fullWidth }, ref) => {
5 | const d = new Date();
6 | const y = d.getFullYear();
7 | const from = +BLOG.since;
8 |
9 | return (
10 |
33 | );
34 | });
35 |
36 | Footer.displayName = 'Footer';
37 |
38 | export default Footer;
39 |
--------------------------------------------------------------------------------
/app/components/Gitalk/assets/icon/arrow_down.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/components/Gitalk/assets/icon/edit.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/app/components/Gitalk/assets/icon/github.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/app/components/Gitalk/assets/icon/heart.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/app/components/Gitalk/assets/icon/heart_on.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/app/components/Gitalk/assets/icon/reply.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/app/components/Gitalk/assets/icon/tip.svg:
--------------------------------------------------------------------------------
1 |
9 |
--------------------------------------------------------------------------------
/app/components/Gitalk/component/action.tsx:
--------------------------------------------------------------------------------
1 | const Action = ({ className, onClick, text }) => (
2 |
3 | {text}
4 |
5 | );
6 |
7 | export default Action;
8 |
--------------------------------------------------------------------------------
/app/components/Gitalk/component/avatar.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import GithubSVG from '../assets/icon/github.svg';
3 |
4 | const Avatar = ({ src, className, alt, defaultSrc = GithubSVG }) => {
5 | const [imgSrc, setImageSrc] = useState(src || defaultSrc);
6 | return (
7 |
8 |

setImageSrc(defaultSrc)}
14 | />
15 |
16 | );
17 | };
18 |
19 | export default Avatar;
20 |
--------------------------------------------------------------------------------
/app/components/Gitalk/component/button.tsx:
--------------------------------------------------------------------------------
1 | const Button = ({
2 | className,
3 | getRef,
4 | onClick,
5 | onMouseDown,
6 | text,
7 | isLoading,
8 | }) => (
9 |
18 | );
19 |
20 | export default Button;
21 |
--------------------------------------------------------------------------------
/app/components/Gitalk/component/comment.tsx:
--------------------------------------------------------------------------------
1 | import { formatDistanceToNow, parseISO } from 'date-fns';
2 | import { de, es, fr, ko, pl, ru, zhCN, zhTW } from 'date-fns/locale';
3 | import { Component } from 'react';
4 | import Avatar from './avatar';
5 | import Svg from './svg';
6 |
7 | if (typeof window !== 'undefined') {
8 | window.GT_i18n_LocaleMap = {
9 | zh: zhCN,
10 | 'zh-CN': zhCN,
11 | 'zh-TW': zhTW,
12 | 'es-ES': es,
13 | fr: fr,
14 | ru: ru,
15 | pl: pl,
16 | ko: ko,
17 | de: de,
18 | };
19 | }
20 |
21 | export default class Comment extends Component {
22 | shouldComponentUpdate({ comment }) {
23 | return comment !== this.props.comment;
24 | }
25 |
26 | componentDidMount() {
27 | const comment = this.node;
28 | const emailResponse = comment.querySelector('.email-hidden-toggle>a');
29 | if (emailResponse) {
30 | emailResponse.addEventListener(
31 | 'click',
32 | (e) => {
33 | e.preventDefault();
34 | comment
35 | .querySelector('.email-hidden-reply')
36 | .classList.toggle('expanded');
37 | },
38 | true
39 | );
40 | }
41 | }
42 |
43 | render() {
44 | const {
45 | comment,
46 | user,
47 | language,
48 | commentedText = '',
49 | admin = [],
50 | replyCallback,
51 | likeCallback,
52 | } = this.props;
53 | const enableEdit = user && comment.user.login === user.login;
54 | const isAdmin = ~[]
55 | .concat(admin)
56 | .map((a) => a.toLowerCase())
57 | .indexOf(comment.user.login.toLowerCase());
58 | const reactions = comment.reactions;
59 |
60 | let reactionTotalCount = '';
61 | if (reactions && reactions.totalCount) {
62 | reactionTotalCount = reactions.totalCount;
63 | if (
64 | reactions.totalCount === 100 &&
65 | reactions.pageInfo &&
66 | reactions.pageInfo.hasNextPage
67 | ) {
68 | reactionTotalCount = '100+';
69 | }
70 | }
71 |
72 | return (
73 | {
75 | this.node = node;
76 | }}
77 | className={`gt-comment ${isAdmin ? 'gt-comment-admin' : ''}`}
78 | >
79 |
84 |
85 |
151 |
152 | );
153 | }
154 | }
155 |
--------------------------------------------------------------------------------
/app/components/Gitalk/component/svg.tsx:
--------------------------------------------------------------------------------
1 | import ArrowDown from '../assets/icon/arrow_down.svg';
2 | import Edit from '../assets/icon/edit.svg';
3 | import Github from '../assets/icon/github.svg';
4 | import Heart from '../assets/icon/heart.svg';
5 | import HeartOn from '../assets/icon/heart_on.svg';
6 | import Reply from '../assets/icon/reply.svg';
7 | import Tip from '../assets/icon/tip.svg';
8 |
9 | const map = {
10 | arrow_down: ArrowDown,
11 | edit: Edit,
12 | github: Github,
13 | heart: Heart,
14 | heart_on: HeartOn,
15 | reply: Reply,
16 | tip: Tip,
17 | };
18 |
19 | const SVG = ({ className, text, name }) => {
20 | const Icon = map[name];
21 | return (
22 |
23 |
24 | {text && {text}}
25 |
26 | );
27 | };
28 |
29 | export default SVG;
30 |
--------------------------------------------------------------------------------
/app/components/Gitalk/const.ts:
--------------------------------------------------------------------------------
1 | export const GT_ACCESS_TOKEN = 'GT_ACCESS_TOKEN';
2 | export const GT_VERSION = '1.7.2';
3 | export const GT_COMMENT = 'GT_COMMENT';
4 |
--------------------------------------------------------------------------------
/app/components/Gitalk/graphql/getComments.ts:
--------------------------------------------------------------------------------
1 | import { axiosGithub } from '../util';
2 |
3 | const getQL = (vars, pagerDirection) => {
4 | const cursorDirection = pagerDirection === 'last' ? 'before' : 'after';
5 | const ql = `
6 | query getIssueAndComments(
7 | $owner: String!,
8 | $repo: String!,
9 | $id: Int!,
10 | $cursor: String,
11 | $pageSize: Int!
12 | ) {
13 | repository(owner: $owner, name: $repo) {
14 | issue(number: $id) {
15 | title
16 | url
17 | bodyHTML
18 | createdAt
19 | comments(${pagerDirection}: $pageSize, ${cursorDirection}: $cursor) {
20 | totalCount
21 | pageInfo {
22 | ${pagerDirection === 'last' ? 'hasPreviousPage' : 'hasNextPage'}
23 | ${cursorDirection === 'before' ? 'startCursor' : 'endCursor'}
24 | }
25 | nodes {
26 | id
27 | databaseId
28 | author {
29 | avatarUrl
30 | login
31 | url
32 | }
33 | bodyHTML
34 | body
35 | createdAt
36 | reactions(first: 100, content: HEART) {
37 | totalCount
38 | viewerHasReacted
39 | pageInfo{
40 | hasNextPage
41 | }
42 | nodes {
43 | id
44 | databaseId
45 | user {
46 | login
47 | }
48 | }
49 | }
50 | }
51 | }
52 | }
53 | }
54 | }
55 | `;
56 |
57 | if (vars.cursor === null) delete vars.cursor;
58 |
59 | return {
60 | operationName: 'getIssueAndComments',
61 | query: ql,
62 | variables: vars,
63 | };
64 | };
65 |
66 | function getComments(issue) {
67 | const { owner, repo, perPage, pagerDirection, defaultAuthor } = this.options;
68 | const { cursor, comments } = this.state;
69 | return axiosGithub
70 | .post(
71 | '/graphql',
72 | getQL(
73 | {
74 | owner,
75 | repo,
76 | id: issue.number,
77 | pageSize: perPage,
78 | cursor,
79 | },
80 | pagerDirection
81 | ),
82 | {
83 | headers: {
84 | Authorization: `bearer ${this.accessToken}`,
85 | },
86 | }
87 | )
88 | .then((res) => {
89 | const data = res.data.data.repository.issue.comments;
90 | const items = data.nodes.map((node) => {
91 | const author = node.author || defaultAuthor;
92 |
93 | return {
94 | id: node.databaseId,
95 | gId: node.id,
96 | user: {
97 | avatar_url: author.avatarUrl,
98 | login: author.login,
99 | html_url: author.url,
100 | },
101 | created_at: node.createdAt,
102 | body_html: node.bodyHTML,
103 | body: node.body,
104 | html_url: `https://github.com/${owner}/${repo}/issues/${issue.number}#issuecomment-${node.databaseId}`,
105 | reactions: node.reactions,
106 | };
107 | });
108 |
109 | let cs;
110 |
111 | if (pagerDirection === 'last') {
112 | cs = [...items, ...comments];
113 | } else {
114 | cs = [...comments, ...items];
115 | }
116 |
117 | const isLoadOver =
118 | data.pageInfo.hasPreviousPage === false ||
119 | data.pageInfo.hasNextPage === false;
120 | this.setState({
121 | comments: cs,
122 | isLoadOver,
123 | cursor: data.pageInfo.startCursor || data.pageInfo.endCursor,
124 | });
125 | return cs;
126 | });
127 | }
128 |
129 | export default getComments;
130 |
--------------------------------------------------------------------------------
/app/components/Gitalk/index.tsx:
--------------------------------------------------------------------------------
1 | import autosize from 'autosize';
2 | import Polyglot from 'node-polyglot';
3 | import { Component } from 'react';
4 | import FlipMove from 'react-flip-move';
5 |
6 | import Action from './component/action';
7 | import Avatar from './component/avatar';
8 | import Button from './component/button';
9 | import Comment from './component/comment';
10 | import Svg from './component/svg';
11 | import { GT_ACCESS_TOKEN, GT_COMMENT, GT_VERSION } from './const';
12 | import QLGetComments from './graphql/getComments';
13 | import {
14 | axiosGithub,
15 | axiosJSON,
16 | formatErrorMsg,
17 | getMetaContent,
18 | hasClassInParent,
19 | queryParse,
20 | queryStringify,
21 | } from './util';
22 | import zhCN from './zh-CN.json';
23 |
24 | const i18n = () =>
25 | new Polyglot({
26 | phrases: zhCN,
27 | locale: 'zh-CN',
28 | });
29 |
30 | class GitalkComponent extends Component {
31 | state = {
32 | user: null,
33 | issue: null,
34 | comments: [],
35 | localComments: [],
36 | comment: '',
37 | page: 1,
38 | pagerDirection: 'last',
39 | cursor: null,
40 | previewHtml: '',
41 |
42 | isNoInit: false,
43 | isIniting: true,
44 | isCreating: false,
45 | isLoading: false,
46 | isLoadMore: false,
47 | isLoadOver: false,
48 | isIssueCreating: false,
49 | isPopupVisible: false,
50 | isInputFocused: false,
51 | isPreview: false,
52 |
53 | isOccurError: false,
54 | errorMsg: '',
55 | };
56 |
57 | constructor(props) {
58 | super(props);
59 | this.options = Object.assign(
60 | {},
61 | {
62 | id: window.location.href,
63 | number: -1,
64 | labels: ['Gitalk'],
65 | title: window.document.title,
66 | body: '', // window.location.href + header.meta[description]
67 | language: window.navigator.language || window.navigator.userLanguage,
68 | perPage: 10,
69 | pagerDirection: 'last', // last or first
70 | createIssueManually: false,
71 | distractionFreeMode: false,
72 | proxy:
73 | 'https://proxy.rustc.cloud/?https://github.com/login/oauth/access_token',
74 | flipMoveOptions: {
75 | staggerDelayBy: 150,
76 | appearAnimation: 'accordionVertical',
77 | enterAnimation: 'accordionVertical',
78 | leaveAnimation: 'accordionVertical',
79 | },
80 | enableHotKey: true,
81 |
82 | url: window.location.href,
83 |
84 | defaultAuthor: {
85 | avatarUrl: '//avatars1.githubusercontent.com/u/29697133?s=50',
86 | login: 'null',
87 | url: '',
88 | },
89 |
90 | updateCountCallback: null,
91 | },
92 | props.options
93 | );
94 |
95 | this.state.pagerDirection = this.options.pagerDirection;
96 | const storedComment = window.localStorage.getItem(GT_COMMENT);
97 | if (storedComment) {
98 | this.state.comment = decodeURIComponent(storedComment);
99 | window.localStorage.removeItem(GT_COMMENT);
100 | }
101 |
102 | const query = queryParse();
103 | if (query.code) {
104 | const code = query.code;
105 | delete query.code;
106 | const replacedUrl = `${window.location.origin}${
107 | window.location.pathname
108 | }?${queryStringify(query)}${window.location.hash}`;
109 | history.replaceState(null, null, replacedUrl);
110 | this.options = Object.assign(
111 | {},
112 | this.options,
113 | {
114 | url: replacedUrl,
115 | id: replacedUrl,
116 | },
117 | props.options
118 | );
119 |
120 | axiosJSON
121 | .post(this.options.proxy, {
122 | code,
123 | client_id: this.options.clientID,
124 | client_secret: this.options.clientSecret,
125 | })
126 | .then((res) => {
127 | if (res.data && res.data.access_token) {
128 | this.accessToken = res.data.access_token;
129 |
130 | this.getInit()
131 | .then(() => this.setState({ isIniting: false }))
132 | .catch((err) => {
133 | console.log('err:', err);
134 | this.setState({
135 | isIniting: false,
136 | isOccurError: true,
137 | errorMsg: formatErrorMsg(err),
138 | });
139 | });
140 | } else {
141 | // no access_token
142 | console.log('res.data err:', res.data);
143 | this.setState({
144 | isOccurError: true,
145 | errorMsg: formatErrorMsg(new Error('no access token')),
146 | });
147 | }
148 | })
149 | .catch((err) => {
150 | console.log('err: ', err);
151 | this.setState({
152 | isOccurError: true,
153 | errorMsg: formatErrorMsg(err),
154 | });
155 | });
156 | } else {
157 | this.getInit()
158 | .then(() => this.setState({ isIniting: false }))
159 | .catch((err) => {
160 | console.log('err:', err);
161 | this.setState({
162 | isIniting: false,
163 | isOccurError: true,
164 | errorMsg: formatErrorMsg(err),
165 | });
166 | });
167 | }
168 |
169 | this.i18n = i18n(this.options.language);
170 | }
171 |
172 | componentDidUpdate() {
173 | this.commentEL && autosize(this.commentEL);
174 | }
175 |
176 | get accessToken() {
177 | return this._accessToke || window.localStorage.getItem(GT_ACCESS_TOKEN);
178 | }
179 |
180 | set accessToken(token) {
181 | window.localStorage.setItem(GT_ACCESS_TOKEN, token);
182 | this._accessToken = token;
183 | }
184 |
185 | get loginLink() {
186 | const githubOauthUrl = 'https://github.com/login/oauth/authorize';
187 | const { clientID } = this.options;
188 | const query = {
189 | client_id: clientID,
190 | redirect_uri: window.location.href,
191 | scope: 'public_repo',
192 | };
193 | return `${githubOauthUrl}?${queryStringify(query)}`;
194 | }
195 |
196 | get isAdmin() {
197 | const { admin } = this.options;
198 | const { user } = this.state;
199 |
200 | return (
201 | user &&
202 | ~[]
203 | .concat(admin)
204 | .map((a) => a.toLowerCase())
205 | .indexOf(user.login.toLowerCase())
206 | );
207 | }
208 |
209 | getInit() {
210 | return this.getUserInfo()
211 | .then(() => this.getIssue())
212 | .then((issue) => this.getComments(issue));
213 | }
214 |
215 | getUserInfo() {
216 | if (!this.accessToken) {
217 | return new Promise((resolve) => {
218 | resolve();
219 | });
220 | }
221 | return axiosGithub
222 | .get('/user', {
223 | headers: {
224 | Authorization: `token ${this.accessToken}`,
225 | },
226 | })
227 | .then((res) => {
228 | this.setState({ user: res.data });
229 | })
230 | .catch((err) => {
231 | this.logout();
232 | });
233 | }
234 |
235 | getIssueById() {
236 | const { owner, repo, number, clientID, clientSecret } = this.options;
237 | const getUrl = `/repos/${owner}/${repo}/issues/${number}`;
238 | return new Promise((resolve, reject) => {
239 | axiosGithub
240 | .get(`${getUrl}${encodeURIComponent(`?t=${Date.now()}`)}`, {
241 | auth: {
242 | username: clientID,
243 | password: clientSecret,
244 | },
245 | // params: {
246 | // t: Date.now()
247 | // }
248 | })
249 | .then((res) => {
250 | let issue = null;
251 |
252 | if (res && res.data && res.data.number === number) {
253 | issue = res.data;
254 |
255 | this.setState({ issue, isNoInit: false });
256 | }
257 | resolve(issue);
258 | })
259 | .catch((err) => {
260 | // When the status code is 404, promise will be resolved with null
261 | if (err.response.status === 404) resolve(null);
262 | reject(err);
263 | });
264 | });
265 | }
266 |
267 | getIssueByLabels() {
268 | const { owner, repo, id, labels, clientID, clientSecret } = this.options;
269 |
270 | return axiosGithub
271 | .get(
272 | `/repos/${owner}/${repo}/issues${encodeURIComponent(
273 | `?labels=${labels.concat(id).join(',')}&t=${Date.now()}`
274 | )}`,
275 | {
276 | auth: {
277 | username: clientID,
278 | password: clientSecret,
279 | },
280 | // params: {
281 | // labels: labels.concat(id).join(','),
282 | // t: Date.now()
283 | // }
284 | }
285 | )
286 | .then((res) => {
287 | const { createIssueManually } = this.options;
288 | let isNoInit = false;
289 | let issue = null;
290 | if (!(res && res.data && res.data.length)) {
291 | if (!createIssueManually && this.isAdmin) {
292 | return this.createIssue();
293 | }
294 |
295 | isNoInit = true;
296 | } else {
297 | issue = res.data[0];
298 | }
299 | this.setState({ issue, isNoInit });
300 | return issue;
301 | });
302 | }
303 |
304 | getIssue() {
305 | const { number } = this.options;
306 | const { issue } = this.state;
307 | if (issue) {
308 | this.setState({ isNoInit: false });
309 | console.log('issue', issue);
310 | return Promise.resolve(issue);
311 | }
312 |
313 | if (typeof number === 'number' && number > 0) {
314 | return this.getIssueById().then((resIssue) => {
315 | if (!resIssue) return this.getIssueByLabels();
316 | return resIssue;
317 | });
318 | }
319 | return this.getIssueByLabels();
320 | }
321 |
322 | createIssue() {
323 | const { owner, repo, title, body, id, labels, url } = this.options;
324 | return axiosGithub
325 | .post(
326 | `/repos/${owner}/${repo}/issues`,
327 | {
328 | title,
329 | labels: labels.concat(id),
330 | body:
331 | body ||
332 | `${url} \n\n ${
333 | getMetaContent('description') ||
334 | getMetaContent('description', 'og:description') ||
335 | ''
336 | }`,
337 | },
338 | {
339 | headers: {
340 | Authorization: `token ${this.accessToken}`,
341 | },
342 | }
343 | )
344 | .then((res) => {
345 | this.setState({ issue: res.data });
346 | return res.data;
347 | });
348 | }
349 |
350 | // Get comments via v3 api, don't require login, but sorting feature is disable
351 | getCommentsV3 = (issue) => {
352 | const { perPage, clientID, clientSecret } = this.options;
353 | const { page } = this.state;
354 |
355 | return this.getIssue().then((issue) => {
356 | if (!issue) return;
357 |
358 | console.log('issueissue', issue);
359 | const path = issue.comments_url?.replace('https://api.github.com', '');
360 |
361 | return axiosGithub
362 | .get(
363 | `${path}${encodeURIComponent(`?per_page=${perPage}&page=${page}`)}`,
364 | {
365 | headers: {
366 | Accept: 'application/vnd.github.v3.full+json',
367 | },
368 | auth: {
369 | username: clientID,
370 | password: clientSecret,
371 | },
372 | // params: {
373 | // per_page: perPage,
374 | // page
375 | // }
376 | }
377 | )
378 | .then((res) => {
379 | const { comments, issue } = this.state;
380 | let isLoadOver = false;
381 | const cs = comments.concat(res.data);
382 | if (cs.length >= issue?.comments || res.data.length < perPage) {
383 | isLoadOver = true;
384 | }
385 | this.setState({
386 | comments: cs,
387 | isLoadOver,
388 | page: page + 1,
389 | });
390 | return cs;
391 | });
392 | });
393 | };
394 |
395 | getComments(issue) {
396 | if (!issue) return;
397 | // Get comments via v4 graphql api, login required and sorting feature is available
398 | if (this.accessToken) return QLGetComments.call(this, issue);
399 | return this.getCommentsV3(issue);
400 | }
401 |
402 | createComment() {
403 | const { comment, localComments, comments } = this.state;
404 |
405 | return this.getIssue()
406 | .then((issue) =>
407 | axiosGithub.post(
408 | issue.comments_url,
409 | {
410 | body: comment,
411 | },
412 | {
413 | headers: {
414 | Accept: 'application/vnd.github.v3.full+json',
415 | Authorization: `token ${this.accessToken}`,
416 | },
417 | }
418 | )
419 | )
420 | .then((res) => {
421 | this.setState({
422 | comment: '',
423 | comments: comments.concat(res.data),
424 | localComments: localComments.concat(res.data),
425 | });
426 | });
427 | }
428 |
429 | logout() {
430 | this.setState({ user: null });
431 | window.localStorage.removeItem(GT_ACCESS_TOKEN);
432 | }
433 |
434 | getRef = (e) => {
435 | this.publicBtnEL = e;
436 | };
437 |
438 | reply = (replyComment) => () => {
439 | const { comment } = this.state;
440 | const replyCommentBody = replyComment.body;
441 | let replyCommentArray = replyCommentBody.split('\n');
442 | replyCommentArray.unshift(`@${replyComment.user.login}`);
443 | replyCommentArray = replyCommentArray.map((t) => `> ${t}`);
444 | replyCommentArray.push('');
445 | replyCommentArray.push('');
446 | if (comment) replyCommentArray.unshift('');
447 | this.setState({ comment: comment + replyCommentArray.join('\n') }, () => {
448 | autosize.update(this.commentEL);
449 | this.commentEL.focus();
450 | });
451 | };
452 |
453 | like(comment) {
454 | const { owner, repo } = this.options;
455 | const { user } = this.state;
456 | let { comments } = this.state;
457 |
458 | axiosGithub
459 | .post(
460 | `/repos/${owner}/${repo}/issues/comments/${comment.id}/reactions`,
461 | {
462 | content: 'heart',
463 | },
464 | {
465 | headers: {
466 | Authorization: `token ${this.accessToken}`,
467 | Accept: 'application/vnd.github.squirrel-girl-preview',
468 | },
469 | }
470 | )
471 | .then((res) => {
472 | comments = comments.map((c) => {
473 | if (c.id === comment.id) {
474 | if (c.reactions) {
475 | if (
476 | !~c.reactions.nodes.findIndex(
477 | (n) => n.user.login === user.login
478 | )
479 | ) {
480 | c.reactions.totalCount += 1;
481 | }
482 | } else {
483 | c.reactions = { nodes: [] };
484 | c.reactions.totalCount = 1;
485 | }
486 |
487 | c.reactions.nodes.push(res.data);
488 | c.reactions.viewerHasReacted = true;
489 | return Object.assign({}, c);
490 | }
491 | return c;
492 | });
493 |
494 | this.setState({
495 | comments,
496 | });
497 | });
498 | }
499 |
500 | unLike(comment) {
501 | const { user } = this.state;
502 | let { comments } = this.state;
503 |
504 | // const { user } = this.state
505 | // let id
506 | // comment.reactions.nodes.forEach(r => {
507 | // if (r.user.login = user.login) id = r.databaseId
508 | // })
509 | // return axiosGithub.delete(`/reactions/${id}`, {
510 | // headers: {
511 | // Authorization: `token ${this.accessToken}`,
512 | // Accept: 'application/vnd.github.squirrel-girl-preview'
513 | // }
514 | // }).then(res => {
515 | // console.log('res:', res)
516 | // })
517 |
518 | const getQL = (id) => ({
519 | operationName: 'RemoveReaction',
520 | query: `
521 | mutation RemoveReaction{
522 | removeReaction (input:{
523 | subjectId: "${id}",
524 | content: HEART
525 | }) {
526 | reaction {
527 | content
528 | }
529 | }
530 | }
531 | `,
532 | });
533 |
534 | axiosGithub
535 | .post('/graphql', getQL(comment.gId), {
536 | headers: {
537 | Authorization: `bearer ${this.accessToken}`,
538 | },
539 | })
540 | .then((res) => {
541 | if (res.data) {
542 | comments = comments.map((c) => {
543 | if (c.id === comment.id) {
544 | const index = c.reactions.nodes.findIndex(
545 | (n) => n.user.login === user.login
546 | );
547 | if (~index) {
548 | c.reactions.totalCount -= 1;
549 | c.reactions.nodes.splice(index, 1);
550 | }
551 | c.reactions.viewerHasReacted = false;
552 | return Object.assign({}, c);
553 | }
554 | return c;
555 | });
556 |
557 | this.setState({
558 | comments,
559 | });
560 | }
561 | });
562 | }
563 |
564 | handlePopup = (e) => {
565 | e.preventDefault();
566 | e.stopPropagation();
567 | const isVisible = !this.state.isPopupVisible;
568 | const hideHandle = (e1) => {
569 | if (hasClassInParent(e1.target, 'gt-user', 'gt-popup')) {
570 | return;
571 | }
572 | window.document.removeEventListener('click', hideHandle);
573 | this.setState({ isPopupVisible: false });
574 | };
575 | this.setState({ isPopupVisible: isVisible });
576 | if (isVisible) {
577 | window.document.addEventListener('click', hideHandle);
578 | } else {
579 | window.document.removeEventListener('click', hideHandle);
580 | }
581 | };
582 |
583 | handleLogin = () => {
584 | const { comment } = this.state;
585 | window.localStorage.setItem(GT_COMMENT, encodeURIComponent(comment));
586 | window.location.href = this.loginLink;
587 | };
588 |
589 | handleIssueCreate = () => {
590 | this.setState({ isIssueCreating: true });
591 | this.createIssue()
592 | .then((issue) => {
593 | this.setState({
594 | isIssueCreating: false,
595 | isOccurError: false,
596 | });
597 | return this.getComments(issue);
598 | })
599 | .catch((err) => {
600 | this.setState({
601 | isIssueCreating: false,
602 | isOccurError: true,
603 | errorMsg: formatErrorMsg(err),
604 | });
605 | })
606 | .then((res) => {
607 | if (res) {
608 | this.setState({
609 | isNoInit: false,
610 | });
611 | }
612 | });
613 | };
614 |
615 | handleCommentCreate = (e) => {
616 | if (!this.state.comment.length) {
617 | e && e.preventDefault();
618 | this.commentEL.focus();
619 | return;
620 | }
621 | this.setState((state) => {
622 | if (state.isCreating) return;
623 |
624 | this.createComment()
625 | .then(() =>
626 | this.setState({
627 | isCreating: false,
628 | isOccurError: false,
629 | })
630 | )
631 | .catch((err) => {
632 | this.setState({
633 | isCreating: false,
634 | isOccurError: true,
635 | errorMsg: formatErrorMsg(err),
636 | });
637 | });
638 | return { isCreating: true };
639 | });
640 | };
641 |
642 | handleCommentPreview = (e) => {
643 | this.setState({
644 | isPreview: !this.state.isPreview,
645 | });
646 | if (!this.state.isPreview) {
647 | return;
648 | }
649 | axiosGithub
650 | .post(
651 | '/markdown',
652 | {
653 | text: this.state.comment,
654 | },
655 | {
656 | headers: this.accessToken && {
657 | Authorization: `token ${this.accessToken}`,
658 | },
659 | }
660 | )
661 | .then((res) => {
662 | this.setState({
663 | previewHtml: res.data,
664 | });
665 | })
666 | .catch((err) => {
667 | this.setState({
668 | isOccurError: true,
669 | errorMsg: formatErrorMsg(err),
670 | });
671 | });
672 | };
673 |
674 | handleCommentLoad = () => {
675 | const { issue, isLoadMore } = this.state;
676 | if (isLoadMore) return;
677 | this.setState({ isLoadMore: true });
678 | console.log('issue', issue);
679 | this.getComments(issue).then(() => this.setState({ isLoadMore: false }));
680 | };
681 |
682 | handleCommentChange = (e) => this.setState({ comment: e.target.value });
683 | handleLogout = () => {
684 | this.logout();
685 | window.location.reload();
686 | };
687 |
688 | handleCommentFocus = (e) => {
689 | const { distractionFreeMode } = this.options;
690 | if (!distractionFreeMode) return e.preventDefault();
691 | this.setState({ isInputFocused: true });
692 | };
693 |
694 | handleCommentBlur = (e) => {
695 | const { distractionFreeMode } = this.options;
696 | if (!distractionFreeMode) return e.preventDefault();
697 | this.setState({ isInputFocused: false });
698 | };
699 |
700 | handleSort = (direction) => (e) => {
701 | this.setState({ pagerDirection: direction });
702 | };
703 |
704 | handleCommentKeyDown = (e) => {
705 | const { enableHotKey } = this.options;
706 | if (enableHotKey && (e.metaKey || e.ctrlKey) && e.keyCode === 13) {
707 | this.publicBtnEL && this.publicBtnEL.focus();
708 | this.handleCommentCreate();
709 | }
710 | };
711 |
712 | initing() {
713 | return (
714 |
715 |
716 |
{this.i18n.t('init')}
717 |
718 | );
719 | }
720 |
721 | noInit() {
722 | const { user, isIssueCreating } = this.state;
723 | const { owner, repo, admin } = this.options;
724 | return (
725 |
726 |
Issues`,
730 | }),
731 | }}
732 | />
733 |
734 | {this.i18n.t('please-contact', {
735 | user: []
736 | .concat(admin)
737 | .map((u) => `@${u}`)
738 | .join(' '),
739 | })}
740 |
741 | {this.isAdmin ? (
742 |
743 |
748 |
749 | ) : null}
750 | {!user && (
751 |
756 | )}
757 |
758 | );
759 | }
760 |
761 | header() {
762 | const { user, comment, isCreating, previewHtml, isPreview } = this.state;
763 | return (
764 |
765 | {user ? (
766 |
771 | ) : (
772 |
773 |
774 |
775 | )}
776 |
833 |
834 | );
835 | }
836 |
837 | comments() {
838 | const { user, comments, isLoadOver, isLoadMore, pagerDirection } =
839 | this.state;
840 | const { language, flipMoveOptions, admin } = this.options;
841 | const totalComments = comments.concat([]);
842 | if (pagerDirection === 'last' && this.accessToken) {
843 | totalComments.reverse();
844 | }
845 | return (
846 |
847 |
848 | {totalComments.map((c) => (
849 |
863 | ))}
864 |
865 | {!totalComments.length && (
866 |
867 | {this.i18n.t('first-comment-person')}
868 |
869 | )}
870 | {!isLoadOver && totalComments.length ? (
871 |
872 |
878 |
879 | ) : null}
880 |
881 | );
882 | }
883 |
884 | meta() {
885 | const { user, issue, isPopupVisible, pagerDirection, localComments } =
886 | this.state;
887 | const cnt = (issue && issue.comments) + localComments.length;
888 | const isDesc = pagerDirection === 'last';
889 | const { updateCountCallback } = this.options;
890 |
891 | // window.GITALK_COMMENTS_COUNT = cnt
892 | if (
893 | updateCountCallback &&
894 | {}.toString.call(updateCountCallback) === '[object Function]'
895 | ) {
896 | try {
897 | updateCountCallback(cnt);
898 | } catch (err) {
899 | console.log(
900 | 'An error occurred executing the updateCountCallback:',
901 | err
902 | );
903 | }
904 | }
905 |
906 | return (
907 |
908 |
${cnt}`,
915 | smart_count: cnt,
916 | }),
917 | }}
918 | />
919 | {isPopupVisible && (
920 |
961 | )}
962 |
963 | {user ? (
964 |
970 | {user.login}
971 |
972 |
973 | ) : (
974 |
980 | {this.i18n.t('anonymous')}
981 |
982 |
983 | )}
984 |
985 |
986 | );
987 | }
988 |
989 | render() {
990 | const { isIniting, isNoInit, isOccurError, errorMsg, isInputFocused } =
991 | this.state;
992 | return (
993 |
996 | {isIniting && this.initing()}
997 | {!isIniting && (isNoInit ? [] : [this.meta()])}
998 | {isOccurError &&
{errorMsg}
}
999 | {!isIniting &&
1000 | (isNoInit ? [this.noInit()] : [this.header(), this.comments()])}
1001 |
1002 | );
1003 | }
1004 | }
1005 |
1006 | export default GitalkComponent;
1007 |
--------------------------------------------------------------------------------
/app/components/Gitalk/util.ts:
--------------------------------------------------------------------------------
1 | import ky from 'ky';
2 |
3 | export const queryParse = (search = window.location.search) => {
4 | if (!search) return {};
5 | const queryString = search[0] === '?' ? search.substring(1) : search;
6 | const query = {};
7 | queryString.split('&').forEach((queryStr) => {
8 | const [key, value] = queryStr.split('=');
9 | /* istanbul ignore else */
10 | if (key) query[decodeURIComponent(key)] = decodeURIComponent(value);
11 | });
12 |
13 | return query;
14 | };
15 |
16 | export const queryStringify = (query) => {
17 | const queryString = Object.keys(query)
18 | .map((key) => `${key}=${encodeURIComponent(query[key] || '')}`)
19 | .join('&');
20 | return queryString;
21 | };
22 |
23 | export const axiosJSON = ky.create({
24 | headers: {
25 | Accept: 'application/json',
26 | },
27 | });
28 |
29 | export const axiosGithub = ky.create({
30 | prefixUrl: 'https://proxy.rustc.cloud/?https://api.github.com',
31 | headers: {
32 | Accept: 'application/json',
33 | },
34 | });
35 |
36 | export const getMetaContent = (name, content) => {
37 | content || (content = 'content');
38 | const el = window.document.querySelector(`meta[name='${name}']`);
39 | return el && el.getAttribute(content);
40 | };
41 |
42 | export const formatErrorMsg = (err) => {
43 | let msg = 'Error: ';
44 | if (err.response && err.response.data && err.response.data.message) {
45 | msg += `${err.response.data.message}. `;
46 | err.response.data.errors &&
47 | (msg += err.response.data.errors.map((e) => e.message).join(', '));
48 | } else {
49 | msg += err.message;
50 | }
51 | return msg;
52 | };
53 |
54 | export const hasClassInParent = (element, ...className) => {
55 | /* istanbul ignore next */
56 | let yes = false;
57 | /* istanbul ignore next */
58 | if (typeof element.className === 'undefined') return false;
59 | /* istanbul ignore next */
60 | const classes = element.className.split(' ');
61 | /* istanbul ignore next */
62 | className.forEach((c, i) => {
63 | /* istanbul ignore next */
64 | yes = yes || classes.indexOf(c) >= 0;
65 | });
66 | /* istanbul ignore next */
67 | if (yes) return yes;
68 | /* istanbul ignore next */
69 | return element.parentNode && hasClassInParent(element.parentNode, className);
70 | };
71 |
--------------------------------------------------------------------------------
/app/components/Gitalk/zh-CN.json:
--------------------------------------------------------------------------------
1 | {
2 | "init": "Gitalk 加载中 ...",
3 | "no-found-related": "未找到相关的 %{link} 进行评论",
4 | "please-contact": "请联系 %{user} 初始化创建",
5 | "init-issue": "初始化 Issue",
6 | "leave-a-comment": "说点什么",
7 | "preview": "预览",
8 | "edit": "编辑",
9 | "comment": "评论",
10 | "support-markdown": "支持 Markdown 语法",
11 | "login-with-github": "使用 GitHub 登录",
12 | "first-comment-person": "来做第一个留言的人吧!",
13 | "commented": "发表于",
14 | "load-more": "加载更多",
15 | "counts": "%{counts} 条评论",
16 | "sort-asc": "从旧到新排序",
17 | "sort-desc": "从新到旧排序",
18 | "logout": "注销",
19 | "anonymous": "未登录用户"
20 | }
21 |
--------------------------------------------------------------------------------
/app/components/Header.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { Link } from '@remix-run/react';
3 | import { forwardRef, useEffect, useRef, useState } from 'react';
4 | import { IoMoonSharp, IoSunnyOutline } from 'react-icons/io5';
5 | import { Theme, useTheme } from 'remix-themes';
6 | import { useLocale } from '~/libs/locale';
7 |
8 | const NavBar = () => {
9 | const locale = useLocale();
10 |
11 | const links = [
12 | { name: locale.NAV.INDEX, to: '/', show: true },
13 | { name: locale.NAV.ABOUT, to: '/about', show: BLOG.showAbout },
14 | { name: locale.NAV.SEARCH, to: '/search', show: true },
15 | { name: locale.NAV.RSS, to: '/atom.xml', show: true, external: true },
16 | ].map((link, id) => ({ ...link, id }));
17 |
18 | return (
19 |
20 |
21 | {links.map(
22 | (link) =>
23 | link.show && (
24 | -
28 |
33 | {link.name}
34 |
35 |
36 | )
37 | )}
38 |
39 |
40 | );
41 | };
42 |
43 | const Header = forwardRef((props, ref) => {
44 | const { fullWidth } = props;
45 | const useSticky = !BLOG.autoCollapsedNavBar;
46 | const navRef = useRef(null);
47 | const sentinalRef = useRef([]);
48 | const [theme, setTheme] = useTheme();
49 | const [hasMounted, setHasMounted] = useState(false);
50 |
51 | useEffect(() => {
52 | setHasMounted(true);
53 | }, []);
54 |
55 | const handler = ([entry]) => {
56 | if (navRef?.current && useSticky) {
57 | if (!entry.isIntersecting && entry !== undefined) {
58 | navRef.current?.classList.add('sticky-nav-full');
59 | } else {
60 | navRef.current?.classList.remove('sticky-nav-full');
61 | }
62 | } else {
63 | navRef.current?.classList.add('remove-sticky');
64 | }
65 | };
66 | useEffect(() => {
67 | const obvserver = new window.IntersectionObserver(handler);
68 | obvserver.observe(sentinalRef.current);
69 | // Don't touch this, I have no idea how it works XD
70 | // return () => {
71 | // if (sentinalRef.current) obvserver.unobserve(sentinalRef.current)
72 | // }
73 | /* eslint-disable-line */
74 | }, [sentinalRef]);
75 |
76 | return (
77 |
78 |
79 |
86 |
87 |
92 | ✨ {BLOG.title}
93 |
94 |
95 |
96 |
97 |
115 |
116 |
117 |
118 | );
119 | });
120 |
121 | Header.displayName = 'Header';
122 |
123 | export default Header;
124 |
--------------------------------------------------------------------------------
/app/components/Pagination.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { Link } from '@remix-run/react';
3 | import { useLocale } from '~/libs/locale';
4 |
5 | const Pagination = ({ page, showNext }) => {
6 | const locale = useLocale();
7 | const currentPage = +page;
8 | let additionalClassName = 'justify-between';
9 | if (currentPage === 1 && showNext) additionalClassName = 'justify-end';
10 | if (currentPage !== 1 && !showNext) additionalClassName = 'justify-start';
11 |
12 | return (
13 |
16 | {currentPage !== 1 && (
17 |
24 |
27 |
28 | )}
29 | {showNext && (
30 |
31 |
34 |
35 | )}
36 |
37 | );
38 | };
39 |
40 | export default Pagination;
41 |
--------------------------------------------------------------------------------
/app/components/PostActions.module.css:
--------------------------------------------------------------------------------
1 | .action {
2 | transition: all 300ms ease-out;
3 | width: 3.5em;
4 | height: 3.5em;
5 | margin: 0 0 1em;
6 | text-decoration: none !important;
7 | }
8 |
9 | .action:last-child {
10 | margin-bottom: 0;
11 | }
12 |
13 | .actionBg {
14 | position: absolute;
15 | top: 0;
16 | left: 0;
17 | right: 0;
18 | bottom: 0;
19 |
20 | display: flex;
21 | flex-direction: column;
22 | justify-content: center;
23 | align-items: center;
24 | }
25 |
26 | .actionBg svg {
27 | @apply text-current;
28 | width: 50%;
29 | height: 50%;
30 | }
31 |
32 | .actionBgPane {
33 | transition: all 300ms ease-out;
34 | }
35 |
36 | .action:hover {
37 | transition: all 100ms ease-out;
38 | }
39 |
40 | .action:hover .actionBgPane {
41 | width: 100%;
42 | height: 100%;
43 | transition: all 100ms ease-out;
44 | }
45 |
46 | .action:hover svg {
47 | transition: fill 100ms ease-out;
48 | fill: var(--bg-color);
49 | }
50 |
51 | :global(.dark-mode) .action:hover svg {
52 | fill: var(--fg-color);
53 | }
54 |
55 | .facebook .actionBgPane {
56 | background: #3b5998;
57 | }
58 | .facebook:hover {
59 | border-color: #3b5998;
60 | }
61 |
62 | .twitter .actionBgPane {
63 | background: #2795e9;
64 | }
65 | .twitter:hover {
66 | border-color: #2795e9;
67 | }
68 |
69 | .linkedin .actionBgPane {
70 | background: #0077b5;
71 | }
72 | .linkedin:hover {
73 | border-color: #0077b5;
74 | }
75 |
76 | .github .actionBgPane {
77 | background: #c9510c;
78 | }
79 | .github:hover {
80 | border-color: #c9510c;
81 | }
82 |
83 | .medium .actionBgPane {
84 | background: #00ab6c;
85 | }
86 | .medium:hover {
87 | border-color: #00ab6c;
88 | }
89 |
90 | .email .actionBgPane {
91 | background: #777;
92 | }
93 | .email:hover {
94 | border-color: #777;
95 | }
96 |
97 | @media only screen and (max-width: 768px) {
98 | .links {
99 | position: relative;
100 | left: 0.5em;
101 | flex-wrap: wrap;
102 | }
103 |
104 | .action:last-child {
105 | margin-right: 1em;
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/app/components/PostActions.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { useLocation } from '@remix-run/react';
3 | import type { ReactNode } from 'react';
4 |
5 | import cs from 'classnames';
6 |
7 | import styles from './PostActions.module.css';
8 |
9 | interface SocialLink {
10 | name: string;
11 | title: string;
12 | icon: ReactNode;
13 | href?: string;
14 | }
15 |
16 | const PostActions: React.FC<{ title: string }> = (props) => {
17 | const { title } = props;
18 | const location = useLocation();
19 | const socialLinks: SocialLink[] = [
20 | {
21 | name: 'twitter',
22 | href: `https://twitter.com/intent/tweet?url=${BLOG.link}${location.pathname}&text=${title}`,
23 | title: 'Twitter',
24 | icon: (
25 |
32 | ),
33 | },
34 | ].filter(Boolean);
35 |
36 | return (
37 |
63 | );
64 | };
65 |
66 | export default PostActions;
67 |
--------------------------------------------------------------------------------
/app/components/Scripts.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 |
3 | const Scripts = () => (
4 | <>
5 | {BLOG.analytics?.providers?.includes('ackee') && (
6 |
11 | )}
12 | {BLOG.analytics?.providers?.includes('ga') && (
13 | <>
14 |
17 |
27 | >
28 | )}
29 | {BLOG.analytics?.providers?.includes('cnzz') && (
30 |
33 | )}
34 | >
35 | );
36 |
37 | export default Scripts;
38 |
--------------------------------------------------------------------------------
/app/components/TableOfContent.tsx:
--------------------------------------------------------------------------------
1 | import cs from 'classnames';
2 | import throttle from 'lodash.throttle';
3 | import type { TableOfContentsEntry } from 'notion-utils';
4 | import { FC, useEffect, useState } from 'react';
5 | import { RiListCheck } from 'react-icons/ri';
6 |
7 | const throttleMs = 100;
8 |
9 | const TableOfContent: FC<{
10 | tableOfContent: Array;
11 | className?: string;
12 | mobile?: boolean;
13 | }> = ({ className, tableOfContent }) => {
14 | const [activeSection, setActiveSection] = useState(null);
15 |
16 | useEffect(() => {
17 | const actionSectionScrollSpy = throttle(() => {
18 | const sections = document.getElementsByClassName('notion-h');
19 |
20 | let prevBBox: DOMRect = null;
21 | let currentSectionId = activeSection;
22 |
23 | for (let i = 0; i < sections.length; ++i) {
24 | const section = sections[i];
25 | if (!section || !(section instanceof Element)) continue;
26 |
27 | if (!currentSectionId) {
28 | currentSectionId = section.getAttribute('data-id');
29 | }
30 |
31 | const bbox = section.getBoundingClientRect();
32 | const prevHeight = prevBBox ? bbox.top - prevBBox.bottom : 0;
33 | const offset = Math.max(150, prevHeight / 4);
34 |
35 | // GetBoundingClientRect returns values relative to the viewport
36 | if (bbox.top - offset < 0) {
37 | currentSectionId = section.getAttribute('data-id');
38 |
39 | prevBBox = bbox;
40 | continue;
41 | }
42 |
43 | break;
44 | }
45 |
46 | setActiveSection(currentSectionId);
47 | }, throttleMs);
48 | window.addEventListener('scroll', actionSectionScrollSpy);
49 |
50 | actionSectionScrollSpy();
51 |
52 | return () => {
53 | window.removeEventListener('scroll', actionSectionScrollSpy);
54 | };
55 | }, [activeSection]);
56 |
57 | return (
58 |
65 |
77 |
78 |
79 | 目录
80 |
81 |
105 |
106 |
107 | );
108 | };
109 |
110 | export default TableOfContent;
111 |
--------------------------------------------------------------------------------
/app/components/TagItem.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@remix-run/react';
2 |
3 | const TagItem = ({ tag }) => (
4 |
5 |
6 | {tag}
7 |
8 |
9 | );
10 |
11 | export default TagItem;
12 |
--------------------------------------------------------------------------------
/app/components/Tags.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from '@remix-run/react';
2 | import { useMemo } from 'react';
3 |
4 | const Tags = ({ tags, currentTag }) => {
5 | const sortedTagKeys = useMemo(() => {
6 | return Object.keys(tags || {})?.sort((a) => {
7 | return a === currentTag ? -1 : 0;
8 | });
9 | }, [tags, currentTag]);
10 |
11 | if (!tags) return null;
12 |
13 | return (
14 |
15 |
16 | {sortedTagKeys.map((key) => {
17 | const selected = key === currentTag;
18 | return (
19 | -
27 |
32 | {`${key} (${tags[key]})`}
33 |
34 |
35 | );
36 | })}
37 |
38 |
39 | );
40 | };
41 |
42 | export default Tags;
43 |
--------------------------------------------------------------------------------
/app/components/Utterances.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { useEffect } from 'react';
3 | const Utterances = ({ issueTerm, layout }) => {
4 | useEffect(() => {
5 | const theme =
6 | BLOG.appearance === 'auto'
7 | ? 'preferred-color-scheme'
8 | : BLOG.appearance === 'light'
9 | ? 'github-light'
10 | : 'github-dark';
11 | const script = document.createElement('script');
12 | const anchor = document.getElementById('comments');
13 | script.setAttribute('src', 'https://utteranc.es/client.js');
14 | script.setAttribute('crossorigin', 'anonymous');
15 | script.setAttribute('async', true);
16 | script.setAttribute('repo', BLOG.comment.utterancesConfig.repo);
17 | script.setAttribute('issue-term', issueTerm);
18 | script.setAttribute('theme', theme);
19 | anchor.appendChild(script);
20 | return () => {
21 | anchor.innerHTML = '';
22 | };
23 | });
24 | return (
25 | <>
26 |
32 | >
33 | );
34 | };
35 |
36 | export default Utterances;
37 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle hydrating your app on the client for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.client
5 | */
6 |
7 | import { RemixBrowser } from '@remix-run/react';
8 | import { startTransition, StrictMode } from 'react';
9 | import { hydrateRoot } from 'react-dom/client';
10 |
11 | startTransition(() => {
12 | hydrateRoot(
13 | document,
14 |
15 |
16 |
17 | );
18 | });
19 |
--------------------------------------------------------------------------------
/app/entry.server.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * By default, Remix will handle generating the HTTP Response for you.
3 | * You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
4 | * For more information, see https://remix.run/file-conventions/entry.server
5 | */
6 |
7 | import type { AppLoadContext, EntryContext } from '@remix-run/cloudflare';
8 | import { RemixServer } from '@remix-run/react';
9 | import { isbot } from 'isbot';
10 | import { renderToReadableStream } from 'react-dom/server';
11 |
12 | export default async function handleRequest(
13 | request: Request,
14 | responseStatusCode: number,
15 | responseHeaders: Headers,
16 | remixContext: EntryContext,
17 | // This is ignored so we can keep it in the template for visibility. Feel
18 | // free to delete this parameter in your app if you're not using it!
19 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
20 | loadContext: AppLoadContext
21 | ) {
22 | const body = await renderToReadableStream(
23 | ,
24 | {
25 | signal: request.signal,
26 | onError(error: unknown) {
27 | // Log streaming rendering errors from inside the shell
28 | console.error(error);
29 | responseStatusCode = 500;
30 | },
31 | }
32 | );
33 |
34 | if (isbot(request.headers.get('user-agent') || '')) {
35 | await body.allReady;
36 | }
37 |
38 | responseHeaders.set('Content-Type', 'text/html');
39 | return new Response(body, {
40 | headers: responseHeaders,
41 | status: responseStatusCode,
42 | });
43 | }
44 |
--------------------------------------------------------------------------------
/app/layouts/layout.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { Link, useNavigate } from '@remix-run/react';
3 | import dayjs from 'dayjs';
4 | import { ExtendedRecordMap } from 'notion-types';
5 | import type { TableOfContentsEntry } from 'notion-utils';
6 | import { Suspense, lazy, useMemo } from 'react';
7 | import { NotionRenderer } from 'react-notion-x';
8 | import { Theme, useTheme } from 'remix-themes';
9 | import { ClientOnly } from '~/components/ClientOnly';
10 | import Container from '~/components/Container';
11 | import PostActions from '~/components/PostActions';
12 | import TableOfContent from '~/components/TableOfContent';
13 | import TagItem from '~/components/TagItem';
14 | import { useLocale } from '~/libs/locale';
15 | import { mapImageUrl, mapPageUrl } from '~/libs/utils';
16 |
17 | const Comments = lazy(() => import('~/components/Comments'));
18 | const Code = lazy(() =>
19 | import('react-notion-x/build/third-party/code').then(async (m) => {
20 | await import('prismjs/prism.js');
21 |
22 | await Promise.all([
23 | import('prismjs/components/prism-bash.js'),
24 | import('prismjs/components/prism-diff.js'),
25 | import('prismjs/components/prism-go.js'),
26 | import('prismjs/components/prism-yaml.js'),
27 | import('prismjs/components/prism-rust.js'),
28 | import('prismjs/components/prism-python.js'),
29 | import('prismjs/components/prism-markup-templating.js'),
30 | import('prismjs/components/prism-php.js'),
31 | import('prismjs/components/prism-javascript.js'),
32 | import('prismjs/components/prism-markup.js'),
33 | import('prismjs/components/prism-typescript.js'),
34 | import('prismjs/components/prism-jsx.js'),
35 | import('prismjs/components/prism-less.js'),
36 | import('prismjs/components/prism-js-templates.js'),
37 | import('prismjs/components/prism-git.js'),
38 | import('prismjs/components/prism-graphql.js'),
39 | import('prismjs/components/prism-solidity.js'),
40 | import('prismjs/components/prism-sql.js'),
41 | import('prismjs/components/prism-wasm.js'),
42 | import('prismjs/components/prism-yaml.js'),
43 | ]);
44 | return {
45 | default: m.Code,
46 | };
47 | })
48 | );
49 | const Collection = lazy(() =>
50 | import('react-notion-x/build/third-party/collection').then((m) => ({
51 | default: m.Collection,
52 | }))
53 | );
54 | const Equation = lazy(() =>
55 | import('react-notion-x/build/third-party/equation').then((m) => ({
56 | default: m.Equation,
57 | }))
58 | );
59 | const TweetEmbed = lazy(() => import('react-tweet-embed'));
60 |
61 | const Tweet = ({ id }: { id: string }) => {
62 | return (
63 |
64 |
65 |
66 | );
67 | };
68 |
69 | interface LayoutProps {
70 | blockMap: ExtendedRecordMap;
71 | frontMatter: any;
72 | fullWidth?: boolean;
73 | coverImage?: string;
74 | tableOfContent?: TableOfContentsEntry[];
75 | theme: Theme;
76 | }
77 |
78 | const Layout: React.FC = ({
79 | children,
80 | coverImage,
81 | blockMap,
82 | tableOfContent,
83 | frontMatter,
84 | fullWidth = false,
85 | }) => {
86 | const locale = useLocale();
87 | const navigate = useNavigate();
88 | const [theme] = useTheme();
89 | const date = frontMatter?.date?.start_date || frontMatter.createdTime;
90 |
91 | const components = useMemo(
92 | () => ({
93 | Equation: (props) => (
94 |
95 |
96 |
97 | ),
98 | Code: (props) => (
99 |
100 |
101 |
102 | ),
103 | Collection: (props) => (
104 |
105 | {() => {
106 | console.log('Collection', Collection);
107 | return (
108 |
109 |
110 |
111 | );
112 | }}
113 |
114 | ),
115 | nextLink: (props) => ,
116 | Tweet,
117 | }),
118 | []
119 | );
120 |
121 | return (
122 |
131 |
132 |
133 |
134 | {frontMatter.title}
135 |
136 | {frontMatter.type !== 'Page' && (
137 |
171 | )}
172 | {children}
173 | {blockMap && (
174 |
175 |
185 |
186 | )}
187 |
188 | {frontMatter.type !== 'Page' && (
189 |
199 | )}
200 |
201 |
219 | {frontMatter.type !== 'Page' && (
220 |
221 |
222 |
223 | )}
224 |
225 | );
226 | };
227 |
228 | export default Layout;
229 |
--------------------------------------------------------------------------------
/app/layouts/search.tsx:
--------------------------------------------------------------------------------
1 | import pick from 'lodash.pick';
2 | import { FC, useMemo, useState } from 'react';
3 | import BlogPost from '~/components/BlogPost';
4 | import Container from '~/components/Container';
5 | import Tags from '~/components/Tags';
6 |
7 | interface SearchLayoutProps {
8 | title?: string;
9 | posts: any[];
10 | tags: object;
11 | currentTag?: string;
12 | hiddenTags?: boolean;
13 | }
14 |
15 | const SearchLayout: FC = ({
16 | title,
17 | hiddenTags = false,
18 | tags,
19 | posts,
20 | currentTag,
21 | }) => {
22 | const [searchValue, setSearchValue] = useState('');
23 | const filteredBlogPosts = useMemo(() => {
24 | if (posts) {
25 | return posts.filter((post) => {
26 | const tagContent = post.tags ? post.tags.join(' ') : '';
27 | const searchContent = post.title + post.summary + tagContent;
28 | return searchContent.toLowerCase().includes(searchValue.toLowerCase());
29 | });
30 | }
31 | return [];
32 | }, [posts, searchValue]);
33 |
34 | const filteredTags = useMemo(
35 | () => (hiddenTags ? pick(tags, currentTag) : tags),
36 | [hiddenTags, tags, currentTag]
37 | );
38 |
39 | return (
40 |
41 | {!hiddenTags && (
42 |
43 |
setSearchValue(e.target.value)}
50 | />
51 |
65 |
66 | )}
67 |
68 |
69 | {!filteredBlogPosts.length && (
70 |
No posts found.
71 | )}
72 | {filteredBlogPosts.slice(0, 20).map((post) => (
73 |
74 | ))}
75 |
76 |
77 | );
78 | };
79 | export default SearchLayout;
80 |
--------------------------------------------------------------------------------
/app/libs/cusdisLang.ts:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 |
3 | const cusdisI18n = [
4 | 'ca',
5 | 'en',
6 | 'es',
7 | 'fi',
8 | 'fr',
9 | 'id',
10 | 'ja',
11 | 'oc',
12 | 'pt-br',
13 | 'tr',
14 | 'zh-cn',
15 | 'zh-tw',
16 | ];
17 |
18 | const loweredLang = BLOG.lang.toLowerCase();
19 |
20 | export const fetchCusdisLang = () => {
21 | if (BLOG.comment.provider !== 'cusdis') return null;
22 | if (loweredLang.startsWith('zh')) {
23 | return (
24 | cusdisI18n.find((i) => loweredLang === i.toLocaleLowerCase()) ?? 'zh-cn'
25 | );
26 | } else {
27 | return (
28 | cusdisI18n.find((i) =>
29 | BLOG.lang.toLowerCase().startsWith(i.toLowerCase())
30 | ) ?? 'en'
31 | );
32 | }
33 | };
34 |
--------------------------------------------------------------------------------
/app/libs/formatDate.ts:
--------------------------------------------------------------------------------
1 | export default function formatDate(date, local) {
2 | const d = new Date(date);
3 | const options = { year: 'numeric', month: 'short', day: 'numeric' };
4 | const res = d.toLocaleDateString(local, options);
5 | return local.slice(0, 2).toLowerCase() === 'zh'
6 | ? res.replace('年', ' 年 ').replace('月', ' 月 ').replace('日', ' 日')
7 | : res;
8 | }
9 |
--------------------------------------------------------------------------------
/app/libs/lang.ts:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 |
3 | const lang = {
4 | en: {
5 | NAV: {
6 | INDEX: 'Blog',
7 | LIFE: 'Life',
8 | RSS: 'RSS',
9 | SEARCH: 'Search',
10 | ABOUT: 'About',
11 | },
12 | PAGINATION: {
13 | PREV: 'Prev',
14 | NEXT: 'Next',
15 | },
16 | POST: {
17 | BACK: 'Back',
18 | TOP: 'Top',
19 | },
20 | TAG: 'Tag',
21 | },
22 | 'zh-CN': {
23 | NAV: {
24 | INDEX: '文章',
25 | LIFE: '生活',
26 | RSS: 'RSS',
27 | SEARCH: '搜索',
28 | ABOUT: '关于',
29 | },
30 | PAGINATION: {
31 | PREV: '上一页',
32 | NEXT: '下一页',
33 | },
34 | POST: {
35 | BACK: '返回',
36 | TOP: '回到顶部',
37 | },
38 | TAG: '标签',
39 | },
40 | };
41 |
42 | export const fetchLocaleLang = () => {
43 | switch (BLOG.lang.toLowerCase()) {
44 | case 'zh-cn':
45 | case 'zh-sg':
46 | return lang['zh-CN'];
47 | case 'zh-hk':
48 | return lang['zh-HK'];
49 | case 'zh-tw':
50 | return lang['zh-TW'];
51 | case 'ja':
52 | case 'ja-jp':
53 | return lang.ja;
54 | case 'es':
55 | case 'es-ES':
56 | return lang.es;
57 | case 'en':
58 | case 'en-us':
59 | default:
60 | return lang.en;
61 | }
62 | };
63 |
--------------------------------------------------------------------------------
/app/libs/locale.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext } from 'react';
2 | import { fetchLocaleLang } from '~/libs/lang';
3 |
4 | const locale = fetchLocaleLang();
5 | const LocaleContext = createContext();
6 |
7 | export function LocaleProvider({ children }) {
8 | return (
9 | {children}
10 | );
11 | }
12 |
13 | export const useLocale = () => useContext(LocaleContext);
14 |
--------------------------------------------------------------------------------
/app/libs/notion.ts:
--------------------------------------------------------------------------------
1 | export * from './notion/getAllPosts';
2 | export { getAllTagsFromPosts } from './notion/getAllTagsFromPosts';
3 | export { getPostBlocks } from './notion/getPostBlocks';
4 |
--------------------------------------------------------------------------------
/app/libs/notion/filterPublishedPosts.ts:
--------------------------------------------------------------------------------
1 | // import BLOG from '#/blog.config'
2 | const current = new Date();
3 | const tomorrow = new Date(current);
4 | tomorrow.setDate(tomorrow.getDate() + 1);
5 | tomorrow.setHours(0, 0, 0, 0);
6 |
7 | export default function filterPublishedPosts({ posts, includePages }) {
8 | if (!posts || !posts.length) return [];
9 | const publishedPosts = posts
10 | .filter((post) =>
11 | includePages
12 | ? post?.type?.[0] === 'Post' || post?.type?.[0] === 'Page'
13 | : post?.type?.[0] === 'Post'
14 | )
15 | .filter((post) => {
16 | const postDate = new Date(post?.date?.start_date || post.createdTime);
17 | return (
18 | post.title &&
19 | post.slug &&
20 | post?.status?.[0] === 'Published' &&
21 | postDate < tomorrow
22 | );
23 | });
24 | return publishedPosts;
25 | }
26 |
--------------------------------------------------------------------------------
/app/libs/notion/getAllPageIds.ts:
--------------------------------------------------------------------------------
1 | import { idToUuid } from 'notion-utils';
2 | export default function getAllPageIds(collectionQuery, viewId) {
3 | const views = Object.values(collectionQuery)[0];
4 | let pageIds = [];
5 | if (viewId) {
6 | const vId = idToUuid(viewId);
7 | pageIds = views[vId]?.blockIds;
8 | } else {
9 | const pageSet = new Set();
10 | Object.values(views).forEach((view) => {
11 | view?.blockIds?.forEach((id) => pageSet.add(id));
12 | });
13 | pageIds = [...pageSet];
14 | }
15 | return pageIds;
16 | }
17 |
--------------------------------------------------------------------------------
/app/libs/notion/getAllPosts.ts:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { Client } from '@notionhq/client';
3 | import { QueryDatabaseParameters } from '@notionhq/client/build/src/api-endpoints';
4 |
5 | // alias
6 | export const getPropertyValue = (property) => {
7 | const { type } = property;
8 |
9 | switch (type) {
10 | case 'text':
11 | case 'title':
12 | case 'rich_text': {
13 | const values = property[type] || [];
14 | return values?.map?.((v) => v?.plain_text || '').join('') || '';
15 | }
16 | case 'select': {
17 | return property[type]?.name || '';
18 | }
19 | case 'multi_select': {
20 | return property[type]?.map(({ name }) => name);
21 | }
22 | case 'date': {
23 | return property[type]?.start || '';
24 | }
25 | case 'last_edited_time': {
26 | return property[type] || '';
27 | }
28 | default: {
29 | return '';
30 | }
31 | }
32 | };
33 |
34 | const handlePost = (post) => {
35 | const properties = Object.keys(post.properties).reduce((acc, curr) => {
36 | const property = post.properties[curr];
37 | const value = getPropertyValue(property);
38 | return { ...acc, [curr]: value };
39 | }, {});
40 |
41 | return {
42 | ...(properties || {}),
43 | id: post.id,
44 | createdTime: properties.date,
45 | editedTime: properties?.last_edited_time,
46 | slug: properties.slug.replace(/^\//, ''),
47 | };
48 | };
49 |
50 | const commonFilter = [{ property: 'status', select: { equals: 'Published' } }];
51 | export const getAllPostsList = async ({
52 | includePages = false,
53 | notionPageId,
54 | notionAccessToken,
55 | }) => {
56 | const notion = new Client({
57 | auth: notionAccessToken,
58 | });
59 | const dbQuery = {
60 | database_id: notionPageId,
61 | filter: {
62 | and: [
63 | ...commonFilter,
64 | ...(includePages
65 | ? []
66 | : [
67 | {
68 | property: 'type',
69 | select: { equals: 'Post' },
70 | },
71 | ]),
72 | ],
73 | },
74 | sorts: [
75 | ...(BLOG.sortByDate
76 | ? [{ property: 'date', direction: 'descending' }]
77 | : []),
78 | ],
79 | };
80 | const response = await notion.databases.query(dbQuery);
81 | const posts = response.results.map(handlePost);
82 |
83 | return posts;
84 | };
85 |
86 | export const getPost = async ({
87 | slug: _slug,
88 | notionPageId,
89 | notionAccessToken,
90 | }) => {
91 | const notion = new Client({
92 | auth: notionAccessToken,
93 | });
94 | const slug = _slug.replace(/^\//, '');
95 | const dbQuery: QueryDatabaseParameters = {
96 | database_id: notionPageId,
97 | filter: {
98 | and: [
99 | {
100 | property: 'slug',
101 | rich_text: {
102 | equals: slug,
103 | },
104 | },
105 | {
106 | or: [
107 | {
108 | property: 'status',
109 | select: { equals: 'Published' },
110 | },
111 | {
112 | property: 'status',
113 | select: { equals: 'Revise' },
114 | },
115 | ],
116 | },
117 | ],
118 | },
119 | };
120 |
121 | const response = await notion.databases.query(dbQuery);
122 | const posts = response.results.map(handlePost);
123 |
124 | return posts;
125 | };
126 |
--------------------------------------------------------------------------------
/app/libs/notion/getAllTagsFromPosts.ts:
--------------------------------------------------------------------------------
1 | export function getAllTagsFromPosts(posts) {
2 | const taggedPosts = posts.filter((post) => post?.tags);
3 | const tags = [...taggedPosts.map((p) => p.tags).flat()]?.map((t) =>
4 | t?.toLowerCase()
5 | );
6 | const tagObj = {};
7 | tags.forEach((tag) => {
8 | if (tag in tagObj) {
9 | tagObj[tag]++;
10 | } else {
11 | tagObj[tag] = 1;
12 | }
13 | });
14 | return tagObj;
15 | }
16 |
--------------------------------------------------------------------------------
/app/libs/notion/getMetadata.ts:
--------------------------------------------------------------------------------
1 | export default function getMetadata(rawMetadata) {
2 | const metadata = {
3 | locked: rawMetadata?.format?.block_locked,
4 | page_full_width: rawMetadata?.format?.page_full_width,
5 | page_font: rawMetadata?.format?.page_font,
6 | page_small_text: rawMetadata?.format?.page_small_text,
7 | created_time: rawMetadata.created_time,
8 | last_edited_time: rawMetadata.last_edited_time,
9 | };
10 | return metadata;
11 | }
12 |
--------------------------------------------------------------------------------
/app/libs/notion/getPageProperties.ts:
--------------------------------------------------------------------------------
1 | import { Client } from '@notionhq/client';
2 | import { getDateValue, getTextContent } from 'notion-utils';
3 |
4 | async function getPageProperties(id, block, schema, authToken) {
5 | const notion = new Client({
6 | auth: authToken,
7 | });
8 | const rawProperties = Object.entries(block?.[id]?.value?.properties || []);
9 | const excludeProperties = ['date', 'select', 'multi_select', 'person'];
10 | const properties = {};
11 | for (let i = 0; i < rawProperties.length; i++) {
12 | const [key, val] = rawProperties[i];
13 | properties.id = id;
14 | if (schema[key]?.type && !excludeProperties.includes(schema[key].type)) {
15 | properties[schema[key].name] = getTextContent(val);
16 | } else {
17 | switch (schema[key]?.type) {
18 | case 'date': {
19 | const dateProperty = getDateValue(val);
20 | delete dateProperty.type;
21 | properties[schema[key].name] = dateProperty;
22 | break;
23 | }
24 | case 'select':
25 | case 'multi_select': {
26 | const selects = getTextContent(val);
27 | if (selects[0]?.length) {
28 | properties[schema[key].name] = selects.split(',');
29 | }
30 | break;
31 | }
32 | case 'person': {
33 | const rawUsers = val.flat();
34 | const users = [];
35 | for (let i = 0; i < rawUsers.length; i++) {
36 | if (rawUsers[i][0][1]) {
37 | const userId = rawUsers[i][0];
38 | const res = await notion.users.retrieve({
39 | user_id: userId,
40 | });
41 |
42 | const user = {
43 | id: res?.id,
44 | first_name: res?.name,
45 | last_name: res?.name,
46 | profile_photo: res?.avatar_url,
47 | };
48 | users.push(user);
49 | }
50 | }
51 | properties[schema[key].name] = users;
52 | break;
53 | }
54 | default:
55 | break;
56 | }
57 | }
58 | }
59 | return properties;
60 | }
61 |
62 | export { getPageProperties as default };
63 |
--------------------------------------------------------------------------------
/app/libs/notion/getPostBlocks.ts:
--------------------------------------------------------------------------------
1 | import { Client } from '@notionhq/client';
2 | import { NotionCompatAPI } from 'notion-compat';
3 |
4 | export async function getPostBlocks(
5 | id: string,
6 | options: {
7 | notionToken: string;
8 | }
9 | ) {
10 | console.log('getPostBlocks');
11 | const notion = new NotionCompatAPI(new Client({ auth: options.notionToken }));
12 | console.log('page id', id);
13 | const pageBlock = await notion.getPage(id);
14 | console.log('pageBlock', pageBlock.notion_user);
15 |
16 | // ref: https://github.com/transitive-bullshit/nextjs-notion-starter-kit/issues/279#issuecomment-1245467818
17 | if (pageBlock && pageBlock.signed_urls) {
18 | const signedUrls = pageBlock.signed_urls;
19 | const newSignedUrls = {};
20 | for (const p in signedUrls) {
21 | if (signedUrls[p] && signedUrls[p].includes('.amazonaws.com/')) {
22 | console.log('skip : ' + signedUrls[p]);
23 | continue;
24 | }
25 | newSignedUrls[p] = signedUrls[p];
26 | }
27 | pageBlock.signed_urls = newSignedUrls;
28 | }
29 |
30 | return pageBlock;
31 | }
32 |
--------------------------------------------------------------------------------
/app/libs/notion/preview-images.ts:
--------------------------------------------------------------------------------
1 | import ky from 'ky';
2 | import lqip from 'lqip-modern';
3 | import { ExtendedRecordMap, PreviewImage, PreviewImageMap } from 'notion-types';
4 | import { getPageImageUrls, normalizeUrl } from 'notion-utils';
5 | import pMap from 'p-map';
6 | import pMemoize from 'p-memoize';
7 |
8 | import { mapImageUrl } from '../utils';
9 |
10 | export async function getPreviewImageMap(
11 | recordMap: ExtendedRecordMap
12 | ): Promise {
13 | const urls: string[] = getPageImageUrls(recordMap, {
14 | mapImageUrl,
15 | }).filter(Boolean);
16 |
17 | const previewImagesMap = Object.fromEntries(
18 | await pMap(
19 | urls,
20 | async (url) => {
21 | const cacheKey = normalizeUrl(url);
22 | const previewImage = await getPreviewImage(url, { cacheKey });
23 | return previewImage ? [cacheKey, previewImage] : null;
24 | },
25 | {
26 | concurrency: 8,
27 | }
28 | )
29 | );
30 |
31 | return previewImagesMap;
32 | }
33 |
34 | async function createPreviewImage(
35 | url: string,
36 | { cacheKey }: { cacheKey: string }
37 | ): Promise {
38 | try {
39 | const response = await ky.get(url).arrayBuffer();
40 | const body = Buffer.from(response);
41 | const result = await lqip(body);
42 |
43 | const previewImage = {
44 | originalWidth: result.metadata.originalWidth,
45 | originalHeight: result.metadata.originalHeight,
46 | dataURIBase64: result.metadata.dataURIBase64,
47 | };
48 |
49 | return previewImage;
50 | } catch (err) {
51 | console.warn('failed to create preview image', url, err.message);
52 | return null;
53 | }
54 | }
55 |
56 | export const getPreviewImage = pMemoize(createPreviewImage);
57 |
--------------------------------------------------------------------------------
/app/libs/rss.ts:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { Feed } from 'feed';
3 |
4 | export function generateRss(posts) {
5 | const year = new Date().getFullYear();
6 | const feed = new Feed({
7 | title: BLOG.title,
8 | description: BLOG.description,
9 | id: `${BLOG.link}/${BLOG.path}`,
10 | link: `${BLOG.link}/${BLOG.path}`,
11 | language: BLOG.lang,
12 | favicon: `${BLOG.link}/favicon.png`,
13 | copyright: `All rights reserved ${year}, ${BLOG.author}`,
14 | author: {
15 | name: BLOG.author,
16 | email: BLOG.email,
17 | link: BLOG.link,
18 | },
19 | });
20 | posts.forEach((post) => {
21 | feed.addItem({
22 | title: post.title,
23 | id: `${BLOG.link}/${post.slug}`,
24 | link: `${BLOG.link}/${post.slug}`,
25 | description: post.summary,
26 | date: new Date(post?.date?.start_date || post.createdTime),
27 | });
28 | });
29 | return feed.rss2();
30 | }
31 |
--------------------------------------------------------------------------------
/app/libs/utils.ts:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { Block } from 'notion-types';
3 | import { defaultMapImageUrl } from 'notion-utils';
4 | import { type MapImageUrlFn } from 'react-notion-x';
5 |
6 | const buildJsDelivrLink = (user, repo, version, path) => {
7 | if (version === 'latest') {
8 | return `https://cdn.jsdelivr.net/gh/${user}/${repo}/${path}`;
9 | }
10 |
11 | return `https://cdn.jsdelivr.net/gh/${user}/${repo}@${version}/${path}`;
12 | };
13 |
14 | export const gitHub2jsDelivr = (gitHub: string) => {
15 | const pattern =
16 | /^https?:\/\/(?:github|raw\.githubusercontent)\.com\/([^/]+)\/([^/]+)(?:\/blob)?\/([^/]+)\/(.*)$/i;
17 | const match = pattern.exec(gitHub);
18 |
19 | if (match) {
20 | const [, user, repo, version, file] = match;
21 |
22 | return buildJsDelivrLink(user, repo, version, file);
23 | }
24 |
25 | return gitHub;
26 | };
27 |
28 | export const mapPageUrl = (id) => {
29 | return 'https://www.notion.so/' + id.replace(/-/g, '');
30 | };
31 |
32 | export const mapCoverUrl = (url: string) => {
33 | return 'https://www.notion.so' + url;
34 | };
35 |
36 | export const mapImageUrl: MapImageUrlFn = (
37 | url: string | undefined,
38 | block: Block
39 | ) => {
40 | try {
41 | if (new URL(url || '').host === BLOG.defaultImageHost) {
42 | return url;
43 | }
44 | } catch (e) {
45 | console.warn('[mapImageUrl WARN]', url, e);
46 | return url;
47 | }
48 | return defaultMapImageUrl(url, block) || '';
49 | };
50 |
51 | export const environment = process.env.NODE_ENV || 'development';
52 | export const isDev = environment === 'development';
53 |
--------------------------------------------------------------------------------
/app/libs/withCache.ts:
--------------------------------------------------------------------------------
1 | import isEmpty from 'lodash.isempty';
2 |
3 | export const CACHE_KEY = {
4 | blogList: 'blog_list',
5 | getBlogDetail: (slug: string) => `blog_detail_${slug}`,
6 | getBlogBlocks: (slug: string, id: string) =>
7 | `blog_detail_${slug}_blocks_${id}`,
8 | };
9 |
10 | type CacheKeyValue =
11 | | typeof CACHE_KEY.blogList
12 | | ReturnType;
13 |
14 | interface CachedData {
15 | data: T;
16 | contentHash: string;
17 | lastUpdated: string;
18 | }
19 |
20 | const generateContentHash = async (content: any): Promise => {
21 | const contentBuffer = new TextEncoder().encode(JSON.stringify(content));
22 |
23 | const digest = await crypto.subtle.digest(
24 | {
25 | name: 'SHA-256',
26 | },
27 | contentBuffer
28 | );
29 |
30 | let contentHash = '';
31 | new Uint8Array(digest).forEach((b) => {
32 | contentHash += b.toString(16).padStart(2, '0');
33 | });
34 |
35 | return contentHash;
36 | };
37 |
38 | async function updateCacheIfNeeded(
39 | KV: KVNamespace,
40 | fetchFn: (...args: any[]) => Promise,
41 | options: { cacheKey: string; getContentForHash?: (data: T) => any },
42 | oldContentHash: string
43 | ) {
44 | // fetch latest data
45 | const freshData = await fetchFn();
46 |
47 | // calculate new data hash
48 | const contentForHash = options.getContentForHash
49 | ? options.getContentForHash(freshData)
50 | : freshData;
51 | const newContentHash = await generateContentHash(contentForHash);
52 |
53 | // if content hash is different, update cache
54 | if (newContentHash !== oldContentHash) {
55 | await KV.put(
56 | options.cacheKey,
57 | JSON.stringify({
58 | data: freshData,
59 | contentHash: newContentHash,
60 | lastUpdated: new Date().toISOString(),
61 | })
62 | );
63 | console.log(`Cache updated for ${options.cacheKey}`);
64 | } else {
65 | console.log(`Content unchanged for ${options.cacheKey}, cache not updated`);
66 | }
67 | }
68 |
69 | async function fetchAndCacheData(
70 | KV: KVNamespace,
71 | fetchFn: (...args: any[]) => Promise,
72 | options: { cacheKey: string; getContentForHash?: (data: T) => any }
73 | ): Promise {
74 | // fetch data
75 | const data = await fetchFn();
76 | console.log('fetchAndCacheData');
77 |
78 | if (isEmpty(data)) {
79 | return data;
80 | }
81 |
82 | const contentForHash = options.getContentForHash
83 | ? options.getContentForHash(data)
84 | : data;
85 | const contentHash = await generateContentHash(contentForHash);
86 |
87 | try {
88 | await KV.put(
89 | options.cacheKey,
90 | JSON.stringify({
91 | data,
92 | contentHash,
93 | lastUpdated: new Date().toISOString(),
94 | })
95 | );
96 | } catch (error) {
97 | console.error(error);
98 | }
99 |
100 | return data;
101 | }
102 |
103 | export const withKVCache = (
104 | fetchFn: (...args: any[]) => Promise,
105 | options: {
106 | KV: KVNamespace;
107 | updateCache?: boolean;
108 | cacheKey: CacheKeyValue;
109 | getContentForHash?: (data: T) => any;
110 | }
111 | ): Promise<[T, string]> => {
112 | const { KV, cacheKey, getContentForHash, updateCache = false } = options;
113 |
114 | console.log('[withKVCache] updateCache', updateCache);
115 |
116 | return (async () => {
117 | console.log('cacheKey', cacheKey);
118 | if (!KV) {
119 | console.log('no KV');
120 | const data = await fetchFn();
121 | const contentForHash = getContentForHash ? getContentForHash(data) : data;
122 | const contentHash = await generateContentHash(contentForHash);
123 | return [data, contentHash];
124 | }
125 |
126 | console.log('cacheKey_2', cacheKey);
127 |
128 | try {
129 | const cachedData = await KV.get>(cacheKey, 'json');
130 | console.log('cachedData.contentHash', cachedData?.contentHash);
131 |
132 | if (cachedData && cachedData?.contentHash) {
133 | console.log('cached return');
134 | if (updateCache) {
135 | // async update cache
136 | await updateCacheIfNeeded(
137 | KV,
138 | fetchFn,
139 | { cacheKey, getContentForHash },
140 | cachedData.contentHash
141 | ).catch(console.error);
142 | }
143 |
144 | return [cachedData.data, cachedData.contentHash];
145 | }
146 | } catch (error) {
147 | console.error(error);
148 | }
149 |
150 | console.log('no cachedData');
151 | // if no cache, fetch and cache data
152 | const data = await fetchAndCacheData(KV, fetchFn, {
153 | cacheKey,
154 | getContentForHash,
155 | });
156 |
157 | if (isEmpty(data)) {
158 | console.log('no data');
159 | return [data, ''];
160 | }
161 | console.log('no cache, fetch and cache data');
162 | const contentForHash =
163 | getContentForHash && data ? getContentForHash(data) : data;
164 | const contentHash = await generateContentHash(contentForHash);
165 |
166 | console.log('new contentHash', contentHash);
167 | return [data, contentHash];
168 | })();
169 | };
170 |
--------------------------------------------------------------------------------
/app/root.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import {
3 | LinksFunction,
4 | LoaderFunctionArgs,
5 | MetaFunction,
6 | } from '@remix-run/cloudflare';
7 | import {
8 | isRouteErrorResponse,
9 | Links,
10 | Meta,
11 | Outlet,
12 | Scripts,
13 | ScrollRestoration,
14 | useLoaderData,
15 | useNavigation,
16 | useRouteError,
17 | } from '@remix-run/react';
18 | import clsx from 'clsx';
19 | import NProgress from 'nprogress';
20 | import nProgressStyles from 'nprogress/nprogress.css?url';
21 | import { lazy, Suspense, useEffect } from 'react';
22 | import { IconContext } from 'react-icons';
23 | import 'react-notion-x/src/styles.css';
24 | import {
25 | PreventFlashOnWrongTheme,
26 | Theme,
27 | ThemeProvider,
28 | useTheme,
29 | } from 'remix-themes';
30 | import { ClientOnly } from '~/components/ClientOnly';
31 | import { LocaleProvider } from '~/libs/locale';
32 | import '~/styles/gitalk.css';
33 | import '~/styles/globals.css';
34 | import '~/styles/notion.css';
35 | import { themeSessionResolver } from './sessions.server';
36 | import './tailwind.css';
37 |
38 | export const links: LinksFunction = () => [
39 | { rel: 'stylesheet', href: nProgressStyles },
40 | ];
41 |
42 | const Ackee = lazy(() => import('~/components/Ackee'));
43 |
44 | export function ErrorBoundary() {
45 | const error = useRouteError();
46 | return (
47 |
48 |
49 | Oops!
50 |
51 |
52 |
53 | {isRouteErrorResponse(error)
54 | ? `${error.status} ${error.statusText}`
55 | : error instanceof Error
56 | ? error.message
57 | : 'Unknown Error'}
58 |
59 |
60 |
61 |
62 | );
63 | }
64 |
65 | export async function loader({ request }: LoaderFunctionArgs) {
66 | const { getTheme } = await themeSessionResolver(request);
67 | return {
68 | theme: getTheme(),
69 | };
70 | }
71 |
72 | export const meta: MetaFunction = ({ data }) => {
73 | const url = BLOG.path.length ? `${BLOG.link}/${BLOG.path}` : BLOG.link;
74 | const image = `${BLOG.ogImageGenerateURL}/${encodeURIComponent(
75 | BLOG.title
76 | )}.png?theme=${
77 | data?.theme === Theme.DARK ? 'dark' : 'light'
78 | }&md=1&fontSize=125px&images=https%3A%2F%2Fnobelium.vercel.app%2Flogo-for-dark-bg.svg`;
79 |
80 | return [
81 | { title: BLOG.title },
82 | { property: 'og:title', content: BLOG.title },
83 | { name: 'twitter:title', content: BLOG.title },
84 | {
85 | name: 'description',
86 | content: BLOG.description,
87 | },
88 | {
89 | property: 'og:description',
90 | content: BLOG.description,
91 | },
92 | {
93 | name: 'twitter:description',
94 | content: BLOG.description,
95 | },
96 | {
97 | property: 'og:url',
98 | content: url,
99 | },
100 | {
101 | name: 'twitter:image',
102 | content: image,
103 | },
104 | {
105 | property: 'og:image',
106 | content: image,
107 | },
108 | ];
109 | };
110 |
111 | export function App() {
112 | const data = useLoaderData();
113 | const [theme] = useTheme();
114 |
115 | return (
116 |
123 |
124 |
125 |
126 |
127 |
128 | {BLOG.seo.googleSiteVerification && (
129 |
133 | )}
134 | {BLOG.seo.keywords && (
135 |
136 | )}
137 |
138 |
139 |
140 |
141 |
142 |
143 | {BLOG?.font === 'serif' ? (
144 | <>
145 |
152 |
159 | >
160 | ) : (
161 | <>
162 |
169 |
176 | >
177 | )}
178 |
179 | {['zh', 'ja', 'ko'].includes(
180 | BLOG.lang.slice(0, 2).toLocaleLowerCase()
181 | ) && (
182 | <>
183 |
188 |
195 |
201 | >
202 | )}
203 |
204 |
209 |
215 | {BLOG.appearance === 'auto' ? (
216 | <>
217 |
222 |
227 | >
228 | ) : (
229 |
237 | )}
238 |
239 |
240 |
241 |
242 |
243 |
244 |
245 |
246 | );
247 | }
248 |
249 | export default function AppWithProviders() {
250 | const navigation = useNavigation();
251 | const data = useLoaderData();
252 | useEffect(() => {
253 | if (navigation.state === 'loading' || navigation.state === 'submitting') {
254 | NProgress.start();
255 | } else {
256 | NProgress.done();
257 | }
258 | }, [navigation.state]);
259 |
260 | return (
261 |
262 |
263 | <>
264 | {BLOG.isProd && BLOG?.analytics?.providers.includes('ackee') && (
265 |
266 | {() => (
267 |
268 |
272 |
273 | )}
274 |
275 | )}
276 |
280 |
281 |
282 | >
283 |
284 |
285 | );
286 | }
287 |
--------------------------------------------------------------------------------
/app/routes/$slug.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import {
3 | json,
4 | LinksFunction,
5 | MetaFunction,
6 | type LoaderFunctionArgs,
7 | } from '@remix-run/cloudflare';
8 | import { useLoaderData } from '@remix-run/react';
9 | import katexStyles from 'katex/dist/katex.min.css?url';
10 | import { Block, PageBlock } from 'notion-types';
11 | import {
12 | getPageImageUrls,
13 | getPageTableOfContents,
14 | uuidToId,
15 | } from 'notion-utils';
16 | import 'prismjs';
17 | import { Theme } from 'remix-themes';
18 | import Layout from '~/layouts/layout';
19 | import { getPost, getPostBlocks } from '~/libs/notion';
20 | import { mapImageUrl } from '~/libs/utils';
21 | import { CACHE_KEY, withKVCache } from '~/libs/withCache';
22 | import { themeSessionResolver } from '~/sessions.server';
23 |
24 | export const links: LinksFunction = () => [
25 | { rel: 'stylesheet', href: katexStyles },
26 | ];
27 |
28 | const BlogPost = () => {
29 | const { post, coverImage, blockMap, tableOfContent, theme } =
30 | useLoaderData();
31 | if (!post) return null;
32 | return (
33 |
41 | );
42 | };
43 |
44 | export const meta: MetaFunction = ({ data }) => {
45 | const title = data?.post?.title || BLOG.title;
46 | const description = data?.post?.description || BLOG.description;
47 | const url = `${BLOG.link}/${data?.post?.slug}`;
48 | const image =
49 | data?.coverImage ||
50 | `${BLOG.ogImageGenerateURL}/${encodeURIComponent(BLOG.title)}.png?theme=${
51 | data?.theme === Theme.DARK ? 'dark' : 'light'
52 | }&md=1&fontSize=125px&images=https%3A%2F%2Fnobelium.vercel.app%2Flogo-for-dark-bg.svg`;
53 |
54 | return [
55 | { title: title },
56 | {
57 | name: 'description',
58 | content: description,
59 | },
60 | { title: title },
61 | { property: 'og:title', content: title },
62 | { name: 'twitter:title', content: title },
63 | {
64 | name: 'description',
65 | content: description,
66 | },
67 | {
68 | property: 'og:description',
69 | content: description,
70 | },
71 | {
72 | name: 'twitter:description',
73 | content: description,
74 | },
75 | {
76 | property: 'og:url',
77 | content: url,
78 | },
79 | ...(image
80 | ? [
81 | {
82 | name: 'twitter:image',
83 | content: image,
84 | },
85 | {
86 | property: 'og:image',
87 | content: image,
88 | },
89 | ]
90 | : []),
91 | ];
92 | };
93 |
94 | export const loader = async (params: LoaderFunctionArgs) => {
95 | const { slug } = params.params;
96 | const { NOTION_ACCESS_TOKEN, NOTION_PAGE_ID, KV } =
97 | params.context.cloudflare.env;
98 | const { getTheme } = await themeSessionResolver(params.request);
99 | const { searchParams } = new URL(params.request.url);
100 | const updateCache = !!searchParams.get('update');
101 |
102 | console.log('updateCache', updateCache);
103 |
104 | if (!slug) {
105 | throw new Response('404 Not Found', { status: 404 });
106 | }
107 |
108 | console.log('$slug', slug);
109 | const [posts, contentHash] = await withKVCache(
110 | async () => {
111 | const post = await getPost({
112 | slug,
113 | notionPageId: NOTION_PAGE_ID,
114 | notionAccessToken: NOTION_ACCESS_TOKEN,
115 | });
116 | return post;
117 | },
118 | {
119 | KV,
120 | updateCache,
121 | cacheKey: CACHE_KEY.getBlogDetail(slug),
122 | }
123 | );
124 |
125 | console.log('posts', posts.length);
126 |
127 | const [post] = posts;
128 |
129 | if (!post) {
130 | throw new Response('', { status: 404 });
131 | }
132 |
133 | const [blockMap, blockHash] = await withKVCache(
134 | async () => {
135 | console.log('post.id', post.id);
136 | return await getPostBlocks(post.id, {
137 | notionToken: NOTION_ACCESS_TOKEN,
138 | });
139 | },
140 | {
141 | KV,
142 | cacheKey: CACHE_KEY.getBlogBlocks(slug, post.id),
143 | updateCache,
144 | getContentForHash: (data) => {
145 | // @ts-ignore
146 | return data?.raw?.page?.last_edited_time;
147 | },
148 | }
149 | );
150 |
151 | console.log('blockMap');
152 |
153 | const [coverImage = ''] =
154 | getPageImageUrls(blockMap, {
155 | mapImageUrl: (url: string, block: Block) => {
156 | if (block.format?.page_cover) {
157 | return mapImageUrl(url, block);
158 | }
159 | },
160 | }) || [];
161 |
162 | const keys = Object.keys(blockMap?.block || {});
163 | const block = blockMap?.block?.[keys[0]]?.value as PageBlock;
164 |
165 | console.log('block');
166 |
167 | const pageTableOfContents = getPageTableOfContents(block, blockMap);
168 |
169 | console.log('pageTableOfContents', typeof pageTableOfContents);
170 |
171 | const tableOfContent = Array.isArray(pageTableOfContents)
172 | ? pageTableOfContents?.map(({ id, text, indentLevel }) => ({
173 | id: uuidToId(id),
174 | text,
175 | indentLevel,
176 | }))
177 | : [];
178 |
179 | console.log('post', post);
180 |
181 | return json(
182 | { post, blockMap, coverImage, tableOfContent, theme: getTheme() },
183 | {
184 | headers: {
185 | 'Cache-Control': 'public, stale-while-revalidate=60',
186 | 'X-Content-Hash': contentHash,
187 | 'X-Block-Hash': blockHash,
188 | },
189 | }
190 | );
191 | };
192 |
193 | export default BlogPost;
194 |
--------------------------------------------------------------------------------
/app/routes/_index.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { json, type LoaderFunctionArgs } from '@remix-run/cloudflare';
3 | import { useLoaderData } from '@remix-run/react';
4 | import BlogPost from '~/components/BlogPost';
5 | import Container from '~/components/Container';
6 | import Pagination from '~/components/Pagination';
7 | import { getAllPostsList } from '~/libs/notion';
8 | import { CACHE_KEY, withKVCache } from '~/libs/withCache';
9 |
10 | export const loader = async (params: LoaderFunctionArgs) => {
11 | const { context } = params;
12 | const { NOTION_ACCESS_TOKEN, NOTION_PAGE_ID, KV } = context.cloudflare.env;
13 |
14 | const { searchParams } = new URL(params.request.url);
15 | const updateCache = !!searchParams.get('update');
16 |
17 | console.log('updateCache', updateCache);
18 |
19 | const [posts, contentHash] = await withKVCache(
20 | async () => {
21 | return await getAllPostsList({
22 | includePages: false,
23 | notionPageId: NOTION_PAGE_ID,
24 | notionAccessToken: NOTION_ACCESS_TOKEN,
25 | });
26 | },
27 | {
28 | KV,
29 | cacheKey: CACHE_KEY.blogList,
30 | updateCache,
31 | }
32 | );
33 |
34 | const page = Number(params?.params?.pageId) || 1;
35 | const startIdx = (page - 1) * BLOG.postsPerPage;
36 | const postsToShow = posts.slice(startIdx, startIdx + BLOG.postsPerPage);
37 |
38 | const totalPosts = posts.length;
39 | const showNext = startIdx + BLOG.postsPerPage < totalPosts;
40 |
41 | return json(
42 | {
43 | page,
44 | postsToShow,
45 | showNext,
46 | },
47 | {
48 | headers: {
49 | 'X-Content-Hash': contentHash,
50 | 'Cache-Control': 'public, stale-while-revalidate=60',
51 | },
52 | }
53 | );
54 | };
55 |
56 | export default function Index() {
57 | const { page, postsToShow, showNext } = useLoaderData();
58 | return (
59 |
60 | {postsToShow.map((post) => (
61 |
62 | ))}
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/app/routes/action.set-theme.ts:
--------------------------------------------------------------------------------
1 | import { createThemeAction } from 'remix-themes';
2 |
3 | import { themeSessionResolver } from '../sessions.server';
4 |
5 | export const action = createThemeAction(themeSessionResolver);
6 |
--------------------------------------------------------------------------------
/app/routes/api.webhook.cusdis.ts:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { ActionFunctionArgs, json } from '@remix-run/cloudflare';
3 |
4 | interface NewCommentBody {
5 | type: 'new_comment';
6 | data: {
7 | by_nickname: string;
8 | by_email: string;
9 | content: string;
10 | page_id: string;
11 | page_title: string;
12 | project_title: string;
13 | approve_link: string;
14 | };
15 | }
16 |
17 | export async function action(params: ActionFunctionArgs) {
18 | const { request } = params;
19 | const { type, data } = await request.json();
20 |
21 | if (BLOG.comment.cusdisConfig.autoApproval && type === 'new_comment') {
22 | try {
23 | const { approve_link = '' } = data || {};
24 | const { search } = new URL(approve_link);
25 |
26 | if (search) {
27 | const ret = await fetch(`https://cusdis.com/api/open/approve${search}`);
28 | const data = await ret.text();
29 |
30 | return json({
31 | success: data === 'Approved!',
32 | message: data,
33 | });
34 | }
35 | console.error('approve_link', approve_link);
36 | } catch (e) {
37 | console.error('ERROR', e);
38 | }
39 | }
40 |
41 | return json({
42 | success: false,
43 | });
44 | }
45 |
--------------------------------------------------------------------------------
/app/routes/atom[.]xml.tsx:
--------------------------------------------------------------------------------
1 | import { getAllPostsList } from '~/libs/notion';
2 | import { generateRss } from '~/libs/rss';
3 |
4 | import { LoaderFunctionArgs } from '@remix-run/cloudflare';
5 |
6 | export const loader = async ({ context }: LoaderFunctionArgs) => {
7 | const { NOTION_ACCESS_TOKEN, NOTION_PAGE_ID } = context.cloudflare.env;
8 | const posts = await getAllPostsList({
9 | includePages: false,
10 | notionPageId: NOTION_PAGE_ID,
11 | notionAccessToken: NOTION_ACCESS_TOKEN,
12 | });
13 | const latestPosts = posts.slice(0, 10);
14 | const xmlFeed = generateRss(latestPosts);
15 |
16 | return new Response(xmlFeed, {
17 | status: 200,
18 | headers: {
19 | 'Content-Type': 'application/xml',
20 | 'X-Content-Type-Options': 'nosniff',
21 | 'Cache-Control': 'public, stale-while-revalidate=60',
22 | },
23 | });
24 | };
25 |
--------------------------------------------------------------------------------
/app/routes/page.$pageId..tsx:
--------------------------------------------------------------------------------
1 | import Index from './_index';
2 | export { loader } from './_index';
3 |
4 | export default Index;
5 |
--------------------------------------------------------------------------------
/app/routes/search.tsx:
--------------------------------------------------------------------------------
1 | import BLOG from '#/blog.config';
2 | import { LoaderFunctionArgs, MetaFunction } from '@remix-run/cloudflare';
3 | import { useLoaderData } from '@remix-run/react';
4 | import SearchLayout from '~/layouts/search';
5 | import { getAllPostsList, getAllTagsFromPosts } from '~/libs/notion';
6 |
7 | export const meta: MetaFunction = () => {
8 | return [{ title: `Search - ${BLOG.title}` }];
9 | };
10 |
11 | export const loader = async (params: LoaderFunctionArgs) => {
12 | const { context } = params;
13 | const { NOTION_ACCESS_TOKEN, NOTION_PAGE_ID } = context.cloudflare.env;
14 |
15 | const posts = await getAllPostsList({
16 | includePages: false,
17 | notionPageId: NOTION_PAGE_ID,
18 | notionAccessToken: NOTION_ACCESS_TOKEN,
19 | });
20 | const tags = getAllTagsFromPosts(posts);
21 | return {
22 | tags,
23 | posts,
24 | };
25 | };
26 |
27 | export default function Search() {
28 | const { posts, tags } = useLoaderData();
29 | return ;
30 | }
31 |
--------------------------------------------------------------------------------
/app/sessions.server.ts:
--------------------------------------------------------------------------------
1 | import { createCookieSessionStorage } from '@remix-run/cloudflare';
2 | import { createThemeSessionResolver } from 'remix-themes';
3 |
4 | // You can default to 'development' if process.env.NODE_ENV is not set
5 | const isProduction = process.env.NODE_ENV === 'production';
6 |
7 | const sessionStorage = createCookieSessionStorage({
8 | cookie: {
9 | name: 'theme',
10 | path: '/',
11 | httpOnly: true,
12 | sameSite: 'lax',
13 | secrets: ['s3cr3t'],
14 | // Set domain and secure only if in production
15 | ...(isProduction ? { domain: 'www.rustc.cloud', secure: true } : {}),
16 | },
17 | });
18 |
19 | export const themeSessionResolver = createThemeSessionResolver(sessionStorage);
20 |
--------------------------------------------------------------------------------
/app/styles/globals.css:
--------------------------------------------------------------------------------
1 | @import './prism.css';
2 |
3 | @font-face {
4 | font-family: 'IBM Plex Sans';
5 | font-weight: 100 900;
6 | font-display: swap;
7 | font-style: normal;
8 | font-named-instance: 'Regular';
9 | src: url('/fonts/IBMPlexSansVar-Roman.woff2') format('woff2');
10 | }
11 |
12 | @font-face {
13 | font-family: 'IBM Plex Sans';
14 | font-weight: 100 900;
15 | font-display: swap;
16 | font-style: italic;
17 | font-named-instance: 'Italic';
18 | src: url('/fonts/IBMPlexSansVar-Italic.woff2') format('woff2');
19 | }
20 |
21 | @font-face {
22 | font-family: 'Source Serif';
23 | font-weight: 100 900;
24 | font-display: swap;
25 | font-style: normal;
26 | font-named-instance: 'Regular';
27 | src: url('/fonts/SourceSerif.var.woff2') format('woff2');
28 | }
29 |
30 | @font-face {
31 | font-family: 'Source Serif';
32 | font-weight: 100 900;
33 | font-display: swap;
34 | font-style: italic;
35 | font-named-instance: 'Italic';
36 | src: url('/fonts/SourceSerif-Italic.var.woff2') format('woff2');
37 | }
38 |
39 | :root {
40 | --scrollbarBG: #ffffff00;
41 | --thumbBG: #b8b8b8;
42 | }
43 | body::-webkit-scrollbar {
44 | width: 5px;
45 | }
46 | body {
47 | scrollbar-width: thin;
48 | scrollbar-color: var(--thumbBG) var(--scrollbarBG);
49 | }
50 | body::-webkit-scrollbar-track {
51 | background: var(--scrollbarBG);
52 | }
53 | body::-webkit-scrollbar-thumb {
54 | background-color: var(--thumbBG);
55 | }
56 |
57 | ::selection {
58 | background: rgba(45, 170, 219, 0.3);
59 | }
60 |
61 | .wrapper {
62 | min-height: 100vh;
63 | display: flex;
64 | flex-wrap: nowrap;
65 | align-items: stretch;
66 | justify-content: flex-start;
67 | flex-direction: column;
68 | }
69 |
70 | .sticky-nav {
71 | position: sticky;
72 | z-index: 10;
73 | top: -1px;
74 | backdrop-filter: blur(5px);
75 | transition: all 0.5s cubic-bezier(0.4, 0, 0, 1);
76 | border-bottom-color: transparent;
77 | }
78 |
79 | .remove-sticky {
80 | position: unset;
81 | }
82 |
83 | .sticky-nav-full {
84 | @apply border-b border-opacity-50 border-gray-200 dark:border-gray-600 dark:border-opacity-50;
85 | }
86 |
87 | .header-name {
88 | display: none;
89 | opacity: 0;
90 | overflow: hidden;
91 | }
92 |
93 | .sticky-nav-full .nav {
94 | @apply text-gray-600 dark:text-gray-300;
95 | }
96 |
97 | nav {
98 | flex-wrap: wrap;
99 | line-height: 1.5em;
100 | }
101 |
102 | .article-tags::-webkit-scrollbar {
103 | width: 0 !important;
104 | }
105 |
106 | .tag-container ul::-webkit-scrollbar {
107 | width: 0 !important;
108 | }
109 |
110 | .tag-container ul {
111 | -ms-overflow-style: none;
112 | overflow: -moz-scrollbars-none;
113 | -moz-user-select: none;
114 | -webkit-user-select: none;
115 | -ms-user-select: none;
116 | -khtml-user-select: none;
117 | user-select: none;
118 | }
119 |
120 | @media (min-width: 768px) {
121 | .sticky-nav-full {
122 | @apply max-w-full border-b border-opacity-50 border-gray-200 dark:border-gray-600 dark:border-opacity-50;
123 | }
124 | .header-name {
125 | display: block;
126 | opacity: 0;
127 | transition: all 0.5s cubic-bezier(0.4, 0, 0, 1);
128 | }
129 | .sticky-nav-full .header-name {
130 | opacity: 1;
131 | @apply dark:text-gray-300 text-gray-600;
132 | }
133 | }
134 |
135 | @supports not (backdrop-filter: none) {
136 | .sticky-nav {
137 | backdrop-filter: none;
138 | @apply bg-day bg-opacity-90 dark:bg-night dark:bg-opacity-90;
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/app/styles/notion.css:
--------------------------------------------------------------------------------
1 | /* NOTION CSS OVERRIDE */
2 |
3 | .notion {
4 | @apply text-gray-600 dark:text-gray-300;
5 | overflow-wrap: break-word;
6 | }
7 | main .notion-table-of-contents-item {
8 | white-space: break-spaces;
9 | }
10 | .notion-list-numbered {
11 | max-width: 100%;
12 | }
13 | .notion,
14 | .notion-text,
15 | .notion-quote,
16 | .notion-h-title {
17 | @apply leading-8;
18 | @apply p-0;
19 | }
20 | .notion .notion-hr {
21 | border-top: 1px solid var(--fg-color-0);
22 | }
23 | .notion-page-link {
24 | color: inherit;
25 | }
26 | .notion-list li {
27 | padding: 0;
28 | }
29 |
30 | .notion-blank {
31 | margin: 0;
32 | }
33 |
34 | svg.notion-page-icon {
35 | @apply hidden;
36 | }
37 |
38 | svg + .notion-page-title-text {
39 | @apply border-b-0;
40 | }
41 |
42 | .notion-bookmark {
43 | @apply border;
44 | @apply border-gray-100;
45 | color: inherit;
46 | }
47 |
48 | .notion-bookmark .notion-bookmark-title,
49 | .notion-bookmark .notion-bookmark-link div {
50 | @apply text-gray-900 dark:text-gray-200;
51 | }
52 |
53 | .notion-bookmark .notion-bookmark-description {
54 | @apply text-gray-600 dark:text-gray-300;
55 | }
56 |
57 | .notion-code > code {
58 | @apply text-gray-900;
59 | }
60 |
61 | .notion .notion-code {
62 | font-size: var(--prism-font-size);
63 | line-height: var(--prism-line-height);
64 | }
65 |
66 | .notion-code .token.operator {
67 | background: transparent;
68 | }
69 |
70 | pre[class*='language-'] {
71 | line-height: var(--prism-line-height);
72 | }
73 |
74 | .notion-bookmark:hover {
75 | @apply border-blue-400;
76 | }
77 | .notion-viewport {
78 | z-index: -10;
79 | }
80 | .notion-asset-caption {
81 | @apply text-center;
82 | @apply text-gray-500 dark:text-gray-400;
83 | }
84 | .notion-full-width {
85 | @apply px-0;
86 | }
87 | .notion-page {
88 | @apply w-auto;
89 | @apply px-0;
90 | }
91 | .notion-quote {
92 | padding: 0.2em 0.9em;
93 | font-size: 1em;
94 | line-height: 1.5rem;
95 | }
96 | .notion-collection {
97 | @apply max-w-full;
98 | }
99 | .notion-collection > .notion-collection-header {
100 | @apply px-0 !important;
101 | }
102 | .notion-collection > .notion-table {
103 | @apply max-w-full !important;
104 | }
105 | .notion-collection > .notion-table > .notion-table-view {
106 | @apply px-0 !important;
107 | }
108 | .notion-collection-view-type {
109 | @apply hidden;
110 | }
111 | .notion-collection-row {
112 | @apply hidden;
113 | }
114 |
115 | @media (min-width: 1300px) and (min-height: 300px) {
116 | .notion-page-content-has-aside {
117 | display: flex;
118 | flex-direction: row;
119 | width: calc((100vw + var(--notion-max-width)) / 2.2);
120 | }
121 |
122 | .notion-page-content-has-aside .notion-aside {
123 | display: flex;
124 | }
125 |
126 | .notion-page-content-has-aside .notion-page-content-inner {
127 | width: var(--notion-max-width);
128 | max-width: var(--notion-max-width);
129 | }
130 | }
131 |
132 | .notion-simple-table {
133 | width: 100%;
134 | }
135 | .notion-simple-table td {
136 | border: 1px solid var(--fg-color-1)
137 | }
138 |
139 | .notion .notion-code {
140 | font-size: var(--prism-marker-font-size)
141 | }
142 |
143 | .notion-link {
144 | color: var(--fg-color);
145 | }
146 |
--------------------------------------------------------------------------------
/app/styles/prism.css:
--------------------------------------------------------------------------------
1 | @import 'prism-theme-vars/base.css';
2 |
3 | :root {
4 | --prism-font-family: 'Fira Code', monospace;
5 | --prism-line-height: 1.5em;
6 | }
7 |
8 | html:not(.dark) {
9 | --prism-scheme: light;
10 | --prism-foreground: #393a34;
11 | --prism-background: #f8f8f8;
12 |
13 | --prism-comment: #758575;
14 | --prism-namespace: #444444;
15 | --prism-string: #bc8671;
16 | --prism-punctuation: #80817d;
17 | --prism-literal: #36acaa;
18 | --prism-keyword: #248459;
19 | --prism-function: #849145;
20 | --prism-deleted: #9a050f;
21 | --prism-class: #2b91af;
22 | --prism-builtin: #800000;
23 | --prism-property: #ce9178;
24 | --prism-regex: #ad502b;
25 | }
26 |
27 | /* Overrides */
28 | html.dark {
29 | --prism-scheme: dark;
30 | --prism-foreground: #d4d4d4;
31 | --prism-background: #1e1e1e;
32 |
33 | --prism-namespace: #aaaaaa;
34 | --prism-comment: #758575;
35 | --prism-namespace: #444444;
36 | --prism-string: #ce9178;
37 | --prism-punctuation: #d4d4d4;
38 | --prism-literal: #36acaa;
39 | --prism-keyword: #38a776;
40 | --prism-function: #dcdcaa;
41 | --prism-deleted: #9a050f;
42 | --prism-class: #4ec9b0;
43 | --prism-builtin: #d16969;
44 | --prism-property: #ce9178;
45 | --prism-regex: #ad502b;
46 | }
47 |
--------------------------------------------------------------------------------
/app/tailwind.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
--------------------------------------------------------------------------------
/blog.config.ts:
--------------------------------------------------------------------------------
1 | const BLOG = {
2 | title: '信鑫 Blog',
3 | author: 'ycjcl868',
4 | authorAvatar: '/avatar.jpeg',
5 | email: 'chaolinjin@gmail.com',
6 | defaultImageHost: 'images.rustc.cloud',
7 | link: 'https://www.rustc.cloud',
8 | description: '写写文章的地方',
9 | lang: 'zh-CN', // ['en-US', 'zh-CN', 'zh-HK', 'zh-TW', 'ja-JP', 'es-ES']
10 | dateFormat: 'YYYY-MM-DD',
11 | appearance: 'auto', // ['light', 'dark', 'auto'],
12 | font: 'sans-serif', // ['sans-serif', 'serif']
13 | lightBackground: '#ffffff', // use hex value, don't forget '#' e.g #fffefc
14 | darkBackground: '#18181B', // use hex value, don't forget '#'
15 | path: '', // leave this empty unless you want to deploy Nobelium in a folder
16 | since: 2021, // If leave this empty, current year will be used.
17 | postsPerPage: 10,
18 | sortByDate: true,
19 | showAbout: true,
20 | showArchive: true,
21 | autoCollapsedNavBar: false, // The automatically collapsed navigation bar
22 | ogImageGenerateURL: 'https://og-image-craigary.vercel.app', // The link to generate OG image, don't end with a slash
23 | socialLink: 'https://twitter.com/ycjcl',
24 | seo: {
25 | keywords: ['Blog', 'Website', '信鑫', 'ycjcl868', '博客'],
26 | googleSiteVerification: '', // Remove the value or replace it with your own google site verification code
27 | },
28 | isPreviewImageSupportEnabled: false,
29 | // notionPageId: process.env.NOTION_PAGE_ID, // DO NOT CHANGE THIS!!!
30 | // notionAccessToken: process.env.NOTION_ACCESS_TOKEN, // Useful if you prefer not to make your database public
31 | analytics: {
32 | providers: [], // Currently we support Google Analytics and Ackee, please fill with 'ga' or '', leave it empty to disable it.
33 | ackeeConfig: {
34 | tracker: '', // e.g 'https://ackee.craigary.net/tracker.js'
35 | dataAckeeServer: '', // e.g https://ackee.craigary.net , don't end with a slash
36 | domainId: '', // e.g '0e2257a8-54d4-4847-91a1-0311ea48cc7b'
37 | },
38 | gaConfig: {
39 | measurementId: 'G-QNHPPR60EZ', // e.g: G-XXXXXXXXXX
40 | },
41 | cnzzConfig: {
42 | id: '1279745642',
43 | },
44 | },
45 | comment: {
46 | // support provider: gitalk, utterances, cusdis
47 | provider: 'cusdis', // leave it empty if you don't need any comment plugin
48 | gitalkConfig: {
49 | repo: 'blog', // The repository of store comments
50 | owner: 'ycjcl868',
51 | admin: ['ycjcl868'],
52 | clientID: '26baba385d964968e855',
53 | clientSecret: '56f5bf32b9785258727c624d7fbd2984361315e3',
54 | distractionFreeMode: false,
55 | proxy:
56 | 'https://proxy.rustc.cloud/?https://github.com/login/oauth/access_token',
57 | },
58 | utterancesConfig: {
59 | repo: '',
60 | },
61 | cusdisConfig: {
62 | appId: 'f099af17-208a-4dce-805a-1afcab66c7b1', // data-app-id
63 | host: 'https://cusdis.com', // data-host, change this if you're using self-hosted version
64 | scriptSrc: 'https://cusdis.com/js/cusdis.umd.js', // change this if you're using self-hosted version
65 | autoApproval: true, // auto approval comments
66 | },
67 | },
68 | isProd: process.env.NODE_ENV === 'production', // distinguish between development and production environment (ref: https://vercel.com/docs/environment-variables#system-environment-variables)
69 | };
70 | export default BLOG;
71 |
--------------------------------------------------------------------------------
/cjk.ts:
--------------------------------------------------------------------------------
1 | import BLOG from './blog.config';
2 |
3 | function CJK() {
4 | switch (BLOG.lang.toLowerCase()) {
5 | case 'zh-cn':
6 | case 'zh-sg':
7 | return 'SC';
8 | case 'zh':
9 | case 'zh-hk':
10 | case 'zh-tw':
11 | return 'TC';
12 | case 'ja':
13 | case 'ja-jp':
14 | return 'JP';
15 | case 'ko':
16 | case 'ko-kr':
17 | return 'KR';
18 | default:
19 | return null;
20 | }
21 | }
22 |
23 | export default CJK;
24 |
--------------------------------------------------------------------------------
/desktop.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ycjcl868/blog/1149cb7f208a6f2d8266120bc2935e2620c6216c/desktop.png
--------------------------------------------------------------------------------
/functions/[[path]].ts:
--------------------------------------------------------------------------------
1 | import { createPagesFunctionHandler } from '@remix-run/cloudflare-pages';
2 |
3 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment
4 | // @ts-ignore - the server build file is generated by `remix vite:build`
5 | // eslint-disable-next-line import/no-unresolved
6 | import * as build from '../build/server';
7 |
8 | export const onRequest = createPagesFunctionHandler({ build });
9 |
--------------------------------------------------------------------------------
/load-context.ts:
--------------------------------------------------------------------------------
1 | import { type PlatformProxy } from 'wrangler';
2 |
3 | type Cloudflare = Omit, 'dispose'>;
4 |
5 | declare module '@remix-run/cloudflare' {
6 | interface AppLoadContext {
7 | cloudflare: Cloudflare;
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "blog",
3 | "private": true,
4 | "sideEffects": false,
5 | "type": "module",
6 | "scripts": {
7 | "build": "node scripts/build.js && remix vite:build",
8 | "deploy": "npm run build && wrangler pages deploy",
9 | "dev": "remix vite:dev",
10 | "lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
11 | "start": "wrangler pages dev ./build/client",
12 | "typecheck": "tsc",
13 | "typegen": "wrangler types",
14 | "preview": "npm run build && wrangler pages dev",
15 | "cf-typegen": "wrangler types"
16 | },
17 | "dependencies": {
18 | "@notionhq/client": "^2.3.0",
19 | "@remix-run/cloudflare": "^2.16.0",
20 | "@remix-run/cloudflare-pages": "^2.16.0",
21 | "@remix-run/react": "^2.16.0",
22 | "autosize": "^6.0.1",
23 | "cf-workers-hash": "^1.0.3",
24 | "classnames": "^2.5.1",
25 | "clsx": "^2.1.1",
26 | "date-fns": "^2.30.0",
27 | "dayjs": "^1.11.10",
28 | "feed": "^4.2.2",
29 | "framer-motion": "12.0.0-alpha.0",
30 | "isbot": "^4.1.0",
31 | "katex": "^0.16.11",
32 | "ky": "^1.3.0",
33 | "lodash.pick": "^4.4.0",
34 | "lodash.throttle": "^4.1.1",
35 | "lodash.isempty": "^4.4.0",
36 | "lqip-modern": "^2.0.0",
37 | "node-polyglot": "^2.5.0",
38 | "notion-compat": "7.1.6",
39 | "notion-utils": "7.1.6",
40 | "nprogress": "^0.2.0",
41 | "p-map": "^5.5.0",
42 | "p-memoize": "^7.1.1",
43 | "prism-theme-vars": "^0.2.4",
44 | "prismjs": "^1.29.0",
45 | "react": "^19.0.0",
46 | "react-cusdis": "^2.1.3",
47 | "react-dom": "^19.0.0",
48 | "react-flip-move": "^3.0.5",
49 | "react-icons": "^4.12.0",
50 | "react-notion-x": "7.2.6",
51 | "react-tweet-embed": "^2.0.0",
52 | "react-use": "^17.5.1",
53 | "remix-themes": "^2.0.4",
54 | "use-ackee": "^3.0.1"
55 | },
56 | "devDependencies": {
57 | "dotenv": "^16.4.7",
58 | "@cloudflare/workers-types": "^4.20240806.0",
59 | "@remix-run/dev": "^2.16.0",
60 | "@types/nprogress": "^0.2.3",
61 | "@types/prismjs": "^1.26.4",
62 | "@types/react": "^19.0.10",
63 | "@types/react-dom": "^19.0.4",
64 | "@typescript-eslint/eslint-plugin": "^6.7.4",
65 | "@typescript-eslint/parser": "^6.7.4",
66 | "@vitejs/plugin-legacy": "^5.4.1",
67 | "autoprefixer": "^10.4.19",
68 | "critters": "^0.0.16",
69 | "cross-env": "^7.0.3",
70 | "eslint": "^8.38.0",
71 | "eslint-import-resolver-typescript": "^3.6.1",
72 | "eslint-plugin-import": "^2.28.1",
73 | "eslint-plugin-jsx-a11y": "^6.7.1",
74 | "eslint-plugin-react": "^7.33.2",
75 | "eslint-plugin-react-hooks": "^4.6.0",
76 | "notion-types": "7.1.6",
77 | "postcss": "^8.4.38",
78 | "prettier": "^2.8.8",
79 | "prettier-plugin-organize-imports": "^3.2.4",
80 | "tailwindcss": "^3.4.4",
81 | "typescript": "^5.1.6",
82 | "vite": "^5.1.0",
83 | "vite-tsconfig-paths": "^4.2.1",
84 | "wrangler": "3.57.1"
85 | },
86 | "engines": {
87 | "node": ">=20.0.0"
88 | },
89 | "prettier": {
90 | "singleQuote": true,
91 | "plugins": [
92 | "prettier-plugin-organize-imports"
93 | ]
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/_headers:
--------------------------------------------------------------------------------
1 | /favicon.ico
2 | Cache-Control: public, max-age=3600, s-maxage=3600
3 | /assets/*
4 | Cache-Control: public, max-age=31536000, immutable
5 |
--------------------------------------------------------------------------------
/public/_routes.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 1,
3 | "include": ["/*"],
4 | "exclude": ["/favicon.ico", "/assets/*"]
5 | }
6 |
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ycjcl868/blog/1149cb7f208a6f2d8266120bc2935e2620c6216c/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/avatar.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ycjcl868/blog/1149cb7f208a6f2d8266120bc2935e2620c6216c/public/avatar.jpeg
--------------------------------------------------------------------------------
/public/favicon copy.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ycjcl868/blog/1149cb7f208a6f2d8266120bc2935e2620c6216c/public/favicon copy.ico
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ycjcl868/blog/1149cb7f208a6f2d8266120bc2935e2620c6216c/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ycjcl868/blog/1149cb7f208a6f2d8266120bc2935e2620c6216c/public/favicon.png
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/public/fonts/IBMPlexSansVar-Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ycjcl868/blog/1149cb7f208a6f2d8266120bc2935e2620c6216c/public/fonts/IBMPlexSansVar-Italic.woff2
--------------------------------------------------------------------------------
/public/fonts/IBMPlexSansVar-Roman.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ycjcl868/blog/1149cb7f208a6f2d8266120bc2935e2620c6216c/public/fonts/IBMPlexSansVar-Roman.woff2
--------------------------------------------------------------------------------
/public/fonts/SourceSerif-Italic.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ycjcl868/blog/1149cb7f208a6f2d8266120bc2935e2620c6216c/public/fonts/SourceSerif-Italic.var.woff2
--------------------------------------------------------------------------------
/public/fonts/SourceSerif.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ycjcl868/blog/1149cb7f208a6f2d8266120bc2935e2620c6216c/public/fonts/SourceSerif.var.woff2
--------------------------------------------------------------------------------
/public/polyfill.js:
--------------------------------------------------------------------------------
1 | if (!Object.hasOwn) {
2 | Object.hasOwn = (obj, prop) =>
3 | Object.prototype.hasOwnProperty.call(obj, prop);
4 | console.log('polyfill');
5 | }
6 |
--------------------------------------------------------------------------------
/scripts/build.js:
--------------------------------------------------------------------------------
1 | import dotenv from 'dotenv';
2 | import fs from 'fs';
3 | import path from 'path';
4 | import { fileURLToPath } from 'url';
5 |
6 | const __filename = fileURLToPath(import.meta.url);
7 | const __dirname = path.dirname(__filename);
8 | const rootDir = path.join(__dirname, '..');
9 | dotenv.config({
10 | path: [
11 | path.join(rootDir, '.dev.vars'),
12 | path.join(rootDir, '.vars'),
13 | path.join(rootDir, '.env'),
14 | ],
15 | });
16 |
17 | const templatePath = path.join(rootDir, 'wrangler.toml.tpl');
18 | const outputPath = path.join(rootDir, 'wrangler.toml');
19 |
20 | try {
21 | let templateContent = fs.readFileSync(templatePath, 'utf8');
22 |
23 | const variables = {
24 | CLOUDFLARE_KV_ID: process.env.CLOUDFLARE_KV_ID,
25 | };
26 |
27 | // 替换所有变量
28 | Object.keys(variables).forEach((key) => {
29 | const placeholder =
30 | '\\${\\s*' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*}';
31 | templateContent = templateContent.replace(
32 | new RegExp(placeholder, 'g'),
33 | variables[key]
34 | );
35 | });
36 |
37 | //
38 | fs.writeFileSync(outputPath, templateContent);
39 |
40 | console.log('success build wrangler.toml');
41 | } catch (error) {
42 | console.error('build wrangler.toml error:', error);
43 | process.exit(1);
44 | }
45 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 | import { fontFamily } from 'tailwindcss/defaultTheme';
3 | import BLOG from './blog.config';
4 | import CJK from './cjk';
5 |
6 | const fontSansCJK = !CJK()
7 | ? []
8 | : [`"Noto Sans CJK ${CJK()}"`, `"Noto Sans ${CJK()}"`];
9 | const fontSerifCJK = !CJK()
10 | ? []
11 | : [`"Noto Serif CJK ${CJK()}"`, `"Noto Serif ${CJK()}"`];
12 |
13 | export default {
14 | content: ['./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}'],
15 | darkMode: 'class', // or 'media' or 'class'
16 | theme: {
17 | extend: {
18 | colors: {
19 | day: {
20 | DEFAULT: BLOG.lightBackground || '#ffffff',
21 | },
22 | night: {
23 | DEFAULT: BLOG.darkBackground || '#111827',
24 | },
25 | },
26 | fontFamily: {
27 | sans: ['"IBM Plex Sans"', ...fontFamily.sans, ...fontSansCJK],
28 | serif: ['"Source Serif"', ...fontFamily.serif, ...fontSerifCJK],
29 | noEmoji: [
30 | '"IBM Plex Sans"',
31 | 'ui-sans-serif',
32 | 'system-ui',
33 | '-apple-system',
34 | 'BlinkMacSystemFont',
35 | 'sans-serif',
36 | ],
37 | },
38 | },
39 | },
40 | plugins: [],
41 | } satisfies Config;
42 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [
3 | "*.ts",
4 | "**/*.ts",
5 | "**/*.tsx",
6 | "**/.server/**/*.ts",
7 | "**/.server/**/*.tsx",
8 | "**/.client/**/*.ts",
9 | "**/.client/**/*.tsx"
10 | ],
11 | "compilerOptions": {
12 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
13 | "types": [
14 | "@remix-run/cloudflare",
15 | "vite/client",
16 | "@cloudflare/workers-types/2023-07-01"
17 | ],
18 | "isolatedModules": true,
19 | "esModuleInterop": true,
20 | "jsx": "react-jsx",
21 | "module": "ESNext",
22 | "moduleResolution": "Bundler",
23 | "resolveJsonModule": true,
24 | "target": "ES2022",
25 | "strict": true,
26 | "allowJs": true,
27 | "skipLibCheck": true,
28 | "forceConsistentCasingInFileNames": true,
29 | "baseUrl": ".",
30 | "paths": {
31 | "~/*": ["./app/*"],
32 | "#/*": ["./*"]
33 | },
34 |
35 | // Vite takes care of building everything, not tsc.
36 | "noEmit": true
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import {
2 | vitePlugin as remix,
3 | cloudflareDevProxyVitePlugin as remixCloudflareDevProxy,
4 | } from '@remix-run/dev';
5 | import legacy from '@vitejs/plugin-legacy';
6 | import { defineConfig } from 'vite';
7 | import tsconfigPaths from 'vite-tsconfig-paths';
8 |
9 | export default defineConfig({
10 | plugins: [
11 | legacy({
12 | targets: ['defaults', 'not IE 11'],
13 | polyfills: ['es/object/has-own'],
14 | modernPolyfills: ['es/object/has-own'],
15 | }),
16 | remixCloudflareDevProxy(),
17 | remix({
18 | ssr: true,
19 | future: {
20 | // v3_lazyRouteDiscovery: true,
21 | unstable_optimizeDeps: true,
22 | v3_fetcherPersist: true,
23 | v3_relativeSplatPath: true,
24 | v3_throwAbortReason: true,
25 | },
26 | }),
27 | tsconfigPaths(),
28 | ],
29 | build: {
30 | target: 'es2015',
31 | minify: true,
32 | cssMinify: true,
33 | },
34 | });
35 |
--------------------------------------------------------------------------------
/worker-configuration.d.ts:
--------------------------------------------------------------------------------
1 | // Generated by Wrangler
2 | // After adding bindings to `wrangler.toml`, regenerate this interface via `npm run cf-typegen`
3 | interface Env {
4 | NOTION_PAGE_ID: string;
5 | NOTION_ACCESS_TOKEN: string;
6 | KV: KVNamespace;
7 | }
8 |
--------------------------------------------------------------------------------
/wrangler.toml.tpl:
--------------------------------------------------------------------------------
1 | #:schema node_modules/wrangler/config-schema.json
2 | name = "blog"
3 | compatibility_date = "2024-08-06"
4 | pages_build_output_dir = "./build/client"
5 |
6 | # Automatically place your workloads in an optimal location to minimize latency.
7 | # If you are running back-end logic in a Pages Function, running it closer to your back-end infrastructure
8 | # rather than the end user may result in better performance.
9 | # Docs: https://developers.cloudflare.com/pages/functions/smart-placement/#smart-placement
10 | # [placement]
11 | # mode = "smart"
12 |
13 | # Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
14 | # Docs:
15 | # - https://developers.cloudflare.com/pages/functions/bindings/#environment-variables
16 | # Note: Use secrets to store sensitive data.
17 | # - https://developers.cloudflare.com/pages/functions/bindings/#secrets
18 |
19 | # Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network
20 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#workers-ai
21 | # [ai]
22 | # binding = "AI"
23 |
24 | # Bind a D1 database. D1 is Cloudflare’s native serverless SQL database.
25 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#d1-databases
26 | # [[d1_databases]]
27 | # binding = "MY_DB"
28 | # database_name = "my-database"
29 | # database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
30 |
31 | # Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model.
32 | # Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps.
33 | # Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects
34 | # [[durable_objects.bindings]]
35 | # name = "MY_DURABLE_OBJECT"
36 | # class_name = "MyDurableObject"
37 | # script_name = 'my-durable-object'
38 |
39 | # Bind a KV Namespace. Use KV as persistent storage for small key-value pairs.
40 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#kv-namespaces
41 | [[kv_namespaces]]
42 | binding = "KV"
43 | id = "${CLOUDFLARE_KV_ID}" # replace with your KV ID
44 |
45 | # Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer.
46 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#queue-producers
47 | # [[queues.producers]]
48 | # binding = "MY_QUEUE"
49 | # queue = "my-queue"
50 |
51 | # Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files.
52 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#r2-buckets
53 | # [[r2_buckets]]
54 | # binding = "MY_BUCKET"
55 | # bucket_name = "my-bucket"
56 |
57 | # Bind another Worker service. Use this binding to call another Worker without network overhead.
58 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#service-bindings
59 | # [[services]]
60 | # binding = "MY_SERVICE"
61 | # service = "my-service"
62 |
63 | # To use different bindings for preview and production environments, follow the examples below.
64 | # When using environment-specific overrides for bindings, ALL bindings must be specified on a per-environment basis.
65 | # Docs: https://developers.cloudflare.com/pages/functions/wrangler-configuration#environment-specific-overrides
66 |
67 | ######## PREVIEW environment config ########
68 |
69 | # [env.preview.vars]
70 | # API_KEY = "xyz789"
71 |
72 | # [[env.preview.kv_namespaces]]
73 | # binding = "MY_KV_NAMESPACE"
74 | # id = ""
75 |
76 | ######## PRODUCTION environment config ########
77 |
78 | # [env.production.vars]
79 | # API_KEY = "abc123"
80 |
81 | # [[env.production.kv_namespaces]]
82 | # binding = "MY_KV_NAMESPACE"
83 | # id = ""
84 |
--------------------------------------------------------------------------------