Merge pull request #6 from patarapolw/next

use Next.js
This commit is contained in:
polv authored and GitHub committed 2020-12-17 23:17:49 +07:00
commit 5879ef8590
34 files changed
+7117 -1

No files matched your search

+1 -1
View File
@@ -5,5 +5,5 @@
".env.*": "dotenv"
},
"whitespacepp.backColorWS": "",
"editor.tabSize": 4
"editor.tabSize": 2
}
+26
View File
@@ -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
}
}
+37
View File
@@ -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/
+30
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
import theme from '~/build/theme.json'
import { ITheme } from '~/server/theme'
export const THEME: ITheme = theme
+49
View File
@@ -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'])
}
+8
View File
@@ -0,0 +1,8 @@
export function formatDate (s: string | number) {
return new Date(s).toLocaleDateString([], {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'short'
})
}
+70
View File
@@ -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
+134
View File
@@ -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> &hellip; </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> &hellip; </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
+44
View File
@@ -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&nbsp;
{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
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="next" />
/// <reference types="next/types/global" />
+11
View File
@@ -0,0 +1,11 @@
module.exports = {
async redirects () {
return [
{
source: '/',
destination: '/blog',
permanent: false
}
]
}
}
+60
View File
@@ -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"
}
}
}
+20
View File
@@ -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
+55
View File
@@ -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
+12
View File
@@ -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)
}
+52
View File
@@ -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)
})
}
}
+23
View File
@@ -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({})
}
}
+125
View File
@@ -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('/')
})
}
}
+57
View File
@@ -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
}
}
+45
View File
@@ -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

+11
View File
@@ -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')
})
+19
View File
@@ -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)
+153
View File
@@ -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()
})
)
}
}
+91
View File
@@ -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()
}
+110
View File
@@ -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()
}
})
})
}
+7
View File
@@ -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)
+42
View File
@@ -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
}
}
+64
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
.tags-container > span + span {
margin-left: 0.6em;
}
+23
View File
@@ -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