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}${tag}>`;
+
+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}${tag}>`;
+}
+
+function isTag(text, tag) {
+ return text.startsWith(`<${tag}>`);
+}
+
+function parser(markdown, delimiter, tag) {
+ const pattern = new RegExp(`${delimiter}(.+)${delimiter}`);
+ const replacement = `<${tag}>$1${tag}>`;
+ 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 [`${innerHtml}`, true];
+ }
+ }
+ return [null, list];
+}
+
+function parseParagraph(markdown, list) {
+ if (!list) {
+ return [parseText(markdown, false), false];
+ } 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 =
+ '- Item 1 with a # in the text
- Item 2 with * in the text
';
+ 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 ."
+ }
+}