{
"path": "/3mf6xazbcj22u",
"site": "at://did:plc:lvkhxfkdwqgwrpdek3h3q2gc/site.standard.publication/3m2ojl75sm22f",
"tags": [
"unison",
"markdown",
"parser",
"html-parse"
],
"$type": "site.standard.document",
"title": "Parse HTML into Markdown in Unison",
"content": {
"$type": "pub.leaflet.content",
"pages": [
{
"id": "019c7471-ed4f-7660-a0aa-fb9abde4b804",
"$type": "pub.leaflet.pages.linearDocument",
"blocks": [
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "This is a sequel to my earlier post on parsing HTML in Unison:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.blockquote",
"facets": [
{
"index": {
"byteEnd": 34,
"byteStart": 0
},
"features": [
{
"uri": "https://notes.kaushikc.org/3mdxmxpjkg22c",
"$type": "pub.leaflet.richtext.facet#link"
}
]
}
],
"plaintext": "html-parse - HTML parser in Unison"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.horizontalRule"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 10,
"byteStart": 0
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#bold"
},
{
"uri": "https://share.unison-lang.org/@kaychaks/html-parse",
"$type": "pub.leaflet.richtext.facet#link"
}
]
},
{
"index": {
"byteEnd": 66,
"byteStart": 53
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 166,
"byteStart": 162
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 194,
"byteStart": 180
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#italic"
}
]
}
],
"plaintext": "html-parse now has a second interpreter for the same parseHtmlText pipeline. Same parser, same malformed-HTML recovery, same entrypoint. But instead of producing Html, it produces typed markdown output, with an AST, rendered text, diagnostics, and typed fatal failures."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 37,
"byteStart": 24
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 65,
"byteStart": 56
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
}
],
"plaintext": "The key insight is that parseHtmlText works through the HtmlBuild ability. Swapping the handler is all it takes to get a completely different output type:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.code",
"language": "haskell",
"plaintext": "\n-- existing\n\nbuildHtml do parseHtmlText \"<h1>Hello</h1>\"\n\n\n-- new\n\nbuildMarkdown do parseHtmlText \"<h1>Hello</h1>\"\n\n",
"syntaxHighlightingTheme": "catppuccin-frappe"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 130,
"byteStart": 117
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
}
],
"plaintext": "In place of a new parser or token walker, I decided to piggyback on the the malformed-HTML recovery stack machine in buildHtmlTree"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.header",
"level": 2,
"facets": [],
"plaintext": "Output is a typed envelope, not just Text"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 230,
"byteStart": 226
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 255,
"byteStart": 238
},
"features": [
{
"uri": "https://share.unison-lang.org/@kaychaks/html-parse/code/releases/1.2.0/latest/types/internal/markdown/ConversionResult",
"$type": "pub.leaflet.richtext.facet#link"
}
]
}
],
"plaintext": "I made a major mistake in the HTML parser to not have proper diagnostics and failure modelling. Hence, the first design decision I took for the markdown builder is that the result type is not just the final structured type or Text, it is ConverstionResult:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"url": "https://share.unison-lang.org/@kaychaks/html-parse/code/releases/1.2.0/latest/types/internal/markdown/ConversionResult",
"$type": "pub.leaflet.blocks.iframe",
"height": 404
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 63,
"byteStart": 55
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 113,
"byteStart": 102
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 167,
"byteStart": 164
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 219,
"byteStart": 212
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 227,
"byteStart": 223
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 251,
"byteStart": 247
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
}
],
"plaintext": "This gives callers four independent things to inspect. rendered is empty if there is a fatal failure. diagnostics accumulates recoverable policy events regardless. ast is available even when rendering fails. And failure is None on the happy path, Some with a typed reason when something exceeded a bound."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "The AST itself has block and inline constructors covering the initial supported tags."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"url": "https://share.unison-lang.org/@kaychaks/html-parse/code/releases/1.2.0/latest/types/internal/markdown/Block",
"$type": "pub.leaflet.blocks.iframe",
"height": 516
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"url": "https://share.unison-lang.org/@kaychaks/html-parse/code/releases/1.2.0/latest/types/internal/markdown/Inline",
"$type": "pub.leaflet.blocks.iframe",
"height": 472
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.header",
"level": 2,
"facets": [],
"plaintext": "Opinions encoded as types"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "Markdown, being simple on the surface, gets really weird when specifications like CommonMark or GFM are involved. I might enhance the library later to support all that weirdness, but for now, I am trying to remain dead simple."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "Hence, rather than leaving tag behaviour implicit, the library classifies every HTML tag into one of five classes:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.code",
"language": "haskell",
"plaintext": "\ntype TagClass\n\n = SemanticTag -- h1..h6, p, a, em, strong, ul, ol, li, code, pre, blockquote, hr, br\n\n | UnsafeTag -- script, style\n\n | DeferredTag -- img, table, video, and table structure tags\n\n | TransparentTag -- known wrapper tags with no markdown equivalent\n\n | UnknownTag -- everything else\n\n",
"syntaxHighlightingTheme": "catppuccin-frappe"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "Policy dispatch is then a pure function of config and class:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.code",
"language": "haskell",
"plaintext": "\ntype PolicyAction = \n EmitDeferredRawHtml \n | SuppressDeferredRawHtml\n\n\npolicy.apply : MarkdownPolicy -> TagClass -> PolicyAction\n\npolicy.apply policy = cases\n\n DeferredTag -> \n if allowDeferredRawHtml policy then \n EmitDeferredRawHtml \n else \n SuppressDeferredRawHtml\n\n _ -> EmitDeferredRawHtml\n\n",
"syntaxHighlightingTheme": "catppuccin-frappe"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 9,
"byteStart": 0
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 36,
"byteStart": 24
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 99,
"byteStart": 85
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 114,
"byteStart": 104
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 153,
"byteStart": 142
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
}
],
"plaintext": "UnsafeTag never reaches policy.apply because the subtree is dropped before dispatch. TransparentTag and UnknownTag are always unwrapped. Only DeferredTag is configurable."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "Failures are also an explicit closed enum:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"url": "https://share.unison-lang.org/@kaychaks/html-parse/code/releases/1.2.0/latest/types/internal/markdown/FailureReason",
"$type": "pub.leaflet.blocks.iframe",
"height": 446
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "There is no generic \"conversion failed\" variant. Each bound has its own constructor so callers can match on the exact reason without parsing strings."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.header",
"level": 2,
"facets": [],
"plaintext": "The guard pipeline"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "Conversion runs in three guarded stages. Each stage either short-circuits with a typed failure or passes control forward:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": ""
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.image",
"image": {
"$type": "blob",
"ref": {
"$link": "bafkreiekhuqrzmjdv4oxtlfx7oqj4czsuxg3cyfpvbp57n5xoo4cxmqctq"
},
"mimeType": "image/png",
"size": 140204
},
"aspectRatio": {
"width": 1262,
"height": 1628
}
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": ""
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 10,
"byteStart": 0
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 80,
"byteStart": 68
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 149,
"byteStart": 138
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 246,
"byteStart": 238
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 292,
"byteStart": 289
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 308,
"byteStart": 297
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
}
],
"plaintext": "guardInput catches token-count violations before any tree is built. guardConvert catches structural violations after the AST is produced. guardRender catches output-size violations after rendering. A failure at any stage returns an empty rendered string, but earlier stages still populate ast and diagnostics so callers have something to inspect."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "The conversion stage also emits diagnostics for every recoverable policy event, regardless of whether the overall result is a failure. These are warnings, not errors, and they do not short-circuit the pipeline."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.header",
"level": 2,
"facets": [],
"plaintext": "Wrapping up"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "So let's look at some examples of how all those design and implementation concepts work in practice."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.header",
"level": 3,
"facets": [],
"plaintext": "Happy paths"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": ""
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.code",
"language": "haskell",
"plaintext": "\nbuildMarkdown do parseHtmlText \"<h1>Hello</h1><p>world</p>\"\n\n⧨\n\nConversionResult\n\n (MarkdownAst\n\n [ Heading 1 [PlainText \"Hello\"]\n\n , Paragraph [PlainText \"world\"]\n\n ])\n\n \"# Hello\\n\\nworld\\n\\n\"\n\n []\n\n None\n\n",
"syntaxHighlightingTheme": "catppuccin-frappe"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "Inline formatting with a link:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.code",
"language": "haskell",
"plaintext": "\nbuildMarkdown do\n\n parseHtmlText \"<p>Some <strong>bold</strong> and <em>italic</em> with <a href=\\\"https://example.com\\\">docs</a>.</p>\"\n\n⧨\n\nConversionResult\n\n (MarkdownAst\n\n [ Paragraph\n\n [ PlainText \"Some \"\n\n , Strong [PlainText \"bold\"]\n\n , PlainText \" and \"\n\n , Emphasis [PlainText \"italic\"]\n\n , PlainText \" with \"\n\n , Link \"https://example.com\" \"\" [PlainText \"docs\"]\n\n , PlainText \".\"\n\n ]\n\n ])\n\n \"Some **bold** and *italic* with [docs](https://example.com).\\n\\n\"\n\n []\n\n None\n\n",
"syntaxHighlightingTheme": "catppuccin-frappe"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.header",
"level": 3,
"facets": [],
"plaintext": "Unhappy paths"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": ""
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [],
"plaintext": "Unsafe content is dropped and does not appear in the rendered output:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.code",
"language": "haskell",
"plaintext": "\nresult = buildMarkdown do\n\n parseHtmlText \"<p>ok</p><script>alert(1)</script><p>end</p>\"\n\nConversionResult.rendered result\n\n⧨\n\n\"ok\\n\\nend\\n\\n\"\n\n",
"syntaxHighlightingTheme": "catppuccin-frappe"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 8,
"byteStart": 0
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
}
],
"plaintext": "alert(1) never makes it into output. There is no diagnostic for this: unsafe subtrees are silently tombstoned by design because emitting a warning for script tags in real-world HTML would be overwhelming noise."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 64,
"byteStart": 0
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#bold"
}
]
}
],
"plaintext": "Deferred tags emit raw HTML and a diagnostic with a source path:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.code",
"language": "haskell",
"plaintext": "\nresult = buildMarkdown do parseHtmlText \"<custom><img src=\\\"x\\\"/></custom>\"\n\ndiagnostics result\n\n⧨\n\n[ Diagnostic PolicyStage DeferredToRawHtml \"img\" (SourceRef \"img\" [0, 0])\n\n, Diagnostic PolicyStage UnknownTagUnwrapped \"custom\" (SourceRef \"custom\" [0])\n\n]\n\n",
"syntaxHighlightingTheme": "catppuccin-frappe"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 13,
"byteStart": 4
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 158,
"byteStart": 152
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
}
],
"plaintext": "The SourceRef carries the tag name and the index path through the tree, so callers know exactly where in the input each policy event occurred. The path [0, 0] means the first child of the first root node."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 42,
"byteStart": 0
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#bold"
}
]
}
],
"plaintext": "Hard limit produces a typed fatal failure:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.code",
"language": "haskell",
"plaintext": "\nlimits = maxInputTokens.set 1 MarkdownLimits.defaults\n\nconfig = MarkdownLimits.set limits MarkdownConfig.defaults\n\nresult = buildMarkdownWith config do parseHtmlText \"<p>a</p><p>b</p>\"\n\n(ConversionResult.rendered result, ConversionResult.failure result)\n\n⧨\n\n( \"\"\n\n, Some (Failure InputTokenLimitExceeded 1 6 \"input token count exceeded maxInputTokens\")\n\n)\n\n",
"syntaxHighlightingTheme": "catppuccin-frappe"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 8,
"byteStart": 0
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 26,
"byteStart": 19
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
}
],
"plaintext": "rendered is empty. failure tells you exactly which limit was breached, what the configured limit was, and what the actual count was."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 65,
"byteStart": 0
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#bold"
}
]
}
],
"plaintext": "Policy toggle changes behavior without touching conversion logic:"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.code",
"language": "haskell",
"plaintext": "\nstrict = MarkdownPolicy.allowDeferredRawHtml.set false MarkdownPolicy.defaults\n\nconfig = MarkdownPolicy.set strict MarkdownConfig.defaults\n\nresult = buildMarkdownWith config do parseHtmlText \"<p>text</p><img src=\\\"x\\\"/>\"\n\nConversionResult.rendered result\n\n⧨\n\n\"text\\n\\n\"\n\n",
"syntaxHighlightingTheme": "catppuccin-frappe"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 33,
"byteStart": 5
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
},
{
"index": {
"byteEnd": 44,
"byteStart": 39
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#code"
}
]
}
],
"plaintext": "With allowDeferredRawHtml = false, the <img> is suppressed entirely. The diagnostic still fires."
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.horizontalRule"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.blockquote",
"facets": [
{
"index": {
"byteEnd": 57,
"byteStart": 55
},
"features": [
{
"uri": "https://share.unison-lang.org/@kaychaks/html-parse/contributions/2",
"$type": "pub.leaflet.richtext.facet#link"
}
]
}
],
"plaintext": "All changes for this release are visible via this PR - #2"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.horizontalRule"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 101,
"byteStart": 89
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#italic"
}
]
},
{
"index": {
"byteEnd": 116,
"byteStart": 103
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#bold"
}
]
}
],
"plaintext": "This is the next building block for my ultimate intent: an AT Protocol-based RSS reader. More to come, happy hacking 🤘"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.horizontalRule"
}
},
{
"$type": "pub.leaflet.pages.linearDocument#block",
"block": {
"$type": "pub.leaflet.blocks.text",
"facets": [
{
"index": {
"byteEnd": 34,
"byteStart": 0
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#highlight"
},
{
"$type": "pub.leaflet.richtext.facet#italic"
}
]
},
{
"index": {
"byteEnd": 502,
"byteStart": 34
},
"features": [
{
"$type": "pub.leaflet.richtext.facet#italic"
}
]
}
],
"textSize": "small",
"plaintext": "If you have read this far, thanks! I am available for contract, temporary, freelance, and advisory roles where I can work closely with stakeholders or as an IC to solve hard product and engineering problems quickly. I am open to working in a variety of languages - Haskell, Rust, TypeScript/JavaScript, and, of course, Unison. I am fun to work with and have almost 20 years of experience, with expertise in functional programming, distributed systems, cloud-native architectures, and agentic workflows."
}
}
]
}
]
},
"bskyPostRef": {
"cid": "bafyreiec5k4i7atznghqko2fkm35dvee4bvzo2juresogzlcsudqsejnte",
"uri": "at://did:plc:lvkhxfkdwqgwrpdek3h3q2gc/app.bsky.feed.post/3mf6xb7sa2k2u",
"commit": {
"cid": "bafyreiezykrwmj2y4agaaflfjcm72jd5pch5yv6gvny264qkbdf2qml4gm",
"rev": "3mf6xb7uvsx2c"
},
"validationStatus": "valid"
},
"description": "New version of `html-parse` library",
"publishedAt": "2026-02-19T06:20:41.935Z"
}