// 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, "'");
}