34 files changed
+7117
-1
No files matched your search
Vendored
+1
-1
@@ -5,5 +5,5 @@
|
||||
".env.*": "dotenv"
|
||||
},
|
||||
"whitespacepp.backColorWS": "",
|
||||
"editor.tabSize": 4
|
||||
"editor.tabSize": 2
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es2021: true,
|
||||
node: true
|
||||
},
|
||||
extends: ['plugin:react/recommended', 'standard'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true
|
||||
},
|
||||
ecmaVersion: 12,
|
||||
sourceType: 'module'
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect'
|
||||
}
|
||||
},
|
||||
plugins: ['react', '@typescript-eslint'],
|
||||
rules: {
|
||||
'react/react-in-jsx-scope': 0,
|
||||
'multiline-ternary': 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
/build/
|
||||
/public/media/
|
||||
@@ -0,0 +1,30 @@
|
||||
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `pages/index.js`. The page auto-updates as you edit the file.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/import?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
|
||||
@@ -0,0 +1,4 @@
|
||||
import theme from '~/build/theme.json'
|
||||
import { ITheme } from '~/server/theme'
|
||||
|
||||
export const THEME: ITheme = theme
|
||||
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import { THEME } from './global'
|
||||
|
||||
export type Theme = 'light' | 'dark'
|
||||
|
||||
export interface CommentsConfig {
|
||||
host: string
|
||||
site_id: string
|
||||
url?: string
|
||||
max_shown_comments?: number
|
||||
theme?: Theme
|
||||
page_title?: string
|
||||
node?: string | HTMLElement
|
||||
locale?: string
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
__colors__?: any
|
||||
}
|
||||
|
||||
type Remark42Instance = {
|
||||
changeTheme(theme: Theme): void
|
||||
destroy(): void
|
||||
}
|
||||
|
||||
declare global {
|
||||
export interface Window {
|
||||
remark_config: CommentsConfig
|
||||
remark42Instance?: Remark42Instance
|
||||
REMARK42: {
|
||||
changeTheme(theme: Theme): void
|
||||
destroy?: () => void
|
||||
createInstance: (
|
||||
remark_config: CommentsConfig
|
||||
) => Remark42Instance | undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.browser && THEME?.comment?.remark42) {
|
||||
;(function (c) {
|
||||
for (let i = 0; i < c.length; i++) {
|
||||
const d = document
|
||||
const s = d.createElement('script')
|
||||
s.src = THEME.comment.remark42.host + '/web/' + c[i] + '.js'
|
||||
s.defer = true
|
||||
;(d.head || d.body).appendChild(s)
|
||||
}
|
||||
})(['embed'])
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function formatDate (s: string | number) {
|
||||
return new Date(s).toLocaleDateString([], {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { THEME } from '~/assets/global'
|
||||
import Link from 'next/link'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
const Layout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<main>
|
||||
<header>
|
||||
<h1>{THEME.title}</h1>
|
||||
|
||||
<nav>
|
||||
<ul>
|
||||
<li>
|
||||
<Link href="/">
|
||||
<a>Home</a>
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
{(THEME.nav || []).map((n) =>
|
||||
n.to ? (
|
||||
<Link href={n.to} key={n.name}>
|
||||
<a>{n.name}</a>
|
||||
</Link>
|
||||
) : (
|
||||
<a
|
||||
href={n.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
key={n.name}
|
||||
>
|
||||
{n.name}
|
||||
</a>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<form>
|
||||
<input name="q" type="search" placeholder="Search..." />
|
||||
<div className="icon">🔎</div>
|
||||
</form>
|
||||
</nav>
|
||||
</header>
|
||||
{children}
|
||||
|
||||
<style jsx>{`
|
||||
nav ul {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
form {
|
||||
float: right;
|
||||
max-width: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
form .icon {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
input {
|
||||
padding-right: 1.5em;
|
||||
}
|
||||
`}</style>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
||||
@@ -0,0 +1,134 @@
|
||||
import { IPost } from '~/server/db'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import PostTeaser from './PostTeaser'
|
||||
|
||||
const PostQuery = ({
|
||||
defaults
|
||||
}: {
|
||||
defaults: {
|
||||
posts: IPost[]
|
||||
count: number
|
||||
}
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
|
||||
const page = parseInt(router.query.page as string) || 1
|
||||
const makePageUrl = (p: number) => {
|
||||
let pathname = router.pathname
|
||||
.replace(/\/$/, '')
|
||||
.replace(/\d+$/, '')
|
||||
.replace(/\/$/, '')
|
||||
if (pathname !== '/tag' && pathname !== '/blog') {
|
||||
pathname = '/blog'
|
||||
}
|
||||
|
||||
if (p > 1) {
|
||||
pathname += '/' + p
|
||||
}
|
||||
|
||||
if (router.query.q) {
|
||||
pathname += `?q=${encodeURIComponent(router.query.q as string)}`
|
||||
}
|
||||
|
||||
return pathname
|
||||
}
|
||||
|
||||
const [posts, setPosts] = useState(defaults.posts)
|
||||
const [count, setCount] = useState(defaults.count)
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query.q) {
|
||||
const querystrings: string[] = []
|
||||
for (const [k, v] of Object.entries({
|
||||
q: router.query.q,
|
||||
page: router.query.page,
|
||||
tag: router.query.tag
|
||||
})) {
|
||||
if (v) {
|
||||
querystrings.push(`${k}=${encodeURIComponent(v as string)}`)
|
||||
}
|
||||
}
|
||||
|
||||
fetch(`/api/q${querystrings.length ? `?${querystrings.join('&')}` : ''}`)
|
||||
.then((r) => r.json())
|
||||
.then(({ result, count }) => {
|
||||
setPosts(result)
|
||||
setCount(count)
|
||||
})
|
||||
} else {
|
||||
setPosts(defaults.posts)
|
||||
setCount(defaults.count)
|
||||
}
|
||||
}, [router.query.q, router.query.page, router.query.tag])
|
||||
|
||||
const totalPages = Math.ceil(count / 5)
|
||||
|
||||
return (
|
||||
<article>
|
||||
{posts.length ? (
|
||||
posts.map((p) => <PostTeaser post={p} key={p.path} />)
|
||||
) : (
|
||||
<p>No posts found.</p>
|
||||
)}
|
||||
|
||||
{count > 5 ? (
|
||||
<nav>
|
||||
<ul>
|
||||
{page > 1 ? (
|
||||
<li>
|
||||
<Link href={makePageUrl(1)}>
|
||||
<a aria-label="page 1">1</a>
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
{page > 3 ? (
|
||||
<li>
|
||||
<span> … </span>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
{page > 2 ? (
|
||||
<li>
|
||||
<Link href={makePageUrl(page - 1)}>
|
||||
<a aria-label={`page ${page - 1}`}>{page - 1}</a>
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
<li>
|
||||
<span>{page}</span>
|
||||
</li>
|
||||
|
||||
{page < totalPages - 1 ? (
|
||||
<li>
|
||||
<Link href={makePageUrl(page + 1)}>
|
||||
<a aria-label={`page ${page + 1}`}>{page + 1}</a>
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
{page < totalPages - 2 ? (
|
||||
<li>
|
||||
<span> … </span>
|
||||
</li>
|
||||
) : null}
|
||||
|
||||
{page < totalPages ? (
|
||||
<li>
|
||||
<Link href={makePageUrl(totalPages)}>
|
||||
<a aria-label={`page ${totalPages}`}>{totalPages}</a>
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</nav>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostQuery
|
||||
@@ -0,0 +1,44 @@
|
||||
import { THEME } from '~/assets/global'
|
||||
import { formatDate } from '~/assets/util'
|
||||
import { IPostTeaser } from '~/server/db'
|
||||
import Link from 'next/link'
|
||||
|
||||
const PostTeaser = ({ post }: { post: IPostTeaser }) => {
|
||||
return (
|
||||
<section>
|
||||
<h2>
|
||||
<Link href={`/post/${post.path}`}>
|
||||
<a>{post.title}</a>
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
<small>
|
||||
<span>
|
||||
by
|
||||
{THEME.author.url ? (
|
||||
<a
|
||||
href={THEME.author.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{THEME.author.name}
|
||||
</a>
|
||||
) : (
|
||||
<span>{THEME.author.name}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{post.date ? (
|
||||
<span style={{ float: 'right' }}>{formatDate(post.date)}</span>
|
||||
) : null}
|
||||
</small>
|
||||
|
||||
<article
|
||||
style={{ marginTop: '2rem' }}
|
||||
dangerouslySetInnerHTML={{ __html: post.excerptHtml }}
|
||||
></article>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostTeaser
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/types/global" />
|
||||
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
async redirects () {
|
||||
return [
|
||||
{
|
||||
source: '/',
|
||||
destination: '/blog',
|
||||
permanent: false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "@zhdiary/next",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"ts": "ts-node -O '{\"module\":\"commonjs\"}'",
|
||||
"prebuild": "yarn ts scripts/make.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-yaml": "^3.14.1",
|
||||
"lokijs": "^1.5.11",
|
||||
"lunr": "^2.3.9",
|
||||
"next": "10.0.3",
|
||||
"react": "17.0.1",
|
||||
"react-dom": "17.0.1",
|
||||
"zod": "^1.11.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@patarapolw/scope-css": "^1.3.0",
|
||||
"@types/cheerio": "^0.22.23",
|
||||
"@types/ejs": "^3.0.5",
|
||||
"@types/fs-extra": "^9.0.5",
|
||||
"@types/js-yaml": "^3.12.5",
|
||||
"@types/lokijs": "^1.5.3",
|
||||
"@types/lunr": "^2.3.3",
|
||||
"@types/node": "^14.14.14",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-dom": "^17.0.0",
|
||||
"@types/rimraf": "^3.0.0",
|
||||
"@types/showdown": "^1.9.3",
|
||||
"@typescript-eslint/eslint-plugin": "^4.10.0",
|
||||
"@typescript-eslint/parser": "^4.10.0",
|
||||
"cheerio": "^1.0.0-rc.3",
|
||||
"dotenv": "^8.2.0",
|
||||
"ejs": "^3.1.5",
|
||||
"eslint": "^7.12.1",
|
||||
"eslint-config-standard": "^16.0.2",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.21.5",
|
||||
"fs-extra": "^9.0.1",
|
||||
"import-sort-parser-typescript": "^6.0.0",
|
||||
"nodejieba": "^2.5.1",
|
||||
"prettier": "^2.2.1",
|
||||
"rimraf": "^3.0.2",
|
||||
"showdown": "^1.9.1",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.1.3"
|
||||
},
|
||||
"importSort": {
|
||||
".ts, .tsx, .config.ts": {
|
||||
"parser": "typescript",
|
||||
"style": "module"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { THEME } from '~/assets/global'
|
||||
import Layout from '~/components/Layout'
|
||||
import Head from 'next/head'
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const MyApp = ({ Component, pageProps }) => {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{THEME.title}</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</Head>
|
||||
<Layout>
|
||||
<Component {...pageProps} />
|
||||
</Layout>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MyApp
|
||||
@@ -0,0 +1,55 @@
|
||||
import THEME from '~/build/theme.json'
|
||||
import Document, { Head, Html, Main, NextScript } from 'next/document'
|
||||
|
||||
class MyDocument extends Document {
|
||||
static async getInitialProps (ctx) {
|
||||
const initialProps = await Document.getInitialProps(ctx)
|
||||
return { ...initialProps }
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
<meta httpEquiv="Content-Type" content="text/html;charset=UTF-8" />
|
||||
<meta name="description" content={THEME.description} />
|
||||
|
||||
<link
|
||||
rel="shortcut icon"
|
||||
href="/media/favicon.ico"
|
||||
type="image/x-icon"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href={
|
||||
THEME.awsm
|
||||
? `https://unpkg.com/awsm.css/dist/awsm_theme_${THEME.awsm}.min.css`
|
||||
: 'https://unpkg.com/awsm.css/dist/awsm.min.css'
|
||||
}
|
||||
/>
|
||||
|
||||
{THEME.analytics?.plausible ? (
|
||||
<script
|
||||
src="https://plausible.io/js/plausible.js"
|
||||
async
|
||||
defer
|
||||
data-domain={THEME.analytics.plausible}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<script
|
||||
src="https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-loader.js"
|
||||
defer
|
||||
/>
|
||||
<script type="module" src="/webcomponents.js"></script>
|
||||
</Head>
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default MyDocument
|
||||
@@ -0,0 +1,12 @@
|
||||
import { doSearch } from '~/server'
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
|
||||
export default async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const r = await doSearch({
|
||||
q: req.query.q as string,
|
||||
tag: req.query.tag as string,
|
||||
page: parseInt(req.query.page as string) || 1
|
||||
})
|
||||
|
||||
res.json(r)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import PostQuery from '~/components/PostQuery'
|
||||
import { doSearch } from '~/server'
|
||||
import { IPost, dbPost, initDatabase } from '~/server/db'
|
||||
import { GetStaticPaths, GetStaticProps } from 'next'
|
||||
|
||||
const BlogPagePaged = ({
|
||||
count,
|
||||
result
|
||||
}: {
|
||||
count: number
|
||||
result: IPost[]
|
||||
}) => {
|
||||
return (
|
||||
<PostQuery
|
||||
defaults={{
|
||||
posts: result,
|
||||
count
|
||||
}}
|
||||
></PostQuery>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlogPagePaged
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
await initDatabase()
|
||||
|
||||
return {
|
||||
paths: Array(
|
||||
Math.ceil(
|
||||
dbPost.find({
|
||||
date: { $exists: true } as any
|
||||
}).length / 5
|
||||
) - 1
|
||||
)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
params: {
|
||||
page: (i + 2).toString()
|
||||
}
|
||||
})),
|
||||
fallback: false
|
||||
}
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (ctx) => {
|
||||
return {
|
||||
props: await doSearch({
|
||||
page: parseInt(ctx.params.page as string)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import PostQuery from '~/components/PostQuery'
|
||||
import { doSearch } from '~/server'
|
||||
import { IPost } from '~/server/db'
|
||||
import { GetStaticProps } from 'next'
|
||||
|
||||
const BlogPage = ({ count, result }: { count: number; result: IPost[] }) => {
|
||||
return (
|
||||
<PostQuery
|
||||
defaults={{
|
||||
posts: result,
|
||||
count
|
||||
}}
|
||||
></PostQuery>
|
||||
)
|
||||
}
|
||||
|
||||
export default BlogPage
|
||||
|
||||
export const getStaticProps: GetStaticProps = async () => {
|
||||
return {
|
||||
props: await doSearch({})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import '~/assets/remark42'
|
||||
|
||||
import { THEME } from '~/assets/global'
|
||||
import { formatDate } from '~/assets/util'
|
||||
import { IPost, dbPost, initDatabase } from '~/server/db'
|
||||
import styles from '~/styles/PostPage.module.css'
|
||||
import { GetStaticPaths, GetStaticProps } from 'next'
|
||||
import Head from 'next/head'
|
||||
import Link from 'next/link'
|
||||
import { createRef, useEffect } from 'react'
|
||||
|
||||
const PostPage = (post: IPost) => {
|
||||
const remark42Ref = createRef<HTMLDivElement>()
|
||||
|
||||
useEffect(() => {
|
||||
if (remark42Ref.current) {
|
||||
if (window.remark42Instance) {
|
||||
window.remark42Instance.destroy()
|
||||
}
|
||||
|
||||
window.remark42Instance = window.REMARK42.createInstance({
|
||||
node: remark42Ref.current,
|
||||
host: THEME.comment.remark42.host,
|
||||
site_id: THEME.comment.remark42.siteId
|
||||
})
|
||||
}
|
||||
}, [remark42Ref.current])
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Head>
|
||||
<title>
|
||||
{post.title} - {THEME.title}
|
||||
</title>
|
||||
<meta property="og:title" content={post.title} />
|
||||
<meta property="twitter:title" content={post.title} />
|
||||
|
||||
<meta name="description" content={post.content.substr(0, 140)} />
|
||||
<meta property="og:description" content={post.content.substr(0, 140)} />
|
||||
<meta property="twitter:title" content={post.content.substr(0, 140)} />
|
||||
|
||||
{post.tag ? (
|
||||
<meta name="keywords" content={post.tag.join(',')} />
|
||||
) : null}
|
||||
|
||||
{post.image ? (
|
||||
<>
|
||||
<meta property="og:image" content={post.image} />
|
||||
<meta property="twitter:title" content={post.image} />
|
||||
</>
|
||||
) : null}
|
||||
</Head>
|
||||
<section>
|
||||
<h2>{post.title}</h2>
|
||||
|
||||
<small>
|
||||
<span>
|
||||
by{' '}
|
||||
{THEME.author.url ? (
|
||||
<a
|
||||
href={THEME.author.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{THEME.author.name}
|
||||
</a>
|
||||
) : (
|
||||
<span>{THEME.author.name}</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{post.date ? (
|
||||
<span style={{ float: 'right' }}>{formatDate(post.date)}</span>
|
||||
) : null}
|
||||
</small>
|
||||
</section>
|
||||
|
||||
<section dangerouslySetInnerHTML={{ __html: post.contentHtml }} />
|
||||
|
||||
{post.tag ? (
|
||||
<section className={styles['tags-container']}>
|
||||
<span>Tags:</span>
|
||||
{post.tag.map((t) => (
|
||||
<span key={t}>
|
||||
<Link href={`/tag/${t}`}>
|
||||
<a>{t}</a>
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{THEME.comment?.remark42 ? (
|
||||
<footer>
|
||||
<div ref={remark42Ref}></div>
|
||||
</footer>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostPage
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
await initDatabase()
|
||||
|
||||
return {
|
||||
paths: dbPost
|
||||
.find()
|
||||
.map(({ path }) => ({
|
||||
params: { path: path.split(/\//g).map(encodeURIComponent) }
|
||||
})),
|
||||
fallback: false
|
||||
}
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (ctx) => {
|
||||
await initDatabase()
|
||||
|
||||
return {
|
||||
props: dbPost.findOne({
|
||||
path: (ctx.params.path as string[]).join('/')
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import PostQuery from '~/components/PostQuery'
|
||||
import { doSearch } from '~/server'
|
||||
import { IPost, dbPost, initDatabase } from '~/server/db'
|
||||
import { GetStaticPaths, GetStaticProps } from 'next'
|
||||
|
||||
const TagPagePaged = ({
|
||||
count,
|
||||
result
|
||||
}: {
|
||||
count: number
|
||||
result: IPost[]
|
||||
}) => {
|
||||
return (
|
||||
<PostQuery
|
||||
defaults={{
|
||||
posts: result,
|
||||
count
|
||||
}}
|
||||
></PostQuery>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagPagePaged
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (ctx) => {
|
||||
return {
|
||||
props: await doSearch({
|
||||
page: parseInt(ctx.params.page as string),
|
||||
tag: ctx.params.tag as string
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
await initDatabase()
|
||||
const tMap = new Map<string, number>()
|
||||
|
||||
dbPost.find().map(({ tag = [] }) => {
|
||||
return tag.map((t) => {
|
||||
return tMap.set(t, (tMap.get(t) || 0) + 1)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
paths: Array.from(tMap).flatMap(([t, n]) =>
|
||||
Array(Math.ceil(n / 5) - 1)
|
||||
.fill(null)
|
||||
.map((_, i) => ({
|
||||
params: {
|
||||
tag: t,
|
||||
page: (i + 2).toString()
|
||||
}
|
||||
}))
|
||||
),
|
||||
fallback: false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import PostQuery from '~/components/PostQuery'
|
||||
import { doSearch } from '~/server'
|
||||
import { IPost, dbPost, initDatabase } from '~/server/db'
|
||||
import { GetStaticPaths, GetStaticProps } from 'next'
|
||||
|
||||
const TagPage = ({ count, result }: { count: number; result: IPost[] }) => {
|
||||
return (
|
||||
<PostQuery
|
||||
defaults={{
|
||||
posts: result,
|
||||
count
|
||||
}}
|
||||
></PostQuery>
|
||||
)
|
||||
}
|
||||
|
||||
export default TagPage
|
||||
|
||||
export const getStaticPaths: GetStaticPaths = async () => {
|
||||
await initDatabase()
|
||||
const tMap = new Map<string, number>()
|
||||
|
||||
dbPost.find().map(({ tag = [] }) => {
|
||||
return tag.map((t) => {
|
||||
return tMap.set(t, (tMap.get(t) || 0) + 1)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
paths: Array.from(tMap).map(([t]) => ({
|
||||
params: {
|
||||
tag: t
|
||||
}
|
||||
})),
|
||||
fallback: false
|
||||
}
|
||||
}
|
||||
|
||||
export const getStaticProps: GetStaticProps = async (ctx) => {
|
||||
return {
|
||||
props: await doSearch({
|
||||
tag: ctx.params.tag as string
|
||||
})
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,11 @@
|
||||
/* global WebComponents */
|
||||
WebComponents.waitFor(() => {
|
||||
// At this point we are guaranteed that all required polyfills have
|
||||
// loaded, and can use web components API's.
|
||||
// The standard pattern is to load element definitions that call
|
||||
// `customElements.define` here.
|
||||
// Note: returning the import's promise causes the custom elements
|
||||
// polyfill to wait until all definitions are loaded and then upgrade
|
||||
// the document in one batch, for better performance.
|
||||
return import('https://unpkg.com/@patarapolw/make-html-x-card')
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import dotenv from 'dotenv'
|
||||
|
||||
import { THEME_FILENAME } from '../server/theme'
|
||||
|
||||
dotenv.config({ path: '../../.env' })
|
||||
dotenv.config()
|
||||
|
||||
export const CONTENT_PATH = path.resolve(
|
||||
process.env.CONTENT_PATH ||
|
||||
(fs.existsSync('../../_post') ? '../..' : 'content')
|
||||
)
|
||||
|
||||
export const srcPath = (
|
||||
root: '_post' | '_media' | typeof THEME_FILENAME,
|
||||
...ps: string[]
|
||||
): string => path.join(CONTENT_PATH, root, ...ps)
|
||||
@@ -0,0 +1,153 @@
|
||||
import cheerio from 'cheerio'
|
||||
import crypto from 'crypto'
|
||||
import ejs from 'ejs'
|
||||
import scopeCss from '@patarapolw/scope-css'
|
||||
import showdown from 'showdown'
|
||||
|
||||
export class MakeHtml {
|
||||
$ = cheerio.load('')
|
||||
|
||||
mdConverter = new showdown.Converter({
|
||||
noHeaderId: true,
|
||||
parseImgDimensions: true,
|
||||
simplifiedAutoLink: true,
|
||||
// literalMidWordUnderscores: true,
|
||||
strikethrough: true,
|
||||
tables: true,
|
||||
tasklists: true,
|
||||
// simpleLineBreaks: true,
|
||||
// openLinksInNewWindow: true,
|
||||
backslashEscapesHTMLTags: true,
|
||||
emoji: true,
|
||||
underline: true,
|
||||
disableForced4SpacesIndentedSublists: true,
|
||||
metadata: true
|
||||
})
|
||||
|
||||
constructor () {
|
||||
this.mdConverter.addExtension(
|
||||
{
|
||||
type: 'lang',
|
||||
regex: /\\\n/g,
|
||||
replace: '<br />'
|
||||
},
|
||||
'commonmark-simple-linebreak'
|
||||
)
|
||||
|
||||
/**
|
||||
* ! For some reasons default underscore italic fails to render...
|
||||
*/
|
||||
this.mdConverter.addExtension(
|
||||
{
|
||||
type: 'lang',
|
||||
regex: /([^\\])_{1}(.*?[^\\])_{1}/g,
|
||||
replace: '$1<i>$2</i>'
|
||||
},
|
||||
'underscore-italic'
|
||||
)
|
||||
|
||||
/**
|
||||
* ! This is the only way to preserve syntax highlighting, while output multiline strings correctly
|
||||
*/
|
||||
this.mdConverter.addExtension(
|
||||
{
|
||||
type: 'lang',
|
||||
regex: /(^|\n)```html parsed([^]+)\n```\n/g,
|
||||
replace: (_: string, p1: string, p2: string) => {
|
||||
return (
|
||||
p1 +
|
||||
ejs.render(p2, {
|
||||
rmWhitespace: true,
|
||||
beautify: false
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
'html-parsed'
|
||||
)
|
||||
}
|
||||
|
||||
async parse (
|
||||
filename: string
|
||||
): Promise<{
|
||||
html: string
|
||||
text: string
|
||||
}> {
|
||||
const id =
|
||||
'md-' +
|
||||
crypto
|
||||
.createHash('sha256')
|
||||
.update(filename)
|
||||
.digest()
|
||||
.toString('hex')
|
||||
.substr(0, 8)
|
||||
|
||||
const $div = this.$(`<div id="${id}">`)
|
||||
$div.html(await this.getHTML(filename))
|
||||
|
||||
$div.find('style').each((_, el) => {
|
||||
const $el = this.$(el)
|
||||
const html = $el.html() || ''
|
||||
if (html.trim()) {
|
||||
$el.html(scopeCss(html, `#${id}`))
|
||||
}
|
||||
})
|
||||
|
||||
$div.find('a').each((_, el) => {
|
||||
const $el = this.$(el)
|
||||
const href = $el.attr('href') || ''
|
||||
|
||||
if (/^https?:\/\//.test(href)) {
|
||||
$el.attr({
|
||||
rel: 'noopener',
|
||||
target: '_blank'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
html: this.$('<div>').append($div).html() || '',
|
||||
text: $div.text().trim()
|
||||
}
|
||||
}
|
||||
|
||||
private async getHTML (filename: string) {
|
||||
return this.mdConverter.makeHtml(
|
||||
await ejs.renderFile(filename, {
|
||||
youtube: (id: string) =>
|
||||
ejs.render(
|
||||
/* html */ `
|
||||
<div style="position:relative;padding-top:56.25%;">
|
||||
<iframe src="https://www.youtube.com/embed/${id}"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
style="position:absolute;top:0;left:0;width:100%;height:100%"></iframe>
|
||||
</div>`,
|
||||
{ rmWhitespace: true, beautify: false }
|
||||
),
|
||||
xCard: (opts: {
|
||||
href: string
|
||||
image?: string
|
||||
title: string
|
||||
description?: string
|
||||
}) =>
|
||||
this.$('<div>')
|
||||
.append(
|
||||
this.$('<x-card>')
|
||||
.attr(opts)
|
||||
.append(
|
||||
this.$('<a>')
|
||||
.attr({
|
||||
href: opts.href,
|
||||
rel: 'noopener',
|
||||
target: '_blank'
|
||||
})
|
||||
.text(opts.title)
|
||||
)
|
||||
)
|
||||
.html()
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { IPost, db, dbPost, initDatabase, zMatter, zPost } from '../server/db'
|
||||
import { buildPath, dstMediaPath } from '../server/dir'
|
||||
|
||||
import { MakeHtml } from './make-html'
|
||||
import { THEME_FILENAME } from '../server/theme'
|
||||
import fg from 'fast-glob'
|
||||
import fs from 'fs-extra'
|
||||
import jieba from 'nodejieba'
|
||||
import lunr from 'lunr'
|
||||
import rimraf from 'rimraf'
|
||||
import { srcPath } from './dir'
|
||||
import yaml from 'js-yaml'
|
||||
|
||||
async function main () {
|
||||
rimraf.sync(dstMediaPath())
|
||||
rimraf.sync(buildPath())
|
||||
|
||||
await fs.copy(srcPath('_media'), dstMediaPath()).catch((e) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e)
|
||||
})
|
||||
await fs.ensureDir(buildPath())
|
||||
|
||||
await fs.writeFile(
|
||||
buildPath('theme.json'),
|
||||
JSON.stringify(
|
||||
yaml.safeLoad(await fs.readFile(srcPath(THEME_FILENAME), 'utf8'))
|
||||
)
|
||||
)
|
||||
|
||||
const files = await fg('**/*.md', {
|
||||
cwd: srcPath('_post')
|
||||
})
|
||||
|
||||
const makeHtml = new MakeHtml()
|
||||
const rawJson: IPost[] = []
|
||||
|
||||
await initDatabase()
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (f) => {
|
||||
const header = zMatter.parse(
|
||||
yaml.safeLoad(
|
||||
fs.readFileSync(srcPath('_post', f), 'utf-8').split(/\n---\n/)[0]
|
||||
)
|
||||
)
|
||||
|
||||
const { html, text } = await makeHtml.parse(srcPath('_post', f))
|
||||
|
||||
const p: IPost = {
|
||||
...header,
|
||||
date: header.date ? header.date.getTime() : undefined,
|
||||
createdAt: header.createdAt ? header.createdAt.getTime() : undefined,
|
||||
updatedAt: header.updatedAt ? header.updatedAt.getTime() : undefined,
|
||||
path: f.replace(/\.mdx?$/, ''),
|
||||
excerptHtml: html.split(/<!-- excerpt(?:_separator)? -->/)[0],
|
||||
contentHtml: html,
|
||||
content: text
|
||||
}
|
||||
|
||||
dbPost.insert(zPost.parse(p))
|
||||
rawJson.push({
|
||||
...p,
|
||||
content: jieba.cutForSearch(p.content).join(' ')
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
db.save()
|
||||
|
||||
fs.writeFileSync(
|
||||
buildPath('idx.json'),
|
||||
JSON.stringify(
|
||||
lunr(function () {
|
||||
this.ref('path')
|
||||
this.field('title', { boost: 5 })
|
||||
this.field('tag', { boost: 5 })
|
||||
this.field('category', { boost: 5 })
|
||||
this.field('content')
|
||||
|
||||
rawJson.map((doc) => {
|
||||
return this.add(doc)
|
||||
})
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/* eslint-disable import/no-mutable-exports */
|
||||
|
||||
import idxJson from '~/build/idx.json'
|
||||
import Loki, { Collection } from 'lokijs'
|
||||
import lunr from 'lunr'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { buildPath } from './dir'
|
||||
|
||||
const _zMatter = {
|
||||
priority: z.number().optional(),
|
||||
title: z.string(),
|
||||
image: z.string().optional(),
|
||||
tag: z.array(z.string()).optional(),
|
||||
category: z.array(z.string()).optional()
|
||||
}
|
||||
|
||||
export const zMatter = z.object({
|
||||
..._zMatter,
|
||||
date: z.date().optional(),
|
||||
createdAt: z.date().optional(),
|
||||
updatedAt: z.date().optional()
|
||||
})
|
||||
|
||||
export type IMatter = z.infer<typeof zMatter>
|
||||
|
||||
const zMatterSerialized = z.object({
|
||||
..._zMatter,
|
||||
date: z.number().optional(),
|
||||
createdAt: z.number().optional(),
|
||||
updatedAt: z.number().optional()
|
||||
})
|
||||
|
||||
export type IMatterSerialized = z.infer<typeof zMatterSerialized>
|
||||
|
||||
export const zPost = z
|
||||
.object({
|
||||
path: z.string(),
|
||||
content: z.string(),
|
||||
excerptHtml: z.string(),
|
||||
contentHtml: z.string()
|
||||
})
|
||||
.merge(zMatterSerialized)
|
||||
|
||||
export type IPost = z.infer<typeof zPost> & {
|
||||
path: string
|
||||
title: string
|
||||
content: string
|
||||
excerptHtml: string
|
||||
contentHtml: string
|
||||
}
|
||||
|
||||
export type IPostTeaser = {
|
||||
path: string
|
||||
date?: number
|
||||
title: string
|
||||
image?: string
|
||||
content: string
|
||||
excerptHtml: string
|
||||
}
|
||||
|
||||
export interface IPostSearch {
|
||||
path: string
|
||||
title: string
|
||||
tag?: string[]
|
||||
category?: string[]
|
||||
/**
|
||||
* Cleaned and jieba-separated markdown
|
||||
*/
|
||||
content: string
|
||||
}
|
||||
|
||||
export let db: Loki
|
||||
|
||||
export let dbPost: Collection<IPost>
|
||||
|
||||
export let idx: lunr.Index
|
||||
|
||||
export async function initDatabase () {
|
||||
if (db) {
|
||||
return
|
||||
}
|
||||
|
||||
idx = lunr.Index.load(idxJson)
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
db = new Loki(buildPath('data.loki'), {
|
||||
autoload: true,
|
||||
autoloadCallback: () => {
|
||||
dbPost = db.getCollection('post')
|
||||
|
||||
if (!dbPost) {
|
||||
dbPost = db.addCollection('post', {
|
||||
unique: ['path'],
|
||||
indices: [
|
||||
'date',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'priority',
|
||||
'tag',
|
||||
'category'
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import path from 'path'
|
||||
|
||||
export const buildPath = (...ps: string[]): string =>
|
||||
path.resolve(process.cwd(), 'build', ...ps)
|
||||
|
||||
export const dstMediaPath = (...ps: string[]): string =>
|
||||
path.resolve(process.cwd(), 'public/media', ...ps)
|
||||
@@ -0,0 +1,42 @@
|
||||
import { dbPost, idx, initDatabase } from './db'
|
||||
|
||||
export async function doSearch ({
|
||||
q,
|
||||
tag,
|
||||
page = 1
|
||||
}: {
|
||||
q?: string
|
||||
tag?: string
|
||||
page?: number
|
||||
}) {
|
||||
await initDatabase()
|
||||
|
||||
const perPage = 5
|
||||
const offset = (page - 1) * perPage
|
||||
|
||||
let cond = dbPost.chain().find({
|
||||
date: { $exists: true } as any
|
||||
})
|
||||
|
||||
if (q) {
|
||||
cond = cond.find({
|
||||
path: { $in: idx.search(q).map(({ ref }) => ref) }
|
||||
})
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
cond = cond.find({
|
||||
tag: { $contains: tag }
|
||||
})
|
||||
}
|
||||
|
||||
cond = cond.simplesort('date', { desc: true })
|
||||
|
||||
const count = cond.branch().count()
|
||||
const result = cond.offset(offset).limit(perPage).data()
|
||||
|
||||
return {
|
||||
count,
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from 'fs'
|
||||
|
||||
import yaml from 'js-yaml'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { buildPath } from './dir'
|
||||
|
||||
export const THEME_FILENAME = 'theme.yml'
|
||||
|
||||
export const zTheme = z
|
||||
.object({
|
||||
title: z.string(),
|
||||
awsm: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
nav: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string(),
|
||||
to: z.string(),
|
||||
href: z.undefined()
|
||||
})
|
||||
.or(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
href: z.string(),
|
||||
to: z.undefined()
|
||||
})
|
||||
)
|
||||
)
|
||||
.optional(),
|
||||
author: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
name: z.string()
|
||||
})
|
||||
.nonstrict(),
|
||||
analytics: z
|
||||
.object({
|
||||
plausible: z.string().optional()
|
||||
})
|
||||
.optional(),
|
||||
comment: z
|
||||
.object({
|
||||
remark42: z
|
||||
.object({
|
||||
host: z.string(),
|
||||
siteId: z.string()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.nonstrict()
|
||||
|
||||
export type ITheme = z.infer<typeof zTheme>
|
||||
|
||||
export function getTheme () {
|
||||
return zTheme.parse(
|
||||
yaml.safeLoad(fs.readFileSync(buildPath(THEME_FILENAME), 'utf-8'))
|
||||
)
|
||||
}
|
||||
Whitespace-only changes.
@@ -0,0 +1,3 @@
|
||||
.tags-container > span + span {
|
||||
margin-left: 0.6em;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": false,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user