import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime" ;
import {
markdownToIR,
normalizeLowercaseStringOrEmpty,
type MarkdownIR,
type MarkdownStyle,
renderMarkdownIRChunksWithinLimit,
} from "openclaw/plugin-sdk/text-runtime" ;
type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER" ;
export type SignalTextStyleRange = {
start: number;
length: number;
style: SignalTextStyle;
};
export type SignalFormattedText = {
text: string;
styles: SignalTextStyleRange[];
};
type SignalMarkdownOptions = {
tableMode?: MarkdownTableMode;
};
type SignalStyleSpan = {
start: number;
end: number;
style: SignalTextStyle;
};
type Insertion = {
pos: number;
length: number;
};
function normalizeUrlForComparison(url: string): string {
let normalized = normalizeLowercaseStringOrEmpty(url);
// Strip protocol
normalized = normalized.replace(/^https?:\/\//, "");
// Strip www. prefix
normalized = normalized.replace(/^www\./, "" );
// Strip trailing slashes
normalized = normalized.replace(/\/+$/, "" );
return normalized;
}
function mapStyle(style: MarkdownStyle): SignalTextStyle | null {
switch (style) {
case "bold" :
return "BOLD" ;
case "italic" :
return "ITALIC" ;
case "strikethrough" :
return "STRIKETHROUGH" ;
case "code" :
case "code_block" :
return "MONOSPACE" ;
case "spoiler" :
return "SPOILER" ;
default :
return null ;
}
}
function mergeStyles(styles: SignalTextStyleRange[]): SignalTextStyleRange[] {
const sorted = [...styles].toSorted((a, b) => {
if (a.start !== b.start) {
return a.start - b.start;
}
if (a.length !== b.length) {
return a.length - b.length;
}
return a.style.localeCompare(b.style);
});
const merged: SignalTextStyleRange[] = [];
for (const style of sorted) {
const prev = merged[merged.length - 1 ];
if (prev && prev.style === style.style && style.start <= prev.start + prev.length) {
const prevEnd = prev.start + prev.length;
const nextEnd = Math.max(prevEnd, style.start + style.length);
prev.length = nextEnd - prev.start;
continue ;
}
merged.push({ ...style });
}
return merged;
}
function clampStyles(styles: SignalTextStyleRange[], maxLength: number): SignalTextStyleRange[] {
const clamped: SignalTextStyleRange[] = [];
for (const style of styles) {
const start = Math.max(0 , Math.min(style.start, maxLength));
const end = Math.min(style.start + style.length, maxLength);
const length = end - start;
if (length > 0 ) {
clamped.push({ start, length, style: style.style });
}
}
return clamped;
}
function applyInsertionsToStyles(
spans: SignalStyleSpan[],
insertions: Insertion[],
): SignalStyleSpan[] {
if (insertions.length === 0 ) {
return spans;
}
const sortedInsertions = [...insertions].toSorted((a, b) => a.pos - b.pos);
let updated = spans;
let cumulativeShift = 0 ;
for (const insertion of sortedInsertions) {
const insertionPos = insertion.pos + cumulativeShift;
const next: SignalStyleSpan[] = [];
for (const span of updated) {
if (span.end <= insertionPos) {
next.push(span);
continue ;
}
if (span.start >= insertionPos) {
next.push({
start: span.start + insertion.length,
end: span.end + insertion.length,
style: span.style,
});
continue ;
}
if (span.start < insertionPos && span.end > insertionPos) {
if (insertionPos > span.start) {
next.push({
start: span.start,
end: insertionPos,
style: span.style,
});
}
const shiftedStart = insertionPos + insertion.length;
const shiftedEnd = span.end + insertion.length;
if (shiftedEnd > shiftedStart) {
next.push({
start: shiftedStart,
end: shiftedEnd,
style: span.style,
});
}
}
}
updated = next;
cumulativeShift += insertion.length;
}
return updated;
}
function renderSignalText(ir: MarkdownIR): SignalFormattedText {
const text = ir.text ?? "" ;
if (!text) {
return { text: "" , styles: [] };
}
const sortedLinks = [...ir.links].toSorted((a, b) => a.start - b.start);
let out = "" ;
let cursor = 0 ;
const insertions: Insertion[] = [];
for (const link of sortedLinks) {
if (link.start < cursor) {
continue ;
}
out += text.slice(cursor, link.end);
const href = link.href.trim();
const label = text.slice(link.start, link.end);
const trimmedLabel = label.trim();
if (href) {
if (!trimmedLabel) {
out += href;
insertions.push({ pos: link.end, length: href.length });
} else {
// Check if label is similar enough to URL that showing both would be redundant
const normalizedLabel = normalizeUrlForComparison(trimmedLabel);
let comparableHref = href;
if (href.startsWith("mailto:" )) {
comparableHref = href.slice("mailto:" .length);
}
const normalizedHref = normalizeUrlForComparison(comparableHref);
// Only show URL if label is meaningfully different from it
if (normalizedLabel !== normalizedHref) {
const addition = ` (${href})`;
out += addition;
insertions.push({ pos: link.end, length: addition.length });
}
}
}
cursor = link.end;
}
out += text.slice(cursor);
const mappedStyles: SignalStyleSpan[] = ir.styles
.map((span) => {
const mapped = mapStyle(span.style);
if (!mapped) {
return null ;
}
return { start: span.start, end: span.end, style: mapped };
})
.filter((span): span is SignalStyleSpan => span !== null );
const adjusted = applyInsertionsToStyles(mappedStyles, insertions);
const trimmedText = out.trimEnd();
const trimmedLength = trimmedText.length;
const clamped = clampStyles(
adjusted.map((span) => ({
start: span.start,
length: span.end - span.start,
style: span.style,
})),
trimmedLength,
);
return {
text: trimmedText,
styles: mergeStyles(clamped),
};
}
export function markdownToSignalText(
markdown: string,
options: SignalMarkdownOptions = {},
): SignalFormattedText {
const ir = markdownToIR(markdown ?? "" , {
linkify: true ,
enableSpoilers: true ,
headingStyle: "bold" ,
blockquotePrefix: "> " ,
tableMode: options.tableMode,
});
return renderSignalText(ir);
}
export function markdownToSignalTextChunks(
markdown: string,
limit: number,
options: SignalMarkdownOptions = {},
): SignalFormattedText[] {
const ir = markdownToIR(markdown ?? "" , {
linkify: true ,
enableSpoilers: true ,
headingStyle: "bold" ,
blockquotePrefix: "> " ,
tableMode: options.tableMode,
});
return renderMarkdownIRChunksWithinLimit({
ir,
limit,
renderChunk: renderSignalText,
measureRendered: (rendered) => rendered.text.length,
}).map(({ rendered }) => rendered);
}
Messung V0.5 in Prozent C=99 H=97 G=97
¤ Dauer der Verarbeitung: 0.12 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland