diff --git a/config.json b/config.json index 64f0636092..d4b1d7a260 100644 --- a/config.json +++ b/config.json @@ -2578,6 +2578,17 @@ "strings" ] }, + { + "slug": "markdown", + "name": "Markdown", + "uuid": "cd666b3a-7114-4ba9-9b2a-7622a2c8c12c", + "practices": [ + "strings", + "string-formatting" + ], + "prerequisites": [], + "difficulty": 5 + }, { "slug": "micro-blog", "name": "Micro Blog", diff --git a/exercises/practice/markdown/.docs/instructions.md b/exercises/practice/markdown/.docs/instructions.md new file mode 100644 index 0000000000..9b756d9917 --- /dev/null +++ b/exercises/practice/markdown/.docs/instructions.md @@ -0,0 +1,13 @@ +# Instructions + +Refactor a Markdown parser. + +The markdown exercise is a refactoring exercise. +There is code that parses a given string with [Markdown syntax][markdown] and returns the associated HTML for that string. +Even though this code is confusingly written and hard to follow, somehow it works and all the tests are passing! +Your challenge is to re-write this code to make it easier to read and maintain while still making sure that all the tests keep passing. + +It would be helpful if you made notes of what you did in your refactoring in comments so reviewers can see that, but it isn't strictly necessary. +The most important thing is to make the code better! + +[markdown]: https://guides.github.com/features/mastering-markdown/ diff --git a/exercises/practice/markdown/.eslintrc b/exercises/practice/markdown/.eslintrc new file mode 100644 index 0000000000..1d4446029c --- /dev/null +++ b/exercises/practice/markdown/.eslintrc @@ -0,0 +1,14 @@ +{ + "root": true, + "extends": "@exercism/eslint-config-javascript", + "env": { + "jest": true + }, + "overrides": [ + { + "files": [".meta/proof.ci.js", ".meta/exemplar.js", "*.spec.js"], + "excludedFiles": ["custom.spec.js"], + "extends": "@exercism/eslint-config-javascript/maintainers" + } + ] +} diff --git a/exercises/practice/markdown/.gitignore b/exercises/practice/markdown/.gitignore new file mode 100644 index 0000000000..31c57dd53a --- /dev/null +++ b/exercises/practice/markdown/.gitignore @@ -0,0 +1,5 @@ +/node_modules +/bin/configlet +/bin/configlet.exe +/pnpm-lock.yaml +/yarn.lock diff --git a/exercises/practice/markdown/.meta/config.json b/exercises/practice/markdown/.meta/config.json new file mode 100644 index 0000000000..63d94efeb4 --- /dev/null +++ b/exercises/practice/markdown/.meta/config.json @@ -0,0 +1,17 @@ +{ + "authors": [ + "Cool-Katt" + ], + "files": { + "solution": [ + "markdown.js" + ], + "test": [ + "markdown.spec.js" + ], + "example": [ + ".meta/proof.ci.js" + ] + }, + "blurb": "Refactor a Markdown parser." +} diff --git a/exercises/practice/markdown/.meta/proof.ci.js b/exercises/practice/markdown/.meta/proof.ci.js new file mode 100644 index 0000000000..6d7ccde979 --- /dev/null +++ b/exercises/practice/markdown/.meta/proof.ci.js @@ -0,0 +1,55 @@ +const wrapInTag = (tag, text) => `<${tag}>${text}`; + +const REPLACERS = { + paragraph: { + operationNumber: 1, + regexPattern: /^(.+)$/gim, + replacer: (match, p1) => + match.startsWith('*') || match.startsWith('#') + ? match + : wrapInTag('p', p1), + }, + heading: { + operationNumber: 2, + regexPattern: /^(#{1,7})\s*(.+)/gim, + replacer: (match, p1, p2) => + p1.length > 6 ? wrapInTag('p', match) : wrapInTag(`h${p1.length}`, p2), + }, + bold: { + operationNumber: 3, + regexPattern: /__(.+)__/gim, + replacer: (match, p1) => wrapInTag('strong', p1), + }, + italic: { + operationNumber: 4, + regexPattern: /_(.+)_/gim, + replacer: (match, p1) => wrapInTag('em', p1), + }, + listItem: { + operationNumber: 5, + regexPattern: /^\*\s(.+)/gim, + replacer: (match, p1) => wrapInTag('li', p1), + }, + newLine: { + operationNumber: 6, + regexPattern: /\n/gim, + replacer: () => '', + }, + list: { + operationNumber: 7, + regexPattern: /(
  • .+<\/li>)/gim, + replacer: (match, p1) => wrapInTag('ul', p1), + }, +}; + +const sortedOperations = Object.values(REPLACERS).sort( + (a, b) => a.operationNumber - b.operationNumber, +); + +export function parse(markdown) { + return sortedOperations.reduce( + (text, { regexPattern, replacer }) => + text.replaceAll(new RegExp(regexPattern), replacer), + markdown, + ); +} diff --git a/exercises/practice/markdown/.meta/tests.toml b/exercises/practice/markdown/.meta/tests.toml new file mode 100644 index 0000000000..28b7baa720 --- /dev/null +++ b/exercises/practice/markdown/.meta/tests.toml @@ -0,0 +1,66 @@ +# This is an auto-generated file. +# +# Regenerating this file via `configlet sync` will: +# - Recreate every `description` key/value pair +# - Recreate every `reimplements` key/value pair, where they exist in problem-specifications +# - Remove any `include = true` key/value pair (an omitted `include` key implies inclusion) +# - Preserve any other key/value pair +# +# As user-added comments (using the # character) will be removed when this file +# is regenerated, comments can be added via a `comment` key. + +[e75c8103-a6b8-45d9-84ad-e68520545f6e] +description = "parses normal text as a paragraph" + +[69a4165d-9bf8-4dd7-bfdc-536eaca80a6a] +description = "parsing italics" + +[ec345a1d-db20-4569-a81a-172fe0cad8a1] +description = "parsing bold text" + +[51164ed4-5641-4909-8fab-fbaa9d37d5a8] +description = "mixed normal, italics and bold text" + +[ad85f60d-0edd-4c6a-a9b1-73e1c4790d15] +description = "with h1 header level" + +[d0f7a31f-6935-44ac-8a9a-1e8ab16af77f] +description = "with h2 header level" + +[9df3f500-0622-4696-81a7-d5babd9b5f49] +description = "with h3 header level" + +[50862777-a5e8-42e9-a3b8-4ba6fcd0ed03] +description = "with h4 header level" + +[ee1c23ac-4c86-4f2a-8b9c-403548d4ab82] +description = "with h5 header level" + +[13b5f410-33f5-44f0-a6a7-cfd4ab74b5d5] +description = "with h6 header level" + +[6dca5d10-5c22-4e2a-ac2b-bd6f21e61939] +description = "with h7 header level" +include = false + +[81c0c4db-435e-4d77-860d-45afacdad810] +description = "h7 header level is a paragraph" +reimplements = "6dca5d10-5c22-4e2a-ac2b-bd6f21e61939" + +[25288a2b-8edc-45db-84cf-0b6c6ee034d6] +description = "unordered lists" + +[7bf92413-df8f-4de8-9184-b724f363c3da] +description = "With a little bit of everything" + +[0b3ed1ec-3991-4b8b-8518-5cb73d4a64fe] +description = "with markdown symbols in the header text that should not be interpreted" + +[113a2e58-78de-4efa-90e9-20972224d759] +description = "with markdown symbols in the list item text that should not be interpreted" + +[e65e46e2-17b7-4216-b3ac-f44a1b9bcdb4] +description = "with markdown symbols in the paragraph text that should not be interpreted" + +[f0bbbbde-0f52-4c0c-99ec-be4c60126dd4] +description = "unordered lists close properly with preceding and following lines" diff --git a/exercises/practice/markdown/.npmrc b/exercises/practice/markdown/.npmrc new file mode 100644 index 0000000000..d26df800bb --- /dev/null +++ b/exercises/practice/markdown/.npmrc @@ -0,0 +1 @@ +audit=false diff --git a/exercises/practice/markdown/LICENSE b/exercises/practice/markdown/LICENSE new file mode 100644 index 0000000000..90e73be03b --- /dev/null +++ b/exercises/practice/markdown/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Exercism + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/exercises/practice/markdown/babel.config.js b/exercises/practice/markdown/babel.config.js new file mode 100644 index 0000000000..b781d5a667 --- /dev/null +++ b/exercises/practice/markdown/babel.config.js @@ -0,0 +1,4 @@ +module.exports = { + presets: ['@exercism/babel-preset-javascript'], + plugins: [], +}; diff --git a/exercises/practice/markdown/markdown.js b/exercises/practice/markdown/markdown.js new file mode 100644 index 0000000000..23b2e82092 --- /dev/null +++ b/exercises/practice/markdown/markdown.js @@ -0,0 +1,101 @@ +function wrap(text, tag) { + return `<${tag}>${text}`; +} + +function isTag(text, tag) { + return text.startsWith(`<${tag}>`); +} + +function parser(markdown, delimiter, tag) { + const pattern = new RegExp(`${delimiter}(.+)${delimiter}`); + const replacement = `<${tag}>$1`; + return markdown.replace(pattern, replacement); +} + +function parse__(markdown) { + return parser(markdown, '__', 'strong'); +} + +function parse_(markdown) { + return parser(markdown, '_', 'em'); +} + +function parseText(markdown, list) { + const parsedText = parse_(parse__(markdown)); + if (list) { + return parsedText; + } else { + return wrap(parsedText, 'p'); + } +} + +function parseHeader(markdown, list) { + let count = 0; + for (let i = 0; i < markdown.length; i++) { + if (markdown[i] === '#') { + count += 1; + } else { + break; + } + } + if (count === 0 || count > 6) { + return [null, list]; + } + const headerTag = `h${count}`; + const headerHtml = wrap(markdown.substring(count + 1), headerTag); + if (list) { + return [`${headerHtml}`, false]; + } else { + return [headerHtml, false]; + } +} + +function parseLineItem(markdown, list) { + if (markdown.startsWith('*')) { + const innerHtml = wrap(parseText(markdown.substring(2), true), 'li'); + if (list) { + return [innerHtml, true]; + } else { + return [`${parseText(markdown, false)}`, false]; + } +} + +function parseLine(markdown, list) { + let [result, inListAfter] = parseHeader(markdown, list); + if (result === null) { + [result, inListAfter] = parseLineItem(markdown, list); + } + if (result === null) { + [result, inListAfter] = parseParagraph(markdown, list); + } + if (result === null) { + throw new Error('Invalid markdown'); + } + return [result, inListAfter]; +} + +export function parse(markdown) { + const lines = markdown.split('\n'); + let result = ''; + let list = false; + for (let i = 0; i < lines.length; i++) { + let [lineResult, newList] = parseLine(lines[i], list); + result += lineResult; + list = newList; + } + if (list) { + return result + ''; + } else { + return result; + } +} diff --git a/exercises/practice/markdown/markdown.spec.js b/exercises/practice/markdown/markdown.spec.js new file mode 100644 index 0000000000..db3fea3ff6 --- /dev/null +++ b/exercises/practice/markdown/markdown.spec.js @@ -0,0 +1,127 @@ +import { parse } from './markdown'; + +describe('Markdown', () => { + test('parses normal text as a paragraph', () => { + const markdown = 'This will be a paragraph'; + const expected = '

    This will be a paragraph

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('parsing italics', () => { + const markdown = '_This will be italic_'; + const expected = '

    This will be italic

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('parsing bold text', () => { + const markdown = '__This will be bold__'; + const expected = '

    This will be bold

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('mixed normal, italics and bold text', () => { + const markdown = 'This will _be_ __mixed__'; + const expected = '

    This will be mixed

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('with h1 header level', () => { + const markdown = '# This will be an h1'; + const expected = '

    This will be an h1

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('with h2 header level', () => { + const markdown = '## This will be an h2'; + const expected = '

    This will be an h2

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('with h3 header level', () => { + const markdown = '### This will be an h3'; + const expected = '

    This will be an h3

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('with h4 header level', () => { + const markdown = '#### This will be an h4'; + const expected = '

    This will be an h4

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('with h5 header level', () => { + const markdown = '##### This will be an h5'; + const expected = '
    This will be an h5
    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('with h6 header level', () => { + const markdown = '###### This will be an h6'; + const expected = '
    This will be an h6
    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('with h7 header level', () => { + const markdown = '####### This will not be an h7'; + const expected = '

    ####### This will not be an h7

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('unordered lists', () => { + const markdown = '* Item 1\n' + '* Item 2'; + const expected = ''; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('with a little bit of everything', () => { + const markdown = '# Header!\n' + '* __Bold Item__\n' + '* _Italic Item_'; + const expected = + '

    Header!

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('with markdown symbols in the header text that should not be interpreted', () => { + const markdown = '# This is a header with # and * in the text'; + const expected = '

    This is a header with # and * in the text

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('with markdown symbols in the list item text that should not be interpreted', () => { + const markdown = + '* Item 1 with a # in the text\n' + '* Item 2 with * in the text'; + const expected = + ''; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('with markdown symbols in the paragraph text that should not be interpreted', () => { + const markdown = 'This is a paragraph with # and * in the text'; + const expected = '

    This is a paragraph with # and * in the text

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); + + xtest('unordered lists close properly with preceding and following lines', () => { + const markdown = + '# Start a list\n' + '* Item 1\n' + '* Item 2\n' + 'End a list'; + const expected = + '

    Start a list

    End a list

    '; + const actual = parse(markdown); + expect(actual).toEqual(expected); + }); +}); diff --git a/exercises/practice/markdown/package.json b/exercises/practice/markdown/package.json new file mode 100644 index 0000000000..3da317b27c --- /dev/null +++ b/exercises/practice/markdown/package.json @@ -0,0 +1,34 @@ +{ + "name": "@exercism/javascript-markdown", + "description": "Exercism practice exercise on markdown", + "author": "Katrina Owen", + "contributors": [ + "Cool-Katt (https://github.com/Cool-Katt)", + "Derk-Jan Karrenbeld (https://derk-jan.com)", + "Tejas Bubane (https://tejasbubane.github.io/)" + ], + "private": true, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/exercism/javascript", + "directory": "exercises/practice/markdown" + }, + "devDependencies": { + "@babel/core": "^7.23.0", + "@exercism/babel-preset-javascript": "^0.2.1", + "@exercism/eslint-config-javascript": "^0.6.0", + "@types/jest": "^29.5.4", + "@types/node": "^20.5.6", + "babel-jest": "^29.6.4", + "core-js": "~3.32.2", + "eslint": "^8.49.0", + "jest": "^29.7.0" + }, + "dependencies": {}, + "scripts": { + "test": "jest ./*", + "watch": "jest --watch ./*", + "lint": "eslint ." + } +}