commit 01f5636937d484185a4044cc9a059aad2e991d40
parent 81d71c6596aa99501aeaa07e8776169b257ee6a3
Author: Yongbin Kim <iam@yongbin.kim>
Date: Mon, 30 Jan 2023 04:16:16 +0900
BREAKING CHANGE: 문서 렌더링 관련 로직 개선
- 인라인 문법 파서 추가 (링크, 볼드체 등등...)
- render 함수가 html string 대신 JSX를 반환하도록 변경
- httpcache를 제거하고, pagecache를 추가
- page의 최종 html 대신 parse 후 Token[]을 캐시
- link 렌더러 추가
Signed-off-by: Yongbin Kim <iam@yongbin.kim>
Diffstat:
16 files changed, 912 insertions(+), 337 deletions(-)
diff --git a/lib/htmlcache.ts b/lib/htmlcache.ts
@@ -1,35 +0,0 @@
-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/markup/__snapshots__/render.test.tsx.snap b/lib/markup/__snapshots__/render.test.tsx.snap
@@ -0,0 +1,137 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render mixed list should be ok 1`] = `
+[
+ <React.Fragment>
+ <ul>
+ <li>
+ <React.Fragment>
+ <React.Fragment>
+ List Item 1
+ </React.Fragment>
+ </React.Fragment>
+ <br />
+ <React.Fragment>
+ <React.Fragment>
+ List Item 2
+ </React.Fragment>
+ </React.Fragment>
+ </li>
+ <li>
+ <React.Fragment>
+ <React.Fragment>
+ List Item
+ </React.Fragment>
+ </React.Fragment>
+ </li>
+ <li>
+ <React.Fragment>
+ <React.Fragment>
+ List Item
+ </React.Fragment>
+ </React.Fragment>
+ <React.Fragment>
+ <ol>
+ <li>
+ <React.Fragment>
+ <React.Fragment>
+ List Item
+ </React.Fragment>
+ </React.Fragment>
+ </li>
+ <li>
+ <React.Fragment>
+ <React.Fragment>
+ List Item
+ </React.Fragment>
+ </React.Fragment>
+ </li>
+ <li>
+ <React.Fragment>
+ <React.Fragment>
+ List Item
+ </React.Fragment>
+ </React.Fragment>
+ </li>
+ </ol>
+ </React.Fragment>
+ </li>
+ </ul>
+ </React.Fragment>,
+]
+`;
+
+exports[`Render should render everything 1`] = `
+[
+ <p>
+ <React.Fragment>
+ Hello, World!
+ </React.Fragment>
+ </p>,
+ <hr />,
+ <React.Fragment>
+ <ul>
+ <li>
+ <React.Fragment>
+ <React.Fragment>
+ This is a test.
+ </React.Fragment>
+ <React.Fragment />
+ </React.Fragment>
+ </li>
+ <li>
+ <React.Fragment>
+ <React.Fragment>
+ This is a test.
+ </React.Fragment>
+ <React.Fragment>
+ <React.Fragment>
+ Multiple lines.
+ </React.Fragment>
+ </React.Fragment>
+ </React.Fragment>
+ </li>
+ </ul>
+ </React.Fragment>,
+ <hr />,
+ <React.Fragment>
+ <ol>
+ <li>
+ <React.Fragment>
+ <React.Fragment>
+ This is a test.
+ </React.Fragment>
+ <React.Fragment />
+ </React.Fragment>
+ </li>
+ <li>
+ <React.Fragment>
+ <React.Fragment>
+ This is a test.
+ </React.Fragment>
+ <React.Fragment />
+ </React.Fragment>
+ </li>
+ </ol>
+ </React.Fragment>,
+ <hr />,
+ <h1>
+ <React.Fragment>
+ Hello, World!
+ </React.Fragment>
+ </h1>,
+ <h1>
+ <React.Fragment>
+ This is a test.
+ </React.Fragment>
+ </h1>,
+ <hr />,
+ <blockquote>
+ <p>
+ <React.Fragment>
+ This is a test.
+ </React.Fragment>
+ </p>
+ </blockquote>,
+]
+`;
diff --git a/lib/markup/parse.test.ts b/lib/markup/parse.test.ts
@@ -11,19 +11,33 @@ const parseTests: Array<{ label: string; src: string; expected: Token[] }> = [{
World!
This is a test.`,
- expected: [
- { key: 'paragraph', text: 'Hello,\nWorld!' },
- { key: 'paragraph', text: 'This is a test.' },
- ],
+ expected: [{
+ key: 'paragraph', children: [{
+ key: 'text', text: 'Hello,',
+ }, {
+ key: 'linebreak',
+ }, {
+ key: 'text', text: 'World!',
+ }],
+ }, {
+ key: 'paragraph', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }],
}, {
label: 'heading',
src: `# Hello, World!
## This is a test.`,
- expected: [
- { key: 'heading', level: 1, text: 'Hello, World!' },
- { key: 'heading', level: 2, text: 'This is a test.' },
- ],
+ expected: [{
+ key: 'heading', level: 1, children: [{
+ key: 'text', text: 'Hello, World!',
+ }],
+ }, {
+ key: 'heading', level: 2, children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }],
}, {
label: 'horizontal rule',
src: `Hello, World!
@@ -31,11 +45,17 @@ This is a test.`,
----
This is a test.`,
- expected: [
- { key: 'paragraph', text: 'Hello, World!' },
- { key: 'horizontal-rule' },
- { key: 'paragraph', text: 'This is a test.' },
- ],
+ expected: [{
+ key: 'paragraph', children: [{
+ key: 'text', text: 'Hello, World!',
+ }],
+ }, {
+ key: 'horizontal-rule',
+ }, {
+ key: 'paragraph', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }],
}, {
label: 'list',
src: `Hello, World!
@@ -50,29 +70,53 @@ This is a test.`,
- This is a test.
This is a test.`,
- expected: [
- { key: 'paragraph', text: 'Hello, World!' },
- { key: 'list-start', listType: 'unordered' },
- { key: 'list-item-start' },
- { key: 'text', text: 'This is a test.' },
- { key: 'list-start', listType: 'unordered' },
- { key: 'list-item-start' },
- { key: 'text', text: 'This is a test.' },
- { key: 'text', text: 'Multiple lines.' },
- { key: 'list-item-end' },
- { key: 'list-end', listType: 'unordered' },
- { key: 'list-item-end' },
- { key: 'list-item-start' },
- { key: 'text', text: 'List supports any block syntax.' },
- { key: 'heading', level: 1, text: 'For Example,' },
- { key: 'text', text: 'Heading.' },
- { key: 'list-item-end' },
- { key: 'list-item-start' },
- { key: 'text', text: 'This is a test.' },
- { key: 'list-item-end' },
- { key: 'list-end', listType: 'unordered' },
- { key: 'paragraph', text: 'This is a test.' },
- ],
+ expected: [{
+ key: 'paragraph', children: [{ key: 'text', text: 'Hello, World!' }],
+ }, {
+ key: 'list', listType: 'unordered', children: [{
+ key: 'list-item', children: [{
+ key: 'text', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }, {
+ key: 'list', listType: 'unordered', children: [{
+ key: 'list-item', children: [{
+ key: 'text', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }, {
+ key: 'text', children: [{
+ key: 'text', text: 'Multiple lines.',
+ }],
+ }],
+ }],
+ }],
+ }, {
+ key: 'list-item', children: [{
+ key: 'text', children: [{
+ key: 'text', text: 'List supports any block syntax.',
+ }],
+ }, {
+ key: 'heading', level: 1, children: [{
+ key: 'text', text: 'For Example,',
+ }],
+ }, {
+ key: 'text', children: [{
+ key: 'text', text: 'Heading.',
+ }],
+ }],
+ }, {
+ key: 'list-item', children: [{
+ key: 'text', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }],
+ }],
+ }, {
+ key: 'paragraph', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }],
}, {
label: 'blockquote',
src: `Hello, World!
@@ -86,20 +130,37 @@ This is a test.`,
> > > This is a test.
This is a test.`,
- expected: [
- { key: 'paragraph', text: 'Hello, World!' },
- { key: 'blockquote-start' },
- { key: 'paragraph', text: 'This is a test.' },
- { key: 'paragraph', text: 'Blockquote supports any block syntax.' },
- { key: 'blockquote-start' },
- { key: 'paragraph', text: 'This is a test.' },
- { key: 'blockquote-start' },
- { key: 'paragraph', text: 'This is a test.' },
- { key: 'blockquote-end' },
- { key: 'blockquote-end' },
- { key: 'blockquote-end' },
- { key: 'paragraph', text: 'This is a test.' },
- ],
+ expected: [{
+ key: 'paragraph', children: [{
+ key: 'text', text: 'Hello, World!',
+ }],
+ }, {
+ key: 'blockquote', children: [{
+ key: 'paragraph', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }, {
+ key: 'paragraph', children: [{
+ key: 'text', text: 'Blockquote supports any block syntax.',
+ }],
+ }, {
+ key: 'blockquote', children: [{
+ key: 'paragraph', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }, {
+ key: 'blockquote', children: [{
+ key: 'paragraph', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }],
+ }],
+ }],
+ }, {
+ key: 'paragraph', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }],
}]
describe('Parse', () => {
diff --git a/lib/markup/parse.ts b/lib/markup/parse.ts
@@ -1,3 +1,4 @@
+import { parseInline } from '@/lib/markup/parse_inline'
import { ListType, Token } from '@/lib/markup/token'
const regexHeading = /^(#{1,6})\s+([^\n]+)\s*(?:$|\n)/
@@ -29,7 +30,7 @@ function parseBlock (src: string, useParagraph: boolean): Token[] {
out.push({
key: 'heading',
level: caps[1].length,
- text: caps[2],
+ children: parseInline(caps[2]),
})
src = src.slice(caps[0].length)
@@ -59,9 +60,10 @@ function parseBlock (src: string, useParagraph: boolean): Token[] {
if (caps != null) {
const innerSrc = stripPrefix(caps[1], '>(?: |(?=\n))')
- out.push({ key: 'blockquote-start' })
- out.push(...parseBlock(innerSrc, true))
- out.push({ key: 'blockquote-end' })
+ out.push({
+ key: 'blockquote',
+ children: parseBlock(innerSrc, true),
+ })
src = src.slice(caps[0].length)
continue
@@ -70,7 +72,7 @@ function parseBlock (src: string, useParagraph: boolean): Token[] {
if (useParagraph) {
caps = regexParagraph.exec(src)
if (caps != null) {
- out.push({ key: 'paragraph', text: caps[1] })
+ out.push({ key: 'paragraph', children: parseInline(caps[1]) })
src = src.slice(caps[0].length)
continue
@@ -78,7 +80,7 @@ function parseBlock (src: string, useParagraph: boolean): Token[] {
} else {
caps = regexText.exec(src)
if (caps != null) {
- out.push({ key: 'text', text: caps[1] })
+ out.push({ key: 'text', children: parseInline(caps[1]) })
src = src.slice(caps[0].length)
continue
@@ -96,7 +98,7 @@ function parseList (src: string, listType: ListType, out: Token[]): string {
? regexOrderedListItem
: regexUnorderedListItem
- out.push({ key: 'list-start', listType })
+ const listItems: Token[] = []
let caps: RegExpExecArray | null
for (; src.length > 0;) {
@@ -109,9 +111,10 @@ function parseList (src: string, listType: ListType, out: Token[]): string {
: '.{2}',
)
- out.push({ key: 'list-item-start' })
- out.push(...parseBlock(listSrc, false))
- out.push({ key: 'list-item-end' })
+ listItems.push({
+ key: 'list-item',
+ children: parseBlock(listSrc, false),
+ })
src = src.slice(caps[0].length)
continue
@@ -120,7 +123,11 @@ function parseList (src: string, listType: ListType, out: Token[]): string {
break
}
- out.push({ key: 'list-end', listType })
+ out.push({
+ key: 'list',
+ listType,
+ children: listItems,
+ })
return src
}
diff --git a/lib/markup/parse_inline.test.ts b/lib/markup/parse_inline.test.ts
@@ -0,0 +1,91 @@
+import { parseInline } from '@/lib/markup/parse_inline'
+import { InlineToken } from '@/lib/markup/token'
+
+const inlineTests: Array<{
+ name: string
+ input: string
+ expected: InlineToken[]
+}> = [
+ {
+ name: 'simple',
+ input: 'Hello, world!',
+ expected: [
+ { key: 'text', text: 'Hello, world!' },
+ ],
+ },
+ {
+ name: 'bold',
+ input: '**Hello, world!**',
+ expected: [
+ { key: 'bold', text: 'Hello, world!' },
+ ],
+ },
+ {
+ name: 'italic',
+ input: '//Hello, world!//',
+ expected: [
+ { key: 'italic', text: 'Hello, world!' },
+ ],
+ },
+ {
+ name: 'underline',
+ input: '__Hello, world!__',
+ expected: [
+ { key: 'underline', text: 'Hello, world!' },
+ ],
+ },
+ {
+ name: 'strikethrough',
+ input: '~~Hello, world!~~',
+ expected: [
+ { key: 'strikethrough', text: 'Hello, world!' },
+ ],
+ },
+ {
+ name: 'code',
+ input: '`Hello, world!`',
+ expected: [
+ { key: 'code', text: 'Hello, world!' },
+ ],
+ },
+ {
+ name: 'link',
+ input: '[[Hello, world!]]',
+ expected: [
+ { key: 'link', path: 'Hello, world!', text: 'Hello, world!' },
+ ],
+ },
+ {
+ name: 'link with text',
+ input: '[[Hello, world!|Hello, world!]]',
+ expected: [
+ { key: 'link', path: 'Hello, world!', text: 'Hello, world!' },
+ ],
+ },
+ {
+ name: 'multiple',
+ input: '**Hello**, //world!//',
+ expected: [
+ { key: 'bold', text: 'Hello' },
+ { key: 'text', text: ', ' },
+ { key: 'italic', text: 'world!' },
+ ],
+ },
+ {
+ name: 'multiple lines',
+ input: 'Hello,\nworld!',
+ expected: [
+ { key: 'text', text: 'Hello,' },
+ { key: 'linebreak' },
+ { key: 'text', text: 'world!' },
+ ],
+ }
+]
+
+describe('Inline', function () {
+ for (const test of inlineTests) {
+ it(test.name, function () {
+ expect(parseInline(test.input)).toEqual(test.expected)
+ })
+ }
+})
diff --git a/lib/markup/parse_inline.ts b/lib/markup/parse_inline.ts
@@ -0,0 +1,93 @@
+import { InlineToken } from '@/lib/markup/token'
+
+const inlinePatterns: Array<{
+ marker: string
+ regex?: RegExp
+ token: (...args: string[]) => InlineToken
+}> = [
+ {
+ marker: '\n',
+ regex: /^\n+/,
+ token: () => ({ key: 'linebreak' }),
+ },
+ {
+ marker: '**',
+ token: (text) => ({ key: 'bold', text }),
+ },
+ {
+ marker: '//',
+ token: (text) => ({ key: 'italic', text }),
+ },
+ {
+ marker: '__',
+ token: (text) => ({ key: 'underline', text }),
+ },
+ {
+ marker: '~~',
+ token: (text) => ({ key: 'strikethrough', text }),
+ },
+ {
+ marker: '`',
+ token: (text) => ({ key: 'code', text }),
+ },
+ {
+ marker: '[[',
+ regex: /^\[\[([^\]|]+)(?:\|([^\]|]+))?]]/,
+ token: (path) => ({ key: 'link', path, text: path }),
+ },
+]
+
+!(function (): any {
+ let allMarkers: string[] = []
+
+ // 각 패턴의 정규식 컴파일
+ for (const pattern of inlinePatterns) {
+ // 마커가 정규식 토큰으로 취급되지 않도록 이스케이프 문자 추가
+ const marker = pattern.marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+
+ // 일반 텍스트 패턴을 위한 마커 목록에 추가
+ allMarkers.push(marker)
+
+ // 정규식이 정의된 경우 패스
+ if (pattern.regex != null) {
+ continue
+ }
+
+ pattern.regex = new RegExp(`^${marker}([^${marker}]+)${marker}`)
+ }
+
+ // 일반 텍스트 패턴 추가
+ inlinePatterns.push({
+ marker: '',
+ regex: new RegExp(`^(.+?)(?=${allMarkers.join('|')}|$)`),
+ token: (text) => ({ key: 'text', text }),
+ })
+})()
+
+export function parseInline (src: string): InlineToken[] {
+ const tokens: InlineToken[] = []
+
+ mainLoop:
+ while (src.length > 0) {
+ for (const pattern of inlinePatterns) {
+ const match = pattern.regex!.exec(src)
+ if (match == null) {
+ continue
+ }
+
+ // 토큰 생성
+ const token = pattern.token(...match.slice(1))
+
+ // 토큰 목록에 추가
+ tokens.push(token)
+
+ // 소스에서 토큰 길이만큼 잘라냄
+ src = src.slice(match[0].length)
+ continue mainLoop
+ }
+
+ throw new Error(`unreachable`)
+ }
+
+ return tokens
+}
diff --git a/lib/markup/render.test.ts b/lib/markup/render.test.ts
@@ -1,93 +0,0 @@
-import { render } from './render'
-
-describe('Render', () => {
- it('should render everything', () => {
- expect(render([
- { key: 'paragraph', text: 'Hello, World!' },
-
- { key: 'horizontal-rule' },
-
- { key: 'list-start', listType: 'unordered' },
- { key: 'list-item-start' },
- { key: 'text', text: 'This is a test.' },
- { key: 'list-start', listType: 'unordered' },
- { key: 'list-item-start' },
- { key: 'text', text: 'This is a test.' },
- { key: 'text', text: 'Multiple lines.' },
- { key: 'list-item-end' },
- { key: 'list-end', listType: 'unordered' },
- { key: 'list-item-end' },
- { key: 'list-end', listType: 'unordered' },
-
- { key: 'horizontal-rule' },
-
- { key: 'list-start', listType: 'ordered' },
- { key: 'list-item-start' },
- { key: 'text', text: 'This is a test.' },
- { key: 'list-item-end' },
- { key: 'list-item-start' },
- { key: 'text', text: 'This is a test.' },
- { key: 'list-item-end' },
- { key: 'list-end', listType: 'ordered' },
-
- { key: 'horizontal-rule' },
-
- { key: 'heading', level: 1, text: 'Hello, World!' },
- { key: 'heading', level: 2, text: 'This is a test.' },
-
- { key: 'horizontal-rule' },
-
- { key: 'blockquote-start' },
- { key: 'paragraph', text: 'This is a test.' },
- { key: 'blockquote-end' },
-
- { key: 'horizontal-rule' },
-
- { key: 'paragraph', text: 'This is a test.' },
- ])).toBe([
- '<p>Hello, World!</p>',
-
- '<hr>',
-
- '<ul>',
- '<li>',
- 'This is a test.',
- '<ul>',
- '<li>',
- 'This is a test.',
- 'Multiple lines.',
- '</li>',
- '</ul>',
- '</li>',
- '</ul>',
-
- '<hr>',
-
- '<ol>',
- '<li>',
- 'This is a test.',
- '</li>',
- '<li>',
- 'This is a test.',
- '</li>',
- '</ol>',
-
- '<hr>',
-
- '<h1>Hello, World!</h1>',
- '<h2>This is a test.</h2>',
-
- '<hr>',
-
- '<blockquote>',
- '<p>This is a test.</p>',
- '</blockquote>',
-
- '<hr>',
-
- '<p>This is a test.</p>',
- ].join(''))
- })
-})
-
-export {}
diff --git a/lib/markup/render.test.tsx b/lib/markup/render.test.tsx
@@ -0,0 +1,182 @@
+import { Token } from '@/lib/markup/token'
+import { render } from './render'
+
+describe('Render', () => {
+ it('should render everything', () => {
+ const tokens: Token[] = [{
+ key: 'paragraph', children: [{
+ key: 'text', text: 'Hello, World!',
+ }],
+ }, {
+ key: 'horizontal-rule',
+ }, {
+ key: 'list', listType: 'unordered', children: [{
+ key: 'list-item', children: [{
+ key: 'text', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }],
+ }, {
+ key: 'list-item', children: [{
+ key: 'text', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }, {
+ key: 'text', children: [{
+ key: 'text', text: 'Multiple lines.',
+ }],
+ }],
+ }],
+ }, {
+ key: 'horizontal-rule',
+ }, {
+ key: 'list', listType: 'ordered', children: [{
+ key: 'list-item', children: [{
+ key: 'text', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }],
+ }, {
+ key: 'list-item', children: [{
+ key: 'text', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }],
+ }],
+ }, {
+ key: 'horizontal-rule',
+ }, {
+ key: 'heading', level: 1, children: [{
+ key: 'text', text: 'Hello, World!',
+ }],
+ }, {
+ key: 'heading', level: 2, children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }, {
+ key: 'horizontal-rule',
+ }, {
+ key: 'blockquote', children: [{
+ key: 'paragraph', children: [{
+ key: 'text', text: 'This is a test.',
+ }],
+ }],
+ }]
+
+ expect(render(tokens)).toMatchSnapshot()
+ })
+
+ it('mixed list should be ok', () => {
+ const tokens: Token[] = [
+ {
+ "key": "list",
+ "listType": "unordered",
+ "children": [
+ {
+ "key": "list-item",
+ "children": [
+ {
+ "key": "text",
+ "children": [
+ {
+ "key": "text",
+ "text": "List Item 1"
+ }
+ ]
+ },
+ {
+ "key": "text",
+ "children": [
+ {
+ "key": "text",
+ "text": "List Item 2"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "key": "list-item",
+ "children": [
+ {
+ "key": "text",
+ "children": [
+ {
+ "key": "text",
+ "text": "List Item"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "key": "list-item",
+ "children": [
+ {
+ "key": "text",
+ "children": [
+ {
+ "key": "text",
+ "text": "List Item"
+ }
+ ]
+ },
+ {
+ "key": "list",
+ "listType": "ordered",
+ "children": [
+ {
+ "key": "list-item",
+ "children": [
+ {
+ "key": "text",
+ "children": [
+ {
+ "key": "text",
+ "text": "List Item"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "key": "list-item",
+ "children": [
+ {
+ "key": "text",
+ "children": [
+ {
+ "key": "text",
+ "text": "List Item"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "key": "list-item",
+ "children": [
+ {
+ "key": "text",
+ "children": [
+ {
+ "key": "text",
+ "text": "List Item"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ ]
+
+ expect(render(tokens)).toMatchSnapshot()
+ })
+})
+
+export {}
diff --git a/lib/markup/render.ts b/lib/markup/render.ts
@@ -1,46 +0,0 @@
-import { parse } from './parse'
-import { Token } from './token'
-
-export function render (source: string): string
-export function render (tokens: Token[]): string
-export function render (sourceOrTokens: string | Token[]): string {
- if (typeof sourceOrTokens === 'string') {
- sourceOrTokens = parse(sourceOrTokens)
- }
- let html = ''
- for (const token of sourceOrTokens) {
- switch (token.key) {
- case 'heading':
- html += `<h${token.level}>${token.text}</h${token.level}>`
- break
- case 'horizontal-rule':
- html += '<hr>'
- break
- case 'list-start':
- html += `<${token.listType === 'unordered' ? 'ul' : 'ol'}>`
- break
- case 'list-item-start':
- html += '<li>'
- break
- case 'list-item-end':
- html += '</li>'
- break
- case 'list-end':
- html += `</${token.listType === 'unordered' ? 'ul' : 'ol'}>`
- break
- case 'blockquote-start':
- html += '<blockquote>'
- break
- case 'blockquote-end':
- html += '</blockquote>'
- break
- case 'paragraph':
- html += `<p>${token.text}</p>`
- break
- case 'text':
- html += token.text
- break
- }
- }
- return html
-}
diff --git a/lib/markup/render.tsx b/lib/markup/render.tsx
@@ -0,0 +1,155 @@
+import { Fragment, ReactElement } from 'react'
+import { parse } from './parse'
+import { InlineToken, Token } from './token'
+
+export interface RenderOptions {
+ linkRenderer?: (path: string, text: string) => ReactElement
+}
+
+export function render (tokens: Token[], opts?: RenderOptions): ReactElement[]
+export function render (source: string, opts?: RenderOptions): ReactElement[]
+export function render (sourceOrTokens: string | Token[], opts?: RenderOptions): ReactElement[] {
+ if (typeof sourceOrTokens === 'string') {
+ sourceOrTokens = parse(sourceOrTokens)
+ }
+
+ const idxRef = { idx: 0 }
+
+ return renderBlock(sourceOrTokens, opts, idxRef)
+}
+
+function renderBlock (tokens: Token[], opts: RenderOptions | undefined, idxRef: { idx: number }): ReactElement[] {
+ const elements: ReactElement[] = []
+ for (let i = 0; i < tokens.length; i++) {
+ const token = tokens[i]
+
+ switch (token.key) {
+ case 'heading':
+ elements.push(
+ <h1 key={idxRef.idx++}>
+ {renderInline(token.children, opts, idxRef)}
+ </h1>,
+ )
+ break
+ case 'horizontal-rule':
+ elements.push(
+ <hr key={idxRef.idx++}/>,
+ )
+ break
+ case 'list':
+ elements.push(
+ <Fragment key={idxRef.idx++}>
+ {token.listType === 'unordered'
+ ? <ul>{renderList(token.children, opts, idxRef)}</ul>
+ : <ol>{renderList(token.children, opts, idxRef)}</ol>}
+ </Fragment>,
+ )
+ break
+ case 'blockquote':
+ elements.push(
+ <blockquote key={idxRef.idx++}>
+ {renderBlock(token.children, opts, idxRef)}
+ </blockquote>,
+ )
+ break
+ case 'paragraph':
+ elements.push(
+ <p key={idxRef.idx++}>
+ {renderInline(token.children, opts, idxRef)}
+ </p>,
+ )
+ break
+ case 'text':
+ for (let isBegin = true; i < tokens.length; i++) {
+ const token = tokens[i]
+ if (token.key !== 'text') {
+ i--
+ break
+ }
+
+ if (isBegin) {
+ isBegin = false
+ } else {
+ elements.push(<br key={idxRef.idx++}/>)
+ }
+
+ elements.push(
+ <Fragment key={idxRef.idx++}>
+ {renderInline(token.children, opts, idxRef)}
+ </Fragment>,
+ )
+ }
+ break
+ }
+ }
+
+ return elements
+}
+
+function renderList (tokens: Token[], opts: RenderOptions | undefined, idxRef: { idx: number }): ReactElement[] {
+ const elements: ReactElement[] = []
+ for (const token of tokens) {
+ if (token.key !== 'list-item') {
+ continue
+ }
+
+ elements.push(
+ <li key={idxRef.idx++}>
+ {renderBlock(token.children, opts, idxRef)}
+ </li>,
+ )
+ }
+ return elements
+}
+
+function renderInline (tokens: InlineToken[], opts: RenderOptions | undefined, idxRef: { idx: number }): ReactElement[] {
+ const elements: ReactElement[] = []
+ for (const token of tokens) {
+ switch (token.key) {
+ case 'text':
+ elements.push(
+ <Fragment key={idxRef.idx++}>{token.text}</Fragment>,
+ )
+ break
+ case 'linebreak':
+ elements.push(
+ <br key={idxRef.idx++}/>,
+ )
+ break
+ case 'bold':
+ elements.push(
+ <b key={idxRef.idx++}>{token.text}</b>,
+ )
+ break
+ case 'italic':
+ elements.push(
+ <i key={idxRef.idx++}>{token.text}</i>,
+ )
+ break
+ case 'strikethrough':
+ elements.push(
+ <s key={idxRef.idx++}>{token.text}</s>,
+ )
+ break
+ case 'underline':
+ elements.push(
+ <u key={idxRef.idx++}>{token.text}</u>,
+ )
+ break
+ case 'code':
+ elements.push(
+ <code key={idxRef.idx++}>{token.text}</code>,
+ )
+ break
+ case 'link':
+ elements.push(
+ <Fragment key={idxRef.idx++}>
+ {opts?.linkRenderer?.(token.path, token.text)
+ ?? <a href={token.path}>{token.text}</a>}
+ </Fragment>,
+ )
+ break
+ }
+ }
+ return elements
+}
diff --git a/lib/markup/token.ts b/lib/markup/token.ts
@@ -1,29 +1,51 @@
export type ListType = 'unordered' | 'ordered'
-export type Token = {
- key: 'heading'
- level: number
+export type InlineToken = {
+ key: 'text'
text: string
} | {
- key: 'horizontal-rule'
+ key: 'linebreak'
} | {
- key: 'list-start'
- listType: ListType
+ key: 'bold'
+ text: string
+} | {
+ key: 'italic'
+ text: string
+} | {
+ key: 'strikethrough'
+ text: string
+} | {
+ key: 'underline'
+ text: string
+} | {
+ key: 'code'
+ text: string
} | {
- key: 'list-item-start'
+ key: 'link'
+ path: string
+ text: string
+}
+
+export type Token = {
+ key: 'heading'
+ level: number
+ children: InlineToken[]
} | {
- key: 'list-item-end'
+ key: 'horizontal-rule'
} | {
- key: 'list-end'
+ key: 'list'
listType: ListType
+ children: Token[]
} | {
- key: 'blockquote-start'
+ key: 'list-item'
+ children: Token[]
} | {
- key: 'blockquote-end'
+ key: 'blockquote'
+ children: Token[]
} | {
key: 'paragraph'
- text: string
+ children: InlineToken[]
} | {
key: 'text'
- text: string
+ children: InlineToken[]
}
diff --git a/lib/models/wiki_page.ts b/lib/models/wiki_page.ts
@@ -13,14 +13,13 @@ export interface WikiPage {
}
const SQL_PUT_WIKI_PAGE = `
- insert into wiki_pages (wiki_id, path, text_id, html)
- values (?, ?, ?, ?)
- on duplicate key update text_id = values(text_id),
- html = values(html)
+ insert into wiki_pages (wiki_id, path, text_id)
+ values (?, ?, ?)
+ on duplicate key update text_id = values(text_id)
`
export const putWikiPage = modelBehaviour<
- [wikiId: number, path: string, textId: number, html: string],
+ [wikiId: number, path: string, textId: number],
number
>(async (conn, args) => {
const [result] = await conn.query<OkPacket>({
@@ -60,41 +59,6 @@ export const getWikiPage = modelBehaviour<
}
})
-export interface WikiHtmlPage extends WikiPage {
- html: string
-}
-
-const SQL_GET_WIKI_HTML_PAGE = `
- select wiki_id, path, html, text_id, acl_data, created_at, updated_at
- from wiki_pages
- where wiki_id = ?
- and path = ?
-`
-
-export const getWikiHtmlPage = modelBehaviour<
- [wikiId: number, path: string],
- WikiHtmlPage | null
->(async (conn, args) => {
- const [rows] = await conn.query<RowDataPacket[]>({
- sql: SQL_GET_WIKI_HTML_PAGE,
- }, args)
-
- if (rows.length === 0) {
- return null
- }
-
- const row = rows[0]
- return {
- wikiId: row[0],
- path: row[1],
- html: row[2],
- textId: row[3],
- acl: row[4],
- createdAt: row[5],
- updatedAt: row[6],
- }
-})
-
export interface WikiRawPage extends WikiPage {
content: string
}
diff --git a/lib/pagecache.ts b/lib/pagecache.ts
@@ -0,0 +1,41 @@
+import { Token } from '@/lib/markup'
+import { getRedis } from '@/lib/redis'
+import { parseIntOrDefault } from './utils/number'
+
+const DEFAULT_CACHE_TTL = 60 * 60 // 1 hour
+
+export function pageCacheKey (textId: number) {
+ return `pagecache:text/${textId}`
+}
+
+export async function hasPageCache (textId: number) {
+ const redis = await getRedis()
+ return (await redis.exists(pageCacheKey(textId))) === 1
+}
+
+export async function getPageCache (textId: number) {
+ const redis = await getRedis()
+ const data = await redis.get(pageCacheKey(textId))
+ if (data === null) {
+ return null
+ }
+ return JSON.parse(data) as Token[]
+}
+
+export async function putPageCache (textId: number, tokens: Token[]) {
+ const redis = await getRedis()
+ const key = pageCacheKey(textId)
+ const data = JSON.stringify(tokens)
+ await redis.set(key, data)
+ await refreshCacheImpl(redis, key)
+}
+
+export async function refreshPageCache (textId: number) {
+ const redis = await getRedis()
+ await refreshCacheImpl(redis, pageCacheKey(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/pages/api/wiki/[slug]/[...path].tsx b/pages/api/wiki/[slug]/[...path].tsx
@@ -1,6 +1,5 @@
import { ApiError, ERR_INTERNAL, ERR_NOT_FOUND } from '@/lib/apierror'
import { ERR_CODE_EMPTY_CONTENT } from '@/lib/error_codes'
-import { render } from '@/lib/markup'
import { withConnection } from '@/lib/model_helpers'
import { getWikiAndPageACLViaPath } from '@/lib/models/wiki_acl'
import { createWikiChange } from '@/lib/models/wiki_change'
@@ -108,8 +107,6 @@ async function handlePut (
return
}
- const html = render(content)
-
const token = await authenticationFromCookies(req.cookies)
await withConnection(async (conn) => {
@@ -120,7 +117,7 @@ async function handlePut (
}
const textId = await createWikiText(conn, [content, 'utf-8'])
- const pageId = await putWikiPage(conn, [acl.wikiId, path, textId, html])
+ const pageId = await putWikiPage(conn, [acl.wikiId, path, textId])
await createWikiChange(conn, [
pageId,
token?.uid ?? null,
diff --git a/pages/wiki/[slug]/[...path].tsx b/pages/wiki/[slug]/[...path].tsx
@@ -1,11 +1,10 @@
-import Container from '@/components/layout/Container'
import Hero from '@/components/layout/Hero'
import Section from '@/components/layout/Section'
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 { getPageCache, hasPageCache, putPageCache, refreshPageCache } from '@/lib/pagecache'
+import { parse, render, Token } from '@/lib/markup'
import { withConnection } from '@/lib/model_helpers'
import { getWikiViaSlug } from '@/lib/models/wiki_info'
import { getWikiPage, getWikiPageRevision, WikiPage } from '@/lib/models/wiki_page'
@@ -13,17 +12,13 @@ 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 type WikiViewPageProps = {
- page: WikiPage
- html: string
-} | {
- page: null
+ page?: WikiPage
+ tokens?: Token[]
}
export const getServerSideProps: GetServerSideProps<WikiViewPageProps> = async (context) => {
@@ -50,37 +45,37 @@ export const getServerSideProps: GetServerSideProps<WikiViewPageProps> = async (
? await getWikiPage(conn, [wiki.id, path])
: await getWikiPageRevision(conn, [wiki.id, path, revId])
if (page == null) {
- return { props: { page: null } }
+ return { props: {} }
}
- if (await hasHtmlCache(page.textId)) {
- const html = await getHtmlCache(page.textId)
+ if (await hasPageCache(page.textId)) {
+ const pageTokens = await getPageCache(page.textId)
- if (html != null) {
- await refreshHtmlCache(page.textId)
+ if (pageTokens != null) {
+ await refreshPageCache(page.textId)
return {
props: {
page: page,
- html: html,
+ tokens: pageTokens,
},
}
}
}
- // Cache miss; do render
+ // Cache miss
const wikiText = await getWikiText(conn, [page.textId])
if (wikiText == null) {
- return { props: { page: null } }
+ return { props: {} }
}
const source = await getStringFromWikiText(wikiText)
- const html = render(source)
+ const pageTokens = parse(source)
- await putHtmlCache(page.textId, html)
+ await putPageCache(page.textId, pageTokens)
return {
props: {
page: page,
- html: html,
+ tokens: pageTokens,
},
}
})
@@ -91,30 +86,35 @@ export default function WikiViewPage (props: WikiViewPageProps) {
const [slug, path] = getSlugAndPath(router)
const { rev } = router.query
+ function linkRenderer (path: string, text: string) {
+ return <Link href={`/wiki/${slug}/${path}`}>{text}</Link>
+ }
+
+ if (props.page == null || props.tokens == null) {
+ return (
+ <Hero>
+ <p>문서를 찾지 못했습니다.</p>
+ <p><Link href={`/edit/${slug}/${path}`}>새 문서 만들기</Link></p>
+ </Hero>
+ )
+ }
+
return (
- <>
- {props.page == null ? (
- <>
- <Hero>
- <p>문서를 찾지 못했습니다.</p>
- <p><Link href={`/edit/${slug}/${path}`}>새 문서 만들기</Link></p>
- </Hero>
- </>
- ) : (
- <WikiBase pageKind={'wiki'} title={path ?? ''}>
- <Section>
- <WikiArticle>
- {rev != null && (
- <RevisionAlert
- page={props.page}
- recentURL={`/wiki/${slug}/${path}`}
- />
- )}
- <div dangerouslySetInnerHTML={{ __html: props.html }}/>
- </WikiArticle>
- </Section>
- </WikiBase>
- )}
- </>
+ <WikiBase pageKind={'wiki'} title={path ?? ''}>
+ <Section>
+ <WikiArticle>
+ {rev != null && (
+ <RevisionAlert
+ page={props.page}
+ recentURL={`/wiki/${slug}/${path}`}
+ />
+ )}
+
+ {render(props.tokens, {
+ linkRenderer: linkRenderer,
+ })}
+ </WikiArticle>
+ </Section>
+ </WikiBase>
)
}
diff --git a/sql/0001_base.sql b/sql/0001_base.sql
@@ -63,7 +63,6 @@ create table wiki_pages
wiki_id int not null references wikis (id)
on delete cascade on update cascade,
path varchar(255) not null check ( path <> '' ),
- html text not null,
text_id int not null references wiki_texts (id)
on delete restrict on update cascade,
acl_data json null,