commit 84bc71eb9620d9b642c07c2dec070a337c5ec85d
parent e97580122b4ca752c9385f3e49958ae83f26265a
Author: Yongbin Kim <iam@yongbin.kim>
Date: Sun, 29 Jan 2023 02:54:34 +0900
feat: Revision 기능 추가 및 WikiArticle 스타일 추가
Signed-off-by: Yongbin Kim <iam@yongbin.kim>
Diffstat:
12 files changed, 257 insertions(+), 32 deletions(-)
diff --git a/components/wiki/RevisionAlert.module.css b/components/wiki/RevisionAlert.module.css
@@ -0,0 +1 @@
+.revision-alert{line-height:1.25rem;font-size:.875rem;letter-spacing:.0178571429rem;font-weight:400}.revision-alert p{margin-bottom:1em}/*# sourceMappingURL=RevisionAlert.module.css.map */
diff --git a/components/wiki/RevisionAlert.module.css.map b/components/wiki/RevisionAlert.module.css.map
@@ -0,0 +1 @@
+{"version":3,"sourceRoot":"","sources":["RevisionAlert.module.scss","../../styles/core/_typography.scss"],"names":[],"mappings":"AAEA,gBCoHI,oBACA,kBACA,8BACA,gBDpHF,kBACE","file":"RevisionAlert.module.css"}
+\ No newline at end of file
diff --git a/components/wiki/RevisionAlert.module.scss b/components/wiki/RevisionAlert.module.scss
@@ -0,0 +1,9 @@
+@use 'core/typography';
+
+.revision-alert {
+ @include typography.apply('body-medium');
+
+ p {
+ margin-bottom: 1em;
+ }
+}
diff --git a/components/wiki/RevisionAlert.tsx b/components/wiki/RevisionAlert.tsx
@@ -0,0 +1,23 @@
+import { WikiPage } from '@/lib/models/wiki_page'
+import moment from 'moment'
+import Link from 'next/link'
+import styles from './RevisionAlert.module.css'
+
+export interface RevisionAlertProps {
+ page: WikiPage
+ recentURL: string
+}
+
+export default function RevisionAlert (props: RevisionAlertProps) {
+ return (
+ <blockquote className={styles['revision-alert']}>
+ <p>
+ 현재 {moment(props.page.updatedAt).format('YYYY년 MM월 DD일 HH시 mm분 ss초')}에
+ 수정된 이전 버전을 보고 있습니다.
+ </p>
+ <p>
+ <Link href={props.recentURL}>최신 버전 보기</Link>
+ </p>
+ </blockquote>
+ )
+}
diff --git a/components/wiki/WikiArticle.module.css b/components/wiki/WikiArticle.module.css
@@ -0,0 +1 @@
+.article{line-height:1.5}.article h1{line-height:2.5rem;font-size:2rem;letter-spacing:0rem;font-weight:400}.article h2{line-height:2.25rem;font-size:1.75rem;letter-spacing:0rem;font-weight:400}.article h3{line-height:2rem;font-size:1.5rem;letter-spacing:0rem;font-weight:400}.article h4{line-height:1.75rem;font-size:1.375rem;letter-spacing:0rem;font-weight:400}.article h5{line-height:1.5rem;font-size:1rem;letter-spacing:.009375rem;font-weight:500}.article h6{line-height:1.25rem;font-size:.875rem;letter-spacing:.0071428571rem;font-weight:500}.article ul,.article ol{margin:1rem 0;padding-left:2rem}.article ul li ul,.article ul li ol,.article ol li ul,.article ol li ol{margin:0 1rem;padding-left:0}.article blockquote{border-left:1px solid rgba(0,0,0,0);padding-left:1rem;margin:1rem 0 2rem;border-color:#006b5a}@media(prefers-color-scheme: dark){.article blockquote{border-color:#2cdebf}}.article small{line-height:1.25rem;font-size:.875rem;letter-spacing:.0178571429rem;font-weight:400}.article hr{height:1px;border:none;background-color:#006b5a}@media(prefers-color-scheme: dark){.article hr{background-color:#2cdebf}}/*# sourceMappingURL=WikiArticle.module.css.map */
diff --git a/components/wiki/WikiArticle.module.css.map b/components/wiki/WikiArticle.module.css.map
@@ -0,0 +1 @@
+{"version":3,"sourceRoot":"","sources":["WikiArticle.module.scss","../../styles/core/_typography.scss","../../styles/core/_vars.scss","../../styles/core/_colors.scss"],"names":[],"mappings":"AAIA,SACE,gBAEA,YC+GE,mBACA,eACA,oBACA,gBD/GF,YC4GE,oBACA,kBACA,oBACA,gBD5GF,YCyGE,iBACA,iBACA,oBACA,gBDzGF,YCsGE,oBACA,mBACA,oBACA,gBDtGF,YCmGE,mBACA,eACA,0BACA,gBDnGF,YCgGE,oBACA,kBACA,8BACA,gBD/FF,wBACE,cACA,kBAGE,wEACE,cACA,eAKN,oBACE,oCACA,aExCE,KFyCF,mBAGE,qBGoFJ,mCH1FA,oBAMI,sBAIJ,eCsEE,oBACA,kBACA,8BACA,gBDrEF,YACE,WACA,YAGE,yBGuEJ,mCH5EA,YAKI","file":"WikiArticle.module.css"}
+\ No newline at end of file
diff --git a/components/wiki/WikiArticle.module.scss b/components/wiki/WikiArticle.module.scss
@@ -0,0 +1,61 @@
+@use 'core/vars';
+@use 'core/colors';
+@use 'core/typography';
+
+.article {
+ line-height: 1.5;
+
+ h1 {
+ @include typography.apply('headline-large');
+ }
+ h2 {
+ @include typography.apply('headline-medium');
+ }
+ h3 {
+ @include typography.apply('headline-small');
+ }
+ h4 {
+ @include typography.apply('title-large');
+ }
+ h5 {
+ @include typography.apply('title-medium');
+ }
+ h6 {
+ @include typography.apply('title-small');
+ }
+
+ ul, ol {
+ margin: vars.$gap 0;
+ padding-left: (2 * vars.$gap);
+
+ li {
+ ul, ol {
+ margin: 0 1rem;
+ padding-left: 0;
+ }
+ }
+ }
+
+ blockquote {
+ border-left: 1px solid transparent;
+ padding-left: vars.$gap;
+ margin: vars.$gap 0 (2 * vars.$gap);
+
+ @include colors.apply-themes() using ($theme) {
+ border-color: colors.get($theme, 'primary');
+ }
+ }
+
+ small {
+ @include typography.apply('body-medium');
+ }
+
+ hr {
+ height: 1px;
+ border: none;
+
+ @include colors.apply-themes() using ($theme) {
+ background-color: colors.get($theme, 'primary');
+ }
+ }
+}
diff --git a/components/wiki/WikiArticle.tsx b/components/wiki/WikiArticle.tsx
@@ -1,16 +1,10 @@
-import WikiBase from '@/components/wiki/WikiBase'
+import { ReactNode } from 'react'
+import styles from './WikiArticle.module.css'
-export interface WikiViewProps {
- slug: string
- path: string
- title: string
- html: string
-}
-
-export default function WikiArticle (props: WikiViewProps) {
+export default function WikiArticle (props: { children: ReactNode }) {
return (
- <WikiBase pageKind={'wiki'} title={props.title}>
- <article dangerouslySetInnerHTML={{ __html: props.html }}/>
- </WikiBase>
+ <article className={styles['article']}>
+ {props.children}
+ </article>
)
}
diff --git a/lib/htmlcache.ts b/lib/htmlcache.ts
@@ -0,0 +1,35 @@
+import { getRedis } from '@/lib/redis'
+import { parseIntOrDefault } from './utils/number'
+
+const DEFAULT_CACHE_TTL = 60 * 60 // 1 hour
+
+export function htmlCacheKey (textId: number) {
+ return `htmlcache:text/${textId}`
+}
+
+export async function hasHtmlCache (textId: number) {
+ const redis = await getRedis()
+ return (await redis.exists(htmlCacheKey(textId))) === 1
+}
+
+export async function getHtmlCache (textId: number) {
+ const redis = await getRedis()
+ return await redis.get(htmlCacheKey(textId))
+}
+
+export async function putHtmlCache (textId: number, html: string) {
+ const redis = await getRedis()
+ const key = htmlCacheKey(textId)
+ await redis.set(key, html)
+ await refreshCacheImpl(redis, key)
+}
+
+export async function refreshHtmlCache (textId: number) {
+ const redis = await getRedis()
+ await refreshCacheImpl(redis, htmlCacheKey(textId))
+}
+
+function refreshCacheImpl (redis: Awaited<ReturnType<typeof getRedis>>, key: string) {
+ return redis.expire(key, parseIntOrDefault(process.env.WIKI_PAGE_CACHE_TTL, DEFAULT_CACHE_TTL))
+}
+
diff --git a/lib/models/wiki_page.ts b/lib/models/wiki_page.ts
@@ -131,3 +131,34 @@ export const getWikiRawPage = modelBehaviour<
}
})
+const SQL_GET_WIKI_PAGE_REVISION = `
+ select wp.wiki_id, wp.path, wc.text_id, wp.acl_data, wp.created_at, wc.created_at
+ from wiki_changes wc
+ inner join wiki_pages wp on wc.page_id = wp.id
+ where wp.wiki_id = ?
+ and wp.path = ?
+ and wc.id = ?
+`
+
+export const getWikiPageRevision = modelBehaviour<
+ [wikiId: number, path: string, changeId: number],
+ WikiPage | null
+>(async (conn, args) => {
+ const [rows] = await conn.query<RowDataPacket[]>({
+ sql: SQL_GET_WIKI_PAGE_REVISION,
+ }, args)
+
+ if (rows.length === 0) {
+ return null
+ }
+
+ const row = rows[0]
+ return {
+ wikiId: row[0],
+ path: row[1],
+ textId: row[2],
+ acl: row[3],
+ createdAt: row[4],
+ updatedAt: row[5],
+ }
+})
diff --git a/lib/utils/wiki.ts b/lib/utils/wiki.ts
@@ -1,8 +1,10 @@
+import { WikiText } from '@/lib/models/wiki_text'
import { GetServerSidePropsContext, NextApiRequest } from 'next'
import { NextRouter } from 'next/router'
+import { unzip as decompressGzip } from 'node:zlib'
export const getSlugAndPath = (
- context: GetServerSidePropsContext | NextApiRequest | NextRouter
+ context: GetServerSidePropsContext | NextApiRequest | NextRouter,
): [slug: string | null, path: string | null] => {
const { slug, path } = context.query
if (typeof slug !== 'string' || path == null || !Array.isArray(path)) {
@@ -10,3 +12,17 @@ export const getSlugAndPath = (
}
return [slug, path.join('')]
}
+
+export const getStringFromWikiText = async (wikiText: WikiText): Promise<string> => {
+ switch (wikiText.encoding) {
+ case 'gzip':
+ // TODO: implement gzipped wiki text
+ return 'TODO'
+
+ case 'utf-8':
+ return wikiText.content
+
+ default:
+ throw new Error(`Unknown wiki text encoding: ${wikiText.encoding}`)
+ }
+}
diff --git a/pages/wiki/[slug]/[...path].tsx b/pages/wiki/[slug]/[...path].tsx
@@ -1,28 +1,42 @@
+import RevisionAlert from '@/components/wiki/RevisionAlert'
import WikiArticle from '@/components/wiki/WikiArticle'
+import WikiBase from '@/components/wiki/WikiBase'
+import { getHtmlCache, hasHtmlCache, putHtmlCache, refreshHtmlCache } from '@/lib/htmlcache'
+import { render } from '@/lib/markup'
import { withConnection } from '@/lib/model_helpers'
import { getWikiViaSlug } from '@/lib/models/wiki_info'
-import { getWikiHtmlPage } from '@/lib/models/wiki_page'
+import { getWikiPage, getWikiPageRevision, WikiPage } from '@/lib/models/wiki_page'
+import { getWikiText } from '@/lib/models/wiki_text'
import { ACL_ACTION_READ, resolveACL } from '@/lib/security/acl'
import { authenticationFromCookies } from '@/lib/security/token'
+import { getSlugAndPath, getStringFromWikiText } from '@/lib/utils/wiki'
+import moment from 'moment'
import { GetServerSideProps } from 'next'
+import Link from 'next/link'
+import { useRouter } from 'next/router'
+import { Fragment, useMemo } from 'react'
export interface WikiViewPageProps {
- wikiSlug: string
- path: string
+ page: WikiPage
html: string
}
export const getServerSideProps: GetServerSideProps<WikiViewPageProps> = async (context) => {
- const { slug, path: paths } = context.query
- if (typeof slug !== 'string' || !Array.isArray(paths)) {
- throw new Error('invalid query')
+ const { rev } = context.query
+ const [slug, path] = getSlugAndPath(context)
+ if (slug == null || path == null) {
+ return { notFound: true }
}
- const path = paths.join('/')
+ const revId = rev == null ? null : parseInt(rev as string)
+ if (revId != null && isNaN(revId)) {
+ return { notFound: true }
+ }
const token = await authenticationFromCookies(context.req.cookies)
- const wikiPage = await withConnection(async (conn) => {
+ const result = await withConnection(async (conn):
+ Promise<[page: WikiPage, html: string] | null> => {
const wiki = await getWikiViaSlug(conn, [slug])
if (wiki == null) {
return null
@@ -32,30 +46,66 @@ export const getServerSideProps: GetServerSideProps<WikiViewPageProps> = async (
return null
}
- return await getWikiHtmlPage(conn, [wiki.id, path])
+ const page = revId == null
+ ? await getWikiPage(conn, [wiki.id, path])
+ : await getWikiPageRevision(conn, [wiki.id, path, revId])
+ if (page == null) {
+ return null
+ }
+
+ if (await hasHtmlCache(page.textId)) {
+ const html = await getHtmlCache(page.textId)
+
+ if (html != null) {
+ await refreshHtmlCache(page.textId)
+ return [page, html]
+ }
+ }
+
+ // Cache miss; do render
+ const wikiText = await getWikiText(conn, [page.textId])
+ if (wikiText == null) {
+ return null
+ }
+
+ const source = await getStringFromWikiText(wikiText)
+ const html = render(source)
+
+ await putHtmlCache(page.textId, html)
+ return [page, html]
})
- if (wikiPage == null) {
+ if (result == null) {
return { notFound: true }
}
+ const [page, html] = result
+
return {
props: {
- wikiSlug: slug,
- path: path,
- html: wikiPage.html,
+ page: page,
+ html: html,
},
}
}
export default function WikiViewPage (props: WikiViewPageProps) {
+ const router = useRouter()
+ const [slug, path] = getSlugAndPath(router)
+ const { rev } = router.query
+
return (
<>
- <WikiArticle
- slug={props.wikiSlug}
- path={props.path}
- title={props.path}
- html={props.html}
- />
+ <WikiBase pageKind={'wiki'} title={path ?? ''}>
+ <WikiArticle>
+ {rev != null && (
+ <RevisionAlert
+ page={props.page}
+ recentURL={`/wiki/${slug}/${path}`}
+ />
+ )}
+ <div dangerouslySetInnerHTML={{ __html: props.html }}/>
+ </WikiArticle>
+ </WikiBase>
</>
)
}