34 files changed
+7117
-1
No files matched your search
Vendored
+1
-1
@@ -5,5 +5,5 @@
|
|||||||
".env.*": "dotenv"
|
".env.*": "dotenv"
|
||||||
},
|
},
|
||||||
"whitespacepp.backColorWS": "",
|
"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