Concept project
Markdown Blog Engine with a Hand-written Parser
A concept project with a working demo: a split-pane Markdown editor for a blog, built on a small, safe Markdown parser I wrote for it. It validates frontmatter with zod, generates a table of contents and reading time, blocks unsafe links, flags content problems by line, and previews the Google result and social card.

Overview#
A file-based blog is a good fit for most developer and small business sites: posts are Markdown files in Git, the site is static, and there is no CMS to patch. The weak points are the content pipeline itself. Frontmatter typos break pages quietly, headings get skipped, and a pasted link can carry a javascript: URL.
This concept is the editing side of that pipeline. The live demo is a split-pane editor with a preview, an SEO tab and an HTML tab, all driven by a Markdown parser I wrote for this project, with no Markdown library.
My role#
Everything: the parser, the HTML renderer, the frontmatter reader and schema, the content checks, the editor UI and this write-up. It is a concept project, not client work. The sample posts are written for the demo and the site name is fictional.
The problem#
I wanted three things from the editor:
- Fast, honest feedback. A missing description or an invalid date should show up while writing, with the line number, not as a broken page after deploy.
- Output that cannot run code. Markdown allows raw HTML and any URL scheme. On a blog that accepts guest posts or pasted content, that is an XSS hole unless something filters it.
- SEO checks where the writing happens. Title and description length, a sensible heading outline and a preview of how the post will look in search results and when shared.
Writing the parser myself meant I could make safety part of its design instead of a sanitiser bolted on afterwards.
Approach & architecture#
- sourcethen
- frontmatter splitthen
- YAML subset readerthen
- zod schemathen
- errors with line numbers
- sourcethen
- block pass (lines)then
- inline passthen
- AST
- ASTthen
- React renderer (escaped by React)then
- preview
- ASTthen
- toHtml (escaped by hand)then
- HTML tab / RSS
- ASTthen
- walkthen
- TOC + word count + content warnings
Two passes, one AST. The block pass walks lines and recognises fences, ATX headings, rules, quotes, lists and GFM tables. List items are parsed recursively, so nested lists and multi-paragraph items work. Paragraph text then goes through the inline pass, which handles code spans, escapes, links, images, autolinks, and **strong**, *em* and ~~del~~. The result is a typed tree:
export type Block =
| { type: "heading"; depth: 1 | 2 | 3 | 4 | 5 | 6; id: string; children: Inline[]; line: number }
| { type: "paragraph"; children: Inline[]; line: number }
| { type: "code"; lang: string; value: string; line: number }
| { type: "list"; ordered: boolean; start: number; tight: boolean; items: ListItem[]; line: number }
// … blockquote, table, hrEvery block keeps its source line, which is how a warning such as "Image has no alt text" can jump the cursor to the right place.
Safe by construction. There is no HTML node type at all, so <script> in a post is just text. React escapes text when rendering the preview; toHtml escapes by hand for the export. URLs go through one gate:
export function safeUrl(raw: string): string | null {
const url = raw.trim().replace(/^<|>$/g, "");
// Strip control characters and whitespace that browsers ignore inside schemes ("java\nscript:").
const compact = url.replace(/[\u0000- \u007f-\u009f]/g, "");
if (/^(https?:|mailto:)/i.test(compact)) return url;
if (/^[a-z][a-z0-9+.-]*:/i.test(compact)) return null;
return url;
}A blocked link renders as its text with a "blocked link" marker and is reported as an error.
Frontmatter as a contract. A small reader handles the YAML subset blog frontmatter needs (strings, numbers, booleans, [a, b] and - item lists) and records the line of each key. A strict zod schema then checks lengths, date format, an allow-list of tags and unknown keys, and each zod issue is mapped back to its line.
Key challenges#
- Emphasis rules.
snake_case_wordsmust not turn into italics, and*a **b** c*must nest. Underscores are ignored inside words, and a single*skips over a**pair when looking for its closing marker. - Tight and loose lists. A list without blank lines between items renders items without
<p>tags, like CommonMark; a blank line makes it loose. Nested lists inside tight items still render as blocks. - Heading ids. The TOC links need stable, unique ids. Headings are slugified (accents removed, punctuation dropped) and repeated headings get
-1,-2suffixes. - Typing speed. The whole post is re-parsed on each change.
useDeferredValuelets the textarea stay responsive while the preview catches up. - Honest SEO previews. Google cuts titles by pixel width, not characters. The preview uses about 60 characters for the title and 155 for the description and cuts at a word boundary, and the demo page says it is an approximation.
Tech stack#
- Demo: TypeScript, React 19, Next.js and zod. The parser, HTML renderer and highlighter are my own code with no Markdown or highlighting library. Posts persist in
localStorage. - Production design: posts as
.mdxfiles in Git, loaded at build time by Next.js with the same frontmatter schema, static pages per post and tag, an RSS route handler that uses the HTML output, andBlogPostingJSON-LD from the same data the SEO tab shows.
Results#
- A working editor with a formatting toolbar and shortcuts, a live preview with TOC and reading time, a clickable problems list, Google and social card previews, JSON-LD and escaped HTML export.
- The parser handles headings, emphasis, code with simple highlighting, quotes, nested and task lists, tables, links and images, in under 500 lines of TypeScript (
markdown.ts, counted withwc -l). javascript:,data:and other non-web URL schemes are blocked, including ones split by whitespace or control characters, and raw HTML is never rendered.
This is a concept build without real users or traffic, so there are no SEO or performance results to report.
Try the demo#
Open the Markdown blog engine demo. Things to try:
- Open the third sample post, "Draft", and click each problem to jump to its line. Fix them until the list is clean.
- Type
[test](javascript:alert(1))in any post and look at the preview and the problems list. - Make the title longer than 60 characters and watch the Google snippet truncate in the SEO tab.
- Add a
### Sub-headingand see it appear in the table of contents.
What I would do next#
- Write Vitest tests against a selection of the CommonMark spec examples and record which ones the subset passes.
- Add reference-style links, footnotes and heading anchors with copy buttons.
- Build the static side: tag pages, RSS, sitemap and an Open Graph image route that draws the same card as the preview.
- Add scroll sync between the editor and the preview.



