dh_demo

DreamHanks demo project
git clone git://git.lair.cx/dh_demo
Log | Files | Refs | README

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:
Dlib/htmlcache.ts | 35-----------------------------------
Alib/markup/__snapshots__/render.test.tsx.snap | 137+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/markup/parse.test.ts | 161++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mlib/markup/parse.ts | 29++++++++++++++++++-----------
Alib/markup/parse_inline.test.ts | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/markup/parse_inline.ts | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dlib/markup/render.test.ts | 93-------------------------------------------------------------------------------
Alib/markup/render.test.tsx | 182+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dlib/markup/render.ts | 46----------------------------------------------
Alib/markup/render.tsx | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mlib/markup/token.ts | 48+++++++++++++++++++++++++++++++++++-------------
Mlib/models/wiki_page.ts | 44++++----------------------------------------
Alib/pagecache.ts | 41+++++++++++++++++++++++++++++++++++++++++
Mpages/api/wiki/[slug]/[...path].tsx | 5+----
Mpages/wiki/[slug]/[...path].tsx | 88++++++++++++++++++++++++++++++++++++++++----------------------------------------
Msql/0001_base.sql | 1-
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,