From 33add33c6cb6c0d407933cc71a00da7d2300302c Mon Sep 17 00:00:00 2001 From: luca-chen198 Date: Thu, 9 Jul 2026 17:29:51 +0200 Subject: [PATCH 01/20] =?UTF-8?q?feat(copy):=20AST=E2=86=92HTML=20renderer?= =?UTF-8?q?=20for=20clean=20rich=20copy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walk DocumentAST and emit a clean HTML fragment for the editor's rich-copy path, so selected raw markdown copies as proper rich text instead of the in-place-styled NSTextStorage junk (leaked syntax markers, raw caret line, `---` producing nothing). Test-first: 17 tests pin the output for headings, emphasis, code, links, lists, task lists, blockquotes, thematic breaks (the headline bug →
), GFM tables, and HTML escaping. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Renderer/MarkdownHTMLRenderer.swift | 236 ++++++++++++++++++ .../MarkdownHTMLRendererTests.swift | 143 +++++++++++ 2 files changed, 379 insertions(+) create mode 100644 Sources/MarkdownEngine/Renderer/MarkdownHTMLRenderer.swift create mode 100644 Tests/MarkdownEngineTests/MarkdownHTMLRendererTests.swift diff --git a/Sources/MarkdownEngine/Renderer/MarkdownHTMLRenderer.swift b/Sources/MarkdownEngine/Renderer/MarkdownHTMLRenderer.swift new file mode 100644 index 00000000..420674f9 --- /dev/null +++ b/Sources/MarkdownEngine/Renderer/MarkdownHTMLRenderer.swift @@ -0,0 +1,236 @@ +// +// MarkdownHTMLRenderer.swift +// MarkdownEngine +// +// Created by Luca Chen on 09.07.26. +// +// A clean Markdown → HTML fragment renderer. The editor's NSTextStorage holds +// RAW markdown styled in place (syntax markers merely colored, thematic breaks +// drawn by a layout fragment, tables as image attachments), so the default copy +// serializes junk. This walks the semantic `DocumentAST` and emits a clean HTML +// fragment — a sequence of block elements, no / wrapper — that the +// copy override wraps and places on the pasteboard. +// + +import Foundation + +public enum MarkdownHTMLRenderer { + + /// Render `markdown` to an HTML fragment (block elements joined by newlines). + public static func html(from markdown: String) -> String { + let ns = markdown as NSString + let blocks = DocumentAST.parse(markdown) + let pieces = blocks.compactMap { block(for: $0, ns: ns) } + return pieces.joined(separator: "\n") + } + + // MARK: - Blocks + + private static func block(for node: BlockNode, ns: NSString) -> String? { + switch node { + case .heading(let level, _, _, let inlines): + let l = min(max(level, 1), 6) + return "\(renderInlines(inlines, ns: ns))" + + case .paragraph(_, let inlines): + return "

\(renderInlines(inlines, ns: ns))

" + + case .blockquote(let range, _): + return renderBlockquote(range: range, ns: ns) + + case .list(_, let items): + return renderList(items: items, ns: ns) + + case .codeBlock(let range): + return renderCodeBlock(range: range, ns: ns) + + case .blockLatex(let range): + return "
\(escape(ns.substring(with: range).trimmingCharacters(in: .newlines)))
" + + case .table(let range): + return renderTable(range: range, ns: ns) + + case .thematicBreak: + return "
" + + case .blank: + return nil + } + } + + /// Blockquote inlines are parsed over the block range *including* the `> ` + /// markers, so strip the markers per line and re-parse the content clean. + private static func renderBlockquote(range: NSRange, ns: NSString) -> String { + let raw = ns.substring(with: range) + let stripped = raw + .components(separatedBy: "\n") + .map(stripQuoteMarkers) + .filter { !$0.isEmpty } + .joined(separator: "\n") + let inlines = InlineParser.parse(stripped) + return "
\(renderInlines(inlines, ns: stripped as NSString))
" + } + + /// Drop leading indent (≤3 spaces/tabs) then one-or-more `>` each with an + /// optional trailing space, matching the block styler's marker scan. + private static func stripQuoteMarkers(_ line: String) -> String { + var s = Substring(line) + var indent = 0 + while let c = s.first, c == " " || c == "\t", indent < 3 { s = s.dropFirst(); indent += 1 } + while s.first == ">" { + s = s.dropFirst() + if s.first == " " || s.first == "\t" { s = s.dropFirst() } + } + return String(s) + } + + /// Emit `