├── babel.config.js ├── postcss.config.js ├── tsconfig.node.json ├── README.md ├── src ├── env.d.ts ├── api │ ├── __mocks__ │ │ ├── wrong-menu.md │ │ └── correct-menu.md │ ├── linkedin-practices.spec.ts │ └── linkedin-practices.ts ├── practice-parser │ ├── __mocks__ │ │ ├── lexer-tree.md │ │ ├── mock-markdown.ts │ │ └── lexer-mock.md │ ├── lexer-tree.ts │ ├── lexer-tree.spec.ts │ ├── lexer.spec.ts │ └── lexer.ts ├── main.tsx ├── components │ ├── common │ │ ├── __mocks__ │ │ │ └── markdown-content.tsx │ │ ├── error-fallback.tsx │ │ ├── modal.tsx │ │ ├── viewport.hook.ts │ │ ├── local-storage.hook.ts │ │ └── markdown-content.tsx │ ├── practices │ │ ├── __mocks__ │ │ │ ├── practices.mock.ts │ │ │ └── local-storage.mock.ts │ │ ├── piechart.tsx │ │ ├── practices.tsx │ │ ├── practices.hook.spec.ts │ │ ├── practices.hook.ts │ │ ├── information-panel.tsx │ │ └── practices.spec.tsx │ └── practice │ │ ├── __mocks__ │ │ └── data.mock.ts │ │ ├── practice.tsx │ │ └── practice.spec.tsx ├── pages │ ├── linkedin-practices.tsx │ ├── github-practices │ │ ├── utils.spec.ts │ │ ├── utils.ts │ │ └── github-practices.tsx │ ├── live-preview.tsx │ └── linkedin-practices-menu.tsx ├── index.css ├── app.tsx └── assets │ └── md2practice-logo.svg ├── vite.config.ts ├── .gitignore ├── index.html ├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ └── deploy-ghpages.yml ├── jest.config.ts ├── tailwind.config.js ├── tsconfig.json ├── .eslintrc.js ├── package.json └── public └── favicon.svg /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [['@babel/preset-env', { targets: { node: 'current' } }], 3 | ], 4 | }; 5 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "esnext", 5 | "moduleResolution": "node" 6 | }, 7 | "include": ["vite.config.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MD2Practice 2 | Transform markdown to practice for exercise. 3 | ## Features 4 | - LinkedIn Practices from https://github.com/Ebazhanov/linkedin-skill-assessments-quizzes 5 | - Save progress to local storage -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | interface ImportMetaEnv { 3 | readonly VITE_GA_MEASUREMENT_ID: string 4 | // more env variables... 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv 9 | } 10 | -------------------------------------------------------------------------------- /src/api/__mocks__/wrong-menu.md: -------------------------------------------------------------------------------- 1 | ### Some random table 2 | | Tables | Are | Cool | 3 | |----------|:-------------:|------:| 4 | | col 1 is | left-aligned | $1600 | 5 | | col 2 is | centered | $12 | 6 | | col 3 is | right-aligned | $1 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { defineConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | }); 9 | -------------------------------------------------------------------------------- /src/practice-parser/__mocks__/lexer-tree.md: -------------------------------------------------------------------------------- 1 | #### Q1. Question with codespan? 2 | 3 | `with-codespan` 4 | `with-codespan 1` 5 | `with-codespan 2` 6 | `with-codespan 3` 7 | 8 | - [ ] selection 1 9 | - [ ] selection 2 10 | - [x] selection 3 11 | - [ ] selection 4 -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './app'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root'), 11 | ); 12 | -------------------------------------------------------------------------------- /src/components/common/__mocks__/markdown-content.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface MarkdownContentProps { 4 | raw: string; 5 | } 6 | 7 | const MarkdownContent = jest.fn().mockImplementation(( 8 | { raw }: MarkdownContentProps, 9 | ) =>
{raw}
); 10 | 11 | export default MarkdownContent; 12 | -------------------------------------------------------------------------------- /src/components/practices/__mocks__/practices.mock.ts: -------------------------------------------------------------------------------- 1 | import { withId } from '../../../practice-parser/lexer'; 2 | import { mockedPractice, mockedSelection } from '../../practice/__mocks__/data.mock'; 3 | 4 | export const mockedPractices = (length: number) => { 5 | const practices = Array.from({ length }, () => mockedPractice(mockedSelection(4))); 6 | return withId(practices); 7 | }; 8 | -------------------------------------------------------------------------------- /src/components/common/error-fallback.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FallbackProps } from 'react-error-boundary'; 3 | 4 | export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => ( 5 |
6 |

Something went wrong:

7 |
{error.message}
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 | coverage 12 | dist 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | # ENVIRONMENT 28 | .env 29 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | MD2Practice 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/pages/linkedin-practices.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useParams } from 'react-router-dom'; 3 | import GithubPractices from './github-practices/github-practices'; 4 | 5 | export const LinkedInPracticesPage: React.FC = () => { 6 | const { encodedUrl } = useParams<{ encodedUrl: string }>(); 7 | const url = atob(encodedUrl || ''); 8 | 9 | return ( 10 | 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/practices/__mocks__/local-storage.mock.ts: -------------------------------------------------------------------------------- 1 | export const mockedLocalStorage = (() => { 2 | let store: Record = {}; 3 | return { 4 | getItem(key: string) { 5 | return store[key]; 6 | }, 7 | setItem(key: string, value: string) { 8 | store[key] = value.toString(); 9 | }, 10 | clear() { 11 | store = {}; 12 | }, 13 | removeItem(key: string) { 14 | delete store[key]; 15 | }, 16 | }; 17 | })(); 18 | -------------------------------------------------------------------------------- /src/pages/github-practices/utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { convertGithubLink } from './utils'; 2 | 3 | describe('github practices utilities', () => { 4 | it('should convert the github link to raw file link correctly', () => { 5 | const githubLink = 'https://github.com/Ebazhanov/linkedin-skill-assessments-quizzes/blob/master/git/git-quiz.md'; 6 | const rawLink = 'https://raw.githubusercontent.com/Ebazhanov/linkedin-skill-assessments-quizzes/master/git/git-quiz.md'; 7 | expect(convertGithubLink(githubLink)).toEqual(rawLink); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Basic dependabot.yml file with 2 | # minimum configuration for two package managers 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: 'npm' 7 | directory: '/' 8 | schedule: 9 | interval: 'weekly' 10 | day: 'saturday' 11 | versioning-strategy: 'increase' 12 | labels: 13 | - 'dependencies' 14 | open-pull-requests-limit: 5 15 | commit-message: 16 | # cause a release for non-dev-deps 17 | prefix: fix(deps) 18 | # no release for dev-deps 19 | prefix-development: chore(dev-deps) -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | /* Chrome, Safari and Opera */ 7 | .no-scrollbar::-webkit-scrollbar { 8 | display: none; 9 | } 10 | 11 | .no-scrollbar { 12 | -ms-overflow-style: none; /* IE and Edge */ 13 | scrollbar-width: none; /* Firefox */ 14 | } 15 | } 16 | 17 | @layer base { 18 | html { 19 | @apply text-neutral-300 antialiased leading-normal font-mono 20 | } 21 | body { 22 | @apply min-h-screen bg-primary-dark-900 23 | } 24 | } -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | /* 3 | * For a detailed explanation regarding each configuration property and type check, visit: 4 | * https://jestjs.io/docs/configuration 5 | */ 6 | 7 | export default { 8 | preset: 'ts-jest', 9 | transform: { 10 | '^.+\\.(ts|tsx)?$': 'ts-jest', 11 | '^.+\\.(js|jsx)$': 'babel-jest', 12 | }, 13 | collectCoverage: true, 14 | coverageDirectory: 'coverage', 15 | testEnvironment: 'jsdom', 16 | moduleNameMapper: { 17 | '.*/markdown-content': '/src/components/common/__mocks__/markdown-content.tsx', 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /src/components/common/modal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ModalProps { 4 | show: boolean; 5 | children: React.ReactNode; 6 | onClick: React.MouseEventHandler; 7 | } 8 | 9 | const Modal: React.FC = ({ show, children, onClick }) => { 10 | if (show) { 11 | return ( 12 | 17 | ); 18 | } 19 | return null; 20 | }; 21 | 22 | export default Modal; 23 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | const colors = require('tailwindcss/colors'); 3 | 4 | module.exports = { 5 | content: [ 6 | './index.html', 7 | './src/**/*.{vue,js,ts,jsx,tsx}', 8 | ], 9 | theme: { 10 | screens: { 11 | tablet: '640px', 12 | desktop: '1024px', 13 | }, 14 | extend: { 15 | colors: { 16 | primary: { 17 | ...colors.emerald, 18 | dark: { ...colors.slate }, 19 | }, 20 | secondary: { 21 | ...colors.blue, 22 | dark: { 23 | ...colors.violet, 24 | }, 25 | }, 26 | }, 27 | }, 28 | }, 29 | plugins: [], 30 | }; 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": false, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | }, 19 | "include": ["src"], 20 | "exclude": ["**/*.spec.ts", "**/*.spec.tsx"], 21 | "references": [{ "path": "./tsconfig.node.json" }] 22 | } 23 | -------------------------------------------------------------------------------- /src/components/common/viewport.hook.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | export const useViewport = () => { 4 | const [width, setWidth] = useState(window.innerWidth); 5 | // Add a second state variable "height" and default it to the current window height 6 | const [height, setHeight] = useState(window.innerHeight); 7 | 8 | useEffect(() => { 9 | const handleWindowResize = () => { 10 | setWidth(window.innerWidth); 11 | // Set the height in state as well as the width 12 | setHeight(window.innerHeight); 13 | }; 14 | 15 | window.addEventListener('resize', handleWindowResize); 16 | return () => window.removeEventListener('resize', handleWindowResize); 17 | }, []); 18 | 19 | // Return both the height and width 20 | return { width, height }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/pages/github-practices/utils.ts: -------------------------------------------------------------------------------- 1 | import { PracticeParams } from '../../components/practice/practice'; 2 | 3 | export const convertGithubLink = (githubLink: string) => { 4 | const url = new URL(githubLink); 5 | const tokens = url.pathname.split('/'); 6 | const user = tokens[1]; 7 | const repository = tokens[2]; 8 | const branch = tokens[4]; 9 | const path = tokens.slice(5).join('/'); 10 | return `https://raw.githubusercontent.com/${user}/${repository}/${branch}/${path}`; 11 | }; 12 | 13 | // for linkedin assessment incorrect question index, 14 | // because some question still doesn't have answer yet. 15 | export const recorrectQuestionIndex = (data: PracticeParams[]) => data.map((item, idx) => ({ 16 | ...item, 17 | rawQuestion: item.rawQuestion.replace(/#### Q{0,1}\d+/g, `#### Q${idx + 1}`), 18 | })); 19 | -------------------------------------------------------------------------------- /src/practice-parser/__mocks__/mock-markdown.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import { marked } from 'marked'; 3 | import { resolve } from 'path'; 4 | 5 | export const mockMarkdown = (filename: string) => readFileSync(resolve(__dirname, filename), 'utf-8'); 6 | 7 | export const getPracticeByIndex = (lexer: marked.TokensList, index: number) => { 8 | let chunk: marked.Token[] = []; 9 | const practices = lexer.reduce((result:marked.Token[][], token, idx) => { 10 | if (chunk.length > 0 && token.type === 'heading' && token.depth === 4) { 11 | result.push(chunk); 12 | chunk = []; 13 | } 14 | chunk.push(token); 15 | 16 | if (idx + 1 === lexer.length) { 17 | result.push(chunk); 18 | } 19 | return result; 20 | }, []); 21 | return practices[index] as marked.TokensList; 22 | }; 23 | -------------------------------------------------------------------------------- /src/components/practice/__mocks__/data.mock.ts: -------------------------------------------------------------------------------- 1 | import { Chance } from 'chance'; 2 | import { 3 | OptionStatus, PracticeParams, PracticeStatus, SelectionOption, 4 | } from '../practice'; 5 | 6 | const chance = new Chance(); 7 | 8 | export const mockedQuestion = () => chance.string(); 9 | 10 | export const mockedSelection = ( 11 | totalOptions: number = 4, 12 | ): SelectionOption[] => Array.from({ length: totalOptions }, () => ({ 13 | rawOption: chance.string(), 14 | status: OptionStatus.IDLE, 15 | })); 16 | 17 | export const mockedPractice = ( 18 | selection: SelectionOption[], 19 | isMultiple: boolean = false, 20 | ): PracticeParams => { 21 | const range = { min: 0, max: selection.length - 1 }; 22 | return { 23 | rawQuestion: mockedQuestion(), 24 | selection, 25 | answers: isMultiple 26 | ? chance.unique(chance.natural, 2, range) 27 | : chance.unique(chance.natural, 1, range), 28 | status: PracticeStatus.IDLE, 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/practice-parser/__mocks__/lexer-mock.md: -------------------------------------------------------------------------------- 1 | #### Q0. Question with different content? 2 | 3 | ![image1](https://wwww.imageurl.com) 4 | ![image2](https://wwww.imageurl.com) 5 | ![image3](https://wwww.imageurl.com) 6 | `with-codespan 1` 7 | `with-codespan 2` 8 | `with-codespan 3` 9 | ```javascript 10 | const data = null 11 | ``` 12 | ```javascript 13 | const data = null 14 | ``` 15 | 16 | - [ ] selection 1 17 | - [ ] selection 2 18 | - [x] selection 3 19 | - [ ] selection 4 20 | 21 | #### Q1. Normal Selection 22 | 23 | - [ ] selection 1 24 | - [ ] selection 2 25 | - [x] selection 3 26 | - [ ] selection 4 27 | 28 | #### Q2. Normal and Code Selection 29 | 30 | - [x] 31 | ```javascript 32 | const data = null 33 | ``` 34 | - [ ] normal selection 1 35 | - [ ] normal selection 2 36 | - [ ] 37 | ```javascript 38 | const data = null 39 | ``` 40 | 41 | #### Q3. Question without selection available 42 | 43 | #### Q4. Question without answer 44 | - [ ] selection 1 45 | - [ ] selection 2 46 | - [ ] selection 3 47 | - [ ] selection 4 -------------------------------------------------------------------------------- /src/practice-parser/lexer-tree.ts: -------------------------------------------------------------------------------- 1 | import { marked } from 'marked'; 2 | import { curry } from 'ramda'; 3 | 4 | // Reference: https://jrsinclair.com/articles/2019/functional-js-traversing-trees-with-recursive-reduce/ 5 | 6 | type Generic = marked.Tokens.Generic 7 | 8 | function hasChildren(node: Generic) { 9 | return (typeof node === 'object') 10 | && (typeof node.tokens !== 'undefined') 11 | && (node.tokens.length > 0); 12 | } 13 | 14 | export function wrapLexerTokens(tokens: marked.TokensList) { 15 | return { 16 | type: 'root', 17 | tokens: [...tokens], 18 | } as marked.Tokens.Generic; 19 | } 20 | 21 | export const LexerTree = { 22 | reduce: curry((reducerFn: Function, init: unknown, node: Generic): unknown => { 23 | const acc = reducerFn(init, node); 24 | if (!hasChildren(node)) { 25 | return acc; 26 | } 27 | return node.tokens?.reduce(LexerTree.reduce(reducerFn), acc); 28 | }), 29 | map: curry((mapFn: Function, node: Generic): Generic => { 30 | const newNode = mapFn(node); 31 | if (!hasChildren(node)) { 32 | return newNode; 33 | } 34 | newNode.tokens = node.tokens?.map((item) => LexerTree.map(mapFn)(item)); 35 | return newNode; 36 | }), 37 | }; 38 | -------------------------------------------------------------------------------- /src/practice-parser/lexer-tree.spec.ts: -------------------------------------------------------------------------------- 1 | import { marked } from 'marked'; 2 | import { LexerTree, wrapLexerTokens } from './lexer-tree'; 3 | import { getPracticeByIndex, mockMarkdown } from './__mocks__/mock-markdown'; 4 | 5 | describe('marked parser', () => { 6 | const lexer = marked.lexer(mockMarkdown('lexer-tree.md')); 7 | 8 | it('reduce function is working, add all codespan count.', () => { 9 | const data = wrapLexerTokens(getPracticeByIndex(lexer, 0)); 10 | 11 | function sumCodespan(result: number, item: marked.Token) { 12 | return result + ((item.type === 'codespan') ? 1 : 0); 13 | } 14 | const total = LexerTree.reduce(sumCodespan, 0, data); 15 | expect(total).toBe(4); 16 | }); 17 | it('map function is working', () => { 18 | const data = wrapLexerTokens(getPracticeByIndex(lexer, 0)); 19 | function addChildCount(node: marked.Tokens.Generic) { 20 | const count = node.tokens?.length || 0; 21 | return { 22 | ...node, 23 | childCount: count, 24 | }; 25 | } 26 | const tree = LexerTree.map(addChildCount, data); 27 | const childNode = (tree.tokens ? tree.tokens[0] : {}) as {childCount: number}; 28 | expect(childNode.childCount).toBe(1); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request 5 | 6 | jobs: 7 | tests: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [17.x] 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - name: Cache dependencies 19 | id: cache-dependencies 20 | uses: actions/cache@v3 21 | with: 22 | path: '**/node_modules' 23 | key: ${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }} 24 | - name: Install dependencies 25 | if: steps.cache-dependencies.outputs.cache-hit != 'true' 26 | run: yarn --frozen-lockfile 27 | - name: Testing 🧪 28 | run: yarn run test 29 | - name: Build 👷 30 | run: yarn run build 31 | auto-merge: 32 | needs: ['tests'] 33 | runs-on: ubuntu-latest 34 | if: github.actor == 'dependabot[bot]' 35 | permissions: 36 | pull-requests: write 37 | contents: write 38 | steps: 39 | - uses: fastify/github-action-merge-dependabot@v3 40 | with: 41 | github-token: ${{ secrets.GITHUB_TOKEN }} 42 | target: minor 43 | -------------------------------------------------------------------------------- /src/pages/live-preview.tsx: -------------------------------------------------------------------------------- 1 | import { marked } from 'marked'; 2 | import React, { ChangeEventHandler } from 'react'; 3 | import { parsePractices, withId } from '../practice-parser/lexer'; 4 | import { Practices } from '../components/practices/practices'; 5 | import { usePractices } from '../components/practices/practices.hook'; 6 | 7 | const sample = '#### Q1. Normal Selection\n- [x] selection 1\n- [ ] selection 2'; 8 | 9 | const LivePreviewPractices: React.FC = () => { 10 | const sampleLexer = marked.lexer(sample); 11 | const initPractices = withId(parsePractices(sampleLexer)); 12 | 13 | const [practices, { 14 | handleSubmit, 15 | handleSelectionChange, 16 | setPractices, 17 | }] = usePractices(initPractices); 18 | 19 | const handleChange: ChangeEventHandler = (e) => { 20 | const lexer = marked.lexer(e.target.value); 21 | setPractices(withId(parsePractices(lexer))); 22 | }; 23 | 24 | return ( 25 |
26 |