├── 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 |