👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

just go for it on new style

+2502 -3361
+2 -1
biome.json
··· 14 14 "**/index.html", 15 15 "**/vite.config.ts", 16 16 "!**/src/routeTree.gen.ts", 17 - "!**/src/styles.css" 17 + "!**/src/styles.css", 18 + "!**/src/lib/lexicons" 18 19 ] 19 20 }, 20 21 "formatter": {
+5 -5
lexicons/com/deckbelcher/deck/list.json
··· 20 20 "maxGraphemes": 32, 21 21 "description": "Format of the deck (e.g., \"commander\", \"cube\", \"pauper\")." 22 22 }, 23 + "primer": { 24 + "type": "ref", 25 + "ref": "com.deckbelcher.richtext#document", 26 + "description": "Deck primer with strategy, combos, and card choices." 27 + }, 23 28 "cards": { 24 29 "type": "array", 25 30 "items": { ··· 27 32 "ref": "#card" 28 33 }, 29 34 "description": "Array of cards in the decklist." 30 - }, 31 - "primer": { 32 - "type": "ref", 33 - "ref": "com.deckbelcher.richtext", 34 - "description": "Deck primer with strategy, combos, and card choices." 35 35 }, 36 36 "createdAt": { 37 37 "type": "string",
+72 -3
lexicons/com/deckbelcher/richtext.json
··· 9 9 "type": "string", 10 10 "maxLength": 500000, 11 11 "maxGraphemes": 50000, 12 - "description": "The text content." 12 + "description": "The plain text content (no markdown symbols)." 13 + }, 14 + "facets": { 15 + "type": "array", 16 + "items": { 17 + "type": "ref", 18 + "ref": "com.deckbelcher.richtext.facet" 19 + }, 20 + "description": "Annotations of text (mentions, URLs, hashtags, formatting, etc)." 21 + } 22 + }, 23 + "description": "A single paragraph of rich text with optional facet annotations.\nUsed for descriptions and other short formatted text." 24 + }, 25 + "document": { 26 + "type": "object", 27 + "properties": { 28 + "content": { 29 + "type": "array", 30 + "items": { 31 + "type": "union", 32 + "refs": [ 33 + "#paragraphBlock", 34 + "#headingBlock" 35 + ] 36 + }, 37 + "description": "Array of blocks (paragraphs, headings, etc)." 38 + } 39 + }, 40 + "description": "A multi-block rich text document.\nUsed for primers and other long-form content.", 41 + "required": [ 42 + "content" 43 + ] 44 + }, 45 + "paragraphBlock": { 46 + "type": "object", 47 + "properties": { 48 + "text": { 49 + "type": "string", 50 + "maxLength": 500000, 51 + "maxGraphemes": 50000, 52 + "description": "The plain text content (no markdown symbols)." 53 + }, 54 + "facets": { 55 + "type": "array", 56 + "items": { 57 + "type": "ref", 58 + "ref": "com.deckbelcher.richtext.facet" 59 + }, 60 + "description": "Annotations of text (formatting, mentions, links, etc)." 61 + } 62 + }, 63 + "description": "A paragraph block with text and optional facets." 64 + }, 65 + "headingBlock": { 66 + "type": "object", 67 + "properties": { 68 + "level": { 69 + "type": "integer", 70 + "minimum": 1, 71 + "maximum": 6, 72 + "description": "Heading level (1-6)." 73 + }, 74 + "text": { 75 + "type": "string", 76 + "maxLength": 10000, 77 + "maxGraphemes": 1000, 78 + "description": "The plain text content (no markdown symbols)." 13 79 }, 14 80 "facets": { 15 81 "type": "array", ··· 17 83 "type": "ref", 18 84 "ref": "com.deckbelcher.richtext.facet" 19 85 }, 20 - "description": "Annotations of text (mentions, URLs, hashtags, card references, etc)." 86 + "description": "Annotations of text (formatting, mentions, links, etc)." 21 87 } 22 88 }, 23 - "description": "Rich text content with optional facet annotations.\nUsed for primers, descriptions, and other formatted text." 89 + "description": "A heading block with level, text, and optional facets.", 90 + "required": [ 91 + "level" 92 + ] 24 93 } 25 94 } 26 95 }
+17
package-lock.json
··· 11 11 "@atcute/client": "^4.0.5", 12 12 "@atcute/identity-resolver": "^1.1.4", 13 13 "@atcute/oauth-browser-client": "^2.0.1", 14 + "@braintree/sanitize-url": "^7.1.1", 14 15 "@cloudflare/vite-plugin": "^1.13.19", 15 16 "@dnd-kit/core": "^6.3.1", 16 17 "@dnd-kit/utilities": "^3.2.2", ··· 37 38 "prosemirror-view": "^1.41.4", 38 39 "react": "^19.2.0", 39 40 "react-dom": "^19.2.0", 41 + "react-error-boundary": "^6.0.3", 40 42 "recharts": "^3.6.0", 41 43 "sonner": "^2.0.7", 42 44 "tailwindcss": "^4.0.6", ··· 885 887 "engines": { 886 888 "node": ">=14.21.3" 887 889 } 890 + }, 891 + "node_modules/@braintree/sanitize-url": { 892 + "version": "7.1.1", 893 + "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz", 894 + "integrity": "sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==", 895 + "license": "MIT" 888 896 }, 889 897 "node_modules/@cloudflare/kv-asset-handler": { 890 898 "version": "0.4.0", ··· 7590 7598 }, 7591 7599 "peerDependencies": { 7592 7600 "react": "^19.2.0" 7601 + } 7602 + }, 7603 + "node_modules/react-error-boundary": { 7604 + "version": "6.0.3", 7605 + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.3.tgz", 7606 + "integrity": "sha512-5guqn2UYpCFjE8UDMA8J7Kke+YSGBFrKQRJb3XdcaGZXYINZfQXgBt3ifY6MvjkN7QROc5A8zclyoSCwrcRUKw==", 7607 + "license": "MIT", 7608 + "peerDependencies": { 7609 + "react": "^18.0.0 || ^19.0.0" 7593 7610 } 7594 7611 }, 7595 7612 "node_modules/react-is": {
+2
package.json
··· 25 25 "@atcute/client": "^4.0.5", 26 26 "@atcute/identity-resolver": "^1.1.4", 27 27 "@atcute/oauth-browser-client": "^2.0.1", 28 + "@braintree/sanitize-url": "^7.1.1", 28 29 "@cloudflare/vite-plugin": "^1.13.19", 29 30 "@dnd-kit/core": "^6.3.1", 30 31 "@dnd-kit/utilities": "^3.2.2", ··· 51 52 "prosemirror-view": "^1.41.4", 52 53 "react": "^19.2.0", 53 54 "react-dom": "^19.2.0", 55 + "react-error-boundary": "^6.0.3", 54 56 "recharts": "^3.6.0", 55 57 "sonner": "^2.0.7", 56 58 "tailwindcss": "^4.0.6",
+54 -28
src/components/deck/PrimerSection.tsx
··· 1 1 import { ChevronDown, ChevronUp, Pencil } from "lucide-react"; 2 - import type { RefObject } from "react"; 3 - import { useState } from "react"; 4 - import { RichTextEditor } from "@/components/richtext/RichTextEditor"; 5 - import { type ParseResult, RichText } from "@/lib/richtext"; 2 + import { useCallback, useMemo, useState } from "react"; 3 + import { ProseMirrorEditor } from "@/components/richtext/ProseMirrorEditor"; 4 + import { RichtextRenderer } from "@/components/richtext/RichtextRenderer"; 5 + import { schema } from "@/components/richtext/schema"; 6 + import type { Document } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 7 + import { lexiconToTree, treeToLexicon } from "@/lib/richtext-convert"; 8 + import { type PMDocJSON, useProseMirror } from "@/lib/useProseMirror"; 6 9 7 10 interface PrimerSectionProps { 8 - inputRef: RefObject<HTMLTextAreaElement | null>; 9 - onInput: () => void; 10 - defaultValue: string; 11 - parsed: ParseResult; 12 - isDirty?: boolean; 11 + primer?: Document; 12 + onSave?: (doc: Document) => void; 13 13 isSaving?: boolean; 14 14 readOnly?: boolean; 15 15 } ··· 18 18 const LINE_HEIGHT = 1.5; 19 19 20 20 export function PrimerSection({ 21 - inputRef, 22 - onInput, 23 - defaultValue, 24 - parsed, 25 - isDirty, 21 + primer, 22 + onSave, 26 23 isSaving, 27 24 readOnly = false, 28 25 }: PrimerSectionProps) { 29 26 const [isEditing, setIsEditing] = useState(false); 30 27 const [isExpanded, setIsExpanded] = useState(false); 31 28 32 - const hasContent = parsed.text.trim().length > 0; 33 - const lineCount = parsed.text.split("\n").length; 29 + // Convert lexicon to PM tree for editing 30 + const initialPMDoc = useMemo(() => { 31 + if (!primer) return undefined; 32 + return lexiconToTree(primer).toJSON(); 33 + }, [primer]); 34 + 35 + // Wrap onSave to convert PM tree back to lexicon 36 + const handleSave = useCallback( 37 + (pmDocJSON: PMDocJSON) => { 38 + if (!onSave) return; 39 + const pmNode = schema.nodeFromJSON(pmDocJSON); 40 + const lexicon = treeToLexicon(pmNode); 41 + onSave(lexicon); 42 + }, 43 + [onSave], 44 + ); 45 + 46 + const { doc, onChange, isDirty } = useProseMirror({ 47 + initialDoc: initialPMDoc, 48 + onSave: handleSave, 49 + saveDebounceMs: 1500, 50 + }); 51 + 52 + // Get plain text for content check and line count 53 + const plainText = useMemo(() => { 54 + if (!primer?.content) return ""; 55 + return primer.content.map((block) => block.text ?? "").join("\n"); 56 + }, [primer]); 57 + 58 + const hasContent = plainText.trim().length > 0; 59 + const lineCount = plainText.split("\n").length; 34 60 const needsTruncation = lineCount > COLLAPSED_LINES; 35 61 36 62 if (isEditing && !readOnly) { 37 63 return ( 38 64 <div className="space-y-3"> 39 - <RichTextEditor 40 - inputRef={inputRef} 41 - onInput={onInput} 42 - defaultValue={defaultValue} 43 - parsed={parsed} 44 - isDirty={isDirty} 45 - isSaving={isSaving} 65 + <ProseMirrorEditor 66 + defaultValue={doc} 67 + onChange={onChange} 46 68 placeholder="Write about your deck's strategy, key combos, card choices..." 47 69 /> 48 - <div className="flex justify-end"> 70 + <div className="flex items-center justify-between"> 71 + <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 72 + {isSaving && <span>Saving...</span>} 73 + {!isSaving && isDirty && <span>Unsaved changes</span>} 74 + {!isSaving && !isDirty && hasContent && <span>Saved</span>} 75 + </div> 49 76 <button 50 77 type="button" 51 78 onClick={() => setIsEditing(false)} ··· 87 114 : undefined 88 115 } 89 116 > 90 - <RichText 91 - text={parsed.text} 92 - facets={parsed.facets} 93 - className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap" 117 + <RichtextRenderer 118 + doc={primer} 119 + className="text-gray-700 dark:text-gray-300" 94 120 /> 95 121 </div> 96 122
-148
src/components/deck/PrimerSectionPM.tsx
··· 1 - import { ChevronDown, ChevronUp, Pencil } from "lucide-react"; 2 - import { useState } from "react"; 3 - import { DocRenderer } from "@/components/richtext/DocRenderer"; 4 - import { ProseMirrorEditor } from "@/components/richtext/ProseMirrorEditor"; 5 - import { type PMDocJSON, useProseMirror } from "@/lib/useProseMirror"; 6 - 7 - interface PrimerSectionPMProps { 8 - initialDoc?: PMDocJSON; 9 - onSave?: (doc: PMDocJSON) => void; 10 - isSaving?: boolean; 11 - readOnly?: boolean; 12 - } 13 - 14 - const COLLAPSED_LINES = 8; 15 - const LINE_HEIGHT = 1.5; 16 - 17 - function getDocText(doc: PMDocJSON | undefined): string { 18 - if (!doc?.content) return ""; 19 - return doc.content 20 - .map((block) => { 21 - if (block.type === "paragraph" && block.content) { 22 - return block.content.map((node) => node.text ?? "").join(""); 23 - } 24 - return ""; 25 - }) 26 - .join("\n"); 27 - } 28 - 29 - export function PrimerSectionPM({ 30 - initialDoc, 31 - onSave, 32 - isSaving, 33 - readOnly = false, 34 - }: PrimerSectionPMProps) { 35 - const [isEditing, setIsEditing] = useState(false); 36 - const [isExpanded, setIsExpanded] = useState(false); 37 - 38 - const { doc, docJSON, onChange, isDirty } = useProseMirror({ 39 - initialDoc, 40 - onSave, 41 - saveDebounceMs: 1500, 42 - }); 43 - 44 - const plainText = getDocText(docJSON); 45 - const hasContent = plainText.trim().length > 0; 46 - const lineCount = plainText.split("\n").length; 47 - const needsTruncation = lineCount > COLLAPSED_LINES; 48 - 49 - if (isEditing && !readOnly) { 50 - return ( 51 - <div className="space-y-3"> 52 - <ProseMirrorEditor 53 - defaultValue={doc} 54 - onChange={onChange} 55 - placeholder="Write about your deck's strategy, key combos, card choices..." 56 - /> 57 - <div className="flex items-center justify-between"> 58 - <div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 59 - {isSaving && <span>Saving...</span>} 60 - {!isSaving && isDirty && <span>Unsaved changes</span>} 61 - {!isSaving && !isDirty && hasContent && <span>Saved</span>} 62 - </div> 63 - <button 64 - type="button" 65 - onClick={() => setIsEditing(false)} 66 - className="px-3 py-1.5 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 67 - > 68 - Done 69 - </button> 70 - </div> 71 - </div> 72 - ); 73 - } 74 - 75 - if (!hasContent && readOnly) { 76 - return null; 77 - } 78 - 79 - if (!hasContent) { 80 - return ( 81 - <button 82 - type="button" 83 - onClick={() => setIsEditing(true)} 84 - className="text-sm text-gray-400 dark:text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 italic" 85 - > 86 - Add a description... 87 - </button> 88 - ); 89 - } 90 - 91 - return ( 92 - <div> 93 - <div className="relative"> 94 - <div 95 - className={ 96 - !isExpanded && needsTruncation ? "overflow-hidden" : undefined 97 - } 98 - style={ 99 - !isExpanded && needsTruncation 100 - ? { maxHeight: `${COLLAPSED_LINES * LINE_HEIGHT}em` } 101 - : undefined 102 - } 103 - > 104 - <DocRenderer 105 - doc={docJSON} 106 - className="text-gray-700 dark:text-gray-300" 107 - /> 108 - </div> 109 - 110 - {needsTruncation && !isExpanded && ( 111 - <div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-white dark:from-slate-900 to-transparent pointer-events-none" /> 112 - )} 113 - </div> 114 - 115 - <div className="flex items-center gap-2 mt-2"> 116 - {needsTruncation && ( 117 - <button 118 - type="button" 119 - onClick={() => setIsExpanded(!isExpanded)} 120 - className="inline-flex items-center gap-1 px-2 py-1 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 121 - > 122 - {isExpanded ? ( 123 - <> 124 - <ChevronUp className="w-4 h-4" /> 125 - Show less 126 - </> 127 - ) : ( 128 - <> 129 - <ChevronDown className="w-4 h-4" /> 130 - Show more 131 - </> 132 - )} 133 - </button> 134 - )} 135 - {!readOnly && ( 136 - <button 137 - type="button" 138 - onClick={() => setIsEditing(true)} 139 - className="inline-flex items-center gap-1 px-2 py-1 text-sm font-medium rounded-md bg-gray-100 dark:bg-slate-800 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-gray-300" 140 - > 141 - <Pencil className="w-4 h-4" /> 142 - Edit 143 - </button> 144 - )} 145 - </div> 146 - </div> 147 - ); 148 - }
-99
src/components/richtext/RichTextEditor.tsx
··· 1 - import type { RefObject } from "react"; 2 - import type { ParseResult } from "@/lib/richtext"; 3 - import { RichText } from "@/lib/richtext"; 4 - 5 - type SaveState = "saved" | "dirty" | "saving"; 6 - 7 - function getSaveState({ 8 - isDirty, 9 - isSaving, 10 - }: { 11 - isDirty?: boolean; 12 - isSaving?: boolean; 13 - }): SaveState { 14 - if (isSaving) return "saving"; 15 - if (isDirty) return "dirty"; 16 - return "saved"; 17 - } 18 - 19 - function SaveIndicator({ state }: { state: SaveState }) { 20 - const colors: Record<SaveState, string> = { 21 - saving: "text-blue-400 dark:text-blue-500", 22 - dirty: "text-amber-500 dark:text-amber-400", 23 - saved: "text-green-500 dark:text-green-400", 24 - }; 25 - 26 - const labels: Record<SaveState, string> = { 27 - saving: "Saving", 28 - dirty: "Unsaved", 29 - saved: "Saved", 30 - }; 31 - 32 - return ( 33 - <svg 34 - className={`w-3 h-3 ${colors[state]}`} 35 - viewBox="0 0 24 24" 36 - fill="currentColor" 37 - role="img" 38 - aria-label={labels[state]} 39 - > 40 - <circle cx="12" cy="12" r="6" /> 41 - </svg> 42 - ); 43 - } 44 - 45 - export interface RichTextEditorProps { 46 - inputRef: RefObject<HTMLTextAreaElement | null>; 47 - onInput: () => void; 48 - defaultValue: string; 49 - parsed: ParseResult; 50 - isDirty?: boolean; 51 - isSaving?: boolean; 52 - placeholder?: string; 53 - className?: string; 54 - } 55 - 56 - export function RichTextEditor({ 57 - inputRef, 58 - onInput, 59 - defaultValue, 60 - parsed, 61 - isDirty, 62 - isSaving, 63 - placeholder = "Write something...", 64 - className, 65 - }: RichTextEditorProps) { 66 - const saveState = getSaveState({ isDirty, isSaving }); 67 - 68 - return ( 69 - <div className={className}> 70 - <div className="grid grid-cols-2 gap-4 items-stretch"> 71 - <div className="relative flex flex-col"> 72 - <textarea 73 - ref={inputRef} 74 - defaultValue={defaultValue} 75 - onInput={onInput} 76 - placeholder={placeholder} 77 - className="w-full min-h-64 flex-1 p-3 pr-8 border border-gray-300 dark:border-slate-700 rounded-lg bg-white dark:bg-slate-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 resize-y font-mono text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400" 78 - /> 79 - <div className="absolute top-2 right-2"> 80 - <SaveIndicator state={saveState} /> 81 - </div> 82 - </div> 83 - <div className="p-3 border border-gray-300 dark:border-slate-700 rounded-lg bg-gray-50 dark:bg-slate-800/50 min-h-64 overflow-auto"> 84 - {parsed.text ? ( 85 - <RichText 86 - text={parsed.text} 87 - facets={parsed.facets} 88 - className="prose dark:prose-invert prose-sm max-w-none whitespace-pre-wrap" 89 - /> 90 - ) : ( 91 - <span className="text-gray-400 dark:text-gray-500"> 92 - {placeholder} 93 - </span> 94 - )} 95 - </div> 96 - </div> 97 - </div> 98 - ); 99 - }
+166
src/components/richtext/RichtextRenderer.tsx
··· 1 + import { sanitizeUrl } from "@braintree/sanitize-url"; 2 + import { memo, type ReactNode } from "react"; 3 + import type { 4 + Document, 5 + HeadingBlock, 6 + ParagraphBlock, 7 + } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 8 + import type { Main as Facet } from "@/lib/lexicons/types/com/deckbelcher/richtext/facet"; 9 + import { segmentize } from "@/lib/richtext-convert"; 10 + 11 + export interface RichtextRendererProps { 12 + doc: Document | undefined; 13 + className?: string; 14 + } 15 + 16 + export const RichtextRenderer = memo(function RichtextRenderer({ 17 + doc, 18 + className, 19 + }: RichtextRendererProps) { 20 + if (!doc?.content) { 21 + return null; 22 + } 23 + 24 + return ( 25 + <div className={className}> 26 + {doc.content.map((block, i) => ( 27 + // biome-ignore lint/suspicious/noArrayIndexKey: doc is immutable during render 28 + <BlockRenderer key={i} block={block} /> 29 + ))} 30 + </div> 31 + ); 32 + }); 33 + 34 + const BlockRenderer = memo(function BlockRenderer({ 35 + block, 36 + }: { 37 + block: ParagraphBlock | HeadingBlock; 38 + }): ReactNode { 39 + switch (block.$type) { 40 + case "com.deckbelcher.richtext#headingBlock": { 41 + const level = block.level ?? 1; 42 + const content = ( 43 + <TextWithFacets text={block.text} facets={block.facets} /> 44 + ); 45 + switch (level) { 46 + case 1: 47 + return <h1 className="text-2xl font-bold mt-4 mb-2">{content}</h1>; 48 + case 2: 49 + return <h2 className="text-xl font-bold mt-3 mb-2">{content}</h2>; 50 + case 3: 51 + return <h3 className="text-lg font-semibold mt-3 mb-1">{content}</h3>; 52 + case 4: 53 + return ( 54 + <h4 className="text-base font-semibold mt-2 mb-1">{content}</h4> 55 + ); 56 + case 5: 57 + return <h5 className="text-sm font-semibold mt-2 mb-1">{content}</h5>; 58 + case 6: 59 + return <h6 className="text-sm font-medium mt-2 mb-1">{content}</h6>; 60 + default: 61 + return <h1 className="text-2xl font-bold mt-4 mb-2">{content}</h1>; 62 + } 63 + } 64 + 65 + default: { 66 + const isEmpty = !block.text?.trim(); 67 + return ( 68 + <p> 69 + {isEmpty ? ( 70 + <br /> 71 + ) : ( 72 + <TextWithFacets text={block.text} facets={block.facets} /> 73 + )} 74 + </p> 75 + ); 76 + } 77 + } 78 + }); 79 + 80 + function TextWithFacets({ 81 + text, 82 + facets, 83 + }: { 84 + text?: string; 85 + facets?: Facet[]; 86 + }): ReactNode { 87 + if (!text) { 88 + return null; 89 + } 90 + 91 + if (!facets || facets.length === 0) { 92 + return text; 93 + } 94 + 95 + const segments = segmentize(text, facets); 96 + 97 + return ( 98 + <> 99 + {segments.map((segment, i) => ( 100 + // biome-ignore lint/suspicious/noArrayIndexKey: segments are immutable 101 + <SegmentRenderer key={i} segment={segment} /> 102 + ))} 103 + </> 104 + ); 105 + } 106 + 107 + function SegmentRenderer({ 108 + segment, 109 + }: { 110 + segment: { text: string; features: unknown[] }; 111 + }): ReactNode { 112 + let content: ReactNode = segment.text; 113 + 114 + for (const feature of segment.features) { 115 + content = applyFeature(content, feature as Facet["features"][number]); 116 + } 117 + 118 + return content; 119 + } 120 + 121 + function applyFeature( 122 + content: ReactNode, 123 + feature: Facet["features"][number], 124 + ): ReactNode { 125 + switch (feature.$type) { 126 + case "com.deckbelcher.richtext.facet#bold": 127 + return <strong>{content}</strong>; 128 + 129 + case "com.deckbelcher.richtext.facet#italic": 130 + return <em>{content}</em>; 131 + 132 + case "com.deckbelcher.richtext.facet#code": 133 + return ( 134 + <code className="bg-gray-100 dark:bg-slate-800 px-1 rounded font-mono text-sm"> 135 + {content} 136 + </code> 137 + ); 138 + 139 + case "com.deckbelcher.richtext.facet#link": { 140 + const safeUrl = sanitizeUrl(feature.uri); 141 + if (safeUrl === "about:blank") { 142 + return content; 143 + } 144 + return ( 145 + <a 146 + href={safeUrl} 147 + className="text-blue-600 dark:text-blue-400 hover:underline" 148 + target="_blank" 149 + rel="noopener noreferrer" 150 + > 151 + {content} 152 + </a> 153 + ); 154 + } 155 + 156 + case "com.deckbelcher.richtext.facet#mention": 157 + return ( 158 + <span className="inline-flex items-center px-1.5 py-0.5 rounded bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-sm font-medium"> 159 + {content} 160 + </span> 161 + ); 162 + 163 + default: 164 + return content; 165 + } 166 + }
+1278
src/lib/__tests__/richtext-convert.test.ts
··· 1 + import fc from "fast-check"; 2 + import { describe, expect, it } from "vitest"; 3 + import { schema } from "@/components/richtext/schema"; 4 + import { 5 + type LexiconDocument, 6 + lexiconToTree, 7 + treeToLexicon, 8 + } from "@/lib/richtext-convert"; 9 + 10 + describe("treeToLexicon", () => { 11 + describe("paragraphs", () => { 12 + it("converts empty paragraph", () => { 13 + const doc = schema.node("doc", null, [schema.node("paragraph")]); 14 + const result = treeToLexicon(doc); 15 + 16 + expect(result.content).toHaveLength(1); 17 + expect(result.content[0]).toEqual({ 18 + $type: "com.deckbelcher.richtext#paragraphBlock", 19 + text: undefined, 20 + facets: undefined, 21 + }); 22 + }); 23 + 24 + it("converts plain text paragraph", () => { 25 + const doc = schema.node("doc", null, [ 26 + schema.node("paragraph", null, [schema.text("Hello world")]), 27 + ]); 28 + const result = treeToLexicon(doc); 29 + 30 + expect(result.content).toHaveLength(1); 31 + expect(result.content[0]).toEqual({ 32 + $type: "com.deckbelcher.richtext#paragraphBlock", 33 + text: "Hello world", 34 + facets: undefined, 35 + }); 36 + }); 37 + 38 + it("converts multiple paragraphs", () => { 39 + const doc = schema.node("doc", null, [ 40 + schema.node("paragraph", null, [schema.text("First paragraph")]), 41 + schema.node("paragraph", null, [schema.text("Second paragraph")]), 42 + ]); 43 + const result = treeToLexicon(doc); 44 + 45 + expect(result.content).toHaveLength(2); 46 + expect(result.content[0]).toMatchObject({ 47 + $type: "com.deckbelcher.richtext#paragraphBlock", 48 + text: "First paragraph", 49 + }); 50 + expect(result.content[1]).toMatchObject({ 51 + $type: "com.deckbelcher.richtext#paragraphBlock", 52 + text: "Second paragraph", 53 + }); 54 + }); 55 + }); 56 + 57 + describe("headings", () => { 58 + it("converts heading level 1", () => { 59 + const doc = schema.node("doc", null, [ 60 + schema.node("heading", { level: 1 }, [schema.text("Title")]), 61 + ]); 62 + const result = treeToLexicon(doc); 63 + 64 + expect(result.content[0]).toEqual({ 65 + $type: "com.deckbelcher.richtext#headingBlock", 66 + level: 1, 67 + text: "Title", 68 + facets: undefined, 69 + }); 70 + }); 71 + 72 + it("converts heading levels 1-6", () => { 73 + for (let level = 1; level <= 6; level++) { 74 + const doc = schema.node("doc", null, [ 75 + schema.node("heading", { level }, [schema.text(`Heading ${level}`)]), 76 + ]); 77 + const result = treeToLexicon(doc); 78 + 79 + expect(result.content[0]).toMatchObject({ 80 + $type: "com.deckbelcher.richtext#headingBlock", 81 + level, 82 + text: `Heading ${level}`, 83 + }); 84 + } 85 + }); 86 + 87 + it("converts empty heading", () => { 88 + const doc = schema.node("doc", null, [ 89 + schema.node("heading", { level: 2 }), 90 + ]); 91 + const result = treeToLexicon(doc); 92 + 93 + expect(result.content[0]).toEqual({ 94 + $type: "com.deckbelcher.richtext#headingBlock", 95 + level: 2, 96 + text: undefined, 97 + facets: undefined, 98 + }); 99 + }); 100 + }); 101 + 102 + describe("marks", () => { 103 + it("converts bold text", () => { 104 + const doc = schema.node("doc", null, [ 105 + schema.node("paragraph", null, [ 106 + schema.text("bold", [schema.marks.strong.create()]), 107 + ]), 108 + ]); 109 + const result = treeToLexicon(doc); 110 + 111 + expect(result.content[0]).toMatchObject({ 112 + text: "bold", 113 + facets: [ 114 + { 115 + index: { byteStart: 0, byteEnd: 4 }, 116 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 117 + }, 118 + ], 119 + }); 120 + }); 121 + 122 + it("converts italic text", () => { 123 + const doc = schema.node("doc", null, [ 124 + schema.node("paragraph", null, [ 125 + schema.text("italic", [schema.marks.em.create()]), 126 + ]), 127 + ]); 128 + const result = treeToLexicon(doc); 129 + 130 + expect(result.content[0]).toMatchObject({ 131 + text: "italic", 132 + facets: [ 133 + { 134 + index: { byteStart: 0, byteEnd: 6 }, 135 + features: [{ $type: "com.deckbelcher.richtext.facet#italic" }], 136 + }, 137 + ], 138 + }); 139 + }); 140 + 141 + it("converts code text", () => { 142 + const doc = schema.node("doc", null, [ 143 + schema.node("paragraph", null, [ 144 + schema.text("code", [schema.marks.code.create()]), 145 + ]), 146 + ]); 147 + const result = treeToLexicon(doc); 148 + 149 + expect(result.content[0]).toMatchObject({ 150 + text: "code", 151 + facets: [ 152 + { 153 + index: { byteStart: 0, byteEnd: 4 }, 154 + features: [{ $type: "com.deckbelcher.richtext.facet#code" }], 155 + }, 156 + ], 157 + }); 158 + }); 159 + 160 + it("converts link", () => { 161 + const doc = schema.node("doc", null, [ 162 + schema.node("paragraph", null, [ 163 + schema.text("click here", [ 164 + schema.marks.link.create({ href: "https://example.com" }), 165 + ]), 166 + ]), 167 + ]); 168 + const result = treeToLexicon(doc); 169 + 170 + expect(result.content[0]).toMatchObject({ 171 + text: "click here", 172 + facets: [ 173 + { 174 + index: { byteStart: 0, byteEnd: 10 }, 175 + features: [ 176 + { 177 + $type: "com.deckbelcher.richtext.facet#link", 178 + uri: "https://example.com", 179 + }, 180 + ], 181 + }, 182 + ], 183 + }); 184 + }); 185 + 186 + it("converts text with mark in middle", () => { 187 + const doc = schema.node("doc", null, [ 188 + schema.node("paragraph", null, [ 189 + schema.text("before "), 190 + schema.text("bold", [schema.marks.strong.create()]), 191 + schema.text(" after"), 192 + ]), 193 + ]); 194 + const result = treeToLexicon(doc); 195 + 196 + expect(result.content[0]).toMatchObject({ 197 + text: "before bold after", 198 + facets: [ 199 + { 200 + index: { byteStart: 7, byteEnd: 11 }, 201 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 202 + }, 203 + ], 204 + }); 205 + }); 206 + 207 + it("converts adjacent different marks", () => { 208 + const doc = schema.node("doc", null, [ 209 + schema.node("paragraph", null, [ 210 + schema.text("bold", [schema.marks.strong.create()]), 211 + schema.text("italic", [schema.marks.em.create()]), 212 + ]), 213 + ]); 214 + const result = treeToLexicon(doc); 215 + 216 + expect(result.content[0]).toMatchObject({ 217 + text: "bolditalic", 218 + facets: [ 219 + { 220 + index: { byteStart: 0, byteEnd: 4 }, 221 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 222 + }, 223 + { 224 + index: { byteStart: 4, byteEnd: 10 }, 225 + features: [{ $type: "com.deckbelcher.richtext.facet#italic" }], 226 + }, 227 + ], 228 + }); 229 + }); 230 + }); 231 + 232 + describe("unicode and byte offsets", () => { 233 + it("handles emoji correctly", () => { 234 + const doc = schema.node("doc", null, [ 235 + schema.node("paragraph", null, [ 236 + schema.text("Hello "), 237 + schema.text("🎉", [schema.marks.strong.create()]), 238 + schema.text(" world"), 239 + ]), 240 + ]); 241 + const result = treeToLexicon(doc); 242 + 243 + // "Hello " = 6 bytes 244 + // "🎉" = 4 bytes (UTF-8) 245 + expect(result.content[0]).toMatchObject({ 246 + text: "Hello 🎉 world", 247 + facets: [ 248 + { 249 + index: { byteStart: 6, byteEnd: 10 }, 250 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 251 + }, 252 + ], 253 + }); 254 + }); 255 + 256 + it("handles multi-byte characters", () => { 257 + const doc = schema.node("doc", null, [ 258 + schema.node("paragraph", null, [ 259 + schema.text("日本語", [schema.marks.strong.create()]), 260 + ]), 261 + ]); 262 + const result = treeToLexicon(doc); 263 + 264 + // Each Japanese character is 3 bytes in UTF-8 265 + expect(result.content[0]).toMatchObject({ 266 + text: "日本語", 267 + facets: [ 268 + { 269 + index: { byteStart: 0, byteEnd: 9 }, 270 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 271 + }, 272 + ], 273 + }); 274 + }); 275 + 276 + it("handles mixed ASCII and unicode", () => { 277 + const doc = schema.node("doc", null, [ 278 + schema.node("paragraph", null, [ 279 + schema.text("Café "), 280 + schema.text("résumé", [schema.marks.em.create()]), 281 + ]), 282 + ]); 283 + const result = treeToLexicon(doc); 284 + 285 + // "Café " = 6 bytes (é is 2 bytes) 286 + // "résumé" = 8 bytes (é is 2 bytes each) 287 + expect(result.content[0]).toMatchObject({ 288 + text: "Café résumé", 289 + facets: [ 290 + { 291 + index: { byteStart: 6, byteEnd: 14 }, 292 + features: [{ $type: "com.deckbelcher.richtext.facet#italic" }], 293 + }, 294 + ], 295 + }); 296 + }); 297 + }); 298 + 299 + describe("overlapping marks", () => { 300 + it("converts text with bold AND italic", () => { 301 + const doc = schema.node("doc", null, [ 302 + schema.node("paragraph", null, [ 303 + schema.text("both", [ 304 + schema.marks.strong.create(), 305 + schema.marks.em.create(), 306 + ]), 307 + ]), 308 + ]); 309 + const result = treeToLexicon(doc); 310 + 311 + // Marks with the same byte range are merged into a single facet 312 + expect(result.content[0]?.facets).toHaveLength(1); 313 + expect(result.content[0]?.facets?.[0]).toMatchObject({ 314 + index: { byteStart: 0, byteEnd: 4 }, 315 + features: expect.arrayContaining([ 316 + { $type: "com.deckbelcher.richtext.facet#bold" }, 317 + { $type: "com.deckbelcher.richtext.facet#italic" }, 318 + ]), 319 + }); 320 + }); 321 + 322 + it("converts partially overlapping marks", () => { 323 + // "normal BOLD bolditalic ITALIC normal" 324 + const doc = schema.node("doc", null, [ 325 + schema.node("paragraph", null, [ 326 + schema.text("normal "), 327 + schema.text("BOLD ", [schema.marks.strong.create()]), 328 + schema.text("both", [ 329 + schema.marks.strong.create(), 330 + schema.marks.em.create(), 331 + ]), 332 + schema.text(" ITALIC", [schema.marks.em.create()]), 333 + schema.text(" normal"), 334 + ]), 335 + ]); 336 + const result = treeToLexicon(doc); 337 + 338 + // Should have facets for the bold region and italic region 339 + // Bold: "BOLD both" = bytes 7-16 340 + // Italic: "both ITALIC" = bytes 12-23 341 + expect(result.content[0]?.text).toBe("normal BOLD both ITALIC normal"); 342 + 343 + const facets = result.content[0]?.facets ?? []; 344 + const boldFacet = facets.find( 345 + (f) => f.features[0]?.$type === "com.deckbelcher.richtext.facet#bold", 346 + ); 347 + const italicFacet = facets.find( 348 + (f) => f.features[0]?.$type === "com.deckbelcher.richtext.facet#italic", 349 + ); 350 + 351 + expect(boldFacet?.index).toEqual({ byteStart: 7, byteEnd: 16 }); 352 + expect(italicFacet?.index).toEqual({ byteStart: 12, byteEnd: 23 }); 353 + }); 354 + 355 + it("converts bold link", () => { 356 + const doc = schema.node("doc", null, [ 357 + schema.node("paragraph", null, [ 358 + schema.text("click", [ 359 + schema.marks.strong.create(), 360 + schema.marks.link.create({ href: "https://example.com" }), 361 + ]), 362 + ]), 363 + ]); 364 + const result = treeToLexicon(doc); 365 + 366 + // Marks with the same byte range are merged into a single facet 367 + expect(result.content[0]?.facets).toHaveLength(1); 368 + expect(result.content[0]?.facets?.[0]).toMatchObject({ 369 + index: { byteStart: 0, byteEnd: 5 }, 370 + features: expect.arrayContaining([ 371 + { $type: "com.deckbelcher.richtext.facet#bold" }, 372 + { 373 + $type: "com.deckbelcher.richtext.facet#link", 374 + uri: "https://example.com", 375 + }, 376 + ]), 377 + }); 378 + }); 379 + }); 380 + 381 + describe("mixed content", () => { 382 + it("converts document with paragraphs and headings", () => { 383 + const doc = schema.node("doc", null, [ 384 + schema.node("heading", { level: 1 }, [schema.text("Introduction")]), 385 + schema.node("paragraph", null, [schema.text("Some text here.")]), 386 + schema.node("heading", { level: 2 }, [schema.text("Details")]), 387 + schema.node("paragraph", null, [schema.text("More details.")]), 388 + ]); 389 + const result = treeToLexicon(doc); 390 + 391 + expect(result.content).toHaveLength(4); 392 + expect(result.content[0]).toMatchObject({ 393 + $type: "com.deckbelcher.richtext#headingBlock", 394 + level: 1, 395 + text: "Introduction", 396 + }); 397 + expect(result.content[1]).toMatchObject({ 398 + $type: "com.deckbelcher.richtext#paragraphBlock", 399 + text: "Some text here.", 400 + }); 401 + expect(result.content[2]).toMatchObject({ 402 + $type: "com.deckbelcher.richtext#headingBlock", 403 + level: 2, 404 + text: "Details", 405 + }); 406 + expect(result.content[3]).toMatchObject({ 407 + $type: "com.deckbelcher.richtext#paragraphBlock", 408 + text: "More details.", 409 + }); 410 + }); 411 + 412 + it("converts heading with formatting", () => { 413 + const doc = schema.node("doc", null, [ 414 + schema.node("heading", { level: 1 }, [ 415 + schema.text("Important "), 416 + schema.text("Title", [schema.marks.strong.create()]), 417 + ]), 418 + ]); 419 + const result = treeToLexicon(doc); 420 + 421 + expect(result.content[0]).toMatchObject({ 422 + $type: "com.deckbelcher.richtext#headingBlock", 423 + level: 1, 424 + text: "Important Title", 425 + facets: [ 426 + { 427 + index: { byteStart: 10, byteEnd: 15 }, 428 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 429 + }, 430 + ], 431 + }); 432 + }); 433 + }); 434 + }); 435 + 436 + describe("lexiconToTree", () => { 437 + describe("paragraphs", () => { 438 + it("converts empty paragraph", () => { 439 + const lexicon: LexiconDocument = { 440 + content: [ 441 + { 442 + $type: "com.deckbelcher.richtext#paragraphBlock", 443 + }, 444 + ], 445 + }; 446 + const result = lexiconToTree(lexicon); 447 + 448 + expect(result.childCount).toBe(1); 449 + expect(result.child(0).type.name).toBe("paragraph"); 450 + expect(result.child(0).childCount).toBe(0); 451 + }); 452 + 453 + it("converts plain text paragraph", () => { 454 + const lexicon: LexiconDocument = { 455 + content: [ 456 + { 457 + $type: "com.deckbelcher.richtext#paragraphBlock", 458 + text: "Hello world", 459 + }, 460 + ], 461 + }; 462 + const result = lexiconToTree(lexicon); 463 + 464 + expect(result.childCount).toBe(1); 465 + expect(result.child(0).type.name).toBe("paragraph"); 466 + expect(result.child(0).textContent).toBe("Hello world"); 467 + }); 468 + 469 + it("converts multiple paragraphs", () => { 470 + const lexicon: LexiconDocument = { 471 + content: [ 472 + { 473 + $type: "com.deckbelcher.richtext#paragraphBlock", 474 + text: "First", 475 + }, 476 + { 477 + $type: "com.deckbelcher.richtext#paragraphBlock", 478 + text: "Second", 479 + }, 480 + ], 481 + }; 482 + const result = lexiconToTree(lexicon); 483 + 484 + expect(result.childCount).toBe(2); 485 + expect(result.child(0).textContent).toBe("First"); 486 + expect(result.child(1).textContent).toBe("Second"); 487 + }); 488 + }); 489 + 490 + describe("headings", () => { 491 + it("converts heading with level", () => { 492 + const lexicon: LexiconDocument = { 493 + content: [ 494 + { 495 + $type: "com.deckbelcher.richtext#headingBlock", 496 + level: 2, 497 + text: "Section Title", 498 + }, 499 + ], 500 + }; 501 + const result = lexiconToTree(lexicon); 502 + 503 + expect(result.child(0).type.name).toBe("heading"); 504 + expect(result.child(0).attrs.level).toBe(2); 505 + expect(result.child(0).textContent).toBe("Section Title"); 506 + }); 507 + 508 + it("converts empty heading", () => { 509 + const lexicon: LexiconDocument = { 510 + content: [ 511 + { 512 + $type: "com.deckbelcher.richtext#headingBlock", 513 + level: 1, 514 + }, 515 + ], 516 + }; 517 + const result = lexiconToTree(lexicon); 518 + 519 + expect(result.child(0).type.name).toBe("heading"); 520 + expect(result.child(0).attrs.level).toBe(1); 521 + expect(result.child(0).childCount).toBe(0); 522 + }); 523 + }); 524 + 525 + describe("facets/marks", () => { 526 + it("converts bold facet", () => { 527 + const lexicon: LexiconDocument = { 528 + content: [ 529 + { 530 + $type: "com.deckbelcher.richtext#paragraphBlock", 531 + text: "hello bold world", 532 + facets: [ 533 + { 534 + index: { byteStart: 6, byteEnd: 10 }, 535 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 536 + }, 537 + ], 538 + }, 539 + ], 540 + }; 541 + const result = lexiconToTree(lexicon); 542 + const para = result.child(0); 543 + 544 + // Should have 3 text nodes: "hello ", "bold", " world" 545 + expect(para.childCount).toBe(3); 546 + expect(para.child(0).text).toBe("hello "); 547 + expect(para.child(0).marks).toHaveLength(0); 548 + 549 + expect(para.child(1).text).toBe("bold"); 550 + expect(para.child(1).marks).toHaveLength(1); 551 + expect(para.child(1).marks[0].type.name).toBe("strong"); 552 + 553 + expect(para.child(2).text).toBe(" world"); 554 + expect(para.child(2).marks).toHaveLength(0); 555 + }); 556 + 557 + it("converts italic facet", () => { 558 + const lexicon: LexiconDocument = { 559 + content: [ 560 + { 561 + $type: "com.deckbelcher.richtext#paragraphBlock", 562 + text: "emphasis", 563 + facets: [ 564 + { 565 + index: { byteStart: 0, byteEnd: 8 }, 566 + features: [{ $type: "com.deckbelcher.richtext.facet#italic" }], 567 + }, 568 + ], 569 + }, 570 + ], 571 + }; 572 + const result = lexiconToTree(lexicon); 573 + 574 + expect(result.child(0).child(0).marks[0].type.name).toBe("em"); 575 + }); 576 + 577 + it("converts code facet", () => { 578 + const lexicon: LexiconDocument = { 579 + content: [ 580 + { 581 + $type: "com.deckbelcher.richtext#paragraphBlock", 582 + text: "inline code", 583 + facets: [ 584 + { 585 + index: { byteStart: 7, byteEnd: 11 }, 586 + features: [{ $type: "com.deckbelcher.richtext.facet#code" }], 587 + }, 588 + ], 589 + }, 590 + ], 591 + }; 592 + const result = lexiconToTree(lexicon); 593 + const para = result.child(0); 594 + 595 + expect(para.child(1).text).toBe("code"); 596 + expect(para.child(1).marks[0].type.name).toBe("code"); 597 + }); 598 + 599 + it("converts link facet", () => { 600 + const lexicon: LexiconDocument = { 601 + content: [ 602 + { 603 + $type: "com.deckbelcher.richtext#paragraphBlock", 604 + text: "click here", 605 + facets: [ 606 + { 607 + index: { byteStart: 0, byteEnd: 10 }, 608 + features: [ 609 + { 610 + $type: "com.deckbelcher.richtext.facet#link", 611 + uri: "https://example.com", 612 + }, 613 + ], 614 + }, 615 + ], 616 + }, 617 + ], 618 + }; 619 + const result = lexiconToTree(lexicon); 620 + 621 + const link = result.child(0).child(0); 622 + expect(link.marks[0].type.name).toBe("link"); 623 + expect(link.marks[0].attrs.href).toBe("https://example.com"); 624 + }); 625 + 626 + it("converts adjacent facets", () => { 627 + const lexicon: LexiconDocument = { 628 + content: [ 629 + { 630 + $type: "com.deckbelcher.richtext#paragraphBlock", 631 + text: "bolditalic", 632 + facets: [ 633 + { 634 + index: { byteStart: 0, byteEnd: 4 }, 635 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 636 + }, 637 + { 638 + index: { byteStart: 4, byteEnd: 10 }, 639 + features: [{ $type: "com.deckbelcher.richtext.facet#italic" }], 640 + }, 641 + ], 642 + }, 643 + ], 644 + }; 645 + const result = lexiconToTree(lexicon); 646 + const para = result.child(0); 647 + 648 + expect(para.child(0).text).toBe("bold"); 649 + expect(para.child(0).marks[0].type.name).toBe("strong"); 650 + 651 + expect(para.child(1).text).toBe("italic"); 652 + expect(para.child(1).marks[0].type.name).toBe("em"); 653 + }); 654 + }); 655 + 656 + describe("unicode", () => { 657 + it("handles emoji byte offsets", () => { 658 + const lexicon: LexiconDocument = { 659 + content: [ 660 + { 661 + $type: "com.deckbelcher.richtext#paragraphBlock", 662 + text: "Hello 🎉 world", 663 + facets: [ 664 + { 665 + index: { byteStart: 6, byteEnd: 10 }, // 🎉 is 4 bytes 666 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 667 + }, 668 + ], 669 + }, 670 + ], 671 + }; 672 + const result = lexiconToTree(lexicon); 673 + const para = result.child(0); 674 + 675 + expect(para.child(1).text).toBe("🎉"); 676 + expect(para.child(1).marks[0].type.name).toBe("strong"); 677 + }); 678 + 679 + it("handles multi-byte characters", () => { 680 + const lexicon: LexiconDocument = { 681 + content: [ 682 + { 683 + $type: "com.deckbelcher.richtext#paragraphBlock", 684 + text: "日本語", 685 + facets: [ 686 + { 687 + index: { byteStart: 0, byteEnd: 9 }, // 3 chars × 3 bytes 688 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 689 + }, 690 + ], 691 + }, 692 + ], 693 + }; 694 + const result = lexiconToTree(lexicon); 695 + 696 + expect(result.child(0).child(0).text).toBe("日本語"); 697 + expect(result.child(0).child(0).marks[0].type.name).toBe("strong"); 698 + }); 699 + }); 700 + 701 + describe("overlapping facets", () => { 702 + it("converts overlapping bold and italic", () => { 703 + const lexicon: LexiconDocument = { 704 + content: [ 705 + { 706 + $type: "com.deckbelcher.richtext#paragraphBlock", 707 + text: "overlap", 708 + facets: [ 709 + { 710 + index: { byteStart: 0, byteEnd: 7 }, 711 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 712 + }, 713 + { 714 + index: { byteStart: 0, byteEnd: 7 }, 715 + features: [{ $type: "com.deckbelcher.richtext.facet#italic" }], 716 + }, 717 + ], 718 + }, 719 + ], 720 + }; 721 + const result = lexiconToTree(lexicon); 722 + const textNode = result.child(0).child(0); 723 + 724 + expect(textNode.text).toBe("overlap"); 725 + expect(textNode.marks).toHaveLength(2); 726 + expect(textNode.marks.map((m) => m.type.name).sort()).toEqual([ 727 + "em", 728 + "strong", 729 + ]); 730 + }); 731 + 732 + it("converts partially overlapping facets", () => { 733 + // "BOLD both ITALIC" 734 + // Bold covers "BOLD both" (0-9) 735 + // Italic covers "both ITALIC" (5-16) 736 + const lexicon: LexiconDocument = { 737 + content: [ 738 + { 739 + $type: "com.deckbelcher.richtext#paragraphBlock", 740 + text: "BOLD both ITALIC", 741 + facets: [ 742 + { 743 + index: { byteStart: 0, byteEnd: 9 }, 744 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 745 + }, 746 + { 747 + index: { byteStart: 5, byteEnd: 16 }, 748 + features: [{ $type: "com.deckbelcher.richtext.facet#italic" }], 749 + }, 750 + ], 751 + }, 752 + ], 753 + }; 754 + const result = lexiconToTree(lexicon); 755 + const para = result.child(0); 756 + 757 + // Segmenter should split into: "BOLD " (bold), "both" (bold+italic), " ITALIC" (italic) 758 + expect(para.childCount).toBe(3); 759 + 760 + expect(para.child(0).text).toBe("BOLD "); 761 + expect(para.child(0).marks.map((m) => m.type.name)).toEqual(["strong"]); 762 + 763 + expect(para.child(1).text).toBe("both"); 764 + expect( 765 + para 766 + .child(1) 767 + .marks.map((m) => m.type.name) 768 + .sort(), 769 + ).toEqual(["em", "strong"]); 770 + 771 + expect(para.child(2).text).toBe(" ITALIC"); 772 + expect(para.child(2).marks.map((m) => m.type.name)).toEqual(["em"]); 773 + }); 774 + }); 775 + 776 + describe("unknown block types", () => { 777 + it("treats unknown block type as paragraph", () => { 778 + const lexicon: LexiconDocument = { 779 + content: [ 780 + { 781 + // biome-ignore lint/suspicious/noExplicitAny: testing unknown block types 782 + $type: "com.deckbelcher.richtext#unknownBlock" as any, 783 + text: "Some text", 784 + }, 785 + ], 786 + }; 787 + const result = lexiconToTree(lexicon); 788 + 789 + expect(result.child(0).type.name).toBe("paragraph"); 790 + expect(result.child(0).textContent).toBe("Some text"); 791 + }); 792 + }); 793 + }); 794 + 795 + describe("roundtrip", () => { 796 + function roundtrip(doc: ReturnType<typeof schema.node>) { 797 + const lexicon = treeToLexicon(doc); 798 + return lexiconToTree(lexicon); 799 + } 800 + 801 + it("preserves overlapping bold and italic", () => { 802 + const original = schema.node("doc", null, [ 803 + schema.node("paragraph", null, [ 804 + schema.text("both", [ 805 + schema.marks.strong.create(), 806 + schema.marks.em.create(), 807 + ]), 808 + ]), 809 + ]); 810 + const result = roundtrip(original); 811 + 812 + expect(result.eq(original)).toBe(true); 813 + }); 814 + 815 + it("preserves partially overlapping marks", () => { 816 + const original = schema.node("doc", null, [ 817 + schema.node("paragraph", null, [ 818 + schema.text("BOLD ", [schema.marks.strong.create()]), 819 + schema.text("both", [ 820 + schema.marks.strong.create(), 821 + schema.marks.em.create(), 822 + ]), 823 + schema.text(" ITALIC", [schema.marks.em.create()]), 824 + ]), 825 + ]); 826 + const result = roundtrip(original); 827 + 828 + expect(result.eq(original)).toBe(true); 829 + }); 830 + 831 + it("preserves triple marks", () => { 832 + const original = schema.node("doc", null, [ 833 + schema.node("paragraph", null, [ 834 + schema.text("everything", [ 835 + schema.marks.strong.create(), 836 + schema.marks.em.create(), 837 + schema.marks.code.create(), 838 + ]), 839 + ]), 840 + ]); 841 + const result = roundtrip(original); 842 + 843 + expect(result.eq(original)).toBe(true); 844 + }); 845 + 846 + it("preserves plain text paragraph", () => { 847 + const original = schema.node("doc", null, [ 848 + schema.node("paragraph", null, [schema.text("Hello world")]), 849 + ]); 850 + const result = roundtrip(original); 851 + 852 + expect(result.eq(original)).toBe(true); 853 + }); 854 + 855 + it("preserves bold text", () => { 856 + const original = schema.node("doc", null, [ 857 + schema.node("paragraph", null, [ 858 + schema.text("normal "), 859 + schema.text("bold", [schema.marks.strong.create()]), 860 + schema.text(" normal"), 861 + ]), 862 + ]); 863 + const result = roundtrip(original); 864 + 865 + expect(result.eq(original)).toBe(true); 866 + }); 867 + 868 + it("preserves italic text", () => { 869 + const original = schema.node("doc", null, [ 870 + schema.node("paragraph", null, [ 871 + schema.text("emphasis", [schema.marks.em.create()]), 872 + ]), 873 + ]); 874 + const result = roundtrip(original); 875 + 876 + expect(result.eq(original)).toBe(true); 877 + }); 878 + 879 + it("preserves code text", () => { 880 + const original = schema.node("doc", null, [ 881 + schema.node("paragraph", null, [ 882 + schema.text("const x = 1", [schema.marks.code.create()]), 883 + ]), 884 + ]); 885 + const result = roundtrip(original); 886 + 887 + expect(result.eq(original)).toBe(true); 888 + }); 889 + 890 + it("preserves links", () => { 891 + const original = schema.node("doc", null, [ 892 + schema.node("paragraph", null, [ 893 + schema.text("Visit "), 894 + schema.text("our site", [ 895 + schema.marks.link.create({ href: "https://example.com" }), 896 + ]), 897 + ]), 898 + ]); 899 + const result = roundtrip(original); 900 + 901 + expect(result.eq(original)).toBe(true); 902 + }); 903 + 904 + it("preserves headings", () => { 905 + const original = schema.node("doc", null, [ 906 + schema.node("heading", { level: 2 }, [schema.text("Section Title")]), 907 + ]); 908 + const result = roundtrip(original); 909 + 910 + expect(result.eq(original)).toBe(true); 911 + }); 912 + 913 + it("preserves mixed document", () => { 914 + const original = schema.node("doc", null, [ 915 + schema.node("heading", { level: 1 }, [schema.text("Title")]), 916 + schema.node("paragraph", null, [ 917 + schema.text("This is "), 918 + schema.text("bold", [schema.marks.strong.create()]), 919 + schema.text(" and "), 920 + schema.text("italic", [schema.marks.em.create()]), 921 + schema.text("."), 922 + ]), 923 + schema.node("heading", { level: 2 }, [schema.text("Links")]), 924 + schema.node("paragraph", null, [ 925 + schema.text("Check out "), 926 + schema.text("this link", [ 927 + schema.marks.link.create({ href: "https://example.com" }), 928 + ]), 929 + schema.text("!"), 930 + ]), 931 + ]); 932 + const result = roundtrip(original); 933 + 934 + expect(result.eq(original)).toBe(true); 935 + }); 936 + 937 + it("preserves unicode content", () => { 938 + const original = schema.node("doc", null, [ 939 + schema.node("paragraph", null, [ 940 + schema.text("Hello "), 941 + schema.text("🎉", [schema.marks.strong.create()]), 942 + schema.text(" 日本語!"), 943 + ]), 944 + ]); 945 + const result = roundtrip(original); 946 + 947 + expect(result.eq(original)).toBe(true); 948 + }); 949 + 950 + it("preserves empty document", () => { 951 + const original = schema.node("doc", null, [schema.node("paragraph")]); 952 + const result = roundtrip(original); 953 + 954 + expect(result.eq(original)).toBe(true); 955 + }); 956 + 957 + it("preserves multiple empty paragraphs", () => { 958 + const original = schema.node("doc", null, [ 959 + schema.node("paragraph"), 960 + schema.node("paragraph"), 961 + schema.node("paragraph"), 962 + ]); 963 + const result = roundtrip(original); 964 + 965 + expect(result.eq(original)).toBe(true); 966 + }); 967 + }); 968 + 969 + describe("complex unicode", () => { 970 + describe("treeToLexicon", () => { 971 + it("handles combining characters (e + combining acute)", () => { 972 + // "café" with combining acute (e + ́) vs precomposed é 973 + const combining = "cafe\u0301"; // e + combining acute accent 974 + const doc = schema.node("doc", null, [ 975 + schema.node("paragraph", null, [ 976 + schema.text("before "), 977 + schema.text(combining, [schema.marks.strong.create()]), 978 + schema.text(" after"), 979 + ]), 980 + ]); 981 + const result = treeToLexicon(doc); 982 + 983 + // "before " = 7 bytes 984 + // "cafe" = 4 bytes, combining acute = 2 bytes = 6 bytes total 985 + expect(result.content[0]).toMatchObject({ 986 + text: `before ${combining} after`, 987 + facets: [ 988 + { 989 + index: { byteStart: 7, byteEnd: 13 }, 990 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 991 + }, 992 + ], 993 + }); 994 + }); 995 + 996 + it("handles ZWJ emoji sequences (family emoji)", () => { 997 + // Family emoji: 👨‍👩‍👧 (man + ZWJ + woman + ZWJ + girl) 998 + const family = "👨\u200D👩\u200D👧"; 999 + const doc = schema.node("doc", null, [ 1000 + schema.node("paragraph", null, [ 1001 + schema.text(family, [schema.marks.em.create()]), 1002 + ]), 1003 + ]); 1004 + const result = treeToLexicon(doc); 1005 + 1006 + // Each person emoji = 4 bytes, ZWJ = 3 bytes 1007 + // Total: 4 + 3 + 4 + 3 + 4 = 18 bytes 1008 + expect(result.content[0]).toMatchObject({ 1009 + text: family, 1010 + facets: [ 1011 + { 1012 + index: { byteStart: 0, byteEnd: 18 }, 1013 + features: [{ $type: "com.deckbelcher.richtext.facet#italic" }], 1014 + }, 1015 + ], 1016 + }); 1017 + }); 1018 + 1019 + it("handles flag emoji (regional indicators)", () => { 1020 + // US flag: 🇺🇸 (regional indicator U + regional indicator S) 1021 + const flag = "🇺🇸"; 1022 + const doc = schema.node("doc", null, [ 1023 + schema.node("paragraph", null, [ 1024 + schema.text("Go "), 1025 + schema.text(flag, [schema.marks.strong.create()]), 1026 + schema.text("!"), 1027 + ]), 1028 + ]); 1029 + const result = treeToLexicon(doc); 1030 + 1031 + // "Go " = 3 bytes 1032 + // Each regional indicator = 4 bytes, total 8 bytes 1033 + expect(result.content[0]).toMatchObject({ 1034 + text: `Go ${flag}!`, 1035 + facets: [ 1036 + { 1037 + index: { byteStart: 3, byteEnd: 11 }, 1038 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 1039 + }, 1040 + ], 1041 + }); 1042 + }); 1043 + 1044 + it("handles skin tone modifiers", () => { 1045 + // Waving hand with skin tone: 👋🏽 1046 + const wave = "👋🏽"; 1047 + const doc = schema.node("doc", null, [ 1048 + schema.node("paragraph", null, [ 1049 + schema.text(wave, [schema.marks.code.create()]), 1050 + ]), 1051 + ]); 1052 + const result = treeToLexicon(doc); 1053 + 1054 + // Base emoji = 4 bytes, skin tone modifier = 4 bytes = 8 bytes 1055 + expect(result.content[0]).toMatchObject({ 1056 + text: wave, 1057 + facets: [ 1058 + { 1059 + index: { byteStart: 0, byteEnd: 8 }, 1060 + features: [{ $type: "com.deckbelcher.richtext.facet#code" }], 1061 + }, 1062 + ], 1063 + }); 1064 + }); 1065 + }); 1066 + 1067 + describe("roundtrip", () => { 1068 + function roundtrip(doc: ReturnType<typeof schema.node>) { 1069 + const lexicon = treeToLexicon(doc); 1070 + return lexiconToTree(lexicon); 1071 + } 1072 + 1073 + it("preserves combining characters", () => { 1074 + const combining = "cafe\u0301"; 1075 + const original = schema.node("doc", null, [ 1076 + schema.node("paragraph", null, [ 1077 + schema.text(combining, [schema.marks.strong.create()]), 1078 + ]), 1079 + ]); 1080 + const result = roundtrip(original); 1081 + 1082 + expect(result.eq(original)).toBe(true); 1083 + }); 1084 + 1085 + it("preserves ZWJ sequences", () => { 1086 + const family = "👨\u200D👩\u200D👧"; 1087 + const original = schema.node("doc", null, [ 1088 + schema.node("paragraph", null, [ 1089 + schema.text(family, [schema.marks.em.create()]), 1090 + ]), 1091 + ]); 1092 + const result = roundtrip(original); 1093 + 1094 + expect(result.eq(original)).toBe(true); 1095 + }); 1096 + 1097 + it("preserves flag emoji", () => { 1098 + const flag = "🇺🇸"; 1099 + const original = schema.node("doc", null, [ 1100 + schema.node("paragraph", null, [ 1101 + schema.text(`Go ${flag}!`, [schema.marks.strong.create()]), 1102 + ]), 1103 + ]); 1104 + const result = roundtrip(original); 1105 + 1106 + expect(result.eq(original)).toBe(true); 1107 + }); 1108 + 1109 + it("preserves mixed complex unicode with marks", () => { 1110 + const original = schema.node("doc", null, [ 1111 + schema.node("paragraph", null, [ 1112 + schema.text("Hello "), 1113 + schema.text("👨‍👩‍👧", [schema.marks.strong.create()]), 1114 + schema.text(" from "), 1115 + schema.text("🇺🇸", [schema.marks.em.create()]), 1116 + schema.text("!"), 1117 + ]), 1118 + ]); 1119 + const result = roundtrip(original); 1120 + 1121 + expect(result.eq(original)).toBe(true); 1122 + }); 1123 + }); 1124 + }); 1125 + 1126 + describe("property tests", () => { 1127 + // Arbitrary for text that includes various unicode 1128 + const arbText = fc.oneof( 1129 + fc.string({ minLength: 1, maxLength: 50 }), // Basic strings including unicode 1130 + fc.constant("🎉test"), // Emoji with text 1131 + fc.constant("👨‍👩‍👧"), // ZWJ sequence 1132 + fc.constant("🇺🇸"), // Flag 1133 + fc.constant("café"), // Precomposed 1134 + fc.constant("cafe\u0301"), // Combining 1135 + fc.constant("日本語テスト"), // Japanese 1136 + ); 1137 + 1138 + // Arbitrary for marks (subset) 1139 + const arbMarkSet = fc 1140 + .subarray(["strong", "em", "code"] as const, { minLength: 0, maxLength: 3 }) 1141 + .map((markNames) => markNames.map((name) => schema.marks[name].create())); 1142 + 1143 + // Arbitrary for a text node with optional marks 1144 + const arbTextNode = fc 1145 + .tuple(arbText, arbMarkSet) 1146 + .map(([text, marks]) => schema.text(text, marks)); 1147 + 1148 + // Arbitrary for paragraph content (1-5 text nodes) 1149 + const arbParagraphContent = fc.array(arbTextNode, { 1150 + minLength: 1, 1151 + maxLength: 5, 1152 + }); 1153 + 1154 + // Arbitrary for a paragraph block 1155 + const arbParagraph = arbParagraphContent.map((content) => 1156 + schema.node("paragraph", null, content), 1157 + ); 1158 + 1159 + // Arbitrary for heading level 1160 + const arbHeadingLevel = fc.integer({ min: 1, max: 6 }); 1161 + 1162 + // Arbitrary for a heading block 1163 + const arbHeading = fc 1164 + .tuple(arbHeadingLevel, arbParagraphContent) 1165 + .map(([level, content]) => schema.node("heading", { level }, content)); 1166 + 1167 + // Arbitrary for a block (paragraph or heading) 1168 + const arbBlock = fc.oneof(arbParagraph, arbHeading); 1169 + 1170 + // Arbitrary for a document 1171 + const arbDocument = fc 1172 + .array(arbBlock, { minLength: 1, maxLength: 5 }) 1173 + .map((blocks) => schema.node("doc", null, blocks)); 1174 + 1175 + it("roundtrip preserves document equality", () => { 1176 + fc.assert( 1177 + fc.property(arbDocument, (doc) => { 1178 + const lexicon = treeToLexicon(doc); 1179 + const result = lexiconToTree(lexicon); 1180 + return result.eq(doc); 1181 + }), 1182 + { numRuns: 1000 }, 1183 + ); 1184 + }); 1185 + 1186 + it("lexicon text matches tree textContent per block", () => { 1187 + fc.assert( 1188 + fc.property(arbDocument, (doc) => { 1189 + const lexicon = treeToLexicon(doc); 1190 + 1191 + for (let i = 0; i < doc.childCount; i++) { 1192 + const blockText = doc.child(i).textContent; 1193 + const lexiconBlock = lexicon.content[i]; 1194 + const lexiconText = lexiconBlock?.text ?? ""; 1195 + 1196 + if (blockText !== lexiconText) { 1197 + return false; 1198 + } 1199 + } 1200 + return true; 1201 + }), 1202 + { numRuns: 1000 }, 1203 + ); 1204 + }); 1205 + 1206 + it("feature count matches or exceeds mark types", () => { 1207 + fc.assert( 1208 + fc.property(arbDocument, (doc) => { 1209 + const lexicon = treeToLexicon(doc); 1210 + 1211 + for (let i = 0; i < doc.childCount; i++) { 1212 + const block = doc.child(i); 1213 + const lexiconBlock = lexicon.content[i]; 1214 + 1215 + // Count unique marks in this block 1216 + const markTypes = new Set<string>(); 1217 + block.forEach((child) => { 1218 + for (const mark of child.marks) { 1219 + markTypes.add(mark.type.name); 1220 + } 1221 + }); 1222 + 1223 + // Count total features across all facets 1224 + const featureCount = 1225 + lexiconBlock?.facets?.reduce( 1226 + (acc, f) => acc + f.features.length, 1227 + 0, 1228 + ) ?? 0; 1229 + 1230 + // Total features should match or exceed unique mark types 1231 + // (could have more if mark appears in non-contiguous regions) 1232 + if (featureCount < markTypes.size) { 1233 + return false; 1234 + } 1235 + } 1236 + return true; 1237 + }), 1238 + { numRuns: 1000 }, 1239 + ); 1240 + }); 1241 + 1242 + it("byte offsets are valid UTF-8 positions", () => { 1243 + fc.assert( 1244 + fc.property(arbDocument, (doc) => { 1245 + const lexicon = treeToLexicon(doc); 1246 + 1247 + for (const block of lexicon.content) { 1248 + const text = block.text ?? ""; 1249 + const textBytes = new TextEncoder().encode(text); 1250 + const facets = block.facets ?? []; 1251 + 1252 + for (const facet of facets) { 1253 + const { byteStart, byteEnd } = facet.index; 1254 + 1255 + // Check bounds 1256 + if (byteStart < 0 || byteEnd > textBytes.length) { 1257 + return false; 1258 + } 1259 + if (byteStart > byteEnd) { 1260 + return false; 1261 + } 1262 + 1263 + // Check that slicing at these positions produces valid UTF-8 1264 + try { 1265 + new TextDecoder("utf-8", { fatal: true }).decode( 1266 + textBytes.slice(byteStart, byteEnd), 1267 + ); 1268 + } catch { 1269 + return false; 1270 + } 1271 + } 1272 + } 1273 + return true; 1274 + }), 1275 + { numRuns: 1000 }, 1276 + ); 1277 + }); 1278 + });
+4 -2
src/lib/deck-queries.ts
··· 82 82 83 83 const result = await createDeckRecord(agent, { 84 84 $type: "com.deckbelcher.deck.list", 85 - ...deck, 85 + name: deck.name, 86 + format: deck.format, 87 + primer: deck.primer, 86 88 cards: deck.cards.map((card) => ({ 87 89 ...card, 88 90 scryfallId: card.scryfallId as string, ··· 135 137 $type: "com.deckbelcher.deck.list", 136 138 name: deck.name, 137 139 format: deck.format, 140 + primer: deck.primer, 138 141 cards: deck.cards.map((card) => ({ 139 142 ...card, 140 143 scryfallId: card.scryfallId as string, 141 144 })), 142 - primer: deck.primer, 143 145 createdAt: deck.createdAt, 144 146 updatedAt: new Date().toISOString(), 145 147 });
+1 -1
src/lib/lexicons/index.ts
··· 2 2 export * as ComDeckbelcherActorProfile from "./types/com/deckbelcher/actor/profile.js"; 3 3 export * as ComDeckbelcherCollectionList from "./types/com/deckbelcher/collection/list.js"; 4 4 export * as ComDeckbelcherDeckList from "./types/com/deckbelcher/deck/list.js"; 5 - export * as ComDeckbelcherRichtextFacet from "./types/com/deckbelcher/richtext/facet.js"; 6 5 export * as ComDeckbelcherRichtext from "./types/com/deckbelcher/richtext.js"; 6 + export * as ComDeckbelcherRichtextFacet from "./types/com/deckbelcher/richtext/facet.js"; 7 7 export * as ComDeckbelcherSocialLike from "./types/com/deckbelcher/social/like.js";
+5 -5
src/lib/lexicons/types/com/atproto/repo/strongRef.ts
··· 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 4 4 const _mainSchema = /*#__PURE__*/ v.object({ 5 - $type: /*#__PURE__*/ v.optional( 6 - /*#__PURE__*/ v.literal("com.atproto.repo.strongRef"), 7 - ), 8 - cid: /*#__PURE__*/ v.cidString(), 9 - uri: /*#__PURE__*/ v.resourceUriString(), 5 + $type: /*#__PURE__*/ v.optional( 6 + /*#__PURE__*/ v.literal("com.atproto.repo.strongRef"), 7 + ), 8 + cid: /*#__PURE__*/ v.cidString(), 9 + uri: /*#__PURE__*/ v.resourceUriString(), 10 10 }); 11 11 12 12 type main$schematype = typeof _mainSchema;
+53 -53
src/lib/lexicons/types/com/deckbelcher/actor/profile.ts
··· 1 1 import type {} from "@atcute/lexicons"; 2 - import type {} from "@atcute/lexicons/ambient"; 3 2 import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 4 import * as ComDeckbelcherRichtextFacet from "../richtext/facet.js"; 5 5 6 6 const _mainSchema = /*#__PURE__*/ v.record( 7 - /*#__PURE__*/ v.literal("self"), 8 - /*#__PURE__*/ v.object({ 9 - $type: /*#__PURE__*/ v.literal("com.deckbelcher.actor.profile"), 10 - /** 11 - * Timestamp when the profile was created. 12 - */ 13 - createdAt: /*#__PURE__*/ v.datetimeString(), 14 - /** 15 - * Free-form profile description. 16 - * @maxLength 2560 17 - * @maxGraphemes 256 18 - */ 19 - description: /*#__PURE__*/ v.optional( 20 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 21 - /*#__PURE__*/ v.stringLength(0, 2560), 22 - /*#__PURE__*/ v.stringGraphemes(0, 256), 23 - ]), 24 - ), 25 - /** 26 - * Annotations of text in the profile description (mentions, URLs, hashtags, etc). 27 - */ 28 - get descriptionFacets() { 29 - return /*#__PURE__*/ v.optional( 30 - /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 31 - ); 32 - }, 33 - /** 34 - * User's display name. 35 - * @maxLength 640 36 - * @maxGraphemes 64 37 - */ 38 - displayName: /*#__PURE__*/ v.optional( 39 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 40 - /*#__PURE__*/ v.stringLength(0, 640), 41 - /*#__PURE__*/ v.stringGraphemes(0, 64), 42 - ]), 43 - ), 44 - /** 45 - * Free-form pronouns text. 46 - * @maxLength 200 47 - * @maxGraphemes 20 48 - */ 49 - pronouns: /*#__PURE__*/ v.optional( 50 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 51 - /*#__PURE__*/ v.stringLength(0, 200), 52 - /*#__PURE__*/ v.stringGraphemes(0, 20), 53 - ]), 54 - ), 55 - }), 7 + /*#__PURE__*/ v.literal("self"), 8 + /*#__PURE__*/ v.object({ 9 + $type: /*#__PURE__*/ v.literal("com.deckbelcher.actor.profile"), 10 + /** 11 + * Timestamp when the profile was created. 12 + */ 13 + createdAt: /*#__PURE__*/ v.datetimeString(), 14 + /** 15 + * Free-form profile description. 16 + * @maxLength 2560 17 + * @maxGraphemes 256 18 + */ 19 + description: /*#__PURE__*/ v.optional( 20 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 21 + /*#__PURE__*/ v.stringLength(0, 2560), 22 + /*#__PURE__*/ v.stringGraphemes(0, 256), 23 + ]), 24 + ), 25 + /** 26 + * Annotations of text in the profile description (mentions, URLs, hashtags, etc). 27 + */ 28 + get descriptionFacets() { 29 + return /*#__PURE__*/ v.optional( 30 + /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 31 + ); 32 + }, 33 + /** 34 + * User's display name. 35 + * @maxLength 640 36 + * @maxGraphemes 64 37 + */ 38 + displayName: /*#__PURE__*/ v.optional( 39 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 40 + /*#__PURE__*/ v.stringLength(0, 640), 41 + /*#__PURE__*/ v.stringGraphemes(0, 64), 42 + ]), 43 + ), 44 + /** 45 + * Free-form pronouns text. 46 + * @maxLength 200 47 + * @maxGraphemes 20 48 + */ 49 + pronouns: /*#__PURE__*/ v.optional( 50 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 51 + /*#__PURE__*/ v.stringLength(0, 200), 52 + /*#__PURE__*/ v.stringGraphemes(0, 20), 53 + ]), 54 + ), 55 + }), 56 56 ); 57 57 58 58 type main$schematype = typeof _mainSchema; ··· 64 64 export interface Main extends v.InferInput<typeof mainSchema> {} 65 65 66 66 declare module "@atcute/lexicons/ambient" { 67 - interface Records { 68 - "com.deckbelcher.actor.profile": mainSchema; 69 - } 67 + interface Records { 68 + "com.deckbelcher.actor.profile": mainSchema; 69 + } 70 70 }
+61 -61
src/lib/lexicons/types/com/deckbelcher/collection/list.ts
··· 1 1 import type {} from "@atcute/lexicons"; 2 - import type {} from "@atcute/lexicons/ambient"; 3 2 import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 4 import * as ComDeckbelcherRichtext from "../richtext.js"; 5 5 6 6 const _cardItemSchema = /*#__PURE__*/ v.object({ 7 - $type: /*#__PURE__*/ v.optional( 8 - /*#__PURE__*/ v.literal("com.deckbelcher.collection.list#cardItem"), 9 - ), 10 - /** 11 - * Timestamp when this item was added to the list. 12 - */ 13 - addedAt: /*#__PURE__*/ v.datetimeString(), 14 - /** 15 - * Scryfall UUID for the card. 16 - */ 17 - scryfallId: /*#__PURE__*/ v.string(), 7 + $type: /*#__PURE__*/ v.optional( 8 + /*#__PURE__*/ v.literal("com.deckbelcher.collection.list#cardItem"), 9 + ), 10 + /** 11 + * Timestamp when this item was added to the list. 12 + */ 13 + addedAt: /*#__PURE__*/ v.datetimeString(), 14 + /** 15 + * Scryfall UUID for the card. 16 + */ 17 + scryfallId: /*#__PURE__*/ v.string(), 18 18 }); 19 19 const _deckItemSchema = /*#__PURE__*/ v.object({ 20 - $type: /*#__PURE__*/ v.optional( 21 - /*#__PURE__*/ v.literal("com.deckbelcher.collection.list#deckItem"), 22 - ), 23 - /** 24 - * Timestamp when this item was added to the list. 25 - */ 26 - addedAt: /*#__PURE__*/ v.datetimeString(), 27 - /** 28 - * AT-URI of the deck record. 29 - */ 30 - deckUri: /*#__PURE__*/ v.resourceUriString(), 20 + $type: /*#__PURE__*/ v.optional( 21 + /*#__PURE__*/ v.literal("com.deckbelcher.collection.list#deckItem"), 22 + ), 23 + /** 24 + * Timestamp when this item was added to the list. 25 + */ 26 + addedAt: /*#__PURE__*/ v.datetimeString(), 27 + /** 28 + * AT-URI of the deck record. 29 + */ 30 + deckUri: /*#__PURE__*/ v.resourceUriString(), 31 31 }); 32 32 const _mainSchema = /*#__PURE__*/ v.record( 33 - /*#__PURE__*/ v.tidString(), 34 - /*#__PURE__*/ v.object({ 35 - $type: /*#__PURE__*/ v.literal("com.deckbelcher.collection.list"), 36 - /** 37 - * Timestamp when the list was created. 38 - */ 39 - createdAt: /*#__PURE__*/ v.datetimeString(), 40 - /** 41 - * Description of the list. 42 - */ 43 - get description() { 44 - return /*#__PURE__*/ v.optional(ComDeckbelcherRichtext.mainSchema); 45 - }, 46 - /** 47 - * Items in the list. 48 - */ 49 - get items() { 50 - return /*#__PURE__*/ v.array( 51 - /*#__PURE__*/ v.variant([cardItemSchema, deckItemSchema]), 52 - ); 53 - }, 54 - /** 55 - * Name of the list. 56 - * @maxLength 1280 57 - * @maxGraphemes 128 58 - */ 59 - name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 60 - /*#__PURE__*/ v.stringLength(0, 1280), 61 - /*#__PURE__*/ v.stringGraphemes(0, 128), 62 - ]), 63 - /** 64 - * Timestamp when the list was last updated. 65 - */ 66 - updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 67 - }), 33 + /*#__PURE__*/ v.tidString(), 34 + /*#__PURE__*/ v.object({ 35 + $type: /*#__PURE__*/ v.literal("com.deckbelcher.collection.list"), 36 + /** 37 + * Timestamp when the list was created. 38 + */ 39 + createdAt: /*#__PURE__*/ v.datetimeString(), 40 + /** 41 + * Description of the list. 42 + */ 43 + get description() { 44 + return /*#__PURE__*/ v.optional(ComDeckbelcherRichtext.mainSchema); 45 + }, 46 + /** 47 + * Items in the list. 48 + */ 49 + get items() { 50 + return /*#__PURE__*/ v.array( 51 + /*#__PURE__*/ v.variant([cardItemSchema, deckItemSchema]), 52 + ); 53 + }, 54 + /** 55 + * Name of the list. 56 + * @maxLength 1280 57 + * @maxGraphemes 128 58 + */ 59 + name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 60 + /*#__PURE__*/ v.stringLength(0, 1280), 61 + /*#__PURE__*/ v.stringGraphemes(0, 128), 62 + ]), 63 + /** 64 + * Timestamp when the list was last updated. 65 + */ 66 + updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 67 + }), 68 68 ); 69 69 70 70 type cardItem$schematype = typeof _cardItemSchema; ··· 84 84 export interface Main extends v.InferInput<typeof mainSchema> {} 85 85 86 86 declare module "@atcute/lexicons/ambient" { 87 - interface Records { 88 - "com.deckbelcher.collection.list": mainSchema; 89 - } 87 + interface Records { 88 + "com.deckbelcher.collection.list": mainSchema; 89 + } 90 90 }
+83 -83
src/lib/lexicons/types/com/deckbelcher/deck/list.ts
··· 1 1 import type {} from "@atcute/lexicons"; 2 - import type {} from "@atcute/lexicons/ambient"; 3 2 import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 4 import * as ComDeckbelcherRichtext from "../richtext.js"; 5 5 6 6 const _cardSchema = /*#__PURE__*/ v.object({ 7 - $type: /*#__PURE__*/ v.optional( 8 - /*#__PURE__*/ v.literal("com.deckbelcher.deck.list#card"), 9 - ), 10 - /** 11 - * Number of copies in the deck. 12 - * @minimum 1 13 - */ 14 - quantity: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 15 - /*#__PURE__*/ v.integerRange(1), 16 - ]), 17 - /** 18 - * Scryfall UUID for the specific printing. 19 - */ 20 - scryfallId: /*#__PURE__*/ v.string(), 21 - /** 22 - * Which section of the deck this card belongs to. Extensible to support format-specific sections. 23 - */ 24 - section: /*#__PURE__*/ v.string< 25 - "commander" | "mainboard" | "maybeboard" | "sideboard" | (string & {}) 26 - >(), 27 - /** 28 - * User annotations for this card in this deck (e.g., "removal", "wincon", "ramp"). 29 - * @maxLength 128 30 - */ 31 - tags: /*#__PURE__*/ v.optional( 32 - /*#__PURE__*/ v.constrain( 33 - /*#__PURE__*/ v.array( 34 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 35 - /*#__PURE__*/ v.stringLength(0, 640), 36 - /*#__PURE__*/ v.stringGraphemes(0, 64), 37 - ]), 38 - ), 39 - [/*#__PURE__*/ v.arrayLength(0, 128)], 40 - ), 41 - ), 7 + $type: /*#__PURE__*/ v.optional( 8 + /*#__PURE__*/ v.literal("com.deckbelcher.deck.list#card"), 9 + ), 10 + /** 11 + * Number of copies in the deck. 12 + * @minimum 1 13 + */ 14 + quantity: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 15 + /*#__PURE__*/ v.integerRange(1), 16 + ]), 17 + /** 18 + * Scryfall UUID for the specific printing. 19 + */ 20 + scryfallId: /*#__PURE__*/ v.string(), 21 + /** 22 + * Which section of the deck this card belongs to. Extensible to support format-specific sections. 23 + */ 24 + section: /*#__PURE__*/ v.string< 25 + "commander" | "mainboard" | "maybeboard" | "sideboard" | (string & {}) 26 + >(), 27 + /** 28 + * User annotations for this card in this deck (e.g., "removal", "wincon", "ramp"). 29 + * @maxLength 128 30 + */ 31 + tags: /*#__PURE__*/ v.optional( 32 + /*#__PURE__*/ v.constrain( 33 + /*#__PURE__*/ v.array( 34 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 35 + /*#__PURE__*/ v.stringLength(0, 640), 36 + /*#__PURE__*/ v.stringGraphemes(0, 64), 37 + ]), 38 + ), 39 + [/*#__PURE__*/ v.arrayLength(0, 128)], 40 + ), 41 + ), 42 42 }); 43 43 const _mainSchema = /*#__PURE__*/ v.record( 44 - /*#__PURE__*/ v.tidString(), 45 - /*#__PURE__*/ v.object({ 46 - $type: /*#__PURE__*/ v.literal("com.deckbelcher.deck.list"), 47 - /** 48 - * Array of cards in the decklist. 49 - */ 50 - get cards() { 51 - return /*#__PURE__*/ v.array(cardSchema); 52 - }, 53 - /** 54 - * Timestamp when the decklist was created. 55 - */ 56 - createdAt: /*#__PURE__*/ v.datetimeString(), 57 - /** 58 - * Format of the deck (e.g., "commander", "cube", "pauper"). 59 - * @maxLength 320 60 - * @maxGraphemes 32 61 - */ 62 - format: /*#__PURE__*/ v.optional( 63 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 64 - /*#__PURE__*/ v.stringLength(0, 320), 65 - /*#__PURE__*/ v.stringGraphemes(0, 32), 66 - ]), 67 - ), 68 - /** 69 - * Name of the decklist. 70 - * @maxLength 1280 71 - * @maxGraphemes 128 72 - */ 73 - name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 74 - /*#__PURE__*/ v.stringLength(0, 1280), 75 - /*#__PURE__*/ v.stringGraphemes(0, 128), 76 - ]), 77 - /** 78 - * Deck primer with strategy, combos, and card choices. 79 - */ 80 - get primer() { 81 - return /*#__PURE__*/ v.optional(ComDeckbelcherRichtext.mainSchema); 82 - }, 83 - /** 84 - * Timestamp when the decklist was last updated. 85 - */ 86 - updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 87 - }), 44 + /*#__PURE__*/ v.tidString(), 45 + /*#__PURE__*/ v.object({ 46 + $type: /*#__PURE__*/ v.literal("com.deckbelcher.deck.list"), 47 + /** 48 + * Array of cards in the decklist. 49 + */ 50 + get cards() { 51 + return /*#__PURE__*/ v.array(cardSchema); 52 + }, 53 + /** 54 + * Timestamp when the decklist was created. 55 + */ 56 + createdAt: /*#__PURE__*/ v.datetimeString(), 57 + /** 58 + * Format of the deck (e.g., "commander", "cube", "pauper"). 59 + * @maxLength 320 60 + * @maxGraphemes 32 61 + */ 62 + format: /*#__PURE__*/ v.optional( 63 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 64 + /*#__PURE__*/ v.stringLength(0, 320), 65 + /*#__PURE__*/ v.stringGraphemes(0, 32), 66 + ]), 67 + ), 68 + /** 69 + * Name of the decklist. 70 + * @maxLength 1280 71 + * @maxGraphemes 128 72 + */ 73 + name: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 74 + /*#__PURE__*/ v.stringLength(0, 1280), 75 + /*#__PURE__*/ v.stringGraphemes(0, 128), 76 + ]), 77 + /** 78 + * Deck primer with strategy, combos, and card choices. 79 + */ 80 + get primer() { 81 + return /*#__PURE__*/ v.optional(ComDeckbelcherRichtext.documentSchema); 82 + }, 83 + /** 84 + * Timestamp when the decklist was last updated. 85 + */ 86 + updatedAt: /*#__PURE__*/ v.optional(/*#__PURE__*/ v.datetimeString()), 87 + }), 88 88 ); 89 89 90 90 type card$schematype = typeof _cardSchema; ··· 100 100 export interface Main extends v.InferInput<typeof mainSchema> {} 101 101 102 102 declare module "@atcute/lexicons/ambient" { 103 - interface Records { 104 - "com.deckbelcher.deck.list": mainSchema; 105 - } 103 + interface Records { 104 + "com.deckbelcher.deck.list": mainSchema; 105 + } 106 106 }
+105 -22
src/lib/lexicons/types/com/deckbelcher/richtext.ts
··· 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 import * as ComDeckbelcherRichtextFacet from "./richtext/facet.js"; 4 4 5 + const _documentSchema = /*#__PURE__*/ v.object({ 6 + $type: /*#__PURE__*/ v.optional( 7 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext#document"), 8 + ), 9 + /** 10 + * Array of blocks (paragraphs, headings, etc). 11 + */ 12 + get content() { 13 + return /*#__PURE__*/ v.array( 14 + /*#__PURE__*/ v.variant([headingBlockSchema, paragraphBlockSchema]), 15 + ); 16 + }, 17 + }); 18 + const _headingBlockSchema = /*#__PURE__*/ v.object({ 19 + $type: /*#__PURE__*/ v.optional( 20 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext#headingBlock"), 21 + ), 22 + /** 23 + * Annotations of text (formatting, mentions, links, etc). 24 + */ 25 + get facets() { 26 + return /*#__PURE__*/ v.optional( 27 + /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 28 + ); 29 + }, 30 + /** 31 + * Heading level (1-6). 32 + * @minimum 1 33 + * @maximum 6 34 + */ 35 + level: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.integer(), [ 36 + /*#__PURE__*/ v.integerRange(1, 6), 37 + ]), 38 + /** 39 + * The plain text content (no markdown symbols). 40 + * @maxLength 10000 41 + * @maxGraphemes 1000 42 + */ 43 + text: /*#__PURE__*/ v.optional( 44 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 45 + /*#__PURE__*/ v.stringLength(0, 10000), 46 + /*#__PURE__*/ v.stringGraphemes(0, 1000), 47 + ]), 48 + ), 49 + }); 5 50 const _mainSchema = /*#__PURE__*/ v.object({ 6 - $type: /*#__PURE__*/ v.optional( 7 - /*#__PURE__*/ v.literal("com.deckbelcher.richtext"), 8 - ), 9 - /** 10 - * Annotations of text (mentions, URLs, hashtags, card references, etc). 11 - */ 12 - get facets() { 13 - return /*#__PURE__*/ v.optional( 14 - /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 15 - ); 16 - }, 17 - /** 18 - * The text content. 19 - * @maxLength 500000 20 - * @maxGraphemes 50000 21 - */ 22 - text: /*#__PURE__*/ v.optional( 23 - /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 24 - /*#__PURE__*/ v.stringLength(0, 500000), 25 - /*#__PURE__*/ v.stringGraphemes(0, 50000), 26 - ]), 27 - ), 51 + $type: /*#__PURE__*/ v.optional( 52 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext"), 53 + ), 54 + /** 55 + * Annotations of text (mentions, URLs, hashtags, formatting, etc). 56 + */ 57 + get facets() { 58 + return /*#__PURE__*/ v.optional( 59 + /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 60 + ); 61 + }, 62 + /** 63 + * The plain text content (no markdown symbols). 64 + * @maxLength 500000 65 + * @maxGraphemes 50000 66 + */ 67 + text: /*#__PURE__*/ v.optional( 68 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 69 + /*#__PURE__*/ v.stringLength(0, 500000), 70 + /*#__PURE__*/ v.stringGraphemes(0, 50000), 71 + ]), 72 + ), 73 + }); 74 + const _paragraphBlockSchema = /*#__PURE__*/ v.object({ 75 + $type: /*#__PURE__*/ v.optional( 76 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext#paragraphBlock"), 77 + ), 78 + /** 79 + * Annotations of text (formatting, mentions, links, etc). 80 + */ 81 + get facets() { 82 + return /*#__PURE__*/ v.optional( 83 + /*#__PURE__*/ v.array(ComDeckbelcherRichtextFacet.mainSchema), 84 + ); 85 + }, 86 + /** 87 + * The plain text content (no markdown symbols). 88 + * @maxLength 500000 89 + * @maxGraphemes 50000 90 + */ 91 + text: /*#__PURE__*/ v.optional( 92 + /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 93 + /*#__PURE__*/ v.stringLength(0, 500000), 94 + /*#__PURE__*/ v.stringGraphemes(0, 50000), 95 + ]), 96 + ), 28 97 }); 29 98 99 + type document$schematype = typeof _documentSchema; 100 + type headingBlock$schematype = typeof _headingBlockSchema; 30 101 type main$schematype = typeof _mainSchema; 102 + type paragraphBlock$schematype = typeof _paragraphBlockSchema; 31 103 104 + export interface documentSchema extends document$schematype {} 105 + export interface headingBlockSchema extends headingBlock$schematype {} 32 106 export interface mainSchema extends main$schematype {} 107 + export interface paragraphBlockSchema extends paragraphBlock$schematype {} 33 108 109 + export const documentSchema = _documentSchema as documentSchema; 110 + export const headingBlockSchema = _headingBlockSchema as headingBlockSchema; 34 111 export const mainSchema = _mainSchema as mainSchema; 112 + export const paragraphBlockSchema = 113 + _paragraphBlockSchema as paragraphBlockSchema; 35 114 115 + export interface Document extends v.InferInput<typeof documentSchema> {} 116 + export interface HeadingBlock extends v.InferInput<typeof headingBlockSchema> {} 36 117 export interface Main extends v.InferInput<typeof mainSchema> {} 118 + export interface ParagraphBlock 119 + extends v.InferInput<typeof paragraphBlockSchema> {}
+61 -61
src/lib/lexicons/types/com/deckbelcher/richtext/facet.ts
··· 2 2 import * as v from "@atcute/lexicons/validations"; 3 3 4 4 const _boldSchema = /*#__PURE__*/ v.object({ 5 - $type: /*#__PURE__*/ v.optional( 6 - /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#bold"), 7 - ), 5 + $type: /*#__PURE__*/ v.optional( 6 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#bold"), 7 + ), 8 8 }); 9 9 const _byteSliceSchema = /*#__PURE__*/ v.object({ 10 - $type: /*#__PURE__*/ v.optional( 11 - /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#byteSlice"), 12 - ), 13 - /** 14 - * @minimum 0 15 - */ 16 - byteEnd: /*#__PURE__*/ v.integer(), 17 - /** 18 - * @minimum 0 19 - */ 20 - byteStart: /*#__PURE__*/ v.integer(), 10 + $type: /*#__PURE__*/ v.optional( 11 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#byteSlice"), 12 + ), 13 + /** 14 + * @minimum 0 15 + */ 16 + byteEnd: /*#__PURE__*/ v.integer(), 17 + /** 18 + * @minimum 0 19 + */ 20 + byteStart: /*#__PURE__*/ v.integer(), 21 21 }); 22 22 const _codeSchema = /*#__PURE__*/ v.object({ 23 - $type: /*#__PURE__*/ v.optional( 24 - /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#code"), 25 - ), 23 + $type: /*#__PURE__*/ v.optional( 24 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#code"), 25 + ), 26 26 }); 27 27 const _codeBlockSchema = /*#__PURE__*/ v.object({ 28 - $type: /*#__PURE__*/ v.optional( 29 - /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#codeBlock"), 30 - ), 28 + $type: /*#__PURE__*/ v.optional( 29 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#codeBlock"), 30 + ), 31 31 }); 32 32 const _italicSchema = /*#__PURE__*/ v.object({ 33 - $type: /*#__PURE__*/ v.optional( 34 - /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#italic"), 35 - ), 33 + $type: /*#__PURE__*/ v.optional( 34 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#italic"), 35 + ), 36 36 }); 37 37 const _linkSchema = /*#__PURE__*/ v.object({ 38 - $type: /*#__PURE__*/ v.optional( 39 - /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#link"), 40 - ), 41 - uri: /*#__PURE__*/ v.genericUriString(), 38 + $type: /*#__PURE__*/ v.optional( 39 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#link"), 40 + ), 41 + uri: /*#__PURE__*/ v.genericUriString(), 42 42 }); 43 43 const _mainSchema = /*#__PURE__*/ v.object({ 44 - $type: /*#__PURE__*/ v.optional( 45 - /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet"), 46 - ), 47 - get features() { 48 - return /*#__PURE__*/ v.array( 49 - /*#__PURE__*/ v.variant([ 50 - boldSchema, 51 - codeSchema, 52 - codeBlockSchema, 53 - italicSchema, 54 - linkSchema, 55 - mentionSchema, 56 - tagSchema, 57 - ]), 58 - ); 59 - }, 60 - get index() { 61 - return byteSliceSchema; 62 - }, 44 + $type: /*#__PURE__*/ v.optional( 45 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet"), 46 + ), 47 + get features() { 48 + return /*#__PURE__*/ v.array( 49 + /*#__PURE__*/ v.variant([ 50 + boldSchema, 51 + codeSchema, 52 + codeBlockSchema, 53 + italicSchema, 54 + linkSchema, 55 + mentionSchema, 56 + tagSchema, 57 + ]), 58 + ); 59 + }, 60 + get index() { 61 + return byteSliceSchema; 62 + }, 63 63 }); 64 64 const _mentionSchema = /*#__PURE__*/ v.object({ 65 - $type: /*#__PURE__*/ v.optional( 66 - /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#mention"), 67 - ), 68 - did: /*#__PURE__*/ v.didString(), 65 + $type: /*#__PURE__*/ v.optional( 66 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#mention"), 67 + ), 68 + did: /*#__PURE__*/ v.didString(), 69 69 }); 70 70 const _tagSchema = /*#__PURE__*/ v.object({ 71 - $type: /*#__PURE__*/ v.optional( 72 - /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#tag"), 73 - ), 74 - /** 75 - * @maxLength 640 76 - * @maxGraphemes 64 77 - */ 78 - tag: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 79 - /*#__PURE__*/ v.stringLength(0, 640), 80 - /*#__PURE__*/ v.stringGraphemes(0, 64), 81 - ]), 71 + $type: /*#__PURE__*/ v.optional( 72 + /*#__PURE__*/ v.literal("com.deckbelcher.richtext.facet#tag"), 73 + ), 74 + /** 75 + * @maxLength 640 76 + * @maxGraphemes 64 77 + */ 78 + tag: /*#__PURE__*/ v.constrain(/*#__PURE__*/ v.string(), [ 79 + /*#__PURE__*/ v.stringLength(0, 640), 80 + /*#__PURE__*/ v.stringGraphemes(0, 64), 81 + ]), 82 82 }); 83 83 84 84 type bold$schematype = typeof _boldSchema;
+18 -18
src/lib/lexicons/types/com/deckbelcher/social/like.ts
··· 1 1 import type {} from "@atcute/lexicons"; 2 - import type {} from "@atcute/lexicons/ambient"; 3 2 import * as v from "@atcute/lexicons/validations"; 3 + import type {} from "@atcute/lexicons/ambient"; 4 4 import * as ComAtprotoRepoStrongRef from "../../atproto/repo/strongRef.js"; 5 5 6 6 const _mainSchema = /*#__PURE__*/ v.record( 7 - /*#__PURE__*/ v.tidString(), 8 - /*#__PURE__*/ v.object({ 9 - $type: /*#__PURE__*/ v.literal("com.deckbelcher.social.like"), 10 - /** 11 - * Timestamp when the like was created. 12 - */ 13 - createdAt: /*#__PURE__*/ v.datetimeString(), 14 - /** 15 - * Reference to the content being liked. 16 - */ 17 - get subject() { 18 - return ComAtprotoRepoStrongRef.mainSchema; 19 - }, 20 - }), 7 + /*#__PURE__*/ v.tidString(), 8 + /*#__PURE__*/ v.object({ 9 + $type: /*#__PURE__*/ v.literal("com.deckbelcher.social.like"), 10 + /** 11 + * Timestamp when the like was created. 12 + */ 13 + createdAt: /*#__PURE__*/ v.datetimeString(), 14 + /** 15 + * Reference to the content being liked. 16 + */ 17 + get subject() { 18 + return ComAtprotoRepoStrongRef.mainSchema; 19 + }, 20 + }), 21 21 ); 22 22 23 23 type main$schematype = typeof _mainSchema; ··· 29 29 export interface Main extends v.InferInput<typeof mainSchema> {} 30 30 31 31 declare module "@atcute/lexicons/ambient" { 32 - interface Records { 33 - "com.deckbelcher.social.like": mainSchema; 34 - } 32 + interface Records { 33 + "com.deckbelcher.social.like": mainSchema; 34 + } 35 35 }
+362
src/lib/richtext-convert.ts
··· 1 + import type { Mark, Node as ProseMirrorNode } from "prosemirror-model"; 2 + import { schema } from "@/components/richtext/schema"; 3 + import type { 4 + HeadingBlock, 5 + Document as LexiconDocument, 6 + ParagraphBlock, 7 + } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 8 + import type { 9 + Bold, 10 + Code, 11 + Main as Facet, 12 + Italic, 13 + Link, 14 + } from "@/lib/lexicons/types/com/deckbelcher/richtext/facet"; 15 + 16 + export type { LexiconDocument }; 17 + 18 + /** 19 + * Make $type required - distributes over unions. 20 + * Use this when creating records for storage where $type must be present. 21 + */ 22 + type Typed<T> = T extends { $type?: string } 23 + ? T & { $type: NonNullable<T["$type"]> } 24 + : T; 25 + 26 + type Block = Typed<ParagraphBlock | HeadingBlock>; 27 + type Feature = Typed<Bold | Italic | Code | Link>; 28 + 29 + /** 30 + * Convert a tree (ProseMirror doc) to lexicon format for storage. 31 + */ 32 + export function treeToLexicon(doc: ProseMirrorNode): LexiconDocument { 33 + const content: Block[] = []; 34 + 35 + doc.forEach((block) => { 36 + if (block.type.name === "paragraph") { 37 + content.push(paragraphToLexicon(block)); 38 + } else if (block.type.name === "heading") { 39 + content.push(headingToLexicon(block)); 40 + } 41 + // TODO: handle other block types (code_block, blockquote) 42 + }); 43 + 44 + return { content }; 45 + } 46 + 47 + /** 48 + * Convert a paragraph node to lexicon format. 49 + */ 50 + function paragraphToLexicon(node: ProseMirrorNode): Typed<ParagraphBlock> { 51 + const { text, facets } = extractTextAndFacets(node); 52 + return { 53 + $type: "com.deckbelcher.richtext#paragraphBlock", 54 + text: text || undefined, 55 + facets: facets.length > 0 ? facets : undefined, 56 + }; 57 + } 58 + 59 + /** 60 + * Convert a heading node to lexicon format. 61 + */ 62 + function headingToLexicon(node: ProseMirrorNode): Typed<HeadingBlock> { 63 + const { text, facets } = extractTextAndFacets(node); 64 + const level = (node.attrs.level as number) || 1; 65 + return { 66 + $type: "com.deckbelcher.richtext#headingBlock", 67 + level, 68 + text: text || undefined, 69 + facets: facets.length > 0 ? facets : undefined, 70 + }; 71 + } 72 + 73 + /** 74 + * Extract text content and facets from a block node. 75 + */ 76 + function extractTextAndFacets(node: ProseMirrorNode): { 77 + text: string; 78 + facets: Facet[]; 79 + } { 80 + const textParts: string[] = []; 81 + const facets: Facet[] = []; 82 + 83 + // Track active marks and their start byte positions 84 + const activeMarks = new Map<string, { byteStart: number; attrs?: unknown }>(); 85 + 86 + let byteOffset = 0; 87 + 88 + node.forEach((child) => { 89 + if (child.isText && child.text) { 90 + const text = child.text; 91 + const textBytes = new TextEncoder().encode(text); 92 + const startByte = byteOffset; 93 + const endByte = byteOffset + textBytes.length; 94 + 95 + textParts.push(text); 96 + 97 + // Get current marks on this text node 98 + const currentMarkKeys = new Set(child.marks.map((m) => markKey(m))); 99 + 100 + // Close marks that are no longer active 101 + for (const [key, data] of activeMarks) { 102 + if (!currentMarkKeys.has(key)) { 103 + const feature = markToFeature(key, data.attrs); 104 + if (feature) { 105 + facets.push({ 106 + index: { byteStart: data.byteStart, byteEnd: startByte }, 107 + features: [feature], 108 + }); 109 + } 110 + activeMarks.delete(key); 111 + } 112 + } 113 + 114 + // Open new marks 115 + for (const mark of child.marks) { 116 + const key = markKey(mark); 117 + if (!activeMarks.has(key)) { 118 + activeMarks.set(key, { byteStart: startByte, attrs: mark.attrs }); 119 + } 120 + } 121 + 122 + byteOffset = endByte; 123 + } else if (child.type.name === "mention") { 124 + // Inline node - render as @handle placeholder 125 + const handle = (child.attrs.handle as string) || ""; 126 + const displayText = `@${handle}`; 127 + const textBytes = new TextEncoder().encode(displayText); 128 + 129 + textParts.push(displayText); 130 + 131 + if (child.attrs.did) { 132 + facets.push({ 133 + index: { 134 + byteStart: byteOffset, 135 + byteEnd: byteOffset + textBytes.length, 136 + }, 137 + features: [ 138 + { 139 + $type: "com.deckbelcher.richtext.facet#mention", 140 + did: child.attrs.did as `did:${string}:${string}`, 141 + }, 142 + ], 143 + }); 144 + } 145 + 146 + byteOffset += textBytes.length; 147 + } 148 + // TODO: handle cardRef and other inline nodes 149 + }); 150 + 151 + // Close any remaining active marks 152 + for (const [key, data] of activeMarks) { 153 + const feature = markToFeature(key, data.attrs); 154 + if (feature) { 155 + facets.push({ 156 + index: { byteStart: data.byteStart, byteEnd: byteOffset }, 157 + features: [feature], 158 + }); 159 + } 160 + } 161 + 162 + // Merge facets with the same byte range 163 + const mergedFacets = mergeFacets(facets); 164 + 165 + return { text: textParts.join(""), facets: mergedFacets }; 166 + } 167 + 168 + /** 169 + * Merge facets that have the same byte range into single facets with multiple features. 170 + */ 171 + function mergeFacets(facets: Facet[]): Facet[] { 172 + if (facets.length === 0) return []; 173 + 174 + const byRange = new Map<string, Facet>(); 175 + 176 + for (const facet of facets) { 177 + const key = `${facet.index.byteStart}:${facet.index.byteEnd}`; 178 + const existing = byRange.get(key); 179 + if (existing) { 180 + existing.features.push(...facet.features); 181 + } else { 182 + byRange.set(key, { 183 + index: { ...facet.index }, 184 + features: [...facet.features], 185 + }); 186 + } 187 + } 188 + 189 + return Array.from(byRange.values()); 190 + } 191 + 192 + function markKey(mark: Mark): string { 193 + if (mark.type.name === "link" && mark.attrs) { 194 + return `link:${(mark.attrs as { href?: string }).href}`; 195 + } 196 + return mark.type.name; 197 + } 198 + 199 + function markToFeature(key: string, attrs?: unknown): Feature | null { 200 + if (key === "strong") { 201 + return { $type: "com.deckbelcher.richtext.facet#bold" }; 202 + } 203 + if (key === "em") { 204 + return { $type: "com.deckbelcher.richtext.facet#italic" }; 205 + } 206 + if (key === "code") { 207 + return { $type: "com.deckbelcher.richtext.facet#code" }; 208 + } 209 + if (key.startsWith("link:")) { 210 + const href = (attrs as { href?: string })?.href || ""; 211 + return { 212 + $type: "com.deckbelcher.richtext.facet#link", 213 + uri: href as `${string}:${string}`, 214 + }; 215 + } 216 + return null; 217 + } 218 + 219 + /** 220 + * Convert lexicon format to a tree (ProseMirror doc) for editing. 221 + */ 222 + export function lexiconToTree(doc: LexiconDocument): ProseMirrorNode { 223 + const blocks = doc.content.map((block) => lexiconBlockToTree(block)); 224 + return schema.node("doc", null, blocks); 225 + } 226 + 227 + function lexiconBlockToTree( 228 + block: ParagraphBlock | HeadingBlock, 229 + ): ProseMirrorNode { 230 + switch (block.$type) { 231 + case "com.deckbelcher.richtext#headingBlock": 232 + return lexiconHeadingToTree(block); 233 + default: 234 + return lexiconParagraphToTree(block as ParagraphBlock); 235 + } 236 + } 237 + 238 + function lexiconParagraphToTree(block: ParagraphBlock): ProseMirrorNode { 239 + const text = block.text || ""; 240 + if (!text) { 241 + return schema.node("paragraph"); 242 + } 243 + 244 + const nodes = textAndFacetsToNodes(text, block.facets || []); 245 + return schema.node("paragraph", null, nodes.length > 0 ? nodes : undefined); 246 + } 247 + 248 + function lexiconHeadingToTree(block: HeadingBlock): ProseMirrorNode { 249 + const text = block.text || ""; 250 + const level = block.level || 1; 251 + 252 + if (!text) { 253 + return schema.node("heading", { level }); 254 + } 255 + 256 + const nodes = textAndFacetsToNodes(text, block.facets || []); 257 + return schema.node( 258 + "heading", 259 + { level }, 260 + nodes.length > 0 ? nodes : undefined, 261 + ); 262 + } 263 + 264 + export interface Segment { 265 + text: string; 266 + features: unknown[]; 267 + } 268 + 269 + /** 270 + * Segment text by facet boundaries, accumulating features from all facets 271 + * that cover each byte range. Handles overlapping facets correctly. 272 + */ 273 + export function segmentize(text: string, facets: Facet[]): Segment[] { 274 + if (facets.length === 0) { 275 + return [{ text, features: [] }]; 276 + } 277 + 278 + // Collect all unique byte positions where facets start or end 279 + const positions = new Set<number>([0]); 280 + const textBytes = new TextEncoder().encode(text); 281 + positions.add(textBytes.length); 282 + 283 + for (const facet of facets) { 284 + positions.add(facet.index.byteStart); 285 + positions.add(facet.index.byteEnd); 286 + } 287 + 288 + // Sort positions 289 + const sortedPositions = Array.from(positions).sort((a, b) => a - b); 290 + 291 + // Create segments between each pair of adjacent positions 292 + const segments: Segment[] = []; 293 + const decoder = new TextDecoder(); 294 + 295 + for (let i = 0; i < sortedPositions.length - 1; i++) { 296 + const start = sortedPositions[i]; 297 + const end = sortedPositions[i + 1]; 298 + 299 + // Extract text for this byte range 300 + const segmentText = decoder.decode(textBytes.slice(start, end)); 301 + if (!segmentText) continue; 302 + 303 + // Collect all features from facets that cover this segment 304 + const features: unknown[] = []; 305 + for (const facet of facets) { 306 + if (facet.index.byteStart <= start && facet.index.byteEnd >= end) { 307 + features.push(...facet.features); 308 + } 309 + } 310 + 311 + segments.push({ text: segmentText, features }); 312 + } 313 + 314 + return segments; 315 + } 316 + 317 + function textAndFacetsToNodes( 318 + text: string, 319 + facets: Facet[], 320 + ): ProseMirrorNode[] { 321 + if (facets.length === 0) { 322 + return [schema.text(text)]; 323 + } 324 + 325 + const segments = segmentize(text, facets); 326 + 327 + const nodes: ProseMirrorNode[] = []; 328 + for (const segment of segments) { 329 + if (!segment.text) continue; 330 + 331 + const marks: Mark[] = []; 332 + for (const feature of segment.features) { 333 + const mark = featureToMark(feature); 334 + if (mark) { 335 + marks.push(mark); 336 + } 337 + } 338 + 339 + // TODO: handle mentions as inline nodes instead of marked text 340 + 341 + nodes.push(schema.text(segment.text, marks)); 342 + } 343 + 344 + return nodes; 345 + } 346 + 347 + function featureToMark(feature: unknown): Mark | null { 348 + const f = feature as { $type?: string; uri?: string }; 349 + switch (f.$type) { 350 + case "com.deckbelcher.richtext.facet#bold": 351 + return schema.marks.strong.create(); 352 + case "com.deckbelcher.richtext.facet#italic": 353 + return schema.marks.em.create(); 354 + case "com.deckbelcher.richtext.facet#code": 355 + return schema.marks.code.create(); 356 + case "com.deckbelcher.richtext.facet#link": 357 + return schema.marks.link.create({ href: f.uri }); 358 + // Mentions are inline nodes, not marks - handled separately 359 + default: 360 + return null; 361 + } 362 + }
-515
src/lib/richtext/__tests__/__snapshots__/parser.test.ts.snap
··· 1 - // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 - 3 - exports[`parseMarkdown > snapshot: > parses correctly 1`] = ` 4 - { 5 - "facets": [], 6 - "text": "", 7 - } 8 - `; 9 - 10 - exports[`parseMarkdown > snapshot: * > parses correctly 1`] = ` 11 - { 12 - "facets": [], 13 - "text": "*", 14 - } 15 - `; 16 - 17 - exports[`parseMarkdown > snapshot: ** > parses correctly 1`] = ` 18 - { 19 - "facets": [], 20 - "text": "**", 21 - } 22 - `; 23 - 24 - exports[`parseMarkdown > snapshot: *** > parses correctly 1`] = ` 25 - { 26 - "facets": [], 27 - "text": "***", 28 - } 29 - `; 30 - 31 - exports[`parseMarkdown > snapshot: **** > parses correctly 1`] = ` 32 - { 33 - "facets": [], 34 - "text": "****", 35 - } 36 - `; 37 - 38 - exports[`parseMarkdown > snapshot: ***bold and italic*** > parses correctly 1`] = ` 39 - { 40 - "facets": [ 41 - { 42 - "features": [ 43 - { 44 - "$type": "com.deckbelcher.richtext.facet#bold", 45 - }, 46 - ], 47 - "index": { 48 - "byteEnd": 15, 49 - "byteStart": 0, 50 - }, 51 - }, 52 - { 53 - "features": [ 54 - { 55 - "$type": "com.deckbelcher.richtext.facet#italic", 56 - }, 57 - ], 58 - "index": { 59 - "byteEnd": 15, 60 - "byteStart": 0, 61 - }, 62 - }, 63 - ], 64 - "text": "bold and italic", 65 - } 66 - `; 67 - 68 - exports[`parseMarkdown > snapshot: **a**b**c** > parses correctly 1`] = ` 69 - { 70 - "facets": [ 71 - { 72 - "features": [ 73 - { 74 - "$type": "com.deckbelcher.richtext.facet#bold", 75 - }, 76 - ], 77 - "index": { 78 - "byteEnd": 1, 79 - "byteStart": 0, 80 - }, 81 - }, 82 - { 83 - "features": [ 84 - { 85 - "$type": "com.deckbelcher.richtext.facet#bold", 86 - }, 87 - ], 88 - "index": { 89 - "byteEnd": 3, 90 - "byteStart": 2, 91 - }, 92 - }, 93 - ], 94 - "text": "abc", 95 - } 96 - `; 97 - 98 - exports[`parseMarkdown > snapshot: **a日b** > parses correctly 1`] = ` 99 - { 100 - "facets": [ 101 - { 102 - "features": [ 103 - { 104 - "$type": "com.deckbelcher.richtext.facet#bold", 105 - }, 106 - ], 107 - "index": { 108 - "byteEnd": 5, 109 - "byteStart": 0, 110 - }, 111 - }, 112 - ], 113 - "text": "a日b", 114 - } 115 - `; 116 - 117 - exports[`parseMarkdown > snapshot: **bold** and *italic* together > parses correctly 1`] = ` 118 - { 119 - "facets": [ 120 - { 121 - "features": [ 122 - { 123 - "$type": "com.deckbelcher.richtext.facet#bold", 124 - }, 125 - ], 126 - "index": { 127 - "byteEnd": 4, 128 - "byteStart": 0, 129 - }, 130 - }, 131 - { 132 - "features": [ 133 - { 134 - "$type": "com.deckbelcher.richtext.facet#italic", 135 - }, 136 - ], 137 - "index": { 138 - "byteEnd": 15, 139 - "byteStart": 9, 140 - }, 141 - }, 142 - ], 143 - "text": "bold and italic together", 144 - } 145 - `; 146 - 147 - exports[`parseMarkdown > snapshot: **nested *italic* in bold** > parses correctly 1`] = ` 148 - { 149 - "facets": [ 150 - { 151 - "features": [ 152 - { 153 - "$type": "com.deckbelcher.richtext.facet#bold", 154 - }, 155 - ], 156 - "index": { 157 - "byteEnd": 21, 158 - "byteStart": 0, 159 - }, 160 - }, 161 - { 162 - "features": [ 163 - { 164 - "$type": "com.deckbelcher.richtext.facet#italic", 165 - }, 166 - ], 167 - "index": { 168 - "byteEnd": 13, 169 - "byteStart": 7, 170 - }, 171 - }, 172 - ], 173 - "text": "nested italic in bold", 174 - } 175 - `; 176 - 177 - exports[`parseMarkdown > snapshot: **日本語** > parses correctly 1`] = ` 178 - { 179 - "facets": [ 180 - { 181 - "features": [ 182 - { 183 - "$type": "com.deckbelcher.richtext.facet#bold", 184 - }, 185 - ], 186 - "index": { 187 - "byteEnd": 9, 188 - "byteStart": 0, 189 - }, 190 - }, 191 - ], 192 - "text": "日本語", 193 - } 194 - `; 195 - 196 - exports[`parseMarkdown > snapshot: **🔥** > parses correctly 1`] = ` 197 - { 198 - "facets": [ 199 - { 200 - "features": [ 201 - { 202 - "$type": "com.deckbelcher.richtext.facet#bold", 203 - }, 204 - ], 205 - "index": { 206 - "byteEnd": 4, 207 - "byteStart": 0, 208 - }, 209 - }, 210 - ], 211 - "text": "🔥", 212 - } 213 - `; 214 - 215 - exports[`parseMarkdown > snapshot: *a*b*c* > parses correctly 1`] = ` 216 - { 217 - "facets": [ 218 - { 219 - "features": [ 220 - { 221 - "$type": "com.deckbelcher.richtext.facet#italic", 222 - }, 223 - ], 224 - "index": { 225 - "byteEnd": 1, 226 - "byteStart": 0, 227 - }, 228 - }, 229 - { 230 - "features": [ 231 - { 232 - "$type": "com.deckbelcher.richtext.facet#italic", 233 - }, 234 - ], 235 - "index": { 236 - "byteEnd": 3, 237 - "byteStart": 2, 238 - }, 239 - }, 240 - ], 241 - "text": "abc", 242 - } 243 - `; 244 - 245 - exports[`parseMarkdown > snapshot: *🔥* and **🔥** > parses correctly 1`] = ` 246 - { 247 - "facets": [ 248 - { 249 - "features": [ 250 - { 251 - "$type": "com.deckbelcher.richtext.facet#italic", 252 - }, 253 - ], 254 - "index": { 255 - "byteEnd": 4, 256 - "byteStart": 0, 257 - }, 258 - }, 259 - { 260 - "features": [ 261 - { 262 - "$type": "com.deckbelcher.richtext.facet#bold", 263 - }, 264 - ], 265 - "index": { 266 - "byteEnd": 13, 267 - "byteStart": 9, 268 - }, 269 - }, 270 - ], 271 - "text": "🔥 and 🔥", 272 - } 273 - `; 274 - 275 - exports[`parseMarkdown > snapshot: @user.bsky.social > parses correctly 1`] = ` 276 - { 277 - "facets": [ 278 - { 279 - "features": [ 280 - { 281 - "$type": "com.deckbelcher.richtext.facet#mention", 282 - "did": "user.bsky.social", 283 - }, 284 - ], 285 - "index": { 286 - "byteEnd": 17, 287 - "byteStart": 0, 288 - }, 289 - }, 290 - ], 291 - "text": "@user.bsky.social", 292 - } 293 - `; 294 - 295 - exports[`parseMarkdown > snapshot: [link](https://example.com) > parses correctly 1`] = ` 296 - { 297 - "facets": [ 298 - { 299 - "features": [ 300 - { 301 - "$type": "com.deckbelcher.richtext.facet#link", 302 - "uri": "https://example.com", 303 - }, 304 - ], 305 - "index": { 306 - "byteEnd": 4, 307 - "byteStart": 0, 308 - }, 309 - }, 310 - ], 311 - "text": "link", 312 - } 313 - `; 314 - 315 - exports[`parseMarkdown > snapshot: \`\` > parses correctly 1`] = ` 316 - { 317 - "facets": [], 318 - "text": "\`\`", 319 - } 320 - `; 321 - 322 - exports[`parseMarkdown > snapshot: \`code\` > parses correctly 1`] = ` 323 - { 324 - "facets": [ 325 - { 326 - "features": [ 327 - { 328 - "$type": "com.deckbelcher.richtext.facet#code", 329 - }, 330 - ], 331 - "index": { 332 - "byteEnd": 4, 333 - "byteStart": 0, 334 - }, 335 - }, 336 - ], 337 - "text": "code", 338 - } 339 - `; 340 - 341 - exports[`parseMarkdown > snapshot: Hello **world**! > parses correctly 1`] = ` 342 - { 343 - "facets": [ 344 - { 345 - "features": [ 346 - { 347 - "$type": "com.deckbelcher.richtext.facet#bold", 348 - }, 349 - ], 350 - "index": { 351 - "byteEnd": 11, 352 - "byteStart": 6, 353 - }, 354 - }, 355 - ], 356 - "text": "Hello world!", 357 - } 358 - `; 359 - 360 - exports[`parseMarkdown > snapshot: Multi-byte: **日本語** emoji **🔥** > parses correctly 1`] = ` 361 - { 362 - "facets": [ 363 - { 364 - "features": [ 365 - { 366 - "$type": "com.deckbelcher.richtext.facet#bold", 367 - }, 368 - ], 369 - "index": { 370 - "byteEnd": 21, 371 - "byteStart": 12, 372 - }, 373 - }, 374 - { 375 - "features": [ 376 - { 377 - "$type": "com.deckbelcher.richtext.facet#bold", 378 - }, 379 - ], 380 - "index": { 381 - "byteEnd": 32, 382 - "byteStart": 28, 383 - }, 384 - }, 385 - ], 386 - "text": "Multi-byte: 日本語 emoji 🔥", 387 - } 388 - `; 389 - 390 - exports[`parseMarkdown > snapshot: This is *italic* text > parses correctly 1`] = ` 391 - { 392 - "facets": [ 393 - { 394 - "features": [ 395 - { 396 - "$type": "com.deckbelcher.richtext.facet#italic", 397 - }, 398 - ], 399 - "index": { 400 - "byteEnd": 14, 401 - "byteStart": 8, 402 - }, 403 - }, 404 - ], 405 - "text": "This is italic text", 406 - } 407 - `; 408 - 409 - exports[`parseMarkdown > snapshot: Unclosed **bold > parses correctly 1`] = ` 410 - { 411 - "facets": [], 412 - "text": "Unclosed **bold", 413 - } 414 - `; 415 - 416 - exports[`parseMarkdown > snapshot: Unclosed *italic > parses correctly 1`] = ` 417 - { 418 - "facets": [], 419 - "text": "Unclosed *italic", 420 - } 421 - `; 422 - 423 - exports[`parseMarkdown > snapshot: a**b**c**d**e > parses correctly 1`] = ` 424 - { 425 - "facets": [ 426 - { 427 - "features": [ 428 - { 429 - "$type": "com.deckbelcher.richtext.facet#bold", 430 - }, 431 - ], 432 - "index": { 433 - "byteEnd": 2, 434 - "byteStart": 1, 435 - }, 436 - }, 437 - { 438 - "features": [ 439 - { 440 - "$type": "com.deckbelcher.richtext.facet#bold", 441 - }, 442 - ], 443 - "index": { 444 - "byteEnd": 4, 445 - "byteStart": 3, 446 - }, 447 - }, 448 - ], 449 - "text": "abcde", 450 - } 451 - `; 452 - 453 - exports[`parseMarkdown > snapshot: no formatting here > parses correctly 1`] = ` 454 - { 455 - "facets": [], 456 - "text": "no formatting here", 457 - } 458 - `; 459 - 460 - exports[`parseMarkdown > snapshot: prefix **日本語** suffix > parses correctly 1`] = ` 461 - { 462 - "facets": [ 463 - { 464 - "features": [ 465 - { 466 - "$type": "com.deckbelcher.richtext.facet#bold", 467 - }, 468 - ], 469 - "index": { 470 - "byteEnd": 16, 471 - "byteStart": 7, 472 - }, 473 - }, 474 - ], 475 - "text": "prefix 日本語 suffix", 476 - } 477 - `; 478 - 479 - exports[`parseMarkdown > snapshot: 👨‍👩‍👧‍👧 **text** 🔥 > parses correctly 1`] = ` 480 - { 481 - "facets": [ 482 - { 483 - "features": [ 484 - { 485 - "$type": "com.deckbelcher.richtext.facet#bold", 486 - }, 487 - ], 488 - "index": { 489 - "byteEnd": 30, 490 - "byteStart": 26, 491 - }, 492 - }, 493 - ], 494 - "text": "👨‍👩‍👧‍👧 text 🔥", 495 - } 496 - `; 497 - 498 - exports[`parseMarkdown > snapshot: 🔥 **bold** > parses correctly 1`] = ` 499 - { 500 - "facets": [ 501 - { 502 - "features": [ 503 - { 504 - "$type": "com.deckbelcher.richtext.facet#bold", 505 - }, 506 - ], 507 - "index": { 508 - "byteEnd": 9, 509 - "byteStart": 5, 510 - }, 511 - }, 512 - ], 513 - "text": "🔥 bold", 514 - } 515 - `;
-150
src/lib/richtext/__tests__/adversarial.test.ts
··· 1 - import { describe, expect, it } from "vitest"; 2 - import { parseMarkdown } from "../parser"; 3 - import { serializeToMarkdown } from "../serializer"; 4 - import { BOLD, type Facet, ITALIC } from "../types"; 5 - 6 - describe("adversarial inputs", () => { 7 - describe("parser handles edge cases", () => { 8 - const ADVERSARIAL_INPUTS = [ 9 - "", 10 - "*", 11 - "**", 12 - "***", 13 - "****", 14 - "*****", 15 - "[[]]", 16 - "[[", 17 - "]]", 18 - "[[[[nested]]]]", 19 - "@".repeat(1000), 20 - "*".repeat(1000), 21 - "**".repeat(500), 22 - "\u0000\u0001\u0002", 23 - "**\u200b**", 24 - "*\u200b*", 25 - "**a\nb**", 26 - "*a\nb*", 27 - "**🔥**", 28 - "*🔥*", 29 - "[[🔥]]", 30 - "\u202EtloB gninthgiL", 31 - "a**b*c**d*e", 32 - "**a*b*c**", 33 - "*a**b**c*", 34 - "\\*not italic\\*", 35 - "\\**not bold\\**", 36 - " ** spaced ** ", 37 - "** no close", 38 - "no open **", 39 - "a]b[c", 40 - "🔥".repeat(100), 41 - "日本語".repeat(100), 42 - "\t\n\r **bold** \t\n\r", 43 - ]; 44 - 45 - it.each(ADVERSARIAL_INPUTS)("never crashes on: %j", (input) => { 46 - expect(() => parseMarkdown(input)).not.toThrow(); 47 - }); 48 - 49 - it.each(ADVERSARIAL_INPUTS)("produces valid facets for: %j", (input) => { 50 - const { text, facets } = parseMarkdown(input); 51 - const encoder = new TextEncoder(); 52 - const bytes = encoder.encode(text); 53 - 54 - for (const facet of facets) { 55 - expect(facet.index.byteStart).toBeGreaterThanOrEqual(0); 56 - expect(facet.index.byteEnd).toBeLessThanOrEqual(bytes.length); 57 - expect(facet.index.byteStart).toBeLessThan(facet.index.byteEnd); 58 - expect(facet.features.length).toBeGreaterThan(0); 59 - } 60 - }); 61 - }); 62 - 63 - describe("serializer handles malformed facets", () => { 64 - const text = "hello world"; 65 - 66 - it.each([ 67 - [[{ index: { byteStart: -1, byteEnd: 5 }, features: [BOLD] }]], 68 - [[{ index: { byteStart: 0, byteEnd: -1 }, features: [BOLD] }]], 69 - [[{ index: { byteStart: 5, byteEnd: 5 }, features: [BOLD] }]], 70 - [[{ index: { byteStart: 10, byteEnd: 5 }, features: [BOLD] }]], 71 - [[{ index: { byteStart: 0, byteEnd: 1000 }, features: [BOLD] }]], 72 - [[{ index: { byteStart: 1000, byteEnd: 2000 }, features: [BOLD] }]], 73 - [[{ index: { byteStart: 0, byteEnd: 5 }, features: [] }]], 74 - [ 75 - [ 76 - { index: { byteStart: 0, byteEnd: 5 }, features: [BOLD] }, 77 - { index: { byteStart: -10, byteEnd: 100 }, features: [ITALIC] }, 78 - ], 79 - ], 80 - ])("handles invalid facets gracefully: %j", (facets) => { 81 - expect(() => serializeToMarkdown(text, facets as Facet[])).not.toThrow(); 82 - }); 83 - 84 - it("skips facets with negative byteStart", () => { 85 - const facets: Facet[] = [ 86 - { index: { byteStart: -1, byteEnd: 5 }, features: [BOLD] }, 87 - ]; 88 - expect(serializeToMarkdown(text, facets)).toBe(text); 89 - }); 90 - 91 - it("skips facets with byteEnd > text length", () => { 92 - const facets: Facet[] = [ 93 - { index: { byteStart: 0, byteEnd: 1000 }, features: [BOLD] }, 94 - ]; 95 - expect(serializeToMarkdown(text, facets)).toBe(text); 96 - }); 97 - 98 - it("skips facets with byteStart >= byteEnd", () => { 99 - const facets: Facet[] = [ 100 - { index: { byteStart: 5, byteEnd: 5 }, features: [BOLD] }, 101 - { index: { byteStart: 8, byteEnd: 3 }, features: [ITALIC] }, 102 - ]; 103 - expect(serializeToMarkdown(text, facets)).toBe(text); 104 - }); 105 - 106 - it("skips facets with empty features", () => { 107 - const facets: Facet[] = [ 108 - { index: { byteStart: 0, byteEnd: 5 }, features: [] }, 109 - ]; 110 - expect(serializeToMarkdown(text, facets)).toBe(text); 111 - }); 112 - 113 - it("processes valid facets while skipping invalid ones", () => { 114 - const facets: Facet[] = [ 115 - { index: { byteStart: -1, byteEnd: 5 }, features: [BOLD] }, 116 - { index: { byteStart: 0, byteEnd: 5 }, features: [ITALIC] }, 117 - { index: { byteStart: 0, byteEnd: 1000 }, features: [BOLD] }, 118 - ]; 119 - expect(serializeToMarkdown(text, facets)).toBe("*hello* world"); 120 - }); 121 - }); 122 - 123 - describe("unicode edge cases", () => { 124 - it("handles zero-width characters", () => { 125 - const input = "**\u200b**"; 126 - const result = parseMarkdown(input); 127 - expect(result.text).toBe("\u200b"); 128 - expect(result.facets).toHaveLength(1); 129 - }); 130 - 131 - it("handles RTL override characters", () => { 132 - const input = "**\u202Etext\u202C**"; 133 - const result = parseMarkdown(input); 134 - expect(result.facets).toHaveLength(1); 135 - }); 136 - 137 - it("handles combining characters", () => { 138 - const input = "**e\u0301**"; // é as e + combining acute 139 - const result = parseMarkdown(input); 140 - expect(result.facets).toHaveLength(1); 141 - }); 142 - 143 - it("handles surrogate pairs (emoji)", () => { 144 - const input = "**\u{1F600}**"; // 😀 145 - const result = parseMarkdown(input); 146 - expect(result.text).toBe("😀"); 147 - expect(result.facets).toHaveLength(1); 148 - }); 149 - }); 150 - });
-146
src/lib/richtext/__tests__/byte-offsets.test.ts
··· 1 - import { describe, expect, it } from "vitest"; 2 - import { ByteString } from "../byte-string"; 3 - import { parseMarkdown } from "../parser"; 4 - import { serializeToMarkdown } from "../serializer"; 5 - import { BOLD, type Facet, ITALIC } from "../types"; 6 - 7 - describe("byte offset handling (Bluesky-inspired)", () => { 8 - describe("grapheme vs byte length", () => { 9 - it("ASCII has equal byte and character length", () => { 10 - const bs = new ByteString("Hello!"); 11 - expect(bs.length).toBe(6); 12 - expect(bs.text.length).toBe(6); 13 - }); 14 - 15 - it("family emoji is 25 bytes but 1 grapheme cluster", () => { 16 - const bs = new ByteString("👨‍👩‍👧‍👧"); 17 - expect(bs.length).toBe(25); 18 - }); 19 - 20 - it("mixed emoji and text", () => { 21 - const bs = new ByteString("👨‍👩‍👧‍👧🔥 good!✅"); 22 - expect(bs.length).toBe(38); 23 - }); 24 - 25 - it("CJK characters are 3 bytes each", () => { 26 - const bs = new ByteString("日本語"); 27 - expect(bs.length).toBe(9); 28 - }); 29 - }); 30 - 31 - describe("facet byte indices with unicode", () => { 32 - it("bold emoji has correct byte indices", () => { 33 - const { text, facets } = parseMarkdown("**🔥**"); 34 - expect(text).toBe("🔥"); 35 - expect(facets).toHaveLength(1); 36 - expect(facets[0].index.byteStart).toBe(0); 37 - expect(facets[0].index.byteEnd).toBe(4); // 🔥 is 4 bytes 38 - }); 39 - 40 - it("bold CJK has correct byte indices", () => { 41 - const { text, facets } = parseMarkdown("**日本語**"); 42 - expect(text).toBe("日本語"); 43 - expect(facets).toHaveLength(1); 44 - expect(facets[0].index.byteStart).toBe(0); 45 - expect(facets[0].index.byteEnd).toBe(9); // 3 chars × 3 bytes 46 - }); 47 - 48 - it("facet after emoji starts at correct byte offset", () => { 49 - const { text, facets } = parseMarkdown("🔥 **bold**"); 50 - expect(text).toBe("🔥 bold"); 51 - expect(facets).toHaveLength(1); 52 - // 🔥 = 4 bytes, space = 1 byte 53 - expect(facets[0].index.byteStart).toBe(5); 54 - expect(facets[0].index.byteEnd).toBe(9); 55 - }); 56 - 57 - it("facet between emojis", () => { 58 - const { text, facets } = parseMarkdown("👨‍👩‍👧‍👧 **text** 🔥"); 59 - expect(text).toBe("👨‍👩‍👧‍👧 text 🔥"); 60 - expect(facets).toHaveLength(1); 61 - // family emoji = 25 bytes, space = 1 byte 62 - expect(facets[0].index.byteStart).toBe(26); 63 - expect(facets[0].index.byteEnd).toBe(30); 64 - }); 65 - }); 66 - 67 - describe("serializer respects byte boundaries", () => { 68 - it("serializes facet at unicode boundary correctly", () => { 69 - const text = "🔥 hello"; 70 - const facets: Facet[] = [ 71 - { index: { byteStart: 5, byteEnd: 10 }, features: [BOLD] }, 72 - ]; 73 - expect(serializeToMarkdown(text, facets)).toBe("🔥 **hello**"); 74 - }); 75 - 76 - it("serializes facet spanning emoji correctly", () => { 77 - const text = "🔥"; 78 - const facets: Facet[] = [ 79 - { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 80 - ]; 81 - expect(serializeToMarkdown(text, facets)).toBe("**🔥**"); 82 - }); 83 - 84 - it("serializes multiple facets around unicode", () => { 85 - const text = "日本語 text 日本語"; 86 - const facets: Facet[] = [ 87 - { index: { byteStart: 0, byteEnd: 9 }, features: [BOLD] }, 88 - { index: { byteStart: 15, byteEnd: 24 }, features: [ITALIC] }, 89 - ]; 90 - expect(serializeToMarkdown(text, facets)).toBe( 91 - "**日本語** text *日本語*", 92 - ); 93 - }); 94 - }); 95 - 96 - describe("slicing at byte boundaries", () => { 97 - it("sliceByBytes handles emoji boundaries", () => { 98 - const bs = new ByteString("🔥hello"); 99 - expect(bs.sliceByBytes(0, 4)).toBe("🔥"); 100 - expect(bs.sliceByBytes(4, 9)).toBe("hello"); 101 - }); 102 - 103 - it("sliceByBytes handles CJK boundaries", () => { 104 - const bs = new ByteString("日本語"); 105 - expect(bs.sliceByBytes(0, 3)).toBe("日"); 106 - expect(bs.sliceByBytes(3, 6)).toBe("本"); 107 - expect(bs.sliceByBytes(6, 9)).toBe("語"); 108 - }); 109 - 110 - it("sliceByBytes handles mixed content", () => { 111 - const bs = new ByteString("a日🔥b"); 112 - expect(bs.sliceByBytes(0, 1)).toBe("a"); 113 - expect(bs.sliceByBytes(1, 4)).toBe("日"); 114 - expect(bs.sliceByBytes(4, 8)).toBe("🔥"); 115 - expect(bs.sliceByBytes(8, 9)).toBe("b"); 116 - }); 117 - }); 118 - 119 - describe("edge cases from real-world scenarios", () => { 120 - it("empty input", () => { 121 - const { text, facets } = parseMarkdown(""); 122 - expect(text).toBe(""); 123 - expect(facets).toHaveLength(0); 124 - expect(serializeToMarkdown(text, facets)).toBe(""); 125 - }); 126 - 127 - it("text with no formatting preserves unicode", () => { 128 - const input = "👨‍👩‍👧‍👧 family emoji and 日本語 text"; 129 - const { text, facets } = parseMarkdown(input); 130 - expect(text).toBe(input); 131 - expect(facets).toHaveLength(0); 132 - }); 133 - 134 - it("overlapping facets serialize correctly", () => { 135 - const text = "hello world"; 136 - const facets: Facet[] = [ 137 - { index: { byteStart: 0, byteEnd: 11 }, features: [BOLD] }, 138 - { index: { byteStart: 6, byteEnd: 11 }, features: [ITALIC] }, 139 - ]; 140 - // Bold wraps everything, italic starts at "world" 141 - // At byte 6: close nothing, open italic → * 142 - // At byte 11: close italic, close bold → *** 143 - expect(serializeToMarkdown(text, facets)).toBe("**hello *world***"); 144 - }); 145 - }); 146 - });
-82
src/lib/richtext/__tests__/byte-string.test.ts
··· 1 - import { describe, expect, it } from "vitest"; 2 - import { ByteString } from "../byte-string"; 3 - 4 - describe("ByteString", () => { 5 - describe("construction", () => { 6 - it("handles empty string", () => { 7 - const bs = new ByteString(""); 8 - expect(bs.text).toBe(""); 9 - expect(bs.length).toBe(0); 10 - }); 11 - 12 - it("handles ASCII text", () => { 13 - const bs = new ByteString("hello"); 14 - expect(bs.text).toBe("hello"); 15 - expect(bs.length).toBe(5); 16 - }); 17 - 18 - it("handles multi-byte UTF-8 characters", () => { 19 - const bs = new ByteString("日本語"); 20 - expect(bs.text).toBe("日本語"); 21 - expect(bs.length).toBe(9); // 3 chars * 3 bytes each 22 - }); 23 - 24 - it("handles emoji (4-byte UTF-8)", () => { 25 - const bs = new ByteString("🔥"); 26 - expect(bs.text).toBe("🔥"); 27 - expect(bs.length).toBe(4); 28 - }); 29 - 30 - it("handles mixed ASCII and multi-byte", () => { 31 - const bs = new ByteString("a日b"); 32 - expect(bs.text).toBe("a日b"); 33 - expect(bs.length).toBe(5); // 1 + 3 + 1 34 - }); 35 - }); 36 - 37 - describe("sliceByBytes", () => { 38 - it("slices ASCII correctly", () => { 39 - const bs = new ByteString("hello world"); 40 - expect(bs.sliceByBytes(0, 5)).toBe("hello"); 41 - expect(bs.sliceByBytes(6, 11)).toBe("world"); 42 - expect(bs.sliceByBytes(0, 11)).toBe("hello world"); 43 - }); 44 - 45 - it("slices multi-byte characters correctly", () => { 46 - const bs = new ByteString("日本語"); 47 - expect(bs.sliceByBytes(0, 3)).toBe("日"); 48 - expect(bs.sliceByBytes(3, 6)).toBe("本"); 49 - expect(bs.sliceByBytes(6, 9)).toBe("語"); 50 - expect(bs.sliceByBytes(0, 9)).toBe("日本語"); 51 - }); 52 - 53 - it("slices mixed content correctly", () => { 54 - const bs = new ByteString("a日b"); 55 - expect(bs.sliceByBytes(0, 1)).toBe("a"); 56 - expect(bs.sliceByBytes(1, 4)).toBe("日"); 57 - expect(bs.sliceByBytes(4, 5)).toBe("b"); 58 - }); 59 - 60 - it("slices emoji correctly", () => { 61 - const bs = new ByteString("hi🔥bye"); 62 - expect(bs.sliceByBytes(0, 2)).toBe("hi"); 63 - expect(bs.sliceByBytes(2, 6)).toBe("🔥"); 64 - expect(bs.sliceByBytes(6, 9)).toBe("bye"); 65 - }); 66 - 67 - it("handles empty slice", () => { 68 - const bs = new ByteString("hello"); 69 - expect(bs.sliceByBytes(2, 2)).toBe(""); 70 - }); 71 - 72 - it("handles slice at start", () => { 73 - const bs = new ByteString("hello"); 74 - expect(bs.sliceByBytes(0, 0)).toBe(""); 75 - }); 76 - 77 - it("handles slice at end", () => { 78 - const bs = new ByteString("hello"); 79 - expect(bs.sliceByBytes(5, 5)).toBe(""); 80 - }); 81 - }); 82 - });
-298
src/lib/richtext/__tests__/parser.test.ts
··· 1 - import { describe, expect, it } from "vitest"; 2 - import { parseMarkdown } from "../parser"; 3 - import { BOLD, ITALIC } from "../types"; 4 - 5 - describe("parseMarkdown", () => { 6 - describe("plain text", () => { 7 - it("returns empty for empty input", () => { 8 - const result = parseMarkdown(""); 9 - expect(result.text).toBe(""); 10 - expect(result.facets).toEqual([]); 11 - }); 12 - 13 - it("returns text unchanged with no markers", () => { 14 - const result = parseMarkdown("hello world"); 15 - expect(result.text).toBe("hello world"); 16 - expect(result.facets).toEqual([]); 17 - }); 18 - }); 19 - 20 - describe("bold", () => { 21 - it("parses simple bold", () => { 22 - const result = parseMarkdown("**bold**"); 23 - expect(result.text).toBe("bold"); 24 - expect(result.facets).toEqual([ 25 - { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 26 - ]); 27 - }); 28 - 29 - it("parses bold in middle of text", () => { 30 - const result = parseMarkdown("hello **world** there"); 31 - expect(result.text).toBe("hello world there"); 32 - expect(result.facets).toEqual([ 33 - { index: { byteStart: 6, byteEnd: 11 }, features: [BOLD] }, 34 - ]); 35 - }); 36 - 37 - it("parses multiple bold spans", () => { 38 - const result = parseMarkdown("**a**b**c**"); 39 - expect(result.text).toBe("abc"); 40 - expect(result.facets).toEqual([ 41 - { index: { byteStart: 0, byteEnd: 1 }, features: [BOLD] }, 42 - { index: { byteStart: 2, byteEnd: 3 }, features: [BOLD] }, 43 - ]); 44 - }); 45 - }); 46 - 47 - describe("italic", () => { 48 - it("parses simple italic", () => { 49 - const result = parseMarkdown("*italic*"); 50 - expect(result.text).toBe("italic"); 51 - expect(result.facets).toEqual([ 52 - { index: { byteStart: 0, byteEnd: 6 }, features: [ITALIC] }, 53 - ]); 54 - }); 55 - 56 - it("parses italic in middle of text", () => { 57 - const result = parseMarkdown("hello *world* there"); 58 - expect(result.text).toBe("hello world there"); 59 - expect(result.facets).toEqual([ 60 - { index: { byteStart: 6, byteEnd: 11 }, features: [ITALIC] }, 61 - ]); 62 - }); 63 - }); 64 - 65 - describe("bold and italic", () => { 66 - it("parses ***bold and italic***", () => { 67 - const result = parseMarkdown("***both***"); 68 - expect(result.text).toBe("both"); 69 - // Should have both bold and italic facets covering the same range 70 - expect(result.facets).toHaveLength(2); 71 - expect(result.facets).toContainEqual({ 72 - index: { byteStart: 0, byteEnd: 4 }, 73 - features: [BOLD], 74 - }); 75 - expect(result.facets).toContainEqual({ 76 - index: { byteStart: 0, byteEnd: 4 }, 77 - features: [ITALIC], 78 - }); 79 - }); 80 - 81 - it("parses nested bold in italic", () => { 82 - const result = parseMarkdown("*italic **bold** italic*"); 83 - expect(result.text).toBe("italic bold italic"); 84 - expect(result.facets).toContainEqual({ 85 - index: { byteStart: 0, byteEnd: 18 }, 86 - features: [ITALIC], 87 - }); 88 - expect(result.facets).toContainEqual({ 89 - index: { byteStart: 7, byteEnd: 11 }, 90 - features: [BOLD], 91 - }); 92 - }); 93 - }); 94 - 95 - describe("unclosed markers", () => { 96 - it("treats unclosed ** as literal", () => { 97 - const result = parseMarkdown("hello **world"); 98 - expect(result.text).toBe("hello **world"); 99 - expect(result.facets).toEqual([]); 100 - }); 101 - 102 - it("treats unclosed * as literal", () => { 103 - const result = parseMarkdown("hello *world"); 104 - expect(result.text).toBe("hello *world"); 105 - expect(result.facets).toEqual([]); 106 - }); 107 - }); 108 - 109 - describe("empty spans", () => { 110 - it("preserves empty bold **** as literal text", () => { 111 - const result = parseMarkdown("a****b"); 112 - expect(result.text).toBe("a****b"); 113 - expect(result.facets).toEqual([]); 114 - }); 115 - 116 - it("treats unpaired ** as literal", () => { 117 - const result = parseMarkdown("a**b"); 118 - expect(result.text).toBe("a**b"); 119 - expect(result.facets).toEqual([]); 120 - }); 121 - }); 122 - 123 - describe("unicode", () => { 124 - it("handles multi-byte characters", () => { 125 - const result = parseMarkdown("**日本語**"); 126 - expect(result.text).toBe("日本語"); 127 - expect(result.facets).toEqual([ 128 - { index: { byteStart: 0, byteEnd: 9 }, features: [BOLD] }, 129 - ]); 130 - }); 131 - 132 - it("handles emoji", () => { 133 - const result = parseMarkdown("**🔥**"); 134 - expect(result.text).toBe("🔥"); 135 - expect(result.facets).toEqual([ 136 - { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 137 - ]); 138 - }); 139 - 140 - it("handles mixed ASCII and unicode", () => { 141 - const result = parseMarkdown("hi **日本語** bye"); 142 - expect(result.text).toBe("hi 日本語 bye"); 143 - expect(result.facets).toEqual([ 144 - { index: { byteStart: 3, byteEnd: 12 }, features: [BOLD] }, 145 - ]); 146 - }); 147 - }); 148 - 149 - describe("inline code", () => { 150 - it("parses inline code", () => { 151 - const result = parseMarkdown("`code`"); 152 - expect(result.text).toBe("code"); 153 - expect(result.facets).toHaveLength(1); 154 - expect(result.facets[0].features[0].$type).toBe( 155 - "com.deckbelcher.richtext.facet#code", 156 - ); 157 - }); 158 - 159 - it("parses code in text", () => { 160 - const result = parseMarkdown("run `npm install` now"); 161 - expect(result.text).toBe("run npm install now"); 162 - expect(result.facets[0].index).toEqual({ byteStart: 4, byteEnd: 15 }); 163 - }); 164 - 165 - it("preserves empty code `` as literal", () => { 166 - const result = parseMarkdown("a``b"); 167 - expect(result.text).toBe("a``b"); 168 - expect(result.facets).toEqual([]); 169 - }); 170 - }); 171 - 172 - describe("code blocks", () => { 173 - it("parses code block", () => { 174 - const result = parseMarkdown("```\nconst x = 1;\n```"); 175 - expect(result.text).toBe("const x = 1;"); 176 - expect(result.facets).toHaveLength(1); 177 - expect(result.facets[0].features[0].$type).toBe( 178 - "com.deckbelcher.richtext.facet#codeBlock", 179 - ); 180 - }); 181 - 182 - it("parses code block with language", () => { 183 - const result = parseMarkdown("```typescript\nconst x = 1;\n```"); 184 - expect(result.text).toBe("const x = 1;"); 185 - }); 186 - 187 - it("parses multi-line code block", () => { 188 - const result = parseMarkdown("```\nline1\nline2\n```"); 189 - expect(result.text).toBe("line1\nline2"); 190 - }); 191 - 192 - it("ignores ``` in middle of line", () => { 193 - const result = parseMarkdown("text ``` more"); 194 - expect(result.text).toBe("text ``` more"); 195 - }); 196 - 197 - it("preserves newline between code block and content after", () => { 198 - const result = parseMarkdown("```\ncode\n```\ncontent after"); 199 - expect(result.text).toBe("code\ncontent after"); 200 - expect(result.facets).toHaveLength(1); 201 - expect(result.facets[0].index).toEqual({ byteStart: 0, byteEnd: 4 }); 202 - }); 203 - }); 204 - 205 - describe("links", () => { 206 - it("parses markdown link", () => { 207 - const result = parseMarkdown("[click here](https://example.com)"); 208 - expect(result.text).toBe("click here"); 209 - expect(result.facets).toHaveLength(1); 210 - expect(result.facets[0].features[0]).toEqual({ 211 - $type: "com.deckbelcher.richtext.facet#link", 212 - uri: "https://example.com", 213 - }); 214 - }); 215 - 216 - it("parses link in text", () => { 217 - const result = parseMarkdown("check [this](https://x.com) out"); 218 - expect(result.text).toBe("check this out"); 219 - }); 220 - 221 - it("treats incomplete link as literal", () => { 222 - const result = parseMarkdown("[text]"); 223 - expect(result.text).toBe("[text]"); 224 - }); 225 - 226 - it("treats link without url as literal", () => { 227 - const result = parseMarkdown("[text]()"); 228 - expect(result.text).toBe("[text]()"); 229 - }); 230 - }); 231 - 232 - describe("mentions", () => { 233 - it("parses valid handle", () => { 234 - const result = parseMarkdown("@user.bsky.social"); 235 - expect(result.text).toBe("@user.bsky.social"); 236 - expect(result.facets).toHaveLength(1); 237 - expect(result.facets[0].features[0]).toEqual({ 238 - $type: "com.deckbelcher.richtext.facet#mention", 239 - did: "user.bsky.social", 240 - }); 241 - }); 242 - 243 - it("ignores @ without valid handle", () => { 244 - const result = parseMarkdown("email@"); 245 - expect(result.text).toBe("email@"); 246 - expect(result.facets).toEqual([]); 247 - }); 248 - 249 - it("ignores @ without dot", () => { 250 - const result = parseMarkdown("@username"); 251 - expect(result.text).toBe("@username"); 252 - expect(result.facets).toEqual([]); 253 - }); 254 - 255 - it("parses mention in sentence", () => { 256 - const result = parseMarkdown("hello @alice.dev!"); 257 - expect(result.text).toBe("hello @alice.dev!"); 258 - expect(result.facets[0].index).toEqual({ byteStart: 6, byteEnd: 16 }); 259 - }); 260 - }); 261 - 262 - describe.each([ 263 - ["Hello **world**!"], 264 - ["This is *italic* text"], 265 - ["***bold and italic***"], 266 - ["**nested *italic* in bold**"], 267 - ["Multi-byte: **日本語** emoji **🔥**"], 268 - ["Unclosed **bold"], 269 - ["Unclosed *italic"], 270 - ["**a**b**c**"], 271 - ["*a*b*c*"], 272 - ["****"], 273 - ["***"], 274 - ["**"], 275 - ["*"], 276 - [""], 277 - ["no formatting here"], 278 - ["**bold** and *italic* together"], 279 - ["a**b**c**d**e"], 280 - // Unicode byte offset edge cases 281 - ["**🔥**"], 282 - ["🔥 **bold**"], 283 - ["**日本語**"], 284 - ["👨‍👩‍👧‍👧 **text** 🔥"], 285 - ["prefix **日本語** suffix"], 286 - ["*🔥* and **🔥**"], 287 - ["**a日b**"], 288 - // New features 289 - ["`code`"], 290 - ["``"], 291 - ["[link](https://example.com)"], 292 - ["@user.bsky.social"], 293 - ])("snapshot: %s", (input) => { 294 - it("parses correctly", () => { 295 - expect(parseMarkdown(input)).toMatchSnapshot(); 296 - }); 297 - }); 298 - });
-172
src/lib/richtext/__tests__/renderer.test.tsx
··· 1 - import { render } from "@testing-library/react"; 2 - import { describe, expect, it } from "vitest"; 3 - import { RichText } from "../renderer"; 4 - import { BOLD, type Facet, ITALIC } from "../types"; 5 - 6 - describe("RichText renderer", () => { 7 - describe("basic rendering", () => { 8 - it("renders null for empty text", () => { 9 - const { container } = render(<RichText text="" />); 10 - expect(container.innerHTML).toBe(""); 11 - }); 12 - 13 - it("renders plain text without formatting", () => { 14 - const { container } = render(<RichText text="hello world" />); 15 - expect(container.textContent).toBe("hello world"); 16 - expect(container.querySelector("strong")).toBeNull(); 17 - expect(container.querySelector("em")).toBeNull(); 18 - }); 19 - 20 - it("applies className to wrapper span", () => { 21 - const { container } = render( 22 - <RichText text="test" className="my-class" />, 23 - ); 24 - expect(container.querySelector("span.my-class")).not.toBeNull(); 25 - }); 26 - }); 27 - 28 - describe("bold formatting", () => { 29 - it("renders bold text", () => { 30 - const facets: Facet[] = [ 31 - { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 32 - ]; 33 - const { container } = render(<RichText text="bold" facets={facets} />); 34 - expect(container.querySelector("strong")?.textContent).toBe("bold"); 35 - }); 36 - 37 - it("renders bold in middle of text", () => { 38 - const facets: Facet[] = [ 39 - { index: { byteStart: 6, byteEnd: 11 }, features: [BOLD] }, 40 - ]; 41 - const { container } = render( 42 - <RichText text="hello world there" facets={facets} />, 43 - ); 44 - expect(container.textContent).toBe("hello world there"); 45 - expect(container.querySelector("strong")?.textContent).toBe("world"); 46 - }); 47 - }); 48 - 49 - describe("italic formatting", () => { 50 - it("renders italic text", () => { 51 - const facets: Facet[] = [ 52 - { index: { byteStart: 0, byteEnd: 6 }, features: [ITALIC] }, 53 - ]; 54 - const { container } = render(<RichText text="italic" facets={facets} />); 55 - expect(container.querySelector("em")?.textContent).toBe("italic"); 56 - }); 57 - }); 58 - 59 - describe("combined formatting", () => { 60 - it("renders bold and italic on same range", () => { 61 - const facets: Facet[] = [ 62 - { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 63 - { index: { byteStart: 0, byteEnd: 4 }, features: [ITALIC] }, 64 - ]; 65 - const { container } = render(<RichText text="both" facets={facets} />); 66 - const strong = container.querySelector("strong"); 67 - const em = container.querySelector("em"); 68 - expect(strong).not.toBeNull(); 69 - expect(em).not.toBeNull(); 70 - expect(container.textContent).toBe("both"); 71 - }); 72 - 73 - it("renders nested formatting", () => { 74 - // "hello world" with bold on all, italic on "world" 75 - const facets: Facet[] = [ 76 - { index: { byteStart: 0, byteEnd: 11 }, features: [BOLD] }, 77 - { index: { byteStart: 6, byteEnd: 11 }, features: [ITALIC] }, 78 - ]; 79 - const { container } = render( 80 - <RichText text="hello world" facets={facets} />, 81 - ); 82 - expect(container.textContent).toBe("hello world"); 83 - // Should have strong elements 84 - expect(container.querySelectorAll("strong").length).toBeGreaterThan(0); 85 - // Should have em element for "world" 86 - expect(container.querySelector("em")?.textContent).toBe("world"); 87 - }); 88 - }); 89 - 90 - describe("unicode handling", () => { 91 - it("renders bold emoji", () => { 92 - const facets: Facet[] = [ 93 - { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 94 - ]; 95 - const { container } = render(<RichText text="🔥" facets={facets} />); 96 - expect(container.querySelector("strong")?.textContent).toBe("🔥"); 97 - }); 98 - 99 - it("renders bold CJK characters", () => { 100 - const facets: Facet[] = [ 101 - { index: { byteStart: 0, byteEnd: 9 }, features: [BOLD] }, 102 - ]; 103 - const { container } = render(<RichText text="日本語" facets={facets} />); 104 - expect(container.querySelector("strong")?.textContent).toBe("日本語"); 105 - }); 106 - 107 - it("handles facet after emoji", () => { 108 - // "🔥 bold" - emoji is 4 bytes, space is 1 109 - const facets: Facet[] = [ 110 - { index: { byteStart: 5, byteEnd: 9 }, features: [BOLD] }, 111 - ]; 112 - const { container } = render(<RichText text="🔥 bold" facets={facets} />); 113 - expect(container.textContent).toBe("🔥 bold"); 114 - expect(container.querySelector("strong")?.textContent).toBe("bold"); 115 - }); 116 - 117 - it("renders family emoji correctly", () => { 118 - const facets: Facet[] = [ 119 - { index: { byteStart: 0, byteEnd: 25 }, features: [BOLD] }, 120 - ]; 121 - const { container } = render( 122 - <RichText text="👨‍👩‍👧‍👧" facets={facets} />, 123 - ); 124 - expect(container.querySelector("strong")?.textContent).toBe("👨‍👩‍👧‍👧"); 125 - }); 126 - }); 127 - 128 - describe("invalid facets", () => { 129 - it("skips facets with negative byteStart", () => { 130 - const facets: Facet[] = [ 131 - { index: { byteStart: -1, byteEnd: 5 }, features: [BOLD] }, 132 - ]; 133 - const { container } = render( 134 - <RichText text="hello world" facets={facets} />, 135 - ); 136 - expect(container.querySelector("strong")).toBeNull(); 137 - }); 138 - 139 - it("skips facets with byteEnd > text length", () => { 140 - const facets: Facet[] = [ 141 - { index: { byteStart: 0, byteEnd: 1000 }, features: [BOLD] }, 142 - ]; 143 - const { container } = render( 144 - <RichText text="hello world" facets={facets} />, 145 - ); 146 - expect(container.querySelector("strong")).toBeNull(); 147 - }); 148 - 149 - it("skips facets with empty features", () => { 150 - const facets: Facet[] = [ 151 - { index: { byteStart: 0, byteEnd: 5 }, features: [] }, 152 - ]; 153 - const { container } = render( 154 - <RichText text="hello world" facets={facets} />, 155 - ); 156 - expect(container.querySelector("strong")).toBeNull(); 157 - expect(container.querySelector("em")).toBeNull(); 158 - }); 159 - 160 - it("processes valid facets while skipping invalid", () => { 161 - const facets: Facet[] = [ 162 - { index: { byteStart: -1, byteEnd: 5 }, features: [BOLD] }, 163 - { index: { byteStart: 0, byteEnd: 5 }, features: [ITALIC] }, 164 - ]; 165 - const { container } = render( 166 - <RichText text="hello world" facets={facets} />, 167 - ); 168 - expect(container.querySelector("strong")).toBeNull(); 169 - expect(container.querySelector("em")?.textContent).toBe("hello"); 170 - }); 171 - }); 172 - });
-141
src/lib/richtext/__tests__/roundtrip.test.ts
··· 1 - import fc from "fast-check"; 2 - import { describe, expect, it } from "vitest"; 3 - import { ByteString } from "../byte-string"; 4 - import { parseMarkdown } from "../parser"; 5 - import { serializeToMarkdown } from "../serializer"; 6 - 7 - describe("roundtrip property tests", () => { 8 - it("parse → serialize roundtrips valid markdown", () => { 9 - // Generate markdown with valid formatting 10 - const wordArb = fc.stringMatching(/^[a-zA-Z0-9 ]{1,10}$/); 11 - 12 - const boldArb = wordArb.map((w) => `**${w}**`); 13 - const italicArb = wordArb.map((w) => `*${w}*`); 14 - const plainArb = wordArb; 15 - 16 - const segmentArb = fc.oneof(boldArb, italicArb, plainArb); 17 - const markdownArb = fc 18 - .array(segmentArb, { minLength: 1, maxLength: 5 }) 19 - .map((segs) => segs.join(" ")); 20 - 21 - fc.assert( 22 - fc.property(markdownArb, (md) => { 23 - const { text, facets } = parseMarkdown(md); 24 - const roundtripped = serializeToMarkdown(text, facets); 25 - expect(roundtripped).toBe(md); 26 - }), 27 - { numRuns: 200 }, 28 - ); 29 - }); 30 - 31 - it("facet byte indices are always valid after parsing", () => { 32 - fc.assert( 33 - fc.property(fc.string(), (input) => { 34 - const { text, facets } = parseMarkdown(input); 35 - const bs = new ByteString(text); 36 - 37 - for (const facet of facets) { 38 - expect(facet.index.byteStart).toBeGreaterThanOrEqual(0); 39 - expect(facet.index.byteEnd).toBeLessThanOrEqual(bs.length); 40 - expect(facet.index.byteStart).toBeLessThan(facet.index.byteEnd); 41 - } 42 - }), 43 - { numRuns: 200 }, 44 - ); 45 - }); 46 - 47 - it("parser never crashes on arbitrary input", () => { 48 - fc.assert( 49 - fc.property(fc.string(), (input) => { 50 - expect(() => parseMarkdown(input)).not.toThrow(); 51 - }), 52 - { numRuns: 500 }, 53 - ); 54 - }); 55 - 56 - it("serializer never crashes on arbitrary facets (adversarial input)", () => { 57 - // Facets from untrusted ATProto records could have any values 58 - const facetArb = fc.record({ 59 - index: fc.record({ 60 - byteStart: fc.integer({ min: -100, max: 1000 }), 61 - byteEnd: fc.integer({ min: -100, max: 1000 }), 62 - }), 63 - features: fc.array( 64 - fc.oneof( 65 - fc.constant({ 66 - $type: "com.deckbelcher.richtext.facet#bold" as const, 67 - }), 68 - fc.constant({ 69 - $type: "com.deckbelcher.richtext.facet#italic" as const, 70 - }), 71 - ), 72 - { minLength: 0, maxLength: 3 }, 73 - ), 74 - }); 75 - 76 - fc.assert( 77 - fc.property( 78 - fc.string(), 79 - fc.array(facetArb, { maxLength: 20 }), 80 - (text, facets) => { 81 - // Serializer must handle ANY input without crashing 82 - expect(() => serializeToMarkdown(text, facets)).not.toThrow(); 83 - }, 84 - ), 85 - { numRuns: 500 }, 86 - ); 87 - }); 88 - 89 - it("handles unicode in roundtrip", () => { 90 - // CJK, emoji, and ASCII mixed 91 - const unicodeWordArb = fc.stringMatching( 92 - /^[\u4e00-\u9fff\u{1F600}-\u{1F64F}a-z]{1,5}$/u, 93 - ); 94 - 95 - const boldArb = unicodeWordArb.map((w) => `**${w}**`); 96 - const italicArb = unicodeWordArb.map((w) => `*${w}*`); 97 - const plainArb = unicodeWordArb; 98 - 99 - const segmentArb = fc.oneof(boldArb, italicArb, plainArb); 100 - const markdownArb = fc 101 - .array(segmentArb, { minLength: 1, maxLength: 5 }) 102 - .map((segs) => segs.join(" ")); 103 - 104 - fc.assert( 105 - fc.property(markdownArb, (md) => { 106 - const { text, facets } = parseMarkdown(md); 107 - const roundtripped = serializeToMarkdown(text, facets); 108 - expect(roundtripped).toBe(md); 109 - }), 110 - { numRuns: 200 }, 111 - ); 112 - }); 113 - 114 - it("handles family emoji (25 bytes) in formatted spans", () => { 115 - const familyEmoji = "👨‍👩‍👧‍👧"; 116 - const inputs = [ 117 - `**${familyEmoji}**`, 118 - `*${familyEmoji}*`, 119 - `${familyEmoji} **text**`, 120 - `**text** ${familyEmoji}`, 121 - `${familyEmoji} **${familyEmoji}** ${familyEmoji}`, 122 - ]; 123 - 124 - for (const input of inputs) { 125 - const { text, facets } = parseMarkdown(input); 126 - const output = serializeToMarkdown(text, facets); 127 - expect(output).toBe(input); 128 - } 129 - }); 130 - 131 - it("arbitrary UTF-8 roundtrips through parse→serialize", () => { 132 - fc.assert( 133 - fc.property(fc.string(), (input) => { 134 - const { text, facets } = parseMarkdown(input); 135 - const output = serializeToMarkdown(text, facets); 136 - expect(output).toBe(input); 137 - }), 138 - { numRuns: 10_000 }, 139 - ); 140 - }); 141 - });
-73
src/lib/richtext/__tests__/serializer.test.ts
··· 1 - import { describe, expect, it } from "vitest"; 2 - import { serializeToMarkdown } from "../serializer"; 3 - import { BOLD, type Facet, ITALIC } from "../types"; 4 - 5 - describe("serializeToMarkdown", () => { 6 - it("returns text unchanged with no facets", () => { 7 - expect(serializeToMarkdown("hello world", [])).toBe("hello world"); 8 - }); 9 - 10 - it("serializes simple bold", () => { 11 - const facets: Facet[] = [ 12 - { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 13 - ]; 14 - expect(serializeToMarkdown("bold", facets)).toBe("**bold**"); 15 - }); 16 - 17 - it("serializes simple italic", () => { 18 - const facets: Facet[] = [ 19 - { index: { byteStart: 0, byteEnd: 6 }, features: [ITALIC] }, 20 - ]; 21 - expect(serializeToMarkdown("italic", facets)).toBe("*italic*"); 22 - }); 23 - 24 - it("serializes bold in middle of text", () => { 25 - const facets: Facet[] = [ 26 - { index: { byteStart: 6, byteEnd: 11 }, features: [BOLD] }, 27 - ]; 28 - expect(serializeToMarkdown("hello world there", facets)).toBe( 29 - "hello **world** there", 30 - ); 31 - }); 32 - 33 - it("serializes multiple bold spans", () => { 34 - const facets: Facet[] = [ 35 - { index: { byteStart: 0, byteEnd: 1 }, features: [BOLD] }, 36 - { index: { byteStart: 2, byteEnd: 3 }, features: [BOLD] }, 37 - ]; 38 - expect(serializeToMarkdown("abc", facets)).toBe("**a**b**c**"); 39 - }); 40 - 41 - it("serializes overlapping bold and italic (same range)", () => { 42 - const facets: Facet[] = [ 43 - { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 44 - { index: { byteStart: 0, byteEnd: 4 }, features: [ITALIC] }, 45 - ]; 46 - expect(serializeToMarkdown("both", facets)).toBe("***both***"); 47 - }); 48 - 49 - it("serializes nested italic in bold", () => { 50 - // "before bold after" = 17 bytes 51 - const facets: Facet[] = [ 52 - { index: { byteStart: 0, byteEnd: 17 }, features: [BOLD] }, 53 - { index: { byteStart: 7, byteEnd: 11 }, features: [ITALIC] }, 54 - ]; 55 - expect(serializeToMarkdown("before bold after", facets)).toBe( 56 - "**before *bold* after**", 57 - ); 58 - }); 59 - 60 - it("handles unicode", () => { 61 - const facets: Facet[] = [ 62 - { index: { byteStart: 0, byteEnd: 9 }, features: [BOLD] }, 63 - ]; 64 - expect(serializeToMarkdown("日本語", facets)).toBe("**日本語**"); 65 - }); 66 - 67 - it("handles emoji", () => { 68 - const facets: Facet[] = [ 69 - { index: { byteStart: 0, byteEnd: 4 }, features: [BOLD] }, 70 - ]; 71 - expect(serializeToMarkdown("🔥", facets)).toBe("**🔥**"); 72 - }); 73 - });
-20
src/lib/richtext/byte-string.ts
··· 1 - const encoder = new TextEncoder(); 2 - const decoder = new TextDecoder(); 3 - 4 - export class ByteString { 5 - readonly text: string; 6 - readonly bytes: Uint8Array; 7 - 8 - constructor(text: string) { 9 - this.text = text; 10 - this.bytes = encoder.encode(text); 11 - } 12 - 13 - get length(): number { 14 - return this.bytes.length; 15 - } 16 - 17 - sliceByBytes(byteStart: number, byteEnd: number): string { 18 - return decoder.decode(this.bytes.slice(byteStart, byteEnd)); 19 - } 20 - }
-28
src/lib/richtext/index.ts
··· 1 - export { ByteString } from "./byte-string"; 2 - export { parseMarkdown } from "./parser"; 3 - export { RichText } from "./renderer"; 4 - export { serializeToMarkdown } from "./serializer"; 5 - export { 6 - BOLD, 7 - type BoldFeature, 8 - type ByteSlice, 9 - CODE, 10 - CODE_BLOCK, 11 - type CodeBlockFeature, 12 - type CodeFeature, 13 - type Facet, 14 - type FormatFeature, 15 - ITALIC, 16 - type ItalicFeature, 17 - isBold, 18 - isCode, 19 - isCodeBlock, 20 - isItalic, 21 - isLink, 22 - isMention, 23 - type LinkFeature, 24 - link, 25 - type MentionFeature, 26 - mention, 27 - type ParseResult, 28 - } from "./types";
-280
src/lib/richtext/lexer.ts
··· 1 - const encoder = new TextEncoder(); 2 - const decoder = new TextDecoder(); 3 - 4 - const ASTERISK = 0x2a; // '*' 5 - const BACKTICK = 0x60; // '`' 6 - const AT = 0x40; // '@' 7 - const LBRACKET = 0x5b; // '[' 8 - const RBRACKET = 0x5d; // ']' 9 - const LPAREN = 0x28; // '(' 10 - const RPAREN = 0x29; // ')' 11 - const NEWLINE = 0x0a; // '\n' 12 - const PERIOD = 0x2e; // '.' 13 - const HYPHEN = 0x2d; // '-' 14 - 15 - export type TokenType = 16 - | "BOLD_MARKER" 17 - | "ITALIC_MARKER" 18 - | "CODE_MARKER" 19 - | "CODE_BLOCK" 20 - | "LINK" 21 - | "MENTION" 22 - | "TEXT"; 23 - 24 - export interface Token { 25 - type: TokenType; 26 - byteStart: number; 27 - byteEnd: number; 28 - } 29 - 30 - export interface LinkToken extends Token { 31 - type: "LINK"; 32 - textStart: number; 33 - textEnd: number; 34 - uriStart: number; 35 - uriEnd: number; 36 - } 37 - 38 - export interface MentionToken extends Token { 39 - type: "MENTION"; 40 - handleStart: number; 41 - handleEnd: number; 42 - } 43 - 44 - export interface CodeBlockToken extends Token { 45 - type: "CODE_BLOCK"; 46 - contentStart: number; 47 - contentEnd: number; 48 - } 49 - 50 - export function isLinkToken(token: Token): token is LinkToken { 51 - return token.type === "LINK"; 52 - } 53 - 54 - export function isMentionToken(token: Token): token is MentionToken { 55 - return token.type === "MENTION"; 56 - } 57 - 58 - export function isCodeBlockToken(token: Token): token is CodeBlockToken { 59 - return token.type === "CODE_BLOCK"; 60 - } 61 - 62 - export function tokenize(input: string): Token[] { 63 - const bytes = encoder.encode(input); 64 - const tokens: Token[] = []; 65 - let i = 0; 66 - let textStart: number | null = null; 67 - 68 - const flushText = () => { 69 - if (textStart !== null && textStart < i) { 70 - tokens.push({ 71 - type: "TEXT", 72 - byteStart: textStart, 73 - byteEnd: i, 74 - }); 75 - textStart = null; 76 - } 77 - }; 78 - 79 - while (i < bytes.length) { 80 - // Check for ``` (code block) - must be at start of line or start of input 81 - if ( 82 - bytes[i] === BACKTICK && 83 - bytes[i + 1] === BACKTICK && 84 - bytes[i + 2] === BACKTICK 85 - ) { 86 - const isStartOfLine = i === 0 || bytes[i - 1] === NEWLINE; 87 - if (isStartOfLine) { 88 - // Find end of opening line (skip optional language identifier) 89 - let contentStart = i + 3; 90 - while (contentStart < bytes.length && bytes[contentStart] !== NEWLINE) { 91 - contentStart++; 92 - } 93 - if (contentStart < bytes.length) { 94 - contentStart++; // skip the newline 95 - } 96 - 97 - // Find closing ``` 98 - let contentEnd = contentStart; 99 - let found = false; 100 - while (contentEnd < bytes.length) { 101 - if ( 102 - bytes[contentEnd] === NEWLINE && 103 - bytes[contentEnd + 1] === BACKTICK && 104 - bytes[contentEnd + 2] === BACKTICK && 105 - bytes[contentEnd + 3] === BACKTICK 106 - ) { 107 - found = true; 108 - break; 109 - } 110 - contentEnd++; 111 - } 112 - 113 - if (found) { 114 - flushText(); 115 - const blockEnd = contentEnd + 4; // newline + ``` 116 - tokens.push({ 117 - type: "CODE_BLOCK", 118 - byteStart: i, 119 - byteEnd: blockEnd, 120 - contentStart, 121 - contentEnd, 122 - } as CodeBlockToken); 123 - i = blockEnd; 124 - continue; 125 - } 126 - } 127 - } 128 - 129 - // Check for ** (bold marker) 130 - if (bytes[i] === ASTERISK && bytes[i + 1] === ASTERISK) { 131 - flushText(); 132 - tokens.push({ type: "BOLD_MARKER", byteStart: i, byteEnd: i + 2 }); 133 - i += 2; 134 - continue; 135 - } 136 - 137 - // Check for * (italic marker) 138 - if (bytes[i] === ASTERISK) { 139 - flushText(); 140 - tokens.push({ type: "ITALIC_MARKER", byteStart: i, byteEnd: i + 1 }); 141 - i += 1; 142 - continue; 143 - } 144 - 145 - // Check for ` (inline code marker) 146 - if (bytes[i] === BACKTICK) { 147 - flushText(); 148 - tokens.push({ type: "CODE_MARKER", byteStart: i, byteEnd: i + 1 }); 149 - i += 1; 150 - continue; 151 - } 152 - 153 - // Check for [text](url) (link) 154 - if (bytes[i] === LBRACKET) { 155 - const linkResult = tryParseLink(bytes, i); 156 - if (linkResult) { 157 - flushText(); 158 - tokens.push(linkResult); 159 - i = linkResult.byteEnd; 160 - continue; 161 - } 162 - } 163 - 164 - // Check for @handle (mention) 165 - if (bytes[i] === AT) { 166 - const mentionResult = tryParseMention(bytes, i); 167 - if (mentionResult) { 168 - flushText(); 169 - tokens.push(mentionResult); 170 - i = mentionResult.byteEnd; 171 - continue; 172 - } 173 - } 174 - 175 - // Regular byte - accumulate into text 176 - if (textStart === null) { 177 - textStart = i; 178 - } 179 - i += 1; 180 - } 181 - 182 - flushText(); 183 - return tokens; 184 - } 185 - 186 - function tryParseLink(bytes: Uint8Array, start: number): LinkToken | null { 187 - // [text](url) 188 - let i = start + 1; // skip [ 189 - const textStart = i; 190 - 191 - // Find ] 192 - while (i < bytes.length && bytes[i] !== RBRACKET && bytes[i] !== NEWLINE) { 193 - i++; 194 - } 195 - if (i >= bytes.length || bytes[i] !== RBRACKET) return null; 196 - const textEnd = i; 197 - i++; // skip ] 198 - 199 - // Must be followed by ( 200 - if (bytes[i] !== LPAREN) return null; 201 - i++; // skip ( 202 - const uriStart = i; 203 - 204 - // Find ) 205 - while (i < bytes.length && bytes[i] !== RPAREN && bytes[i] !== NEWLINE) { 206 - i++; 207 - } 208 - if (i >= bytes.length || bytes[i] !== RPAREN) return null; 209 - const uriEnd = i; 210 - i++; // skip ) 211 - 212 - // Must have non-empty text and uri 213 - if (textEnd <= textStart || uriEnd <= uriStart) return null; 214 - 215 - return { 216 - type: "LINK", 217 - byteStart: start, 218 - byteEnd: i, 219 - textStart, 220 - textEnd, 221 - uriStart, 222 - uriEnd, 223 - }; 224 - } 225 - 226 - function isHandleChar(b: number): boolean { 227 - // a-z, A-Z, 0-9, -, . 228 - return ( 229 - (b >= 0x61 && b <= 0x7a) || 230 - (b >= 0x41 && b <= 0x5a) || 231 - (b >= 0x30 && b <= 0x39) || 232 - b === HYPHEN || 233 - b === PERIOD 234 - ); 235 - } 236 - 237 - function tryParseMention( 238 - bytes: Uint8Array, 239 - start: number, 240 - ): MentionToken | null { 241 - // @handle - ATProto handles: a-z, 0-9, -, . with at least one dot 242 - let i = start + 1; // skip @ 243 - const handleStart = i; 244 - let dotCount = 0; 245 - 246 - while (i < bytes.length && isHandleChar(bytes[i])) { 247 - if (bytes[i] === PERIOD) { 248 - // Can't have consecutive dots or start with dot 249 - if (i === handleStart || bytes[i - 1] === PERIOD) { 250 - return null; 251 - } 252 - dotCount++; 253 - } 254 - i++; 255 - } 256 - 257 - const handleEnd = i; 258 - 259 - // Must have at least one dot (two segments) 260 - if (dotCount < 1) return null; 261 - 262 - // Can't end with dot or hyphen 263 - const lastChar = bytes[handleEnd - 1]; 264 - if (lastChar === PERIOD || lastChar === HYPHEN) return null; 265 - 266 - if (handleEnd <= handleStart) return null; 267 - 268 - return { 269 - type: "MENTION", 270 - byteStart: start, 271 - byteEnd: handleEnd, 272 - handleStart, 273 - handleEnd, 274 - }; 275 - } 276 - 277 - export function getTokenText(input: string, token: Token): string { 278 - const bytes = encoder.encode(input); 279 - return decoder.decode(bytes.slice(token.byteStart, token.byteEnd)); 280 - }
-195
src/lib/richtext/parser.ts
··· 1 - import { 2 - type CodeBlockToken, 3 - isCodeBlockToken, 4 - isLinkToken, 5 - isMentionToken, 6 - type LinkToken, 7 - type MentionToken, 8 - type Token, 9 - tokenize, 10 - } from "./lexer"; 11 - import { 12 - BOLD, 13 - CODE, 14 - CODE_BLOCK, 15 - type Facet, 16 - type FormatFeature, 17 - ITALIC, 18 - link, 19 - mention, 20 - type ParseResult, 21 - } from "./types"; 22 - 23 - const encoder = new TextEncoder(); 24 - const decoder = new TextDecoder(); 25 - 26 - export function parseMarkdown(input: string): ParseResult { 27 - const tokens = tokenize(input); 28 - const bytes = encoder.encode(input); 29 - 30 - // Find matching pairs for bold, italic, and code markers 31 - const boldPairs = findPairs(tokens, "BOLD_MARKER"); 32 - const italicPairs = findPairs(tokens, "ITALIC_MARKER"); 33 - const codePairs = findPairs(tokens, "CODE_MARKER"); 34 - 35 - // Build output: collect text tokens and paired markers' content 36 - const outputBytes: number[] = []; 37 - const facets: Facet[] = []; 38 - 39 - // Track byte position mapping: original byte -> output byte 40 - let outputBytePos = 0; 41 - 42 - const emitBytes = (start: number, end: number) => { 43 - for (let b = start; b < end; b++) { 44 - outputBytes.push(bytes[b]); 45 - } 46 - outputBytePos += end - start; 47 - }; 48 - 49 - const handlePairedMarker = ( 50 - token: Token, 51 - pairs: Map<Token, PairInfo>, 52 - feature: FormatFeature, 53 - ) => { 54 - const pair = pairs.get(token); 55 - if (pair) { 56 - if (pair.isOpen) { 57 - pair.outputByteStart = outputBytePos; 58 - } else { 59 - const openPair = pair.partner; 60 - if ( 61 - openPair?.outputByteStart !== undefined && 62 - outputBytePos > openPair.outputByteStart 63 - ) { 64 - // Non-empty span: create facet 65 - facets.push({ 66 - index: { 67 - byteStart: openPair.outputByteStart, 68 - byteEnd: outputBytePos, 69 - }, 70 - features: [feature], 71 - }); 72 - } else if (openPair?.openToken) { 73 - // Empty span: emit both markers as literal text 74 - emitBytes(openPair.openToken.byteStart, openPair.openToken.byteEnd); 75 - emitBytes(token.byteStart, token.byteEnd); 76 - } 77 - } 78 - } else { 79 - // Unpaired marker - emit as literal text 80 - emitBytes(token.byteStart, token.byteEnd); 81 - } 82 - }; 83 - 84 - for (const token of tokens) { 85 - if (token.type === "TEXT") { 86 - emitBytes(token.byteStart, token.byteEnd); 87 - } else if (token.type === "BOLD_MARKER") { 88 - handlePairedMarker(token, boldPairs, BOLD); 89 - } else if (token.type === "ITALIC_MARKER") { 90 - handlePairedMarker(token, italicPairs, ITALIC); 91 - } else if (token.type === "CODE_MARKER") { 92 - handlePairedMarker(token, codePairs, CODE); 93 - } else if (token.type === "CODE_BLOCK" && isCodeBlockToken(token)) { 94 - const codeBlock = token as CodeBlockToken; 95 - const contentStart = outputBytePos; 96 - 97 - // Copy content bytes 98 - for (let b = codeBlock.contentStart; b < codeBlock.contentEnd; b++) { 99 - outputBytes.push(bytes[b]); 100 - } 101 - outputBytePos += codeBlock.contentEnd - codeBlock.contentStart; 102 - 103 - facets.push({ 104 - index: { byteStart: contentStart, byteEnd: outputBytePos }, 105 - features: [CODE_BLOCK], 106 - }); 107 - } else if (token.type === "LINK" && isLinkToken(token)) { 108 - const linkToken = token as LinkToken; 109 - const textStart = outputBytePos; 110 - 111 - // Copy link text bytes 112 - for (let b = linkToken.textStart; b < linkToken.textEnd; b++) { 113 - outputBytes.push(bytes[b]); 114 - } 115 - outputBytePos += linkToken.textEnd - linkToken.textStart; 116 - 117 - // Extract URI 118 - const uri = decoder.decode( 119 - bytes.slice(linkToken.uriStart, linkToken.uriEnd), 120 - ); 121 - 122 - facets.push({ 123 - index: { byteStart: textStart, byteEnd: outputBytePos }, 124 - features: [link(uri)], 125 - }); 126 - } else if (token.type === "MENTION" && isMentionToken(token)) { 127 - const mentionToken = token as MentionToken; 128 - const mentionStart = outputBytePos; 129 - 130 - // Copy full @handle to output 131 - for (let b = token.byteStart; b < token.byteEnd; b++) { 132 - outputBytes.push(bytes[b]); 133 - } 134 - outputBytePos += token.byteEnd - token.byteStart; 135 - 136 - // Extract handle (without @) 137 - const handle = decoder.decode( 138 - bytes.slice(mentionToken.handleStart, mentionToken.handleEnd), 139 - ); 140 - 141 - facets.push({ 142 - index: { byteStart: mentionStart, byteEnd: outputBytePos }, 143 - features: [mention(handle)], 144 - }); 145 - } 146 - } 147 - 148 - const text = decoder.decode(new Uint8Array(outputBytes)); 149 - return { 150 - text, 151 - facets: facets.sort((a, b) => a.index.byteStart - b.index.byteStart), 152 - }; 153 - } 154 - 155 - interface PairInfo { 156 - isOpen: boolean; 157 - partner?: PairInfo; 158 - openToken?: Token; 159 - outputByteStart?: number; 160 - } 161 - 162 - function findPairs( 163 - tokens: Token[], 164 - markerType: "BOLD_MARKER" | "ITALIC_MARKER" | "CODE_MARKER", 165 - ): Map<Token, PairInfo> { 166 - const pairs = new Map<Token, PairInfo>(); 167 - const stack: { token: Token; info: PairInfo }[] = []; 168 - 169 - for (const token of tokens) { 170 - if (token.type === markerType) { 171 - const open = stack.pop(); 172 - if (open) { 173 - const closeInfo: PairInfo = { 174 - isOpen: false, 175 - partner: open.info, 176 - openToken: open.token, 177 - }; 178 - open.info.partner = closeInfo; 179 - pairs.set(token, closeInfo); 180 - } else { 181 - const openInfo: PairInfo = { isOpen: true, openToken: token }; 182 - stack.push({ token, info: openInfo }); 183 - pairs.set(token, openInfo); 184 - } 185 - } 186 - } 187 - 188 - for (const { token, info } of stack) { 189 - if (!info.partner) { 190 - pairs.delete(token); 191 - } 192 - } 193 - 194 - return pairs; 195 - }
-181
src/lib/richtext/renderer.tsx
··· 1 - import { memo, type ReactNode } from "react"; 2 - import { ByteString } from "./byte-string"; 3 - import { 4 - type Facet, 5 - type FormatFeature, 6 - isBold, 7 - isCode, 8 - isCodeBlock, 9 - isItalic, 10 - isLink, 11 - isMention, 12 - type LinkFeature, 13 - type MentionFeature, 14 - } from "./types"; 15 - 16 - interface RichTextProps { 17 - text: string; 18 - facets?: Facet[]; 19 - className?: string; 20 - } 21 - 22 - interface Segment { 23 - text: string; 24 - bold: boolean; 25 - italic: boolean; 26 - code: boolean; 27 - codeBlock: boolean; 28 - link: LinkFeature | null; 29 - mention: MentionFeature | null; 30 - } 31 - 32 - export const RichText = memo(function RichText({ 33 - text, 34 - facets, 35 - className, 36 - }: RichTextProps): ReactNode { 37 - if (!text) { 38 - return null; 39 - } 40 - 41 - const segments = segmentText(text, facets ?? []); 42 - 43 - return ( 44 - <span className={className}> 45 - {segments.map((segment, i) => renderSegment(segment, i))} 46 - </span> 47 - ); 48 - }); 49 - 50 - function renderSegment(segment: Segment, key: number): ReactNode { 51 - if (segment.codeBlock) { 52 - return ( 53 - <pre 54 - key={key} 55 - className="bg-gray-100 dark:bg-slate-800 p-2 rounded my-2 overflow-x-auto" 56 - > 57 - <code>{segment.text}</code> 58 - </pre> 59 - ); 60 - } 61 - 62 - // Plain text - no wrapper needed 63 - if ( 64 - !segment.bold && 65 - !segment.italic && 66 - !segment.code && 67 - !segment.link && 68 - !segment.mention 69 - ) { 70 - return segment.text; 71 - } 72 - 73 - let content: ReactNode = segment.text; 74 - 75 - // Wrap in formatting elements (innermost to outermost) 76 - if (segment.code) { 77 - content = ( 78 - <code className="bg-gray-100 dark:bg-slate-800 px-1 rounded font-mono text-sm"> 79 - {content} 80 - </code> 81 - ); 82 - } 83 - if (segment.italic) { 84 - content = <em>{content}</em>; 85 - } 86 - if (segment.bold) { 87 - content = <strong>{content}</strong>; 88 - } 89 - 90 - // Links and mentions wrap the formatted content 91 - if (segment.link) { 92 - return ( 93 - <a 94 - key={key} 95 - href={segment.link.uri} 96 - className="text-blue-600 dark:text-blue-400 hover:underline" 97 - target="_blank" 98 - rel="noopener noreferrer" 99 - > 100 - {content} 101 - </a> 102 - ); 103 - } 104 - 105 - if (segment.mention) { 106 - return ( 107 - <span 108 - key={key} 109 - className="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer" 110 - data-did={segment.mention.did} 111 - > 112 - {content} 113 - </span> 114 - ); 115 - } 116 - 117 - // Non-link/mention formatted content needs a keyed wrapper 118 - return <span key={key}>{content}</span>; 119 - } 120 - 121 - function collectFeatures( 122 - start: number, 123 - end: number, 124 - facets: Facet[], 125 - ): FormatFeature[] { 126 - return facets.flatMap((facet) => { 127 - const { byteStart, byteEnd } = facet.index; 128 - if (start >= byteStart && end <= byteEnd) { 129 - return facet.features; 130 - } 131 - return []; 132 - }); 133 - } 134 - 135 - function buildSegment(text: string, features: FormatFeature[]): Segment { 136 - return { 137 - text, 138 - bold: features.some(isBold), 139 - italic: features.some(isItalic), 140 - code: features.some(isCode), 141 - codeBlock: features.some(isCodeBlock), 142 - link: features.find(isLink) ?? null, 143 - mention: features.find(isMention) ?? null, 144 - }; 145 - } 146 - 147 - function segmentText(text: string, facets: Facet[]): Segment[] { 148 - if (facets.length === 0) { 149 - return [buildSegment(text, [])]; 150 - } 151 - 152 - const bs = new ByteString(text); 153 - 154 - const validFacets = facets.filter((f) => { 155 - const { byteStart, byteEnd } = f.index; 156 - return ( 157 - byteStart >= 0 && 158 - byteEnd > byteStart && 159 - byteEnd <= bs.length && 160 - f.features.length > 0 161 - ); 162 - }); 163 - 164 - const boundaries = new Set([0, bs.length]); 165 - for (const facet of validFacets) { 166 - boundaries.add(facet.index.byteStart); 167 - boundaries.add(facet.index.byteEnd); 168 - } 169 - 170 - const sortedBoundaries = Array.from(boundaries).sort((a, b) => a - b); 171 - 172 - return sortedBoundaries 173 - .slice(0, -1) 174 - .map((start, i) => { 175 - const end = sortedBoundaries[i + 1]; 176 - const segText = bs.sliceByBytes(start, end); 177 - const features = collectFeatures(start, end, validFacets); 178 - return buildSegment(segText, features); 179 - }) 180 - .filter((seg) => seg.text.length > 0); 181 - }
-185
src/lib/richtext/serializer.ts
··· 1 - import { ByteString } from "./byte-string"; 2 - import { 3 - type Facet, 4 - isBold, 5 - isCode, 6 - isCodeBlock, 7 - isItalic, 8 - isLink, 9 - isMention, 10 - } from "./types"; 11 - 12 - type BoundaryType = "bold" | "italic" | "code"; 13 - 14 - interface Boundary { 15 - bytePos: number; 16 - type: BoundaryType; 17 - isOpen: boolean; 18 - } 19 - 20 - interface Replacement { 21 - byteStart: number; 22 - byteEnd: number; 23 - output: string; 24 - } 25 - 26 - export function serializeToMarkdown(text: string, facets: Facet[]): string { 27 - if (facets.length === 0) { 28 - return text; 29 - } 30 - 31 - const bs = new ByteString(text); 32 - const boundaries: Boundary[] = []; 33 - const replacements: Replacement[] = []; 34 - 35 - for (const facet of facets) { 36 - const { byteStart, byteEnd } = facet.index; 37 - 38 - // Validate facet bounds 39 - if ( 40 - byteStart < 0 || 41 - byteEnd < 0 || 42 - byteStart >= byteEnd || 43 - byteEnd > bs.length 44 - ) { 45 - continue; 46 - } 47 - 48 - for (const feature of facet.features) { 49 - if (isBold(feature)) { 50 - boundaries.push({ bytePos: byteStart, type: "bold", isOpen: true }); 51 - boundaries.push({ bytePos: byteEnd, type: "bold", isOpen: false }); 52 - } else if (isItalic(feature)) { 53 - boundaries.push({ bytePos: byteStart, type: "italic", isOpen: true }); 54 - boundaries.push({ bytePos: byteEnd, type: "italic", isOpen: false }); 55 - } else if (isCode(feature)) { 56 - boundaries.push({ bytePos: byteStart, type: "code", isOpen: true }); 57 - boundaries.push({ bytePos: byteEnd, type: "code", isOpen: false }); 58 - } else if (isCodeBlock(feature)) { 59 - const content = bs.sliceByBytes(byteStart, byteEnd); 60 - replacements.push({ 61 - byteStart, 62 - byteEnd, 63 - output: `\`\`\`\n${content}\n\`\`\``, 64 - }); 65 - } else if (isLink(feature)) { 66 - const linkText = bs.sliceByBytes(byteStart, byteEnd); 67 - replacements.push({ 68 - byteStart, 69 - byteEnd, 70 - output: `[${linkText}](${feature.uri})`, 71 - }); 72 - } else if (isMention(feature)) { 73 - // Mentions keep @handle in text, so just output as-is 74 - // (the text already includes @handle) 75 - } 76 - } 77 - } 78 - 79 - // If we have replacements, handle them separately 80 - // (they replace entire ranges rather than wrapping) 81 - if (replacements.length > 0) { 82 - return serializeWithReplacements(bs, boundaries, replacements); 83 - } 84 - 85 - // Sort boundaries by position, closes before opens at same position 86 - boundaries.sort((a, b) => { 87 - if (a.bytePos !== b.bytePos) { 88 - return a.bytePos - b.bytePos; 89 - } 90 - if (a.isOpen !== b.isOpen) { 91 - return a.isOpen ? 1 : -1; 92 - } 93 - return 0; 94 - }); 95 - 96 - const parts: string[] = []; 97 - let lastPos = 0; 98 - 99 - for (const boundary of boundaries) { 100 - if (boundary.bytePos > lastPos) { 101 - parts.push(bs.sliceByBytes(lastPos, boundary.bytePos)); 102 - } 103 - 104 - const marker = getMarker(boundary.type); 105 - parts.push(marker); 106 - 107 - lastPos = boundary.bytePos; 108 - } 109 - 110 - if (lastPos < bs.length) { 111 - parts.push(bs.sliceByBytes(lastPos, bs.length)); 112 - } 113 - 114 - return parts.join(""); 115 - } 116 - 117 - function getMarker(type: BoundaryType): string { 118 - switch (type) { 119 - case "bold": 120 - return "**"; 121 - case "italic": 122 - return "*"; 123 - case "code": 124 - return "`"; 125 - } 126 - } 127 - 128 - function serializeWithReplacements( 129 - bs: ByteString, 130 - boundaries: Boundary[], 131 - replacements: Replacement[], 132 - ): string { 133 - // Combine boundaries and replacements into a unified event stream 134 - type Event = 135 - | { type: "boundary"; pos: number; boundary: Boundary } 136 - | { type: "replacement"; pos: number; replacement: Replacement }; 137 - 138 - const events: Event[] = []; 139 - 140 - for (const b of boundaries) { 141 - events.push({ type: "boundary", pos: b.bytePos, boundary: b }); 142 - } 143 - 144 - for (const r of replacements) { 145 - events.push({ type: "replacement", pos: r.byteStart, replacement: r }); 146 - } 147 - 148 - // Sort events by position 149 - events.sort((a, b) => { 150 - if (a.pos !== b.pos) return a.pos - b.pos; 151 - // Replacements come after boundaries at same position 152 - if (a.type !== b.type) return a.type === "boundary" ? -1 : 1; 153 - if (a.type === "boundary" && b.type === "boundary") { 154 - return a.boundary.isOpen ? 1 : -1; 155 - } 156 - return 0; 157 - }); 158 - 159 - const parts: string[] = []; 160 - let lastPos = 0; 161 - 162 - for (const event of events) { 163 - if (event.type === "boundary") { 164 - if (event.pos > lastPos) { 165 - parts.push(bs.sliceByBytes(lastPos, event.pos)); 166 - lastPos = event.pos; 167 - } 168 - parts.push(getMarker(event.boundary.type)); 169 - } else { 170 - // Replacement 171 - const r = event.replacement; 172 - if (r.byteStart > lastPos) { 173 - parts.push(bs.sliceByBytes(lastPos, r.byteStart)); 174 - } 175 - parts.push(r.output); 176 - lastPos = r.byteEnd; 177 - } 178 - } 179 - 180 - if (lastPos < bs.length) { 181 - parts.push(bs.sliceByBytes(lastPos, bs.length)); 182 - } 183 - 184 - return parts.join(""); 185 - }
-134
src/lib/richtext/types.ts
··· 1 - import type { Main as LexiconRichText } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 2 - import type { 3 - ByteSlice as LexiconByteSlice, 4 - Link as LexiconLink, 5 - Mention as LexiconMention, 6 - } from "@/lib/lexicons/types/com/deckbelcher/richtext/facet"; 7 - 8 - // ByteSlice matches lexicon exactly 9 - export interface ByteSlice { 10 - byteStart: number; 11 - byteEnd: number; 12 - } 13 - 14 - // Feature types with required $type for our internal use 15 - export interface BoldFeature { 16 - $type: "com.deckbelcher.richtext.facet#bold"; 17 - } 18 - 19 - export interface ItalicFeature { 20 - $type: "com.deckbelcher.richtext.facet#italic"; 21 - } 22 - 23 - export interface CodeFeature { 24 - $type: "com.deckbelcher.richtext.facet#code"; 25 - } 26 - 27 - export interface CodeBlockFeature { 28 - $type: "com.deckbelcher.richtext.facet#codeBlock"; 29 - } 30 - 31 - export interface LinkFeature { 32 - $type: "com.deckbelcher.richtext.facet#link"; 33 - uri: LexiconLink["uri"]; 34 - } 35 - 36 - export interface MentionFeature { 37 - $type: "com.deckbelcher.richtext.facet#mention"; 38 - did: LexiconMention["did"]; 39 - } 40 - 41 - export interface TagFeature { 42 - $type: "com.deckbelcher.richtext.facet#tag"; 43 - tag: string; 44 - } 45 - 46 - export type FormatFeature = 47 - | BoldFeature 48 - | ItalicFeature 49 - | CodeFeature 50 - | CodeBlockFeature 51 - | LinkFeature 52 - | MentionFeature 53 - | TagFeature; 54 - 55 - export interface Facet { 56 - index: ByteSlice; 57 - features: FormatFeature[]; 58 - } 59 - 60 - export interface ParseResult { 61 - text: string; 62 - facets: Facet[]; 63 - } 64 - 65 - // Type alias for lexicon compatibility 66 - export type RichText = LexiconRichText; 67 - export type { LexiconByteSlice }; 68 - 69 - export function isBold(feature: FormatFeature): feature is BoldFeature { 70 - return feature.$type === "com.deckbelcher.richtext.facet#bold"; 71 - } 72 - 73 - export function isItalic(feature: FormatFeature): feature is ItalicFeature { 74 - return feature.$type === "com.deckbelcher.richtext.facet#italic"; 75 - } 76 - 77 - export function isCode(feature: FormatFeature): feature is CodeFeature { 78 - return feature.$type === "com.deckbelcher.richtext.facet#code"; 79 - } 80 - 81 - export function isCodeBlock( 82 - feature: FormatFeature, 83 - ): feature is CodeBlockFeature { 84 - return feature.$type === "com.deckbelcher.richtext.facet#codeBlock"; 85 - } 86 - 87 - export function isLink(feature: FormatFeature): feature is LinkFeature { 88 - return feature.$type === "com.deckbelcher.richtext.facet#link"; 89 - } 90 - 91 - export function isMention(feature: FormatFeature): feature is MentionFeature { 92 - return feature.$type === "com.deckbelcher.richtext.facet#mention"; 93 - } 94 - 95 - export function isTag(feature: FormatFeature): feature is TagFeature { 96 - return feature.$type === "com.deckbelcher.richtext.facet#tag"; 97 - } 98 - 99 - export const BOLD: BoldFeature = { 100 - $type: "com.deckbelcher.richtext.facet#bold", 101 - }; 102 - 103 - export const ITALIC: ItalicFeature = { 104 - $type: "com.deckbelcher.richtext.facet#italic", 105 - }; 106 - 107 - export const CODE: CodeFeature = { 108 - $type: "com.deckbelcher.richtext.facet#code", 109 - }; 110 - 111 - export const CODE_BLOCK: CodeBlockFeature = { 112 - $type: "com.deckbelcher.richtext.facet#codeBlock", 113 - }; 114 - 115 - export function link(uri: string): LinkFeature { 116 - return { 117 - $type: "com.deckbelcher.richtext.facet#link", 118 - uri: uri as LinkFeature["uri"], 119 - }; 120 - } 121 - 122 - export function mention(did: string): MentionFeature { 123 - return { 124 - $type: "com.deckbelcher.richtext.facet#mention", 125 - did: did as MentionFeature["did"], 126 - }; 127 - } 128 - 129 - export function tag(value: string): TagFeature { 130 - return { 131 - $type: "com.deckbelcher.richtext.facet#tag", 132 - tag: value, 133 - }; 134 - }
-111
src/lib/useRichText.ts
··· 1 - import { 2 - type RefObject, 3 - startTransition, 4 - useCallback, 5 - useEffect, 6 - useMemo, 7 - useRef, 8 - useState, 9 - } from "react"; 10 - import { type ParseResult, parseMarkdown } from "./richtext"; 11 - import { useImperativeDebounce, useImperativeThrottle } from "./useDebounce"; 12 - 13 - export interface UseRichTextOptions { 14 - initialValue?: string; 15 - onSave?: (parsed: ParseResult) => void | Promise<void>; 16 - /** How often to update preview during typing (ms) */ 17 - previewThrottleMs?: number; 18 - /** How long to wait after typing stops before saving (ms) */ 19 - saveDebounceMs?: number; 20 - } 21 - 22 - export interface UseRichTextResult { 23 - inputRef: RefObject<HTMLTextAreaElement | null>; 24 - onInput: () => void; 25 - defaultValue: string; 26 - parsed: ParseResult; 27 - isDirty: boolean; 28 - save: () => void; 29 - } 30 - 31 - export function useRichText({ 32 - initialValue = "", 33 - onSave, 34 - previewThrottleMs = 100, 35 - saveDebounceMs = 1500, 36 - }: UseRichTextOptions = {}): UseRichTextResult { 37 - const inputRef = useRef<HTMLTextAreaElement | null>(null); 38 - const savedValueRef = useRef(initialValue); 39 - const onSaveRef = useRef(onSave); 40 - 41 - // State only updates on throttle/debounce boundaries, not every keystroke 42 - const [previewMarkdown, setPreviewMarkdown] = useState(initialValue); 43 - const [saveState, setSaveState] = useState<"saved" | "dirty">("saved"); 44 - 45 - onSaveRef.current = onSave; 46 - 47 - // Throttle for preview (update every N ms during typing) 48 - const throttle = useImperativeThrottle( 49 - initialValue, 50 - previewThrottleMs, 51 - (value) => { 52 - // Low-priority update - React can interrupt for user input 53 - startTransition(() => { 54 - setPreviewMarkdown(value); 55 - // Mark dirty when preview updates (throttle boundary) 56 - if (value !== savedValueRef.current) { 57 - setSaveState("dirty"); 58 - } 59 - }); 60 - }, 61 - ); 62 - 63 - // Debounce for save (wait N ms after typing stops) 64 - const debounce = useImperativeDebounce( 65 - initialValue, 66 - saveDebounceMs, 67 - (value) => { 68 - if (value !== savedValueRef.current && onSaveRef.current) { 69 - onSaveRef.current(parseMarkdown(value)); 70 - savedValueRef.current = value; 71 - } 72 - setSaveState("saved"); 73 - }, 74 - ); 75 - 76 - const parsed = useMemo( 77 - () => parseMarkdown(previewMarkdown), 78 - [previewMarkdown], 79 - ); 80 - 81 - const onInput = useCallback(() => { 82 - const value = inputRef.current?.value ?? ""; 83 - throttle.update(value); 84 - debounce.update(value); 85 - // No setState here - dirty state updates on throttle boundary 86 - }, [throttle, debounce]); 87 - 88 - const save = useCallback(() => { 89 - throttle.flush(); 90 - debounce.flush(); 91 - }, [throttle, debounce]); 92 - 93 - // Sync with external initialValue changes (e.g., deck reload) 94 - useEffect(() => { 95 - if (inputRef.current) { 96 - inputRef.current.value = initialValue; 97 - } 98 - setPreviewMarkdown(initialValue); 99 - savedValueRef.current = initialValue; 100 - setSaveState("saved"); 101 - }, [initialValue]); 102 - 103 - return { 104 - inputRef, 105 - onInput, 106 - defaultValue: initialValue, 107 - parsed, 108 - isDirty: saveState === "dirty", 109 - save, 110 - }; 111 - }
+84 -35
src/routes/pm-demo.tsx
··· 1 1 import { createFileRoute } from "@tanstack/react-router"; 2 - import { useState } from "react"; 3 - import { PrimerSectionPM } from "@/components/deck/PrimerSectionPM"; 2 + import { useMemo, useState } from "react"; 3 + import { PrimerSection } from "@/components/deck/PrimerSection"; 4 + import type { Document } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 5 + import { lexiconToTree, treeToLexicon } from "@/lib/richtext-convert"; 4 6 import type { PMDocJSON } from "@/lib/useProseMirror"; 5 7 6 8 export const Route = createFileRoute("/pm-demo")({ 7 9 component: ProseMirrorDemo, 8 10 }); 9 11 10 - const SAMPLE_DOC: PMDocJSON = { 11 - type: "doc", 12 + const SAMPLE_DOC: Document = { 12 13 content: [ 13 14 { 14 - type: "paragraph", 15 - content: [ 16 - { type: "text", text: "This is a " }, 15 + $type: "com.deckbelcher.richtext#paragraphBlock", 16 + text: "This is a bold and italic text demo.", 17 + facets: [ 17 18 { 18 - type: "text", 19 - text: "bold", 20 - marks: [{ type: "strong" }], 19 + index: { byteStart: 10, byteEnd: 14 }, 20 + features: [{ $type: "com.deckbelcher.richtext.facet#bold" }], 21 21 }, 22 - { type: "text", text: " and " }, 23 22 { 24 - type: "text", 25 - text: "italic", 26 - marks: [{ type: "em" }], 23 + index: { byteStart: 19, byteEnd: 25 }, 24 + features: [{ $type: "com.deckbelcher.richtext.facet#italic" }], 27 25 }, 28 - { type: "text", text: " text demo." }, 29 26 ], 30 27 }, 31 28 { 32 - type: "paragraph", 33 - content: [ 34 - { type: "text", text: "Try typing " }, 29 + $type: "com.deckbelcher.richtext#paragraphBlock", 30 + text: "Try typing **bold** or *italic* to use markdown shortcuts!", 31 + facets: [ 35 32 { 36 - type: "text", 37 - text: "**bold**", 38 - marks: [{ type: "code" }], 33 + index: { byteStart: 11, byteEnd: 19 }, 34 + features: [{ $type: "com.deckbelcher.richtext.facet#code" }], 39 35 }, 40 - { type: "text", text: " or " }, 41 36 { 42 - type: "text", 43 - text: "*italic*", 44 - marks: [{ type: "code" }], 37 + index: { byteStart: 23, byteEnd: 31 }, 38 + features: [{ $type: "com.deckbelcher.richtext.facet#code" }], 45 39 }, 46 - { type: "text", text: " to use markdown shortcuts!" }, 47 40 ], 48 41 }, 49 42 ], 50 43 }; 51 44 52 45 function ProseMirrorDemo() { 53 - const [savedDoc, setSavedDoc] = useState<PMDocJSON | undefined>(SAMPLE_DOC); 46 + const [savedDoc, setSavedDoc] = useState<Document | undefined>(SAMPLE_DOC); 54 47 const [lastSaved, setLastSaved] = useState<string>(""); 55 48 56 - const handleSave = (doc: PMDocJSON) => { 49 + const handleSave = (doc: Document) => { 57 50 setSavedDoc(doc); 58 51 setLastSaved(new Date().toLocaleTimeString()); 59 52 console.log("Saved doc:", JSON.stringify(doc, null, 2)); 60 53 }; 61 54 55 + // Convert lexicon to PM tree for display 56 + const pmTreeDoc = useMemo(() => { 57 + if (!savedDoc) return null; 58 + try { 59 + return lexiconToTree(savedDoc).toJSON() as PMDocJSON; 60 + } catch (e) { 61 + console.error("Failed to convert to tree:", e); 62 + return null; 63 + } 64 + }, [savedDoc]); 65 + 66 + // Roundtrip: lexicon -> tree -> lexicon 67 + const roundtrippedDoc = useMemo(() => { 68 + if (!savedDoc) return null; 69 + try { 70 + const pmNode = lexiconToTree(savedDoc); 71 + return treeToLexicon(pmNode); 72 + } catch (e) { 73 + console.error("Failed to roundtrip:", e); 74 + return null; 75 + } 76 + }, [savedDoc]); 77 + 62 78 return ( 63 79 <div className="min-h-screen bg-white dark:bg-slate-900 p-8"> 64 80 <div className="max-w-3xl mx-auto space-y-8"> ··· 76 92 <h2 className="text-lg font-semibold text-gray-900 dark:text-white"> 77 93 Editor 78 94 </h2> 79 - <PrimerSectionPM 80 - initialDoc={savedDoc} 95 + <PrimerSection 96 + primer={savedDoc} 81 97 onSave={handleSave} 82 98 readOnly={false} 83 99 /> ··· 88 104 Read-only View 89 105 </h2> 90 106 <div className="p-4 border border-gray-200 dark:border-slate-700 rounded-lg bg-gray-50 dark:bg-slate-800/50"> 91 - <PrimerSectionPM initialDoc={savedDoc} readOnly /> 107 + <PrimerSection primer={savedDoc} readOnly /> 92 108 </div> 93 109 </div> 94 110 ··· 98 114 </p> 99 115 )} 100 116 117 + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> 118 + <details className="text-sm" open> 119 + <summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium"> 120 + Lexicon (storage format) 121 + </summary> 122 + <pre className="mt-2 p-4 bg-gray-100 dark:bg-slate-800 rounded overflow-auto text-xs max-h-96"> 123 + {JSON.stringify(savedDoc, null, 2)} 124 + </pre> 125 + </details> 126 + 127 + <details className="text-sm" open> 128 + <summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium"> 129 + Tree (ProseMirror) 130 + </summary> 131 + <pre className="mt-2 p-4 bg-gray-100 dark:bg-slate-800 rounded overflow-auto text-xs max-h-96"> 132 + {JSON.stringify(pmTreeDoc, null, 2)} 133 + </pre> 134 + </details> 135 + </div> 136 + 101 137 <details className="text-sm"> 102 - <summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"> 103 - View document JSON 138 + <summary className="cursor-pointer text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 font-medium"> 139 + Roundtrip (lexicon → tree → lexicon) 104 140 </summary> 105 - <pre className="mt-2 p-4 bg-gray-100 dark:bg-slate-800 rounded overflow-auto text-xs"> 106 - {JSON.stringify(savedDoc, null, 2)} 141 + <pre className="mt-2 p-4 bg-gray-100 dark:bg-slate-800 rounded overflow-auto text-xs max-h-96"> 142 + {JSON.stringify(roundtrippedDoc, null, 2)} 107 143 </pre> 144 + {roundtrippedDoc && savedDoc && ( 145 + <p className="mt-2 text-xs"> 146 + {JSON.stringify(savedDoc) === JSON.stringify(roundtrippedDoc) ? ( 147 + <span className="text-green-600 dark:text-green-400"> 148 + Roundtrip matches original 149 + </span> 150 + ) : ( 151 + <span className="text-amber-600 dark:text-amber-400"> 152 + Roundtrip differs from original (may be normalized) 153 + </span> 154 + )} 155 + </p> 156 + )} 108 157 </details> 109 158 </div> 110 159 </div>
+22 -18
src/routes/profile/$did/deck/$rkey/index.tsx
··· 2 2 import { type DragEndEvent, useDndMonitor } from "@dnd-kit/core"; 3 3 import { useSuspenseQuery } from "@tanstack/react-query"; 4 4 import { createFileRoute, Link } from "@tanstack/react-router"; 5 - import { useMemo, useState } from "react"; 5 + import { useCallback, useMemo, useState } from "react"; 6 + import { ErrorBoundary } from "react-error-boundary"; 6 7 import { toast } from "sonner"; 7 8 import { CardDragOverlay } from "@/components/deck/CardDragOverlay"; 8 9 import { CardModal } from "@/components/deck/CardModal"; ··· 35 36 updateCardTags, 36 37 } from "@/lib/deck-types"; 37 38 import { formatDisplayName } from "@/lib/format-utils"; 39 + import type { Document } from "@/lib/lexicons/types/com/deckbelcher/richtext"; 38 40 import { getCardByIdQueryOptions } from "@/lib/queries"; 39 - import { serializeToMarkdown } from "@/lib/richtext"; 40 41 import type { ScryfallId } from "@/lib/scryfall-types"; 41 42 import { getImageUri } from "@/lib/scryfall-utils"; 42 43 import { getSelectedCards, type StatsSelection } from "@/lib/stats-selection"; 43 44 import { useAuth } from "@/lib/useAuth"; 44 45 import { useDeckStats } from "@/lib/useDeckStats"; 45 46 import { usePersistedState } from "@/lib/usePersistedState"; 46 - import { useRichText } from "@/lib/useRichText"; 47 47 48 48 export const Route = createFileRoute("/profile/$did/deck/$rkey/")({ 49 49 component: DeckEditorPage, ··· 157 157 // Check if current user is the owner 158 158 const isOwner = session?.info.sub === did; 159 159 160 - // Primer editor state 161 - const primerInitialValue = useMemo( 162 - () => 163 - serializeToMarkdown(deck.primer?.text ?? "", deck.primer?.facets ?? []), 164 - [deck.primer], 165 - ); 166 - const primer = useRichText({ 167 - initialValue: primerInitialValue, 168 - onSave: (parsed) => { 160 + // Primer save handler 161 + const handlePrimerSave = useCallback( 162 + (doc: Document) => { 169 163 if (!isOwner) return; 170 - mutation.mutate({ ...deck, primer: parsed }); 164 + mutation.mutate({ ...deck, primer: doc }); 171 165 }, 172 - saveDebounceMs: 1500, 173 - }); 166 + [isOwner, mutation, deck], 167 + ); 174 168 175 169 // Helper to update deck via mutation 176 170 const updateDeck = async (updater: (prev: Deck) => Deck) => { ··· 438 432 updateDeck={updateDeck} 439 433 highlightedCards={highlightedCards} 440 434 handleCardsChanged={handleCardsChanged} 441 - primer={primer} 435 + primer={deck.primer} 436 + onPrimerSave={handlePrimerSave} 442 437 isSaving={mutation.isPending} 443 438 /> 444 439 </DragDropProvider> ··· 478 473 updateDeck: (updater: (prev: Deck) => Deck) => Promise<void>; 479 474 highlightedCards: Set<ScryfallId>; 480 475 handleCardsChanged: (changedIds: Set<ScryfallId>) => void; 481 - primer: ReturnType<typeof useRichText>; 476 + primer?: Document; 477 + onPrimerSave: (doc: Document) => void; 482 478 isSaving: boolean; 483 479 } 484 480 ··· 516 512 highlightedCards, 517 513 handleCardsChanged, 518 514 primer, 515 + onPrimerSave, 519 516 isSaving, 520 517 }: DeckEditorInnerProps) { 521 518 // Track drag state globally (must be inside DndContext) ··· 548 545 onFormatChange={handleFormatChange} 549 546 readOnly={!isOwner} 550 547 /> 551 - <PrimerSection {...primer} isSaving={isSaving} readOnly={!isOwner} /> 548 + <ErrorBoundary fallback={null}> 549 + <PrimerSection 550 + primer={primer} 551 + onSave={onPrimerSave} 552 + isSaving={isSaving} 553 + readOnly={!isOwner} 554 + /> 555 + </ErrorBoundary> 552 556 </div> 553 557 554 558 {/* Sticky header with search */}
+2
src/styles.css
··· 76 76 /* ProseMirror editor styles */ 77 77 .prosemirror-editor .ProseMirror { 78 78 outline: none; 79 + white-space: pre-wrap; 80 + word-wrap: break-word; 79 81 } 80 82 81 83 .prosemirror-editor .ProseMirror p.is-editor-empty:first-child::before {
+3 -3
typelex/deck-list.tsp
··· 17 17 @maxLength(320) 18 18 format?: string; 19 19 20 + /** Deck primer with strategy, combos, and card choices. */ 21 + primer?: com.deckbelcher.richtext.Document; 22 + 20 23 /** Array of cards in the decklist. */ 21 24 @required 22 25 cards: Card[]; 23 - 24 - /** Deck primer with strategy, combos, and card choices. */ 25 - primer?: com.deckbelcher.richtext.Main; 26 26 27 27 /** Timestamp when the decklist was created. */ 28 28 @required
+42 -4
typelex/richtext.tsp
··· 3 3 4 4 namespace com.deckbelcher.richtext { 5 5 /** 6 - * Rich text content with optional facet annotations. 7 - * Used for primers, descriptions, and other formatted text. 6 + * A single paragraph of rich text with optional facet annotations. 7 + * Used for descriptions and other short formatted text. 8 8 */ 9 9 model Main { 10 - /** The text content. */ 10 + /** The plain text content (no markdown symbols). */ 11 11 @maxGraphemes(50000) 12 12 @maxLength(500000) 13 13 text?: string; 14 14 15 - /** Annotations of text (mentions, URLs, hashtags, card references, etc). */ 15 + /** Annotations of text (mentions, URLs, hashtags, formatting, etc). */ 16 + facets?: com.deckbelcher.richtext.facet.Main[]; 17 + } 18 + 19 + /** 20 + * A multi-block rich text document. 21 + * Used for primers and other long-form content. 22 + */ 23 + model Document { 24 + /** Array of blocks (paragraphs, headings, etc). */ 25 + @required 26 + content: (ParagraphBlock | HeadingBlock | unknown)[]; 27 + } 28 + 29 + /** A paragraph block with text and optional facets. */ 30 + model ParagraphBlock { 31 + /** The plain text content (no markdown symbols). */ 32 + @maxGraphemes(50000) 33 + @maxLength(500000) 34 + text?: string; 35 + 36 + /** Annotations of text (formatting, mentions, links, etc). */ 37 + facets?: com.deckbelcher.richtext.facet.Main[]; 38 + } 39 + 40 + /** A heading block with level, text, and optional facets. */ 41 + model HeadingBlock { 42 + /** Heading level (1-6). */ 43 + @required 44 + @minValue(1) 45 + @maxValue(6) 46 + level: integer; 47 + 48 + /** The plain text content (no markdown symbols). */ 49 + @maxGraphemes(1000) 50 + @maxLength(10000) 51 + text?: string; 52 + 53 + /** Annotations of text (formatting, mentions, links, etc). */ 16 54 facets?: com.deckbelcher.richtext.facet.Main[]; 17 55 } 18 56 }