dh_demo

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

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:
Alib/markup/index.ts | 3+++
Alib/markup/parse.test.ts | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/markup/parse.ts | 130+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/markup/render.test.ts | 93+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Alib/markup/render.ts | 40++++++++++++++++++++++++++++++++++++++++
Alib/markup/token.ts | 29+++++++++++++++++++++++++++++
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 +}