mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix(outbound): strip internal runtime scaffolding
This commit is contained in:
@@ -72,6 +72,26 @@ describe("discordOutbound", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("sanitizes internal runtime scaffolding before Discord delivery", () => {
|
||||||
|
expect(
|
||||||
|
discordOutbound.sanitizeText?.({
|
||||||
|
text: "<previous_response>null</previous_response>visible",
|
||||||
|
payload: { text: "<previous_response>null</previous_response>visible" },
|
||||||
|
}),
|
||||||
|
).toBe("visible");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves Discord-native angle markup while stripping internal scaffolding", () => {
|
||||||
|
expect(
|
||||||
|
discordOutbound.sanitizeText?.({
|
||||||
|
text: "soon <t:1710000000:R> run </deploy:123> <previous_response>null</previous_response>",
|
||||||
|
payload: {
|
||||||
|
text: "soon <t:1710000000:R> run </deploy:123> <previous_response>null</previous_response>",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe("soon <t:1710000000:R> run </deploy:123> ");
|
||||||
|
});
|
||||||
|
|
||||||
it("forwards explicit formatting options to Discord text sends", async () => {
|
it("forwards explicit formatting options to Discord text sends", async () => {
|
||||||
await discordOutbound.sendText?.({
|
await discordOutbound.sendText?.({
|
||||||
cfg: {},
|
cfg: {},
|
||||||
|
|||||||
@@ -26,6 +26,19 @@ import {
|
|||||||
} from "./outbound-send-context.js";
|
} from "./outbound-send-context.js";
|
||||||
|
|
||||||
export const DISCORD_TEXT_CHUNK_LIMIT = 2000;
|
export const DISCORD_TEXT_CHUNK_LIMIT = 2000;
|
||||||
|
const DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE =
|
||||||
|
/<\s*(system-reminder|previous_response)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi;
|
||||||
|
const DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE =
|
||||||
|
/<\s*(?:system-reminder|previous_response)\b[^>]*\/\s*>/gi;
|
||||||
|
const DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE =
|
||||||
|
/<\s*\/?\s*(?:system-reminder|previous_response)\b[^>]*>/gi;
|
||||||
|
|
||||||
|
function stripDiscordInternalRuntimeScaffolding(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE, "")
|
||||||
|
.replace(DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE, "")
|
||||||
|
.replace(DISCORD_INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE, "");
|
||||||
|
}
|
||||||
|
|
||||||
type DiscordThreadBindingsModule = typeof import("./monitor/thread-bindings.js");
|
type DiscordThreadBindingsModule = typeof import("./monitor/thread-bindings.js");
|
||||||
|
|
||||||
@@ -97,6 +110,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
|||||||
maxLines: ctx?.formatting?.maxLinesPerMessage,
|
maxLines: ctx?.formatting?.maxLinesPerMessage,
|
||||||
}),
|
}),
|
||||||
textChunkLimit: DISCORD_TEXT_CHUNK_LIMIT,
|
textChunkLimit: DISCORD_TEXT_CHUNK_LIMIT,
|
||||||
|
sanitizeText: ({ text }) => stripDiscordInternalRuntimeScaffolding(text),
|
||||||
pollMaxOptions: 10,
|
pollMaxOptions: 10,
|
||||||
normalizePayload: ({ payload }) => normalizeDiscordApprovalPayload(payload),
|
normalizePayload: ({ payload }) => normalizeDiscordApprovalPayload(payload),
|
||||||
presentationCapabilities: {
|
presentationCapabilities: {
|
||||||
|
|||||||
@@ -706,6 +706,175 @@ describe("deliverOutboundPayloads", () => {
|
|||||||
expect(sendText).not.toHaveBeenCalled();
|
expect(sendText).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips internal runtime scaffolding added by message_sending hooks before delivery", async () => {
|
||||||
|
hookMocks.runner.hasHooks.mockImplementation(
|
||||||
|
(hookName?: string) => hookName === "message_sending",
|
||||||
|
);
|
||||||
|
hookMocks.runner.runMessageSending.mockResolvedValue({
|
||||||
|
content:
|
||||||
|
"<previous_response>null</previous_response><system-reminder>hidden</system-reminder>visible",
|
||||||
|
});
|
||||||
|
const sendText = vi.fn().mockResolvedValue({
|
||||||
|
channel: "matrix" as const,
|
||||||
|
messageId: "clean",
|
||||||
|
roomId: "!room",
|
||||||
|
});
|
||||||
|
setActivePluginRegistry(
|
||||||
|
createTestRegistry([
|
||||||
|
{
|
||||||
|
pluginId: "matrix",
|
||||||
|
source: "test",
|
||||||
|
plugin: createOutboundTestPlugin({
|
||||||
|
id: "matrix",
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
sendText,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await deliverOutboundPayloads({
|
||||||
|
cfg: {},
|
||||||
|
channel: "matrix",
|
||||||
|
to: "!room",
|
||||||
|
payloads: [{ text: "original" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendText).toHaveBeenCalledWith(expect.objectContaining({ text: "visible" }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips internal runtime scaffolding before adapter payload normalization copies text", async () => {
|
||||||
|
hookMocks.runner.hasHooks.mockImplementation(
|
||||||
|
(hookName?: string) => hookName === "message_sending",
|
||||||
|
);
|
||||||
|
hookMocks.runner.runMessageSending.mockResolvedValue({
|
||||||
|
content: "<previous_response>null</previous_response>visible",
|
||||||
|
});
|
||||||
|
const sendPayload = vi.fn().mockResolvedValue({
|
||||||
|
channel: "matrix" as const,
|
||||||
|
messageId: "clean",
|
||||||
|
roomId: "!room",
|
||||||
|
});
|
||||||
|
setActivePluginRegistry(
|
||||||
|
createTestRegistry([
|
||||||
|
{
|
||||||
|
pluginId: "matrix",
|
||||||
|
source: "test",
|
||||||
|
plugin: createOutboundTestPlugin({
|
||||||
|
id: "matrix",
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
normalizePayload: ({ payload }) => ({
|
||||||
|
...payload,
|
||||||
|
channelData: { copiedText: payload.text },
|
||||||
|
}),
|
||||||
|
sendText: vi.fn(),
|
||||||
|
sendMedia: vi.fn(),
|
||||||
|
sendPayload,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await deliverOutboundPayloads({
|
||||||
|
cfg: {},
|
||||||
|
channel: "matrix",
|
||||||
|
to: "!room",
|
||||||
|
payloads: [{ text: "original" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sendPayload).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
text: "visible",
|
||||||
|
channelData: { copiedText: "visible" },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips internal runtime scaffolding copied into rendered and normalized nested payloads", async () => {
|
||||||
|
const sendPayload = vi.fn().mockResolvedValue({
|
||||||
|
channel: "matrix" as const,
|
||||||
|
messageId: "clean-nested",
|
||||||
|
roomId: "!room",
|
||||||
|
});
|
||||||
|
setActivePluginRegistry(
|
||||||
|
createTestRegistry([
|
||||||
|
{
|
||||||
|
pluginId: "matrix",
|
||||||
|
source: "test",
|
||||||
|
plugin: createOutboundTestPlugin({
|
||||||
|
id: "matrix",
|
||||||
|
outbound: {
|
||||||
|
deliveryMode: "direct",
|
||||||
|
renderPresentation: ({ payload }) => ({
|
||||||
|
...payload,
|
||||||
|
channelData: {
|
||||||
|
renderedText: payload.text,
|
||||||
|
renderedBlocks: [{ text: payload.text }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
normalizePayload: ({ payload }) => {
|
||||||
|
const text = payload.text ?? "";
|
||||||
|
return {
|
||||||
|
...payload,
|
||||||
|
channelData: {
|
||||||
|
...payload.channelData,
|
||||||
|
normalizedText: text,
|
||||||
|
},
|
||||||
|
interactive: {
|
||||||
|
blocks: [{ type: "text", text }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
sendText: vi.fn(),
|
||||||
|
sendMedia: vi.fn(),
|
||||||
|
sendPayload,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
await deliverOutboundPayloads({
|
||||||
|
cfg: {},
|
||||||
|
channel: "matrix",
|
||||||
|
to: "!room",
|
||||||
|
payloads: [
|
||||||
|
{
|
||||||
|
text: "<previous_response>null</previous_response>visible",
|
||||||
|
presentation: {
|
||||||
|
title: "Title",
|
||||||
|
blocks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(JSON.stringify(sendPayload.mock.calls[0]?.[0]?.payload)).not.toContain(
|
||||||
|
"previous_response",
|
||||||
|
);
|
||||||
|
expect(sendPayload).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
payload: expect.objectContaining({
|
||||||
|
text: "visible",
|
||||||
|
channelData: {
|
||||||
|
renderedText: "visible",
|
||||||
|
renderedBlocks: [{ text: "visible" }],
|
||||||
|
normalizedText: "visible",
|
||||||
|
},
|
||||||
|
interactive: {
|
||||||
|
blocks: [{ type: "text", text: "visible" }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("runs adapter after-delivery hooks with the payload delivery results", async () => {
|
it("runs adapter after-delivery hooks with the payload delivery results", async () => {
|
||||||
const afterDeliverPayload = vi.fn();
|
const afterDeliverPayload = vi.fn();
|
||||||
setActivePluginRegistry(
|
setActivePluginRegistry(
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
type OutboundPayloadPlan,
|
type OutboundPayloadPlan,
|
||||||
} from "./payloads.js";
|
} from "./payloads.js";
|
||||||
import { createReplyToDeliveryPolicy } from "./reply-policy.js";
|
import { createReplyToDeliveryPolicy } from "./reply-policy.js";
|
||||||
|
import { stripInternalRuntimeScaffolding } from "./sanitize-text.js";
|
||||||
import { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js";
|
import { resolveOutboundSendDep, type OutboundSendDeps } from "./send-deps.js";
|
||||||
import type { OutboundSessionContext } from "./session-context.js";
|
import type { OutboundSessionContext } from "./session-context.js";
|
||||||
import type { OutboundChannel } from "./targets.js";
|
import type { OutboundChannel } from "./targets.js";
|
||||||
@@ -478,7 +479,7 @@ function normalizePayloadsForChannelDelivery(
|
|||||||
): ReplyPayload[] {
|
): ReplyPayload[] {
|
||||||
const normalizedPayloads: ReplyPayload[] = [];
|
const normalizedPayloads: ReplyPayload[] = [];
|
||||||
for (const payload of projectOutboundPayloadPlanForDelivery(plan)) {
|
for (const payload of projectOutboundPayloadPlanForDelivery(plan)) {
|
||||||
let sanitizedPayload = payload;
|
let sanitizedPayload = stripInternalRuntimeScaffoldingFromPayload(payload);
|
||||||
if (handler.sanitizeText && sanitizedPayload.text) {
|
if (handler.sanitizeText && sanitizedPayload.text) {
|
||||||
if (!handler.shouldSkipPlainTextSanitization?.(sanitizedPayload)) {
|
if (!handler.shouldSkipPlainTextSanitization?.(sanitizedPayload)) {
|
||||||
sanitizedPayload = {
|
sanitizedPayload = {
|
||||||
@@ -491,7 +492,9 @@ function normalizePayloadsForChannelDelivery(
|
|||||||
? handler.normalizePayload(sanitizedPayload)
|
? handler.normalizePayload(sanitizedPayload)
|
||||||
: sanitizedPayload;
|
: sanitizedPayload;
|
||||||
const normalized = normalizedPayload
|
const normalized = normalizedPayload
|
||||||
? normalizeEmptyPayloadForDelivery(normalizedPayload)
|
? normalizeEmptyPayloadForDelivery(
|
||||||
|
stripInternalRuntimeScaffoldingFromPayload(normalizedPayload),
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
if (normalized) {
|
if (normalized) {
|
||||||
normalizedPayloads.push(normalized);
|
normalizedPayloads.push(normalized);
|
||||||
@@ -500,6 +503,43 @@ function normalizePayloadsForChannelDelivery(
|
|||||||
return normalizedPayloads;
|
return normalizedPayloads;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stripInternalRuntimeScaffoldingFromValue(value: unknown): unknown {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return stripInternalRuntimeScaffolding(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
let changed = false;
|
||||||
|
const next = value.map((entry) => {
|
||||||
|
const stripped = stripInternalRuntimeScaffoldingFromValue(entry);
|
||||||
|
changed ||= stripped !== entry;
|
||||||
|
return stripped;
|
||||||
|
});
|
||||||
|
return changed ? next : value;
|
||||||
|
}
|
||||||
|
if (!value || typeof value !== "object") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const proto = Object.getPrototypeOf(value);
|
||||||
|
if (proto !== Object.prototype && proto !== null) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
let changed = false;
|
||||||
|
const next: Record<string, unknown> = {};
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
const stripped = stripInternalRuntimeScaffoldingFromValue(entry);
|
||||||
|
changed ||= stripped !== entry;
|
||||||
|
next[key] = stripped;
|
||||||
|
}
|
||||||
|
return changed ? next : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripInternalRuntimeScaffoldingFromPayload(payload: ReplyPayload): ReplyPayload {
|
||||||
|
const stripped = stripInternalRuntimeScaffoldingFromValue(payload);
|
||||||
|
return stripped && typeof stripped === "object" && !Array.isArray(stripped)
|
||||||
|
? (stripped as ReplyPayload)
|
||||||
|
: payload;
|
||||||
|
}
|
||||||
|
|
||||||
function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
|
function buildPayloadSummary(payload: ReplyPayload): NormalizedOutboundPayload {
|
||||||
return summarizeOutboundPayloadForTransport(payload);
|
return summarizeOutboundPayloadForTransport(payload);
|
||||||
}
|
}
|
||||||
@@ -1032,12 +1072,16 @@ async function deliverOutboundPayloadsCore(
|
|||||||
if (hookResult.cancelled) {
|
if (hookResult.cancelled) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const renderedPayload = await renderPresentationForDelivery(handler, hookResult.payload);
|
const renderedPayload = stripInternalRuntimeScaffoldingFromPayload(
|
||||||
|
await renderPresentationForDelivery(handler, hookResult.payload),
|
||||||
|
);
|
||||||
const normalizedEffectivePayload = handler.normalizePayload
|
const normalizedEffectivePayload = handler.normalizePayload
|
||||||
? handler.normalizePayload(renderedPayload)
|
? handler.normalizePayload(renderedPayload)
|
||||||
: renderedPayload;
|
: renderedPayload;
|
||||||
const effectivePayload = normalizedEffectivePayload
|
const effectivePayload = normalizedEffectivePayload
|
||||||
? normalizeEmptyPayloadForDelivery(normalizedEffectivePayload)
|
? normalizeEmptyPayloadForDelivery(
|
||||||
|
stripInternalRuntimeScaffoldingFromPayload(normalizedEffectivePayload),
|
||||||
|
)
|
||||||
: null;
|
: null;
|
||||||
if (!effectivePayload) {
|
if (!effectivePayload) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { sanitizeForPlainText } from "./sanitize-text.js";
|
import { sanitizeForPlainText, stripInternalRuntimeScaffolding } from "./sanitize-text.js";
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// sanitizeForPlainText
|
// sanitizeForPlainText
|
||||||
@@ -63,6 +63,15 @@ describe("sanitizeForPlainText", () => {
|
|||||||
expect(sanitizeForPlainText('<a href="https://example.com">link</a>')).toBe("link");
|
expect(sanitizeForPlainText('<a href="https://example.com">link</a>')).toBe("link");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips known internal runtime scaffolding tags including underscore names", () => {
|
||||||
|
expect(sanitizeForPlainText("ok <previous_response>null</previous_response> done")).toBe(
|
||||||
|
"ok done",
|
||||||
|
);
|
||||||
|
expect(sanitizeForPlainText("ok <system-reminder>use todos</system-reminder> done")).toBe(
|
||||||
|
"ok done",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves angle-bracket autolinks", () => {
|
it("preserves angle-bracket autolinks", () => {
|
||||||
expect(sanitizeForPlainText("See <https://example.com/path?q=1> now")).toBe(
|
expect(sanitizeForPlainText("See <https://example.com/path?q=1> now")).toBe(
|
||||||
"See https://example.com/path?q=1 now",
|
"See https://example.com/path?q=1 now",
|
||||||
@@ -92,3 +101,26 @@ describe("sanitizeForPlainText", () => {
|
|||||||
expect(sanitizeForPlainText("a<br><br><br><br>b")).toBe("a\n\nb");
|
expect(sanitizeForPlainText("a<br><br><br><br>b")).toBe("a\n\nb");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("stripInternalRuntimeScaffolding", () => {
|
||||||
|
it("removes closed, self-closing, and stray internal runtime tags", () => {
|
||||||
|
expect(
|
||||||
|
stripInternalRuntimeScaffolding(
|
||||||
|
[
|
||||||
|
"before",
|
||||||
|
"<system-reminder>internal hint</system-reminder>",
|
||||||
|
"<previous_response>null</previous_response>",
|
||||||
|
"<system-reminder />",
|
||||||
|
"<previous_response>",
|
||||||
|
"visible",
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
).toBe(["before", "", "", "", "", "visible"].join("\n"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not strip arbitrary XML-like user content", () => {
|
||||||
|
expect(stripInternalRuntimeScaffolding("<note>keep this</note>")).toBe(
|
||||||
|
"<note>keep this</note>",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -11,6 +11,28 @@
|
|||||||
* @see https://github.com/openclaw/openclaw/issues/18558
|
* @see https://github.com/openclaw/openclaw/issues/18558
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const INTERNAL_RUNTIME_SCAFFOLDING_TAGS = ["system-reminder", "previous_response"] as const;
|
||||||
|
const INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN = INTERNAL_RUNTIME_SCAFFOLDING_TAGS.join("|");
|
||||||
|
const INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE = new RegExp(
|
||||||
|
`<\\s*(${INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN})\\b[^>]*>[\\s\\S]*?<\\s*\\/\\s*\\1\\s*>`,
|
||||||
|
"gi",
|
||||||
|
);
|
||||||
|
const INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE = new RegExp(
|
||||||
|
`<\\s*(?:${INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN})\\b[^>]*\\/\\s*>`,
|
||||||
|
"gi",
|
||||||
|
);
|
||||||
|
const INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE = new RegExp(
|
||||||
|
`<\\s*\\/?\\s*(?:${INTERNAL_RUNTIME_SCAFFOLDING_TAG_PATTERN})\\b[^>]*>`,
|
||||||
|
"gi",
|
||||||
|
);
|
||||||
|
|
||||||
|
export function stripInternalRuntimeScaffolding(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(INTERNAL_RUNTIME_SCAFFOLDING_BLOCK_RE, "")
|
||||||
|
.replace(INTERNAL_RUNTIME_SCAFFOLDING_SELF_CLOSING_RE, "")
|
||||||
|
.replace(INTERNAL_RUNTIME_SCAFFOLDING_TAG_RE, "");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert common HTML tags to their plain-text/lightweight-markup equivalents
|
* Convert common HTML tags to their plain-text/lightweight-markup equivalents
|
||||||
* and strip anything that remains.
|
* and strip anything that remains.
|
||||||
@@ -21,7 +43,7 @@
|
|||||||
*/
|
*/
|
||||||
export function sanitizeForPlainText(text: string): string {
|
export function sanitizeForPlainText(text: string): string {
|
||||||
return (
|
return (
|
||||||
text
|
stripInternalRuntimeScaffolding(text)
|
||||||
// Preserve angle-bracket autolinks as plain URLs before tag stripping.
|
// Preserve angle-bracket autolinks as plain URLs before tag stripping.
|
||||||
.replace(/<((?:https?:\/\/|mailto:)[^<>\s]+)>/gi, "$1")
|
.replace(/<((?:https?:\/\/|mailto:)[^<>\s]+)>/gi, "$1")
|
||||||
// Line breaks
|
// Line breaks
|
||||||
@@ -41,7 +63,7 @@ export function sanitizeForPlainText(text: string): string {
|
|||||||
// List items → bullet points
|
// List items → bullet points
|
||||||
.replace(/<li[^>]*>(.*?)<\/li>/gi, "• $1\n")
|
.replace(/<li[^>]*>(.*?)<\/li>/gi, "• $1\n")
|
||||||
// Strip remaining HTML tags (require tag-like structure: <word...>)
|
// Strip remaining HTML tags (require tag-like structure: <word...>)
|
||||||
.replace(/<\/?[a-z][a-z0-9]*\b[^>]*>/gi, "")
|
.replace(/<\/?[a-z][a-z0-9_-]*\b[^>]*>/gi, "")
|
||||||
// Collapse 3+ consecutive newlines into 2
|
// Collapse 3+ consecutive newlines into 2
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user