Intermediate project · Medium
Markdown-subset to HTML converter
You write a converter that turns a small, precisely documented subset of Markdown into HTML, in two separate stages: a parser that produces typed block objects, and a renderer that turns those objects into a string. The types do most of the design work, because a discriminated union forces you to handle every kind of block. You finish with a table of input and expected-output pairs and a runner that prints how many pass.
- Languages
- TypeScript
- Size
- Medium: a few sessions
- Where to build it
- Runs as-is in the TypeScript playground, which runs the real compiler first, so a type error stops it before anything executes. Keep the converter and the test table in the same file. Open the TypeScript playground →
What you will practise
- discriminated unions and exhaustive switch handling
- separating a parser from a renderer
- type narrowing instead of casts
- escaping output so text can never become markup
- writing your own tiny test table and runner
- documenting a grammar precisely enough to test it
Requirements
The project is done when every one of these is true.
- A comment at the top documents the exact subset you support and your whitespace convention for the output, and every example in your tests matches that document.
- A Block discriminated union covers at least heading, paragraph, list and codeBlock, and every block kind is handled in a switch with no default that silently swallows an unknown kind.
- parse takes the source string and returns Block objects; it never returns HTML. render takes Block objects and returns a string; it never looks at the original source.
- Headings are one to six hash characters followed by a space; seven or more hashes is a paragraph, and the text keeps the hashes.
- Inline handling covers bold with double asterisks, italic with single asterisks, inline code with backticks, and links in the bracket-then-parenthesis form.
- Inside inline code and inside fenced code blocks nothing else is interpreted, so a double asterisk there stays literal characters.
- All text is escaped on the way out: ampersand becomes &, less-than becomes <, greater-than becomes >, and a double quote inside an attribute value is escaped too.
- The parser contains no any and no type assertions; unknown shapes are narrowed with real checks instead.
- A tests array of input and expected pairs is run by a function that prints each failure with both strings and finishes with a pass count out of the total.
Milestones
A sensible order to build it in, so something works at every step.
Write the grammar document and the types
Before any code, write the comment describing exactly what you support and define the Block union. Getting the types right here saves most of the debugging later.
Split the source into blocks
Turn the source into lines, group them into blocks on blank lines and fence markers, and return the right block kind for each group with its raw text still inside.
Render blocks with no inline handling
Write render with an exhaustive switch that emits the tags and escapes the text. At this point headings, paragraphs, lists and code blocks all work with plain text.
Add the test table and runner
Write the runner now, not at the end, and add the cases you already support. From here every new feature arrives with its test.
Add inline parsing, code first
Handle inline code before bold, italic and links, so the rule that nothing inside backticks is interpreted is enforced by the order rather than by special cases.
Add bold, italic and links
Add the remaining inline forms to the segments that are not code, escaping link text and link targets as they are emitted.
Go hunting for the cases you did not think of
Try unmatched markers, empty headings, a list of one item, and a file that is only blank lines, and add each one to the test table with the behaviour you decide is correct.
Hints
Open one only when you are stuck. Each gives a little more away.
Show hint 1Hint 1
Do the inline code split first and treat its pieces as untouchable. If bold runs first it will happily reach inside backticks, and you will end up patching that forever.
Show hint 2Hint 2
An exhaustive switch over a discriminated union will tell you at compile time when you add a block kind and forget to render it, as long as you do not add a catch-all default. That compile error is the feature.
Show hint 3Hint 3
Escape once, at the point where text becomes output. Escaping in the parser means the renderer sees already-escaped text and escapes the ampersands again.
Show hint 4Hint 4
Escape the ampersand before the angle brackets, otherwise you rewrite the ampersands you just inserted.
Show hint 5Hint 5
A test table of input and expected strings is much easier to grow than assertions scattered through the file, and it makes a failure print both strings side by side.
Show hint 6Hint 6
The playground type-checks before it runs, so a type error stops the program with the compiler's own message and nothing executes. That is useful: treat the first red message as the next thing to fix rather than as noise.
How to test it
Run these checks yourself, or turn them into automated tests once you know how.
- The two simplest blocks: # Title renders as <h1>Title</h1>, and Plain text. renders as <p>Plain text.</p>.
- Bold inside a paragraph: a **b** c renders as <p>a <strong>b</strong> c</p>.
- Code is escaped and literal: `1 < 2` renders as <p><code>1 < 2</code></p> with the less-than escaped and the backticks gone, and `**not bold**` renders as <p><code>**not bold**</code></p> with the asterisks kept as characters.
- A two-item list: the two lines - one and - two render as <ul><li>one</li><li>two</li></ul>.
- Too many hashes: the input ####### seven renders as <p>####### seven</p>, hashes included.
- A link: the input [site](/code) renders as <p><a href="/code">site</a></p>.
- A bare ampersand: the input A & B renders as <p>A & B</p>, which catches a renderer that only escapes angle brackets.
- Round-trip safety: feed your own grammar document through the converter and confirm the output contains no unescaped angle brackets that you did not emit yourself.
Stretch goals
- Add ordered lists, and decide and document what a list starting at 3 should render as.
- Add blockquotes that can contain paragraphs and lists, which forces the block type to become recursive.
- Add nested inline markup, so bold inside a link and code inside bold both work.
- Reject and report malformed input with a line number instead of silently treating it as a paragraph.
- Add a renderer that outputs plain text instead of HTML, reusing the same parser untouched, which proves the two stages are really separate.