commit 8a4a0225e7bc041f04638c13edd8e278601e5eec
parent 657e4085b559d43ede8b52ee662ae567c54d98e4
Author: Yongbin Kim <iam@yongbin.kim>
Date: Sun, 22 Jan 2023 23:29:19 +0900
feat: 간단한 마크업 언어 파서/렌더러 추가
Signed-off-by: Yongbin Kim <iam@yongbin.kim>
Diffstat:
6 files changed, 406 insertions(+), 0 deletions(-)
diff --git a/lib/markup/index.ts b/lib/markup/index.ts
@@ -0,0 +1,3 @@
+export * from './parse'
+export * from './render'
+export * from './token'
diff --git a/lib/markup/parse.test.ts b/lib/markup/parse.test.ts
@@ -0,0 +1,111 @@
+import { Token } from './token'
+import { parse } from './parse'
+
+const parseTests: Array<{ label: string; src: string; expected: Token[] }> = [{
+ label: 'empty',
+ src: '',
+ expected: [],
+}, {
+ label: 'paragraph',
+ src: `Hello,
+World!
+
+This is a test.`,
+ expected: [
+ { key: 'paragraph', text: 'Hello,\nWorld!' },
+ { key: 'paragraph', 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.' },
+ ],
+}, {
+ label: 'horizontal rule',
+ src: `Hello, World!
+
+----
+
+This is a test.`,
+ expected: [
+ { key: 'paragraph', text: 'Hello, World!' },
+ { key: 'horizontal-rule' },
+ { key: 'paragraph', text: 'This is a test.' },
+ ],
+}, {
+ label: 'list',
+ src: `Hello, World!
+
+- This is a test.
+ - This is a test.
+ Multiple lines.
+- List supports any block syntax.
+
+ # For Example,
+ Heading.
+- 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.' },
+ ],
+}, {
+ label: 'blockquote',
+ src: `Hello, World!
+
+> This is a test.
+>
+> Blockquote supports any block syntax.
+>
+> > 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.' },
+ ],
+}]
+
+describe('Parse', () => {
+ for (const { label, src, expected } of parseTests) {
+ it(label, () => {
+ expect(parse(src)).toEqual(expected)
+ })
+ }
+})
diff --git a/lib/markup/parse.ts b/lib/markup/parse.ts
@@ -0,0 +1,130 @@
+import { ListType, Token } from '@/lib/markup/token'
+
+const regexHeading = /^(#{1,6})\s+([^\n]+)\s*(?:$|\n)/
+const regexUnorderedListItem = /^([*-]\s[^\n]+(?:\n {2}[^\n]*)*)(?:$|\n)/
+const regexOrderedListItem = /^(1\.\s[^\n]+(?:\n {3}[^\n]*)*)(?:$|\n)/
+const regexHorizontalRule = /^-{3,}\s*(?:$|\n)/
+const regexBlockquote = /^((?:(?:^|\n)>\s[^\n]+)+)\s*(?:$|\n)/
+const regexParagraph = /^((?:(?:^|\n[ \t]*)\S[^\n]*)+)\s*(?:$|\n)/
+const regexText = /^([^\n]+)(?:$|\n)/
+const regexSpaces = /^(?:\s+(?:$|\n)|\n)/g
+
+function stripPrefix (text: string, prefix: string) {
+ return text.replace(new RegExp(`^${prefix}`, 'gm'), '')
+}
+
+function parseBlock (src: string, useParagraph: boolean): Token[] {
+ const out: Token[] = []
+
+ let caps: RegExpExecArray | null
+ for (; src.length > 0;) {
+ caps = regexSpaces.exec(src)
+ if (caps != null) {
+ src = src.slice(caps[0].length)
+ continue
+ }
+
+ caps = regexHeading.exec(src)
+ if (caps != null) {
+ out.push({
+ key: 'heading',
+ level: caps[1].length,
+ text: caps[2],
+ })
+
+ src = src.slice(caps[0].length)
+ continue
+ }
+
+ caps = regexHorizontalRule.exec(src)
+ if (caps != null) {
+ out.push({ key: 'horizontal-rule' })
+ src = src.slice(caps[0].length)
+ continue
+ }
+
+ caps = regexUnorderedListItem.exec(src)
+ if (caps != null) {
+ src = parseList(src, 'unordered', out)
+ continue
+ }
+
+ caps = regexOrderedListItem.exec(src)
+ if (caps != null) {
+ src = parseList(src, 'ordered', out)
+ continue
+ }
+
+ caps = regexBlockquote.exec(src)
+ if (caps != null) {
+ const innerSrc = stripPrefix(caps[1], '>(?: |(?=\n))')
+
+ out.push({ key: 'blockquote-start' })
+ out.push(...parseBlock(innerSrc, true))
+ out.push({ key: 'blockquote-end' })
+
+ src = src.slice(caps[0].length)
+ continue
+ }
+
+ if (useParagraph) {
+ caps = regexParagraph.exec(src)
+ if (caps != null) {
+ out.push({ key: 'paragraph', text: caps[1] })
+
+ src = src.slice(caps[0].length)
+ continue
+ }
+ } else {
+ caps = regexText.exec(src)
+ if (caps != null) {
+ out.push({ key: 'text', text: caps[1] })
+
+ src = src.slice(caps[0].length)
+ continue
+ }
+ }
+
+ throw new Error(`unreachable`)
+ }
+
+ return out
+}
+
+function parseList (src: string, listType: ListType, out: Token[]): string {
+ const regexListItem = listType === 'ordered'
+ ? regexOrderedListItem
+ : regexUnorderedListItem
+
+ out.push({ key: 'list-start', listType })
+
+ let caps: RegExpExecArray | null
+ for (; src.length > 0;) {
+ caps = regexListItem.exec(src)
+ if (caps != null) {
+ const listSrc = stripPrefix(
+ caps[1],
+ listType === 'ordered'
+ ? '.{3}'
+ : '.{2}',
+ )
+
+ out.push({ key: 'list-item-start' })
+ out.push(...parseBlock(listSrc, false))
+ out.push({ key: 'list-item-end' })
+
+ src = src.slice(caps[0].length)
+ continue
+ }
+
+ break
+ }
+
+ out.push({ key: 'list-end', listType })
+
+ return src
+}
+
+export function parse (src: string): Token[] {
+ return parseBlock(src, true)
+}
diff --git a/lib/markup/render.test.ts b/lib/markup/render.test.ts
@@ -0,0 +1,93 @@
+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.ts b/lib/markup/render.ts
@@ -0,0 +1,40 @@
+import { Token } from './token'
+
+export function render (tokens: Token[]): string {
+ let html = ''
+ for (const token of tokens) {
+ 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/token.ts b/lib/markup/token.ts
@@ -0,0 +1,29 @@
+export type ListType = 'unordered' | 'ordered'
+
+export type Token = {
+ key: 'heading'
+ level: number
+ text: string
+} | {
+ key: 'horizontal-rule'
+} | {
+ key: 'list-start'
+ listType: ListType
+} | {
+ key: 'list-item-start'
+} | {
+ key: 'list-item-end'
+} | {
+ key: 'list-end'
+ listType: ListType
+} | {
+ key: 'blockquote-start'
+} | {
+ key: 'blockquote-end'
+} | {
+ key: 'paragraph'
+ text: string
+} | {
+ key: 'text'
+ text: string
+}