feat: blockquotes (#18)

* chore: add blockquote lexicon

* feat: add support for blockquote block

* test: add tests for parseTextBlock function

* test: add more tests for parse functions

* chore: changeset

authored by besaid.zone and committed by GitHub f9201531 d3f99313

Changed files
+438 -49
.changeset
lexicons
pub
leaflet
lib
lexicons
types
pub
leaflet
tests
+5
.changeset/tired-suits-double.md
··· 1 + --- 2 + "@nulfrost/leaflet-loader-astro": minor 3 + --- 4 + 5 + Add support for blockquotes
+22
lexicons/pub/leaflet/blocks/blockquote.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.blocks.blockquote", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "required": ["plaintext"], 8 + "properties": { 9 + "plaintext": { 10 + "type": "string" 11 + }, 12 + "facets": { 13 + "type": "array", 14 + "items": { 15 + "type": "ref", 16 + "ref": "pub.leaflet.richtext.facet" 17 + } 18 + } 19 + } 20 + } 21 + } 22 + }
+1
lib/lexicons/index.ts
··· 1 1 export * as ComAtprotoRepoStrongRef from "./types/com/atproto/repo/strongRef.js"; 2 + export * as PubLeafletBlocksBlockquote from "./types/pub/leaflet/blocks/blockquote.js"; 2 3 export * as PubLeafletBlocksCode from "./types/pub/leaflet/blocks/code.js"; 3 4 export * as PubLeafletBlocksHeader from "./types/pub/leaflet/blocks/header.js"; 4 5 export * as PubLeafletBlocksHorizontalRule from "./types/pub/leaflet/blocks/horizontalRule.js";
+23
lib/lexicons/types/pub/leaflet/blocks/blockquote.ts
··· 1 + import type {} from "@atcute/lexicons"; 2 + import * as v from "@atcute/lexicons/validations"; 3 + import * as PubLeafletRichtextFacet from "../richtext/facet.js"; 4 + 5 + const _mainSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("pub.leaflet.blocks.blockquote"), 8 + ), 9 + get facets() { 10 + return /*#__PURE__*/ v.optional( 11 + /*#__PURE__*/ v.array(PubLeafletRichtextFacet.mainSchema), 12 + ); 13 + }, 14 + plaintext: /*#__PURE__*/ v.string(), 15 + }); 16 + 17 + type main$schematype = typeof _mainSchema; 18 + 19 + export interface mainSchema extends main$schematype {} 20 + 21 + export const mainSchema = _mainSchema as mainSchema; 22 + 23 + export interface Main extends v.InferInput<typeof mainSchema> {}
+1 -1
lib/lexicons/types/pub/leaflet/blocks/image.ts
··· 14 14 ), 15 15 alt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.string()), 16 16 get aspectRatio() { 17 - return _aspectRatioSchema; 17 + return aspectRatioSchema; 18 18 }, 19 19 image: /*#__PURE__*/ v.blob(), 20 20 });
+60 -48
lib/utils.ts
··· 4 4 import katex from "katex"; 5 5 import sanitizeHTML from "sanitize-html"; 6 6 import { 7 + PubLeafletBlocksBlockquote, 7 8 PubLeafletBlocksCode, 8 9 PubLeafletBlocksHeader, 9 10 PubLeafletBlocksHorizontalRule, ··· 182 183 "hr", 183 184 "div", 184 185 "span", 186 + "blockquote", 185 187 ], 186 188 selfClosing: ["img"], 187 189 }); ··· 244 246 } 245 247 } 246 248 247 - function parseBlocks({ 249 + export function parseTextBlock(block: PubLeafletBlocksText.Main) { 250 + let html = ""; 251 + const rt = new RichText({ 252 + text: block.plaintext, 253 + facets: block.facets || [], 254 + }); 255 + const children = []; 256 + for (const segment of rt.segments()) { 257 + const link = segment.facet?.find( 258 + (segment) => segment.$type === "pub.leaflet.richtext.facet#link", 259 + ); 260 + const isBold = segment.facet?.find( 261 + (segment) => segment.$type === "pub.leaflet.richtext.facet#bold", 262 + ); 263 + const isCode = segment.facet?.find( 264 + (segment) => segment.$type === "pub.leaflet.richtext.facet#code", 265 + ); 266 + const isStrikethrough = segment.facet?.find( 267 + (segment) => segment.$type === "pub.leaflet.richtext.facet#strikethrough", 268 + ); 269 + const isUnderline = segment.facet?.find( 270 + (segment) => segment.$type === "pub.leaflet.richtext.facet#underline", 271 + ); 272 + const isItalic = segment.facet?.find( 273 + (segment) => segment.$type === "pub.leaflet.richtext.facet#italic", 274 + ); 275 + if (isCode) { 276 + children.push(`<pre><code>${segment.text}</code></pre>`); 277 + } else if (link) { 278 + children.push( 279 + `<a href="${link.uri}" target="_blank" rel="noopener noreferrer">${segment.text}</a>`, 280 + ); 281 + } else if (isBold) { 282 + children.push(`<b>${segment.text}</b>`); 283 + } else if (isStrikethrough) { 284 + children.push(`<s>${segment.text}</s>`); 285 + } else if (isUnderline) { 286 + children.push( 287 + `<span style="text-decoration:underline;">${segment.text}</span>`, 288 + ); 289 + } else if (isItalic) { 290 + children.push(`<i>${segment.text}</i>`); 291 + } else { 292 + children.push(`${segment.text}`); 293 + } 294 + } 295 + html += `<p>${children.join("")}</p>`; 296 + 297 + return html.trim(); 298 + } 299 + 300 + export function parseBlocks({ 248 301 block, 249 302 did, 250 303 }: { ··· 254 307 let html = ""; 255 308 256 309 if (is(PubLeafletBlocksText.mainSchema, block.block)) { 257 - const rt = new RichText({ 258 - text: block.block.plaintext, 259 - facets: block.block.facets || [], 260 - }); 261 - const children = []; 262 - for (const segment of rt.segments()) { 263 - const link = segment.facet?.find( 264 - (segment) => segment.$type === "pub.leaflet.richtext.facet#link", 265 - ); 266 - const isBold = segment.facet?.find( 267 - (segment) => segment.$type === "pub.leaflet.richtext.facet#bold", 268 - ); 269 - const isCode = segment.facet?.find( 270 - (segment) => segment.$type === "pub.leaflet.richtext.facet#code", 271 - ); 272 - const isStrikethrough = segment.facet?.find( 273 - (segment) => 274 - segment.$type === "pub.leaflet.richtext.facet#strikethrough", 275 - ); 276 - const isUnderline = segment.facet?.find( 277 - (segment) => segment.$type === "pub.leaflet.richtext.facet#underline", 278 - ); 279 - const isItalic = segment.facet?.find( 280 - (segment) => segment.$type === "pub.leaflet.richtext.facet#italic", 281 - ); 282 - if (isCode) { 283 - children.push(`<pre><code>${segment.text}</code></pre>`); 284 - } else if (link) { 285 - children.push( 286 - `<a href="${link.uri}" target="_blank" rel="noopener noreferrer">${segment.text}</a>`, 287 - ); 288 - } else if (isBold) { 289 - children.push(`<b>${segment.text}</b>`); 290 - } else if (isStrikethrough) { 291 - children.push(`<s>${segment.text}</s>`); 292 - } else if (isUnderline) { 293 - children.push( 294 - `<span style="text-decoration:underline;">${segment.text}</span>`, 295 - ); 296 - } else if (isItalic) { 297 - children.push(`<i>${segment.text}</i>`); 298 - } else { 299 - children.push(`${segment.text}`); 300 - } 301 - } 302 - html += `<p>${children.join("")}</p>`; 310 + html += parseTextBlock(block.block); 303 311 } 304 312 305 313 if (is(PubLeafletBlocksHeader.mainSchema, block.block)) { ··· 343 351 html += `<div><img src="https://cdn.bsky.app/img/feed_fullsize/plain/${did}/${block.block.image.ref.$link}@jpeg" height="${block.block.aspectRatio.height}" width="${block.block.aspectRatio.width}" alt="${block.block.alt}" /></div>`; 344 352 } 345 353 354 + if (is(PubLeafletBlocksBlockquote.mainSchema, block.block)) { 355 + html += `<blockquote>${parseTextBlock(block.block)}</blockquote>`; 356 + } 357 + 346 358 return html.trim(); 347 359 } 348 360 349 - function renderListItem({ 361 + export function renderListItem({ 350 362 item, 351 363 did, 352 364 }: {
+189
tests/parse-blocks.test.ts
··· 1 + import { expect, test } from "vitest"; 2 + import { parseBlocks } from "../lib/utils"; 3 + 4 + test("should correctly parse an h1 block to an h2 tag", () => { 5 + const html = parseBlocks({ 6 + block: { 7 + $type: "pub.leaflet.pages.linearDocument#block", 8 + block: { 9 + $type: "pub.leaflet.blocks.header", 10 + level: 1, 11 + facets: [], 12 + plaintext: "heading 1", 13 + }, 14 + }, 15 + did: "did:plc:qttsv4e7pu2jl3ilanfgc3zn", 16 + }); 17 + 18 + expect(html).toMatchInlineSnapshot(`"<h2>heading 1</h2>"`); 19 + }); 20 + 21 + test("should correctly parse an h2 block to an h3 tag", () => { 22 + const html = parseBlocks({ 23 + block: { 24 + $type: "pub.leaflet.pages.linearDocument#block", 25 + block: { 26 + $type: "pub.leaflet.blocks.header", 27 + level: 2, 28 + facets: [], 29 + plaintext: "heading 2", 30 + }, 31 + }, 32 + did: "did:plc:qttsv4e7pu2jl3ilanfgc3zn", 33 + }); 34 + 35 + expect(html).toMatchInlineSnapshot(`"<h3>heading 2</h3>"`); 36 + }); 37 + 38 + test("should correctly parse an h3 block to an h4 tag", () => { 39 + const html = parseBlocks({ 40 + block: { 41 + $type: "pub.leaflet.pages.linearDocument#block", 42 + block: { 43 + $type: "pub.leaflet.blocks.header", 44 + level: 3, 45 + facets: [], 46 + plaintext: "heading 3", 47 + }, 48 + }, 49 + did: "did:plc:qttsv4e7pu2jl3ilanfgc3zn", 50 + }); 51 + 52 + expect(html).toMatchInlineSnapshot(`"<h4>heading 3</h4>"`); 53 + }); 54 + 55 + test("should correctly parse a block with no level to an h6 tag", () => { 56 + const html = parseBlocks({ 57 + block: { 58 + $type: "pub.leaflet.pages.linearDocument#block", 59 + block: { 60 + $type: "pub.leaflet.blocks.header", 61 + facets: [], 62 + plaintext: "heading 6", 63 + }, 64 + }, 65 + did: "did:plc:qttsv4e7pu2jl3ilanfgc3zn", 66 + }); 67 + 68 + expect(html).toMatchInlineSnapshot(`"<h6>heading 6</h6>"`); 69 + }); 70 + 71 + test("should correctly parse an unordered list block", () => { 72 + const html = parseBlocks({ 73 + block: { 74 + $type: "pub.leaflet.pages.linearDocument#block", 75 + block: { 76 + $type: "pub.leaflet.blocks.unorderedList", 77 + children: [ 78 + { 79 + $type: "pub.leaflet.blocks.unorderedList#listItem", 80 + content: { 81 + $type: "pub.leaflet.blocks.text", 82 + facets: [ 83 + { 84 + index: { 85 + byteEnd: 18, 86 + byteStart: 0, 87 + }, 88 + features: [ 89 + { 90 + uri: "https://pdsls.dev/", 91 + $type: "pub.leaflet.richtext.facet#link", 92 + }, 93 + ], 94 + }, 95 + { 96 + index: { 97 + byteEnd: 28, 98 + byteStart: 22, 99 + }, 100 + features: [ 101 + { 102 + uri: "https://bsky.app/profile/juli.ee", 103 + $type: "pub.leaflet.richtext.facet#link", 104 + }, 105 + ], 106 + }, 107 + ], 108 + plaintext: "https://pdsls.dev/ by Juliet", 109 + }, 110 + children: [], 111 + }, 112 + { 113 + $type: "pub.leaflet.blocks.unorderedList#listItem", 114 + content: { 115 + $type: "pub.leaflet.blocks.text", 116 + facets: [ 117 + { 118 + index: { 119 + byteEnd: 34, 120 + byteStart: 0, 121 + }, 122 + features: [ 123 + { 124 + uri: "https://github.com/mary-ext/atcute", 125 + $type: "pub.leaflet.richtext.facet#link", 126 + }, 127 + ], 128 + }, 129 + { 130 + index: { 131 + byteEnd: 42, 132 + byteStart: 38, 133 + }, 134 + features: [ 135 + { 136 + uri: "https://bsky.app/profile/mary.my.id", 137 + $type: "pub.leaflet.richtext.facet#link", 138 + }, 139 + ], 140 + }, 141 + ], 142 + plaintext: "https://github.com/mary-ext/atcute by mary", 143 + }, 144 + children: [], 145 + }, 146 + { 147 + $type: "pub.leaflet.blocks.unorderedList#listItem", 148 + content: { 149 + $type: "pub.leaflet.blocks.text", 150 + facets: [ 151 + { 152 + index: { 153 + byteEnd: 27, 154 + byteStart: 0, 155 + }, 156 + features: [ 157 + { 158 + uri: "https://www.microcosm.blue/", 159 + $type: "pub.leaflet.richtext.facet#link", 160 + }, 161 + ], 162 + }, 163 + { 164 + index: { 165 + byteEnd: 35, 166 + byteStart: 31, 167 + }, 168 + features: [ 169 + { 170 + uri: "https://bsky.app/profile/bad-example.com", 171 + $type: "pub.leaflet.richtext.facet#link", 172 + }, 173 + ], 174 + }, 175 + ], 176 + plaintext: "https://www.microcosm.blue/ by phil", 177 + }, 178 + children: [], 179 + }, 180 + ], 181 + }, 182 + }, 183 + did: "did:plc:qttsv4e7pu2jl3ilanfgc3zn", 184 + }); 185 + 186 + expect(html).toMatchInlineSnapshot( 187 + `"<ul><li><p><a href="https://pdsls.dev/" target="_blank" rel="noopener noreferrer">https://pdsls.dev/</a> by <a href="https://bsky.app/profile/juli.ee" target="_blank" rel="noopener noreferrer">Juliet</a></p></li><li><p><a href="https://github.com/mary-ext/atcute" target="_blank" rel="noopener noreferrer">https://github.com/mary-ext/atcute</a> by <a href="https://bsky.app/profile/mary.my.id" target="_blank" rel="noopener noreferrer">mary</a></p></li><li><p><a href="https://www.microcosm.blue/" target="_blank" rel="noopener noreferrer">https://www.microcosm.blue/</a> by <a href="https://bsky.app/profile/bad-example.com" target="_blank" rel="noopener noreferrer">phil</a></p></li></ul>"`, 188 + ); 189 + });
+133
tests/parse-text-blocks.test.ts
··· 1 + import { expect, test } from "vitest"; 2 + import { parseTextBlock } from "../lib/utils"; 3 + 4 + test("should correctly parse a text block without facets", () => { 5 + const html = parseTextBlock({ 6 + $type: "pub.leaflet.blocks.text", 7 + facets: [], 8 + plaintext: "just plaintext no facets", 9 + }); 10 + 11 + expect(html).toMatchInlineSnapshot(`"<p>just plaintext no facets</p>"`); 12 + }); 13 + 14 + test("should correctly parse a text block with bolded text", () => { 15 + const html = parseTextBlock({ 16 + $type: "pub.leaflet.blocks.text", 17 + facets: [ 18 + { 19 + index: { 20 + byteEnd: 11, 21 + byteStart: 0, 22 + }, 23 + features: [ 24 + { 25 + $type: "pub.leaflet.richtext.facet#bold", 26 + }, 27 + ], 28 + }, 29 + ], 30 + plaintext: "bolded text with some plaintext", 31 + }); 32 + 33 + expect(html).toMatchInlineSnapshot( 34 + `"<p><b>bolded text</b> with some plaintext</p>"`, 35 + ); 36 + }); 37 + 38 + test("should correctly parse a text block with an inline link", () => { 39 + const html = parseTextBlock({ 40 + $type: "pub.leaflet.blocks.text", 41 + facets: [ 42 + { 43 + index: { 44 + byteEnd: 27, 45 + byteStart: 0, 46 + }, 47 + features: [ 48 + { 49 + uri: "https://blacksky.community/", 50 + $type: "pub.leaflet.richtext.facet#link", 51 + }, 52 + ], 53 + }, 54 + ], 55 + plaintext: "https://blacksky.community/", 56 + }); 57 + 58 + expect(html).toMatchInlineSnapshot( 59 + `"<p><a href="https://blacksky.community/" target="_blank" rel="noopener noreferrer">https://blacksky.community/</a></p>"`, 60 + ); 61 + }); 62 + 63 + test("should correctly parse a text block with strikethrough text", () => { 64 + const html = parseTextBlock({ 65 + $type: "pub.leaflet.blocks.text", 66 + facets: [ 67 + { 68 + index: { 69 + byteEnd: 13, 70 + byteStart: 0, 71 + }, 72 + features: [ 73 + { 74 + $type: "pub.leaflet.richtext.facet#strikethrough", 75 + }, 76 + ], 77 + }, 78 + ], 79 + plaintext: "strikethrough text with some plaintext", 80 + }); 81 + 82 + expect(html).toMatchInlineSnapshot( 83 + `"<p><s>strikethrough</s> text with some plaintext</p>"`, 84 + ); 85 + }); 86 + 87 + test("should correctly parse a text block with underlined text", () => { 88 + const html = parseTextBlock({ 89 + $type: "pub.leaflet.blocks.text", 90 + facets: [ 91 + { 92 + index: { 93 + byteEnd: 10, 94 + byteStart: 0, 95 + }, 96 + features: [ 97 + { 98 + $type: "pub.leaflet.richtext.facet#underline", 99 + }, 100 + ], 101 + }, 102 + ], 103 + plaintext: "underlined text with some plaintext", 104 + }); 105 + 106 + expect(html).toMatchInlineSnapshot( 107 + `"<p><span style="text-decoration:underline;">underlined</span> text with some plaintext</p>"`, 108 + ); 109 + }); 110 + 111 + test("should correctly parse a text block with italicized text", () => { 112 + const html = parseTextBlock({ 113 + $type: "pub.leaflet.blocks.text", 114 + facets: [ 115 + { 116 + index: { 117 + byteEnd: 10, 118 + byteStart: 0, 119 + }, 120 + features: [ 121 + { 122 + $type: "pub.leaflet.richtext.facet#italic", 123 + }, 124 + ], 125 + }, 126 + ], 127 + plaintext: "italicized text with some plaintext", 128 + }); 129 + 130 + expect(html).toMatchInlineSnapshot( 131 + `"<p><i>italicized</i> text with some plaintext</p>"`, 132 + ); 133 + });
+4
tests/uri-to-rkey.test.ts
··· 12 12 ), 13 13 ).toBe("3lvl7m6jd4s2e"); 14 14 }); 15 + 16 + test("should not pass if invalid uri is passed in", () => { 17 + expect(() => uriToRkey("invalid")).toThrowError(/failed to get rkey/i); 18 + });