// richtext.js - Shared richtext parsing and rendering for tools.slices.network /** * Helper functions for consistent facet type detection. * Handles both $type (stored facets) and __typename (GraphQL responses). */ export function isFacetType(type, facetType) { if (!type) return false; const normalized = type.toLowerCase(); return normalized.includes(facetType.toLowerCase()); } export const FacetTypes = { isLink: (type) => isFacetType(type, 'link'), isBold: (type) => isFacetType(type, 'bold'), isItalic: (type) => isFacetType(type, 'italic'), isCode: (type) => isFacetType(type, 'code') && !isFacetType(type, 'codeblock'), isCodeBlock: (type) => isFacetType(type, 'codeblock'), }; export const BlockTypes = { isParagraph: (type) => isFacetType(type, 'paragraph'), isHeading: (type) => isFacetType(type, 'heading'), isCodeBlock: (type) => isFacetType(type, 'codeblock'), isQuote: (type) => isFacetType(type, 'quote'), isTangledEmbed: (type) => isFacetType(type, 'tangledembed'), isImageEmbed: (type) => isFacetType(type, 'imageembed'), }; /** * Parse markdown-style text into facets for AT Protocol storage. * Detects: code blocks, bold, italic, inline code, URLs * Returns { text, facets } where text preserves delimiters for editing. */ export function parseFacets(text) { if (!text) return { text: "", facets: [] }; const facets = []; const encoder = new TextEncoder(); // Track which character positions are already claimed by a facet const claimedPositions = new Set(); // Helper to check if a range overlaps with claimed positions function isRangeClaimed(start, end) { for (let i = start; i < end; i++) { if (claimedPositions.has(i)) return true; } return false; } // Helper to claim a range function claimRange(start, end) { for (let i = start; i < end; i++) { claimedPositions.add(i); } } // Helper to get byte offset for a character position function getByteOffset(str, charIndex) { return encoder.encode(str.slice(0, charIndex)).length; } // Process code blocks FIRST (highest priority) - they should not be parsed for other patterns const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g; let codeBlockMatch; while ((codeBlockMatch = codeBlockRegex.exec(text)) !== null) { const start = codeBlockMatch.index; const end = start + codeBlockMatch[0].length; if (!isRangeClaimed(start, end)) { claimRange(start, end); const lang = codeBlockMatch[1] || undefined; facets.push({ index: { byteStart: getByteOffset(text, start), byteEnd: getByteOffset(text, end), }, features: [ { $type: "network.slices.tools.richtext.facet#codeBlock", lang, }, ], }); } } // Bold: **text** const boldRegex = /\*\*(.+?)\*\*/g; let boldMatch; while ((boldMatch = boldRegex.exec(text)) !== null) { const start = boldMatch.index; const end = start + boldMatch[0].length; if (!isRangeClaimed(start, end)) { claimRange(start, end); facets.push({ index: { byteStart: getByteOffset(text, start), byteEnd: getByteOffset(text, end), }, features: [{ $type: "network.slices.tools.richtext.facet#bold" }], }); } } // Italic: *text* or _text_ (but not inside bold) const italicRegex = /(?\[\]()]+/g; let urlMatch; while ((urlMatch = urlRegex.exec(text)) !== null) { const start = urlMatch.index; const end = start + urlMatch[0].length; if (!isRangeClaimed(start, end)) { claimRange(start, end); facets.push({ index: { byteStart: getByteOffset(text, start), byteEnd: getByteOffset(text, end), }, features: [ { $type: "network.slices.tools.richtext.facet#link", uri: urlMatch[0], }, ], }); } } // Sort facets by byte position facets.sort((a, b) => a.index.byteStart - b.index.byteStart); return { text, facets }; } /** * Render faceted text as HTML. * Falls back to parseFacets if no facets provided (legacy content). * Strips markdown delimiters for display. */ export function renderFacetedText(text, facets, options = {}) { if (!text) return ""; const { escapeHtml = defaultEscapeHtml } = options; // If no facets provided, parse on the fly (legacy support) if (!facets || facets.length === 0) { const parsed = parseFacets(text); facets = parsed.facets; } if (facets.length === 0) { return escapeHtml(text); } const encoder = new TextEncoder(); const decoder = new TextDecoder(); const bytes = encoder.encode(text); // Sort facets by start position const sortedFacets = [...facets].sort( (a, b) => a.index.byteStart - b.index.byteStart ); let result = ""; let lastEnd = 0; for (const facet of sortedFacets) { // Add text before this facet if (facet.index.byteStart > lastEnd) { const beforeBytes = bytes.slice(lastEnd, facet.index.byteStart); result += escapeHtml(decoder.decode(beforeBytes)); } // Get the faceted text const facetBytes = bytes.slice(facet.index.byteStart, facet.index.byteEnd); let facetText = decoder.decode(facetBytes); // Determine facet type and render const feature = facet.features[0]; const type = feature?.$type || feature?.__typename || ""; if (FacetTypes.isLink(type)) { const uri = feature.uri; result += `${escapeHtml(facetText)}`; } else if (FacetTypes.isBold(type)) { // Strip ** delimiters const content = facetText.replace(/^\*\*|\*\*$/g, ""); result += `${escapeHtml(content)}`; } else if (FacetTypes.isItalic(type)) { // Strip * or _ delimiters const content = facetText.replace(/^\*|\*$|^_|_$/g, ""); result += `${escapeHtml(content)}`; } else if (FacetTypes.isCodeBlock(type)) { // Strip ``` delimiters and extract content const match = facetText.match(/^```(\w*)\n([\s\S]*?)```$/); if (match) { const lang = match[1] || ""; const code = match[2]; const langClass = lang ? ` language-${escapeHtml(lang)}` : ""; result += `
${escapeHtml(code)}
`; } else { result += `
${escapeHtml(facetText)}
`; } } else if (FacetTypes.isCode(type)) { // Strip ` delimiters const content = facetText.replace(/^`|`$/g, ""); result += `${escapeHtml(content)}`; } else { result += escapeHtml(facetText); } lastEnd = facet.index.byteEnd; } // Add remaining text if (lastEnd < bytes.length) { const remainingBytes = bytes.slice(lastEnd); result += escapeHtml(decoder.decode(remainingBytes)); } return result; } /** * Convert text + facets to HTML for contenteditable editing. * Returns HTML string with formatting tags. */ export function facetsToDom(text, facets = []) { if (!text) return ""; if (!facets || facets.length === 0) { return escapeHtmlForDom(text); } const encoder = new TextEncoder(); const decoder = new TextDecoder(); const bytes = encoder.encode(text); // Sort facets by start position const sortedFacets = [...facets].sort( (a, b) => a.index.byteStart - b.index.byteStart ); let result = ""; let lastEnd = 0; for (const facet of sortedFacets) { // Add text before this facet if (facet.index.byteStart > lastEnd) { const beforeBytes = bytes.slice(lastEnd, facet.index.byteStart); result += escapeHtmlForDom(decoder.decode(beforeBytes)); } // Get the faceted text const facetBytes = bytes.slice(facet.index.byteStart, facet.index.byteEnd); const facetText = decoder.decode(facetBytes); // Determine facet type and wrap in tag const feature = facet.features[0]; const type = feature?.$type || feature?.__typename || ""; if (FacetTypes.isLink(type)) { result += `${escapeHtmlForDom(facetText)}`; } else if (FacetTypes.isBold(type)) { result += `${escapeHtmlForDom(facetText)}`; } else if (FacetTypes.isItalic(type)) { result += `${escapeHtmlForDom(facetText)}`; } else if (FacetTypes.isCode(type)) { result += `${escapeHtmlForDom(facetText)}`; } else { result += escapeHtmlForDom(facetText); } lastEnd = facet.index.byteEnd; } // Add remaining text if (lastEnd < bytes.length) { const remainingBytes = bytes.slice(lastEnd); result += escapeHtmlForDom(decoder.decode(remainingBytes)); } return result; } function escapeHtmlForDom(text) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } /** * Extract text and facets from a contenteditable element. * Walks the DOM tree and builds facets from formatting tags. * Returns { text, facets }. */ export function domToFacets(element) { const encoder = new TextEncoder(); let text = ""; const facets = []; function walk(node, activeFormats = []) { if (node.nodeType === Node.TEXT_NODE) { const content = node.textContent || ""; if (content) { const startByte = encoder.encode(text).length; text += content; const endByte = encoder.encode(text).length; // Create facets for each active format for (const format of activeFormats) { facets.push({ index: { byteStart: startByte, byteEnd: endByte }, features: [format], }); } } } else if (node.nodeType === Node.ELEMENT_NODE) { const tag = node.tagName.toLowerCase(); let newFormat = null; if (tag === "strong" || tag === "b") { newFormat = { $type: "network.slices.tools.richtext.facet#bold" }; } else if (tag === "em" || tag === "i") { newFormat = { $type: "network.slices.tools.richtext.facet#italic" }; } else if (tag === "code") { newFormat = { $type: "network.slices.tools.richtext.facet#code" }; } else if (tag === "a") { newFormat = { $type: "network.slices.tools.richtext.facet#link", uri: node.getAttribute("href") || "", }; } const formats = newFormat ? [...activeFormats, newFormat] : activeFormats; for (const child of node.childNodes) { walk(child, formats); } } } walk(element); // Merge adjacent facets of the same type const mergedFacets = mergeFacets(facets); // Detect URLs in plain text that aren't already linked or in code const urlRegex = /https?:\/\/[^\s<>\[\]()]+/g; let urlMatch; while ((urlMatch = urlRegex.exec(text)) !== null) { const startByte = encoder.encode(text.slice(0, urlMatch.index)).length; const endByte = encoder.encode(text.slice(0, urlMatch.index + urlMatch[0].length)).length; // Check if this range is already covered by a link or code facet const alreadyCovered = mergedFacets.some(f => { const type = f.features[0]?.$type || ''; return (FacetTypes.isLink(type) || FacetTypes.isCode(type)) && f.index.byteStart <= startByte && f.index.byteEnd >= endByte; }); if (!alreadyCovered) { mergedFacets.push({ index: { byteStart: startByte, byteEnd: endByte }, features: [{ $type: "network.slices.tools.richtext.facet#link", uri: urlMatch[0], }], }); } } // Re-sort after adding URL facets mergedFacets.sort((a, b) => a.index.byteStart - b.index.byteStart); return { text, facets: mergedFacets }; } /** * Merge adjacent facets of the same type. */ function mergeFacets(facets) { if (facets.length === 0) return []; // Group by type const byType = new Map(); for (const facet of facets) { const type = facet.features[0]?.$type || ""; const key = type + (facet.features[0]?.uri || ""); if (!byType.has(key)) { byType.set(key, []); } byType.get(key).push(facet); } const merged = []; for (const group of byType.values()) { // Sort by start position group.sort((a, b) => a.index.byteStart - b.index.byteStart); let current = null; for (const facet of group) { if (!current) { current = { ...facet, index: { ...facet.index } }; } else if (facet.index.byteStart <= current.index.byteEnd) { // Merge overlapping or adjacent current.index.byteEnd = Math.max(current.index.byteEnd, facet.index.byteEnd); } else { merged.push(current); current = { ...facet, index: { ...facet.index } }; } } if (current) { merged.push(current); } } // Sort by start position merged.sort((a, b) => a.index.byteStart - b.index.byteStart); return merged; } /** * Default HTML escape function. */ function defaultEscapeHtml(text) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }