├── src
├── vite-env.d.ts
├── main.tsx
├── types.ts
├── index.css
├── App.css
├── App.tsx
├── parseMarkdownElements.ts
└── parseMarkdownElements.test.ts
├── vite.config.ts
├── tsconfig.node.json
├── .gitignore
├── index.html
├── .eslintrc.cjs
├── tsconfig.json
├── public
└── typescript.svg
├── package.json
└── README.md
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import { App } from "./App.tsx";
4 | import "./index.css";
5 |
6 | ReactDOM.createRoot(document.getElementById("root")!).render(
7 |
8 |
9 |
10 | );
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Markdown Playground
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export type GetTypesStartingWithPrefix<
2 | Type,
3 | Prefix extends string
4 | > = Type extends `${Prefix}${infer _}` ? Type : never;
5 |
6 | export type Tag = {
7 | type: "normal" | "bold" | "italic";
8 | content: string;
9 | id: string;
10 | };
11 |
12 | export type MarkdownElement =
13 | | {
14 | type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p";
15 | tags: Array;
16 | id: string;
17 | }
18 | | {
19 | type: "breakpoint";
20 | id: string;
21 | };
22 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true
22 | },
23 | "include": ["src"],
24 | "references": [{ "path": "./tsconfig.node.json" }]
25 | }
26 |
--------------------------------------------------------------------------------
/public/typescript.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "markdown-playground",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview",
11 | "test": "vitest"
12 | },
13 | "dependencies": {
14 | "@types/uuid": "^9.0.7",
15 | "react": "^18.2.0",
16 | "react-dom": "^18.2.0",
17 | "uuid": "^9.0.1",
18 | "vitest": "^1.1.0"
19 | },
20 | "devDependencies": {
21 | "@types/react": "^18.2.43",
22 | "@types/react-dom": "^18.2.17",
23 | "@typescript-eslint/eslint-plugin": "^6.14.0",
24 | "@typescript-eslint/parser": "^6.14.0",
25 | "@vitejs/plugin-react": "^4.2.1",
26 | "autoprefixer": "^10.4.16",
27 | "eslint": "^8.55.0",
28 | "eslint-plugin-react-hooks": "^4.6.0",
29 | "eslint-plugin-react-refresh": "^0.4.5",
30 | "typescript": "^5.2.2",
31 | "vite": "^5.0.8"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | *,
2 | *::after,
3 | *::before {
4 | margin: 0;
5 | padding: 0;
6 | box-sizing: inherit;
7 | }
8 |
9 | html {
10 | width: 100%;
11 | height: 100%;
12 | }
13 |
14 | body {
15 | box-sizing: border-box;
16 | height: 100%;
17 | width: 100%;
18 | display: flex;
19 | flex-direction: column;
20 | align-items: center;
21 | }
22 |
23 | #root {
24 | width: 100%;
25 | height: 100%;
26 | display: flex;
27 | flex-direction: column;
28 | align-items: center;
29 | background-color: whitesmoke;
30 | color: rgb(3, 40, 59);
31 | font-family: Arial, Helvetica, sans-serif;
32 | }
33 |
34 | /* Custom Resets */
35 | button {
36 | cursor: pointer;
37 | border: none;
38 | background-color: transparent;
39 | }
40 |
41 | a {
42 | text-decoration: none;
43 | }
44 |
45 | input::placeholder,
46 | textarea::placeholder {
47 | opacity: 0.7;
48 | color: inherit;
49 | }
50 |
51 | input,
52 | textarea {
53 | border: none;
54 | }
55 |
56 | input:focus,
57 | textarea:focus {
58 | outline: none;
59 | }
60 |
61 | input:disabled,
62 | button:disabled {
63 | opacity: 0.5;
64 | }
65 |
66 | a:focus-visible,
67 | button:focus-visible {
68 | outline: 2px solid white;
69 | outline-offset: 2px;
70 | box-shadow: 0 0 0 6px black !important;
71 | }
72 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | main {
2 | height: 100%;
3 | width: 100%;
4 | display: flex;
5 | flex-direction: column;
6 | align-items: center;
7 | padding: 40px 0;
8 | row-gap: 80px;
9 | }
10 |
11 | main h1 {
12 | font-size: calc(50 / 16 * 1rem);
13 | }
14 |
15 | .wrapper {
16 | display: flex;
17 | align-items: center;
18 | justify-content: center;
19 | width: 100%;
20 | height: 80%;
21 | column-gap: 40px;
22 | padding: 0 20px;
23 | }
24 |
25 | .markdown {
26 | display: flex;
27 | flex-direction: column;
28 | height: 100%;
29 | width: 35%;
30 | row-gap: 20px;
31 | }
32 |
33 | .markdown label {
34 | font-size: calc(25 / 16 * 1rem);
35 | font-weight: 500;
36 | }
37 |
38 | .markdown textarea {
39 | padding: 10px;
40 | min-height: 250px;
41 | font-size: calc(18 / 16 * 1rem);
42 | resize: none;
43 | box-shadow: 1px 2px 5px rgba(0, 0, 0, 0.29);
44 | }
45 |
46 | .preview {
47 | display: flex;
48 | flex-direction: column;
49 | row-gap: 40px;
50 | height: 100%;
51 | width: 60%;
52 |
53 | .preview-title {
54 | text-align: center;
55 | font-size: calc(30 / 16 * 1rem);
56 | font-weight: 500;
57 | text-decoration: underline;
58 | }
59 |
60 | .content {
61 | width: 100%;
62 | height: 100%;
63 | display: flex;
64 | flex-direction: column;
65 |
66 | & h1 {
67 | font-size: calc(50 / 16 * 1rem);
68 | }
69 |
70 | & h2 {
71 | font-size: calc(35 / 16 * 1rem);
72 | }
73 |
74 | & h3 {
75 | font-size: calc(30 / 16 * 1rem);
76 | }
77 |
78 | & h4 {
79 | font-size: calc(26 / 16 * 1rem);
80 | }
81 |
82 | & h5 {
83 | font-size: calc(22 / 16 * 1rem);
84 | }
85 |
86 | & h6 {
87 | font-size: calc(18 / 16 * 1rem);
88 | }
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Markdown Live Preview Component
2 |
3 | Building a Markdown component from scratch.
4 |
5 | # TODO
6 |
7 | - [x] Headings
8 | - [x] Breakpoints
9 | - [x] Paragraphs
10 | - [x] Bold
11 | - [x] Italics
12 | - [ ] Bold/Italic nested in each other
13 | - [ ] Links
14 | - [ ] Images
15 | - [ ] Code
16 | - [ ] Blockquotes
17 |
18 | # Data Structure
19 |
20 | Since I do TDD, I started out small. A naive first approach:
21 |
22 | ```ts
23 | export type MarkdownElement =
24 | | {
25 | type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p";
26 | content: string;
27 | id: string;
28 | }
29 | | {
30 | type: "breakpoint";
31 | id: string;
32 | };
33 | ```
34 |
35 | This works for plain strings. However, I want to be able to add bold and italic text inside the normal text. A better approach is having a list of tags instead of plain strings.
36 |
37 | ```ts
38 | export type Tag = {
39 | type: "normal" | "bold" | "italic";
40 | content: string;
41 | id: string;
42 | };
43 |
44 | export type MarkdownElement =
45 | | {
46 | type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p";
47 | tags: Array;
48 | id: string;
49 | }
50 | | {
51 | type: "breakpoint";
52 | id: string;
53 | };
54 | ```
55 |
56 | Now, we can have a paragraph with bold and italic text inside it. This is a good start. Though, we would have to change it to support nesting e.g. bold inside italic.
57 |
58 | # Learnings
59 |
60 | ## IndexOf
61 |
62 | `indexOf` returns the index of the first occurrence of a substring in a string, or -1 if it's not found. It takes an optional second argument, which is the index to start searching from.
63 |
64 | Example: `indexOf('hello world', 'world')` returns 6, 6 being the index of the 7th character in the string.
65 |
66 | ## \n
67 |
68 | `\n` is the newline character. It's used to indicate a new line in a string. It's treated as a single character. Twice would not mean 4 characters, but 2.
69 |
70 | Twice would mean breakpoint. `\n\n` -> breakpoint.
71 |
72 | ## startsWith
73 |
74 | string.startsWith takes a second argument, which is the index to start searching from.
75 |
76 | ## string.match
77 |
78 | string.match takes a regex as an argument, and returns an array of matches. If there are no matches, it returns null. The first item in the array is the full match, and the rest are the capture groups. Capture groups are the parts of the regex that are wrapped in parentheses.
79 |
80 | # Demo
81 |
82 | https://github.com/narutosstudent/react-markdown-component/assets/49603590/1d82fb7b-ba49-464c-9fb2-4f2a7347f141
83 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement, ReactNode, useState } from "react";
2 | import "./App.css";
3 | import { parseMarkdownElements } from "./parseMarkdownElements";
4 | import { MarkdownElement, Tag } from "./types";
5 |
6 | type FunctionalComponentWithChildren = (props: {
7 | children?: ReactNode;
8 | }) => ReactElement;
9 | type FunctionalComponentWithoutChildren = () => ReactElement;
10 | type MarkdownComponentType =
11 | | FunctionalComponentWithChildren
12 | | FunctionalComponentWithoutChildren;
13 |
14 | const MarkdownComponents: Record<
15 | MarkdownElement["type"],
16 | MarkdownComponentType
17 | > = {
18 | h1: ({ children }) => {children}
,
19 | h2: ({ children }) => {children}
,
20 | h3: ({ children }) => {children}
,
21 | h4: ({ children }) => {children}
,
22 | h5: ({ children }) => {children}
,
23 | h6: ({ children }) => {children}
,
24 | p: ({ children }) => {children}
,
25 | breakpoint: () =>
,
26 | };
27 |
28 | const TagComponents: Record<
29 | Tag["type"],
30 | (props: { children: ReactNode }) => ReactElement
31 | > = {
32 | bold: ({ children }) => {children},
33 | italic: ({ children }) => {children},
34 | normal: ({ children }) => <>{children}>,
35 | };
36 |
37 | const DefaultComponent: MarkdownComponentType = ({ children }) => (
38 | {children}
39 | );
40 |
41 | export function App() {
42 | const [textareaContent, setTextareaContent] = useState("");
43 | const [markdownElements, setMarkdownElements] = useState(
44 | []
45 | );
46 |
47 | return (
48 |
49 | Markdown Preview Playground
50 |
51 |
52 |
53 |
63 |
64 |
65 |
Preview
66 |
67 | {markdownElements.map((markdownElement) => {
68 | const Component =
69 | MarkdownComponents[markdownElement.type] || DefaultComponent;
70 |
71 | if (markdownElement.type === "breakpoint") {
72 | return ;
73 | } else {
74 | return (
75 |
76 | {markdownElement.tags.map((tag) => {
77 | const TagComponent = TagComponents[tag.type];
78 |
79 | return (
80 | {tag.content}
81 | );
82 | })}
83 |
84 | );
85 | }
86 | })}
87 |
88 |
89 |
90 |
91 | );
92 | }
93 |
--------------------------------------------------------------------------------
/src/parseMarkdownElements.ts:
--------------------------------------------------------------------------------
1 | import { v4 as uuidv4 } from "uuid";
2 | import { GetTypesStartingWithPrefix, MarkdownElement, Tag } from "./types";
3 |
4 | const BREAKPOINT = "\n\n";
5 | const NEWLINE = "\n";
6 |
7 | const BOLD_SIGN = "**";
8 | const ITALIC_SIGN = "_";
9 |
10 | const REGEX_HEADING = /^#+/; // ^ means start of string, #+ means one or more '#'
11 |
12 | function createTextTag(text: string): Tag {
13 | return { type: "normal", content: text, id: uuidv4() };
14 | }
15 |
16 | function getHeadingLevel(text: string): number {
17 | const match = text.match(REGEX_HEADING);
18 | const fullMatch = match && match[0];
19 | return fullMatch ? fullMatch.length : 0;
20 | }
21 |
22 | function parseHeading(
23 | text: string,
24 | startIndex: number
25 | ): { element: MarkdownElement; endIndex: number } {
26 | const headingLevel = getHeadingLevel(text.slice(startIndex));
27 |
28 | const isNotEndingWithNewline = text.indexOf(NEWLINE, startIndex) === -1;
29 | const headingEndIndex = isNotEndingWithNewline
30 | ? text.length
31 | : text.indexOf(NEWLINE, startIndex);
32 |
33 | const additionToSkipSpaceAfterHash = 1;
34 |
35 | const content = text
36 | .slice(
37 | startIndex + headingLevel + additionToSkipSpaceAfterHash,
38 | headingEndIndex
39 | )
40 | .trim(); // only trims beginning and end
41 |
42 | return {
43 | element: {
44 | type: `h${headingLevel}` as GetTypesStartingWithPrefix<
45 | MarkdownElement,
46 | "h"
47 | >,
48 | tags: [createTextTag(content)],
49 | id: uuidv4(),
50 | },
51 | endIndex: headingEndIndex,
52 | };
53 | }
54 |
55 | function parseBoldText(
56 | text: string
57 | ): { tag: Tag; boldStartIndex: number; afterEndBoldIndex: number } | null {
58 | const boldStartIndex = text.indexOf(BOLD_SIGN);
59 | if (boldStartIndex === -1) return null;
60 |
61 | const boldEndIndex = text.indexOf(BOLD_SIGN, boldStartIndex + 2);
62 | if (boldEndIndex === -1) return null;
63 |
64 | const content = text.slice(boldStartIndex + 2, boldEndIndex).trim();
65 |
66 | return {
67 | tag: {
68 | type: "bold",
69 | content,
70 | id: uuidv4(),
71 | },
72 | boldStartIndex,
73 | afterEndBoldIndex: boldEndIndex + 2, // +2 to account for the '**'
74 | };
75 | }
76 |
77 | function parseItalicText(
78 | text: string
79 | ): { tag: Tag; italicStartIndex: number; afterEndItalicIndex: number } | null {
80 | const italicStartIndex = text.indexOf(ITALIC_SIGN);
81 | if (italicStartIndex === -1) return null;
82 |
83 | const italicEndIndex = text.indexOf(ITALIC_SIGN, italicStartIndex + 1);
84 | if (italicEndIndex === -1) return null;
85 |
86 | const content = text.slice(italicStartIndex + 1, italicEndIndex).trim();
87 |
88 | return {
89 | tag: {
90 | type: "italic",
91 | content,
92 | id: uuidv4(),
93 | },
94 | italicStartIndex,
95 | afterEndItalicIndex: italicEndIndex + 1, // +1 to account for the '_'
96 | };
97 | }
98 |
99 | function createTextTagBeforeSpecialText(
100 | text: string,
101 | specialTextStartIndex: number
102 | ): Tag {
103 | const textBeforeSpecialText = text.slice(0, specialTextStartIndex);
104 | return createTextTag(textBeforeSpecialText);
105 | }
106 |
107 | function hasNormalTextBeforeSpecialText(index: number) {
108 | return index > 0;
109 | }
110 |
111 | function parseParagraph(
112 | text: string,
113 | startIndex: number,
114 | endIndex: number
115 | ): MarkdownElement {
116 | let index = startIndex;
117 | const tags: Tag[] = [];
118 |
119 | while (index < endIndex) {
120 | const remainingText = text.slice(index, endIndex);
121 | const italicResult = parseItalicText(remainingText);
122 | const boldResult = parseBoldText(remainingText);
123 |
124 | if (boldResult || italicResult) {
125 | if (italicResult && boldResult) {
126 | const isItalicBeforeBold =
127 | italicResult.italicStartIndex < boldResult.boldStartIndex;
128 | if (isItalicBeforeBold) {
129 | if (hasNormalTextBeforeSpecialText(italicResult.italicStartIndex)) {
130 | tags.push(
131 | createTextTagBeforeSpecialText(
132 | remainingText,
133 | italicResult.italicStartIndex
134 | )
135 | );
136 | }
137 |
138 | tags.push(italicResult.tag);
139 | index += italicResult.afterEndItalicIndex; // Move index past the italic text
140 |
141 | const remainingTextAfterItalic = remainingText.slice(
142 | italicResult.afterEndItalicIndex
143 | );
144 | const boldResultAfterItalic = parseBoldText(remainingTextAfterItalic);
145 |
146 | if (boldResultAfterItalic) {
147 | if (
148 | hasNormalTextBeforeSpecialText(
149 | boldResultAfterItalic.boldStartIndex
150 | )
151 | ) {
152 | tags.push(
153 | createTextTagBeforeSpecialText(
154 | remainingTextAfterItalic,
155 | boldResultAfterItalic.boldStartIndex
156 | )
157 | );
158 | }
159 |
160 | tags.push(boldResultAfterItalic.tag);
161 |
162 | index += boldResultAfterItalic.afterEndBoldIndex; // Move index past the bold text, we need to do += because we are in the remainingTextAfterItalic
163 | }
164 | } else {
165 | if (hasNormalTextBeforeSpecialText(boldResult.boldStartIndex)) {
166 | tags.push(
167 | createTextTagBeforeSpecialText(
168 | remainingText,
169 | boldResult.boldStartIndex
170 | )
171 | );
172 | }
173 |
174 | tags.push(boldResult.tag);
175 | index += boldResult.afterEndBoldIndex; // Move index past the bold text
176 |
177 | const remainingTextAfterBold = remainingText.slice(
178 | boldResult.afterEndBoldIndex
179 | );
180 | const italicResultAfterBold = parseItalicText(remainingTextAfterBold);
181 |
182 | if (italicResultAfterBold) {
183 | if (
184 | hasNormalTextBeforeSpecialText(
185 | italicResultAfterBold.italicStartIndex
186 | )
187 | ) {
188 | tags.push(
189 | createTextTagBeforeSpecialText(
190 | remainingTextAfterBold,
191 | italicResultAfterBold.italicStartIndex
192 | )
193 | );
194 | }
195 |
196 | tags.push(italicResultAfterBold.tag);
197 |
198 | index += italicResultAfterBold.afterEndItalicIndex; // Move index past the italic text, we need to do += because we are in the remainingTextAfterBold
199 | }
200 | }
201 | }
202 |
203 | if (italicResult && !boldResult) {
204 | if (hasNormalTextBeforeSpecialText(italicResult.italicStartIndex)) {
205 | tags.push(
206 | createTextTagBeforeSpecialText(
207 | remainingText,
208 | italicResult.italicStartIndex
209 | )
210 | );
211 | }
212 |
213 | tags.push(italicResult.tag);
214 | index += italicResult.afterEndItalicIndex; // Move index past the italic text
215 | }
216 |
217 | if (!italicResult && boldResult) {
218 | if (hasNormalTextBeforeSpecialText(boldResult.boldStartIndex)) {
219 | tags.push(
220 | createTextTagBeforeSpecialText(
221 | remainingText,
222 | boldResult.boldStartIndex
223 | )
224 | );
225 | }
226 |
227 | // Add the bold text
228 | tags.push(boldResult.tag);
229 | index += boldResult.afterEndBoldIndex; // Move index past the bold text
230 | }
231 | }
232 |
233 | const isJustPlainText = !boldResult && !italicResult;
234 | if (isJustPlainText) {
235 | tags.push(createTextTag(remainingText));
236 | break;
237 | }
238 | }
239 |
240 | return {
241 | type: "p",
242 | tags,
243 | id: uuidv4(),
244 | };
245 | }
246 |
247 | export function parseMarkdownElements(text: string): MarkdownElement[] {
248 | const elements: MarkdownElement[] = [];
249 | let index = 0;
250 |
251 | while (index < text.length) {
252 | const isHeading =
253 | text[index] === "#" &&
254 | text[index + getHeadingLevel(text.slice(index))] === " "; // make sure it is an actual heading
255 |
256 | const isBreakpoint = text.startsWith(BREAKPOINT, index);
257 |
258 | if (isHeading) {
259 | const parsedHeading = parseHeading(text, index);
260 | if (parsedHeading) {
261 | elements.push(parsedHeading.element);
262 | index = parsedHeading.endIndex;
263 | }
264 |
265 | // If not a valid heading, let the paragraph logic handle it
266 | } else if (isBreakpoint) {
267 | elements.push({ type: "breakpoint", id: uuidv4() });
268 | index += BREAKPOINT.length;
269 | } else {
270 | const endOfCurrentLineIndex =
271 | text.indexOf(NEWLINE, index) === -1
272 | ? text.length
273 | : text.indexOf(NEWLINE, index);
274 |
275 | const element = parseParagraph(text, index, endOfCurrentLineIndex);
276 |
277 | elements.push(element);
278 | index = endOfCurrentLineIndex;
279 | }
280 |
281 | const isIndexLessThanTextLength = index < text.length;
282 | const isAtNewline = text[index] === NEWLINE;
283 | const isNotAtBreakpoint = !text.startsWith(BREAKPOINT, index);
284 | if (isIndexLessThanTextLength && isAtNewline && isNotAtBreakpoint) {
285 | index++; // skip newline
286 | }
287 | }
288 |
289 | return elements;
290 | }
291 |
--------------------------------------------------------------------------------
/src/parseMarkdownElements.test.ts:
--------------------------------------------------------------------------------
1 | import { parseMarkdownElements } from "./parseMarkdownElements";
2 | import { it, expect, describe } from "vitest";
3 | import { MarkdownElement } from "./types";
4 |
5 | describe("headings", () => {
6 | it("should parse a single heading", () => {
7 | const markdown = "# Hello World";
8 | const elements = parseMarkdownElements(markdown);
9 | expect(elements).toEqual([
10 | {
11 | type: "h1",
12 | tags: [
13 | {
14 | type: "normal",
15 | content: "Hello World",
16 | id: expect.any(String),
17 | },
18 | ],
19 | id: expect.any(String),
20 | },
21 | ] satisfies MarkdownElement[]);
22 | });
23 |
24 | it("should parse heading one with breakpoint", () => {
25 | const markdown = "# Hello World\n\n";
26 | const elements = parseMarkdownElements(markdown);
27 | expect(elements).toEqual([
28 | {
29 | type: "h1",
30 | tags: [
31 | {
32 | type: "normal",
33 | content: "Hello World",
34 | id: expect.any(String),
35 | },
36 | ],
37 | id: expect.any(String),
38 | },
39 | {
40 | type: "breakpoint",
41 | id: expect.any(String),
42 | },
43 | ] satisfies MarkdownElement[]);
44 | });
45 |
46 | it("should parse different headings with many breakpoints", () => {
47 | const markdown = "# Hello World\n\n## Hello World\n\n### Hello World\n\n";
48 | const elements = parseMarkdownElements(markdown);
49 | expect(elements).toEqual([
50 | {
51 | type: "h1",
52 | tags: [
53 | {
54 | type: "normal",
55 | content: "Hello World",
56 | id: expect.any(String),
57 | },
58 | ],
59 | id: expect.any(String),
60 | },
61 | {
62 | type: "breakpoint",
63 | id: expect.any(String),
64 | },
65 | {
66 | type: "h2",
67 | tags: [
68 | {
69 | type: "normal",
70 | content: "Hello World",
71 | id: expect.any(String),
72 | },
73 | ],
74 | id: expect.any(String),
75 | },
76 | {
77 | type: "breakpoint",
78 | id: expect.any(String),
79 | },
80 | {
81 | type: "h3",
82 | tags: [
83 | {
84 | type: "normal",
85 | content: "Hello World",
86 | id: expect.any(String),
87 | },
88 | ],
89 | id: expect.any(String),
90 | },
91 | {
92 | type: "breakpoint",
93 | id: expect.any(String),
94 | },
95 | ] satisfies MarkdownElement[]);
96 | });
97 |
98 | it("should return # with no space as a paragraph", () => {
99 | const markdown = "#Hello World";
100 | const elements = parseMarkdownElements(markdown);
101 | expect(elements).toEqual([
102 | {
103 | type: "p",
104 | tags: [
105 | {
106 | type: "normal",
107 | content: "#Hello World",
108 | id: expect.any(String),
109 | },
110 | ],
111 | id: expect.any(String),
112 | },
113 | ] satisfies MarkdownElement[]);
114 | });
115 | });
116 |
117 | describe("paragraphs", () => {
118 | it("should parse a single paragraph", () => {
119 | const markdown = "Hello World";
120 | const elements = parseMarkdownElements(markdown);
121 | expect(elements).toEqual([
122 | {
123 | type: "p",
124 | tags: [
125 | {
126 | type: "normal",
127 | content: "Hello World",
128 | id: expect.any(String),
129 | },
130 | ],
131 | id: expect.any(String),
132 | },
133 | ] satisfies MarkdownElement[]);
134 | });
135 |
136 | it("should parse a single paragraph with a breakpoint", () => {
137 | const markdown = "Hello World\n\n";
138 | const elements = parseMarkdownElements(markdown);
139 | expect(elements).toEqual([
140 | {
141 | type: "p",
142 | tags: [
143 | {
144 | type: "normal",
145 | content: "Hello World",
146 | id: expect.any(String),
147 | },
148 | ],
149 | id: expect.any(String),
150 | },
151 | {
152 | type: "breakpoint",
153 | id: expect.any(String),
154 | },
155 | ] satisfies MarkdownElement[]);
156 | });
157 |
158 | it("should parse multiple paragraphs with many breakpoints", () => {
159 | const markdown = "Hello World\n\nHello World\n\nHello World\n\n";
160 | const elements = parseMarkdownElements(markdown);
161 | expect(elements).toEqual([
162 | {
163 | type: "p",
164 | tags: [
165 | {
166 | type: "normal",
167 | content: "Hello World",
168 | id: expect.any(String),
169 | },
170 | ],
171 | id: expect.any(String),
172 | },
173 | {
174 | type: "breakpoint",
175 | id: expect.any(String),
176 | },
177 | {
178 | type: "p",
179 | tags: [
180 | {
181 | type: "normal",
182 | content: "Hello World",
183 | id: expect.any(String),
184 | },
185 | ],
186 | id: expect.any(String),
187 | },
188 | {
189 | type: "breakpoint",
190 | id: expect.any(String),
191 | },
192 | {
193 | type: "p",
194 | tags: [
195 | {
196 | type: "normal",
197 | content: "Hello World",
198 | id: expect.any(String),
199 | },
200 | ],
201 | id: expect.any(String),
202 | },
203 | {
204 | type: "breakpoint",
205 | id: expect.any(String),
206 | },
207 | ] satisfies MarkdownElement[]);
208 | });
209 |
210 | it("should parse multiple paragraphs with many breakpoints and headings", () => {
211 | const markdown =
212 | "# Hello World\n\nHello World\n\n## Hello World\n\nHello World\n\n### Hello World\n\nHello World\n\n";
213 | const elements = parseMarkdownElements(markdown);
214 | expect(elements).toEqual([
215 | {
216 | type: "h1",
217 | tags: [
218 | {
219 | type: "normal",
220 | content: "Hello World",
221 | id: expect.any(String),
222 | },
223 | ],
224 | id: expect.any(String),
225 | },
226 | {
227 | type: "breakpoint",
228 | id: expect.any(String),
229 | },
230 | {
231 | type: "p",
232 | tags: [
233 | {
234 | type: "normal",
235 | content: "Hello World",
236 | id: expect.any(String),
237 | },
238 | ],
239 | id: expect.any(String),
240 | },
241 | {
242 | type: "breakpoint",
243 | id: expect.any(String),
244 | },
245 | {
246 | type: "h2",
247 | tags: [
248 | {
249 | type: "normal",
250 | content: "Hello World",
251 | id: expect.any(String),
252 | },
253 | ],
254 | id: expect.any(String),
255 | },
256 | {
257 | type: "breakpoint",
258 | id: expect.any(String),
259 | },
260 | {
261 | type: "p",
262 | tags: [
263 | {
264 | type: "normal",
265 | content: "Hello World",
266 | id: expect.any(String),
267 | },
268 | ],
269 | id: expect.any(String),
270 | },
271 | {
272 | type: "breakpoint",
273 | id: expect.any(String),
274 | },
275 | {
276 | type: "h3",
277 | tags: [
278 | {
279 | type: "normal",
280 | content: "Hello World",
281 | id: expect.any(String),
282 | },
283 | ],
284 | id: expect.any(String),
285 | },
286 | {
287 | type: "breakpoint",
288 | id: expect.any(String),
289 | },
290 | {
291 | type: "p",
292 | tags: [
293 | {
294 | type: "normal",
295 | content: "Hello World",
296 | id: expect.any(String),
297 | },
298 | ],
299 | id: expect.any(String),
300 | },
301 | {
302 | type: "breakpoint",
303 | id: expect.any(String),
304 | },
305 | ] satisfies MarkdownElement[]);
306 | });
307 | });
308 |
309 | describe("bold", () => {
310 | it("should parse a single bold", () => {
311 | const markdown = "**Hello World**";
312 | const elements = parseMarkdownElements(markdown);
313 | expect(elements).toEqual([
314 | {
315 | type: "p",
316 | tags: [
317 | {
318 | type: "bold",
319 | content: "Hello World",
320 | id: expect.any(String),
321 | },
322 | ],
323 | id: expect.any(String),
324 | },
325 | ] satisfies MarkdownElement[]);
326 | });
327 |
328 | it("should parse a single bold with a breakpoint", () => {
329 | const markdown = "**Hello World**\n\n";
330 | const elements = parseMarkdownElements(markdown);
331 | expect(elements).toEqual([
332 | {
333 | type: "p",
334 | tags: [
335 | {
336 | type: "bold",
337 | content: "Hello World",
338 | id: expect.any(String),
339 | },
340 | ],
341 | id: expect.any(String),
342 | },
343 | {
344 | type: "breakpoint",
345 | id: expect.any(String),
346 | },
347 | ] satisfies MarkdownElement[]);
348 | });
349 |
350 | it("should parse bold with normal text", () => {
351 | const markdown = "Hello **World**";
352 | const elements = parseMarkdownElements(markdown);
353 | expect(elements).toEqual([
354 | {
355 | type: "p",
356 | tags: [
357 | {
358 | type: "normal",
359 | content: "Hello ",
360 | id: expect.any(String),
361 | },
362 | {
363 | type: "bold",
364 | content: "World",
365 | id: expect.any(String),
366 | },
367 | ],
368 | id: expect.any(String),
369 | },
370 | ] satisfies MarkdownElement[]);
371 | });
372 |
373 | it("should parse bold with normal text with a breakpoint", () => {
374 | const markdown = "Hello **World**\n\n";
375 | const elements = parseMarkdownElements(markdown);
376 | expect(elements).toEqual([
377 | {
378 | type: "p",
379 | tags: [
380 | {
381 | type: "normal",
382 | content: "Hello ",
383 | id: expect.any(String),
384 | },
385 | {
386 | type: "bold",
387 | content: "World",
388 | id: expect.any(String),
389 | },
390 | ],
391 | id: expect.any(String),
392 | },
393 | {
394 | type: "breakpoint",
395 | id: expect.any(String),
396 | },
397 | ] satisfies MarkdownElement[]);
398 | });
399 |
400 | it("should parse bold with normal text, breakpoint and two headings", () => {
401 | const markdown = "Hello **World**\n\n# Hello World\n\n## Hello World\n\n";
402 | const elements = parseMarkdownElements(markdown);
403 | expect(elements).toEqual([
404 | {
405 | type: "p",
406 | tags: [
407 | {
408 | type: "normal",
409 | content: "Hello ",
410 | id: expect.any(String),
411 | },
412 | {
413 | type: "bold",
414 | content: "World",
415 | id: expect.any(String),
416 | },
417 | ],
418 | id: expect.any(String),
419 | },
420 | {
421 | type: "breakpoint",
422 | id: expect.any(String),
423 | },
424 | {
425 | type: "h1",
426 | tags: [
427 | {
428 | type: "normal",
429 | content: "Hello World",
430 | id: expect.any(String),
431 | },
432 | ],
433 | id: expect.any(String),
434 | },
435 | {
436 | type: "breakpoint",
437 | id: expect.any(String),
438 | },
439 | {
440 | type: "h2",
441 | tags: [
442 | {
443 | type: "normal",
444 | content: "Hello World",
445 | id: expect.any(String),
446 | },
447 | ],
448 | id: expect.any(String),
449 | },
450 |
451 | {
452 | type: "breakpoint",
453 | id: expect.any(String),
454 | },
455 | ] satisfies MarkdownElement[]);
456 | });
457 | });
458 |
459 | describe("italics", () => {
460 | it("should parse a single italics", () => {
461 | const markdown = "_Hello World_";
462 | const elements = parseMarkdownElements(markdown);
463 | expect(elements).toEqual([
464 | {
465 | type: "p",
466 | tags: [
467 | {
468 | type: "italic",
469 | content: "Hello World",
470 | id: expect.any(String),
471 | },
472 | ],
473 | id: expect.any(String),
474 | },
475 | ] satisfies MarkdownElement[]);
476 | });
477 |
478 | it("should parse a single italics with a breakpoint", () => {
479 | const markdown = "_Hello World_\n\n";
480 | const elements = parseMarkdownElements(markdown);
481 | expect(elements).toEqual([
482 | {
483 | type: "p",
484 | tags: [
485 | {
486 | type: "italic",
487 | content: "Hello World",
488 | id: expect.any(String),
489 | },
490 | ],
491 | id: expect.any(String),
492 | },
493 | {
494 | type: "breakpoint",
495 | id: expect.any(String),
496 | },
497 | ] satisfies MarkdownElement[]);
498 | });
499 |
500 | it("should parse italics with normal text", () => {
501 | const markdown = "Hello _World_";
502 | const elements = parseMarkdownElements(markdown);
503 | expect(elements).toEqual([
504 | {
505 | type: "p",
506 | tags: [
507 | {
508 | type: "normal",
509 | content: "Hello ",
510 | id: expect.any(String),
511 | },
512 | {
513 | type: "italic",
514 | content: "World",
515 | id: expect.any(String),
516 | },
517 | ],
518 | id: expect.any(String),
519 | },
520 | ] satisfies MarkdownElement[]);
521 | });
522 |
523 | it("should parse italics with bold and breakpoint", () => {
524 | const markdown = "Hello _World_ **Hello World**\n\n";
525 | const elements = parseMarkdownElements(markdown);
526 | expect(elements).toEqual([
527 | {
528 | type: "p",
529 | tags: [
530 | {
531 | type: "normal",
532 | content: "Hello ",
533 | id: expect.any(String),
534 | },
535 | {
536 | type: "italic",
537 | content: "World",
538 | id: expect.any(String),
539 | },
540 | {
541 | type: "normal",
542 | content: " ",
543 | id: expect.any(String),
544 | },
545 | {
546 | type: "bold",
547 | content: "Hello World",
548 | id: expect.any(String),
549 | },
550 | ],
551 | id: expect.any(String),
552 | },
553 | {
554 | type: "breakpoint",
555 | id: expect.any(String),
556 | },
557 | ] satisfies MarkdownElement[]);
558 | });
559 | });
560 |
--------------------------------------------------------------------------------