mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
fix(telegram): preserve authored file-reference links (#105911)
* fix(telegram): preserve authored file links * fix(telegram): preserve authored file links after wrapping * refactor(telegram): rely on markdown link provenance --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
2f00a417ed
commit
198d2f000f
@@ -15,7 +15,11 @@ export function renderTelegramMarkdownIR(
|
||||
ir: MarkdownIR,
|
||||
options: {
|
||||
escapeText: (text: string) => string;
|
||||
buildLink: (link: MarkdownLinkSpan, text: string) => TelegramRenderLink | null;
|
||||
buildLink: (
|
||||
link: MarkdownLinkSpan,
|
||||
text: string,
|
||||
context: { origin: "authored" | "linkify" },
|
||||
) => TelegramRenderLink | null;
|
||||
buildCodeBlockOpen: (span: { language?: string }) => string;
|
||||
},
|
||||
): string {
|
||||
|
||||
@@ -49,7 +49,11 @@ function isTelegramRichLinkHref(href: string): boolean {
|
||||
*
|
||||
* Excluded: .ai, .io, .tv, .fm (popular domain TLDs like x.ai, vercel.io, github.io)
|
||||
*/
|
||||
function buildTelegramLink(link: MarkdownLinkSpan, text: string) {
|
||||
function buildTelegramLink(
|
||||
link: MarkdownLinkSpan,
|
||||
text: string,
|
||||
context: { origin: "authored" | "linkify" },
|
||||
) {
|
||||
const href = link.href.trim();
|
||||
if (!href) {
|
||||
return null;
|
||||
@@ -64,7 +68,7 @@ function buildTelegramLink(link: MarkdownLinkSpan, text: string) {
|
||||
}
|
||||
// Suppress auto-linkified file references (e.g. README.md → http://README.md)
|
||||
const label = text.slice(link.start, link.end);
|
||||
if (isAutoLinkedFileRef(href, label)) {
|
||||
if (context.origin === "linkify" && isAutoLinkedFileRef(href, label)) {
|
||||
return null;
|
||||
}
|
||||
const safeHref = escapeHtmlAttr(href);
|
||||
@@ -178,7 +182,6 @@ function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
const AUTO_LINKED_ANCHOR_PATTERN = /<a\s+href="https?:\/\/([^"]+)"[^>]*>\1<\/a>/gi;
|
||||
const HTML_MODE_TAG_PATTERN = /^<(\/?)([a-zA-Z][a-zA-Z0-9-]*)([^<>]*)>$/;
|
||||
const ESCAPED_HTML_TAG_PATTERN = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)(.*?)>/g;
|
||||
const TELEGRAM_HTML_ANCHOR_PATTERN =
|
||||
@@ -499,15 +502,6 @@ function wrapSegmentFileRefs(
|
||||
}
|
||||
|
||||
export function wrapFileReferencesInHtml(html: string): string {
|
||||
// Safety-net: de-linkify auto-generated anchors where href="http://<label>" (defense in depth for textMode: "html")
|
||||
AUTO_LINKED_ANCHOR_PATTERN.lastIndex = 0;
|
||||
const deLinkified = html.replace(AUTO_LINKED_ANCHOR_PATTERN, (_match, label: string) => {
|
||||
if (!isAutoLinkedFileRef(`http://${label}`, label)) {
|
||||
return _match;
|
||||
}
|
||||
return `<code>${escapeHtml(label)}</code>`;
|
||||
});
|
||||
|
||||
// Track nesting depth for tags that should not be modified
|
||||
let codeDepth = 0;
|
||||
let preDepth = 0;
|
||||
@@ -516,14 +510,14 @@ export function wrapFileReferencesInHtml(html: string): string {
|
||||
let lastIndex = 0;
|
||||
|
||||
// Process tags token-by-token so we can skip protected regions while wrapping plain text.
|
||||
for (const tag of tokenizeHtmlTags(deLinkified)) {
|
||||
for (const tag of tokenizeHtmlTags(html)) {
|
||||
const tagStart = tag.start;
|
||||
const tagEnd = tag.end;
|
||||
const isClosing = tag.closing;
|
||||
const tagName = tag.name;
|
||||
|
||||
// Process text before this tag
|
||||
const textBefore = deLinkified.slice(lastIndex, tagStart);
|
||||
const textBefore = html.slice(lastIndex, tagStart);
|
||||
result += wrapSegmentFileRefs(textBefore, codeDepth, preDepth, anchorDepth);
|
||||
|
||||
// Update tag depth (clamp at 0 for malformed HTML with stray closing tags)
|
||||
@@ -536,12 +530,12 @@ export function wrapFileReferencesInHtml(html: string): string {
|
||||
}
|
||||
|
||||
// Add the tag itself
|
||||
result += deLinkified.slice(tagStart, tagEnd);
|
||||
result += html.slice(tagStart, tagEnd);
|
||||
lastIndex = tagEnd;
|
||||
}
|
||||
|
||||
// Process remaining text
|
||||
const remainingText = deLinkified.slice(lastIndex);
|
||||
const remainingText = html.slice(lastIndex);
|
||||
result += wrapSegmentFileRefs(remainingText, codeDepth, preDepth, anchorDepth);
|
||||
|
||||
return result;
|
||||
|
||||
@@ -85,24 +85,10 @@ describe("wrapFileReferencesInHtml", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("de-linkifies auto-linkified anchors for plain files and paths", () => {
|
||||
const cases = [
|
||||
{
|
||||
input: '<a href="http://README.md">README.md</a>',
|
||||
expected: "<code>README.md</code>",
|
||||
},
|
||||
{
|
||||
input: '<a href="http://squad/friday/HEARTBEAT.md">squad/friday/HEARTBEAT.md</a>',
|
||||
expected: "<code>squad/friday/HEARTBEAT.md</code>",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(wrapFileReferencesInHtml(testCase.input)).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves explicit links where label differs from href", () => {
|
||||
it("preserves explicit links, including file-style labels", () => {
|
||||
const cases = [
|
||||
'<a href="http://README.md">README.md</a>',
|
||||
'<a href="http://squad/friday/HEARTBEAT.md">squad/friday/HEARTBEAT.md</a>',
|
||||
'<a href="http://README.md">click here</a>',
|
||||
'<a href="http://other.md">README.md</a>',
|
||||
] as const;
|
||||
@@ -162,8 +148,18 @@ describe("markdownToTelegramHtml - file reference wrapping", () => {
|
||||
});
|
||||
|
||||
it("preserves explicit markdown links even when href looks like a file ref", () => {
|
||||
const result = markdownToTelegramHtml("[docs](http://README.md)");
|
||||
expect(result).toContain('<a href="http://README.md">docs</a>');
|
||||
expect(markdownToTelegramHtml("[docs](http://README.md)")).toContain(
|
||||
'<a href="http://README.md">docs</a>',
|
||||
);
|
||||
expect(markdownToTelegramHtml("[README.md](https://README.md)")).toContain(
|
||||
'<a href="https://README.md">README.md</a>',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps plain and authored file-style links distinct in the same message", () => {
|
||||
expect(markdownToTelegramHtml("README.md [README.md](https://README.md)")).toBe(
|
||||
'<code>README.md</code> <a href="https://README.md">README.md</a>',
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps file ref after real URL in same message", () => {
|
||||
@@ -184,6 +180,15 @@ describe("markdownToTelegramChunks - file reference wrapping", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves authored file-style links in chunked output", () => {
|
||||
expect(markdownToTelegramChunks("README.md [README.md](https://README.md)", 4096)).toEqual([
|
||||
{
|
||||
html: '<code>README.md</code> <a href="https://README.md">README.md</a>',
|
||||
text: "README.md README.md",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps rendered html chunks within the provided limit", () => {
|
||||
const input = "<".repeat(1500);
|
||||
const chunks = markdownToTelegramChunks(input, 512);
|
||||
|
||||
@@ -165,6 +165,13 @@ describe("markdownToTelegramRichBlocks", () => {
|
||||
expect(hasStyle(text, "code")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves authored file-style links while wrapping bare file refs as code", () => {
|
||||
const { blocks } = markdownToTelegramRichBlocks("README.md [README.md](https://README.md)");
|
||||
const text = blocks[0] && blocks[0].type === "paragraph" ? blocks[0].text : "";
|
||||
expect(collectUrls(text)).toEqual(["https://README.md"]);
|
||||
expect(hasStyle(text, "code")).toBe(true);
|
||||
});
|
||||
|
||||
it("derives plainText from the block projection", () => {
|
||||
const { plainText } = markdownToTelegramRichBlocks("**hello** world");
|
||||
expect(plainText).toContain("hello");
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
isAutoLinkedFileRef,
|
||||
markdownToIRWithMeta,
|
||||
renderMarkdownWithMarkers,
|
||||
sliceMarkdownIR,
|
||||
type MarkdownIR,
|
||||
type MarkdownLinkSpan,
|
||||
@@ -83,13 +84,14 @@ type TelegramLinkAction =
|
||||
function resolveTelegramLinkAction(
|
||||
link: MarkdownLinkSpan,
|
||||
source: string,
|
||||
context: { origin: "authored" | "linkify" },
|
||||
): TelegramLinkAction | null {
|
||||
const href = link.href.trim();
|
||||
if (!href || link.start === link.end) {
|
||||
return null;
|
||||
}
|
||||
const label = source.slice(link.start, link.end);
|
||||
if (isAutoLinkedFileRef(href, label)) {
|
||||
if (context.origin === "linkify" && isAutoLinkedFileRef(href, label)) {
|
||||
// Bare file refs (README.md, openclaw.json) must render as code, not links:
|
||||
// Telegram's server-side entity detection would otherwise re-linkify them
|
||||
// and show spurious domain previews for TLD-like extensions.
|
||||
@@ -105,6 +107,24 @@ function resolveTelegramLinkAction(
|
||||
return { kind: "url", href };
|
||||
}
|
||||
|
||||
function collectTelegramLinkActions(
|
||||
ir: MarkdownIR,
|
||||
): Array<{ start: number; end: number; action: TelegramLinkAction }> {
|
||||
const links: Array<{ start: number; end: number; action: TelegramLinkAction }> = [];
|
||||
renderMarkdownWithMarkers(ir, {
|
||||
styleMarkers: {},
|
||||
escapeText: (text) => text,
|
||||
buildLink: (link, source, context) => {
|
||||
const action = resolveTelegramLinkAction(link, source, context);
|
||||
if (action) {
|
||||
links.push({ start: link.start, end: link.end, action });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build nested RichText from IR spans over [rangeStart, rangeEnd).
|
||||
* Spans that partially overlap are split at shared boundaries (IR contract).
|
||||
@@ -132,12 +152,11 @@ function irRangeToRichText(ir: MarkdownIR, rangeStart: number, rangeEnd: number)
|
||||
const annotationSpans = (slice.annotations ?? []).filter(
|
||||
(span) => span.type === "assistant_transcript_role",
|
||||
);
|
||||
const links = slice.links
|
||||
.filter((link) => !suppressed(link.start, link.end))
|
||||
.flatMap((link) => {
|
||||
const action = resolveTelegramLinkAction(link, text);
|
||||
return action ? [{ start: link.start, end: link.end, action }] : [];
|
||||
});
|
||||
const links = collectTelegramLinkActions({
|
||||
text,
|
||||
styles: [],
|
||||
links: slice.links.filter((link) => !suppressed(link.start, link.end)),
|
||||
});
|
||||
|
||||
const boundaries = new Set<number>([0, text.length]);
|
||||
for (const span of styleSpans) {
|
||||
|
||||
@@ -31,6 +31,35 @@ export type MarkdownLinkSpan = {
|
||||
href: string;
|
||||
};
|
||||
|
||||
// Link provenance is renderer metadata, not part of the public Markdown IR shape.
|
||||
// Every span transform must use copyMarkdownLinkSpan so the private fact survives.
|
||||
const autoLinkedMarkdownLinks = new WeakSet<MarkdownLinkSpan>();
|
||||
|
||||
export function createMarkdownLinkSpan(
|
||||
span: MarkdownLinkSpan,
|
||||
options: { autoLinked?: boolean } = {},
|
||||
): MarkdownLinkSpan {
|
||||
const created = { ...span };
|
||||
if (options.autoLinked) {
|
||||
autoLinkedMarkdownLinks.add(created);
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export function copyMarkdownLinkSpan(
|
||||
span: MarkdownLinkSpan,
|
||||
overrides: Partial<MarkdownLinkSpan> = {},
|
||||
): MarkdownLinkSpan {
|
||||
return createMarkdownLinkSpan(
|
||||
{ ...span, ...overrides },
|
||||
{ autoLinked: autoLinkedMarkdownLinks.has(span) },
|
||||
);
|
||||
}
|
||||
|
||||
export function isAutoLinkedMarkdownLink(span: MarkdownLinkSpan): boolean {
|
||||
return autoLinkedMarkdownLinks.has(span);
|
||||
}
|
||||
|
||||
export type MarkdownAnnotationSpan = {
|
||||
start: number;
|
||||
end: number;
|
||||
@@ -72,7 +101,7 @@ export function clampLinkSpans(spans: MarkdownLinkSpan[], maxLength: number): Ma
|
||||
const start = Math.max(0, Math.min(span.start, maxLength));
|
||||
const end = Math.max(start, Math.min(span.end, maxLength));
|
||||
if (end > start) {
|
||||
clamped.push({ start, end, href: span.href });
|
||||
clamped.push(copyMarkdownLinkSpan(span, { start, end }));
|
||||
}
|
||||
}
|
||||
return clamped;
|
||||
@@ -183,11 +212,12 @@ export function sliceLinkSpans(
|
||||
for (const span of spans) {
|
||||
const bounds = resolveSliceBounds(span, start, end);
|
||||
if (bounds) {
|
||||
sliced.push({
|
||||
start: bounds.start - start,
|
||||
end: bounds.end - start,
|
||||
href: span.href,
|
||||
});
|
||||
sliced.push(
|
||||
copyMarkdownLinkSpan(span, {
|
||||
start: bounds.start - start,
|
||||
end: bounds.end - start,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
return sliced;
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { markdownToIR, sliceMarkdownIR, type MarkdownIR } from "./ir.js";
|
||||
import { renderMarkdownWithMarkers } from "./render.js";
|
||||
|
||||
function collectRenderedLinks(ir: MarkdownIR) {
|
||||
const links: Array<{ href: string; label: string; origin: "authored" | "linkify" }> = [];
|
||||
renderMarkdownWithMarkers(ir, {
|
||||
styleMarkers: {},
|
||||
escapeText: (text) => text,
|
||||
buildLink: (link, text, context) => {
|
||||
links.push({
|
||||
href: link.href,
|
||||
label: text.slice(link.start, link.end),
|
||||
origin: context.origin,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
return links;
|
||||
}
|
||||
|
||||
describe("markdownToIR link provenance", () => {
|
||||
it("keeps provenance out of the public link span while exposing it to renderers", () => {
|
||||
const ir = markdownToIR("README.md [main.ts](https://main.ts)");
|
||||
|
||||
expect(ir.links).toEqual([
|
||||
{ start: 0, end: 9, href: "http://README.md" },
|
||||
{ start: 10, end: 17, href: "https://main.ts" },
|
||||
]);
|
||||
expect(collectRenderedLinks(ir)).toEqual([
|
||||
{ href: "http://README.md", label: "README.md", origin: "linkify" },
|
||||
{ href: "https://main.ts", label: "main.ts", origin: "authored" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves link provenance through slicing", () => {
|
||||
const ir = markdownToIR("prefix README.md suffix");
|
||||
|
||||
expect(collectRenderedLinks(sliceMarkdownIR(ir, 7, 16))).toEqual([
|
||||
{ href: "http://README.md", label: "README.md", origin: "linkify" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves link provenance through table rendering", () => {
|
||||
const ir = markdownToIR("| File |\n| --- |\n| README.md |", { tableMode: "bullets" });
|
||||
|
||||
expect(collectRenderedLinks(ir)).toContainEqual({
|
||||
href: "http://README.md",
|
||||
label: "README.md",
|
||||
origin: "linkify",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
clampAnnotationSpans,
|
||||
clampLinkSpans,
|
||||
clampStyleSpans,
|
||||
copyMarkdownLinkSpan,
|
||||
createMarkdownLinkSpan,
|
||||
createStyleSpan,
|
||||
mergeAnnotationSpans,
|
||||
mergeStyleSpans,
|
||||
@@ -42,6 +44,7 @@ type ListState = {
|
||||
type LinkState = {
|
||||
href: string;
|
||||
labelStart: number;
|
||||
autoLinked: boolean;
|
||||
};
|
||||
|
||||
const OPEN_MARKDOWN_HTML_TAG_PATTERN = /<\/?[a-zA-Z][a-zA-Z0-9-]*\b[^<>]*$/;
|
||||
@@ -63,6 +66,7 @@ type MarkdownToken = {
|
||||
hidden?: boolean;
|
||||
level?: number;
|
||||
map?: [number, number] | null;
|
||||
markup?: string;
|
||||
meta?: unknown;
|
||||
};
|
||||
|
||||
@@ -450,11 +454,8 @@ function handleLinkClose(state: RenderState) {
|
||||
}
|
||||
const start = link.labelStart;
|
||||
const end = target.text.length;
|
||||
if (end <= start) {
|
||||
target.links.push({ start, end, href });
|
||||
return;
|
||||
}
|
||||
target.links.push({ start, end, href });
|
||||
const span = createMarkdownLinkSpan({ start, end, href }, { autoLinked: link.autoLinked });
|
||||
target.links.push(span);
|
||||
}
|
||||
|
||||
function headingStyleFromToken(token: MarkdownToken): MarkdownStyle | null {
|
||||
@@ -536,7 +537,7 @@ function trimCell(cell: TableCell): TableCell {
|
||||
const sliceStart = Math.max(0, span.start - start);
|
||||
const sliceEnd = Math.min(trimmedLength, span.end - start);
|
||||
if (sliceEnd > sliceStart) {
|
||||
trimmedLinks.push({ start: sliceStart, end: sliceEnd, href: span.href });
|
||||
trimmedLinks.push(copyMarkdownLinkSpan(span, { start: sliceStart, end: sliceEnd }));
|
||||
}
|
||||
}
|
||||
const trimmedAnnotations = sliceAnnotationSpans(cell.annotations ?? [], start, end);
|
||||
@@ -562,11 +563,12 @@ function appendCell(state: RenderState, cell: TableCell) {
|
||||
});
|
||||
}
|
||||
for (const link of cell.links) {
|
||||
state.links.push({
|
||||
start: start + link.start,
|
||||
end: start + link.end,
|
||||
href: link.href,
|
||||
});
|
||||
state.links.push(
|
||||
copyMarkdownLinkSpan(link, {
|
||||
start: start + link.start,
|
||||
end: start + link.end,
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const annotation of cell.annotations ?? []) {
|
||||
state.annotations.push({
|
||||
@@ -814,7 +816,11 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
|
||||
case "link_open": {
|
||||
const target = resolveRenderTarget(state);
|
||||
const href = isInsideMarkdownHtmlTag(target.text) ? "" : (getAttr(token, "href") ?? "");
|
||||
target.linkStack.push({ href, labelStart: target.text.length });
|
||||
target.linkStack.push({
|
||||
href,
|
||||
labelStart: target.text.length,
|
||||
autoLinked: token.markup === "linkify",
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "link_close":
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { avoidTrailingHighSurrogateBreak } from "./chunk-text.js";
|
||||
// Markdown Core module implements render aware chunking behavior.
|
||||
import { annotateAssistantTranscriptRoleMessageBoundary } from "./ir-annotations.js";
|
||||
import { mergeAnnotationSpans, type MarkdownAnnotationSpan } from "./ir-spans.js";
|
||||
import {
|
||||
copyMarkdownLinkSpan,
|
||||
isAutoLinkedMarkdownLink,
|
||||
mergeAnnotationSpans,
|
||||
type MarkdownAnnotationSpan,
|
||||
} from "./ir-spans.js";
|
||||
import {
|
||||
sliceMarkdownIR,
|
||||
type MarkdownIR,
|
||||
@@ -287,11 +292,16 @@ function mergeAdjacentLinkSpans(links: MarkdownLinkSpan[]): MarkdownLinkSpan[] {
|
||||
const merged: MarkdownLinkSpan[] = [];
|
||||
for (const link of links) {
|
||||
const last = merged.at(-1);
|
||||
if (last && last.href === link.href && link.start <= last.end) {
|
||||
if (
|
||||
last &&
|
||||
last.href === link.href &&
|
||||
isAutoLinkedMarkdownLink(last) === isAutoLinkedMarkdownLink(link) &&
|
||||
link.start <= last.end
|
||||
) {
|
||||
last.end = Math.max(last.end, link.end);
|
||||
continue;
|
||||
}
|
||||
merged.push({ ...link });
|
||||
merged.push(copyMarkdownLinkSpan(link));
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
@@ -316,11 +326,12 @@ function mergeMarkdownIRChunks(left: MarkdownIR, right: MarkdownIR): MarkdownIR
|
||||
}
|
||||
const shiftedLinks: MarkdownLinkSpan[] = [];
|
||||
for (const link of right.links) {
|
||||
shiftedLinks.push({
|
||||
...link,
|
||||
start: link.start + offset,
|
||||
end: link.end + offset,
|
||||
});
|
||||
shiftedLinks.push(
|
||||
copyMarkdownLinkSpan(link, {
|
||||
start: link.start + offset,
|
||||
end: link.end + offset,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const annotations = mergeAnnotationSpans([...(left.annotations ?? []), ...shiftedAnnotations]);
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { MarkdownAnnotationSpan } from "./ir-spans.js";
|
||||
import {
|
||||
copyMarkdownLinkSpan,
|
||||
isAutoLinkedMarkdownLink,
|
||||
type MarkdownAnnotationSpan,
|
||||
} from "./ir-spans.js";
|
||||
// Markdown Core module implements render behavior.
|
||||
import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from "./ir.js";
|
||||
|
||||
@@ -29,12 +33,22 @@ export type RenderLink = {
|
||||
close: string;
|
||||
};
|
||||
|
||||
type MarkdownLinkOrigin = "authored" | "linkify";
|
||||
|
||||
function getMarkdownLinkOrigin(link: MarkdownLinkSpan): MarkdownLinkOrigin {
|
||||
return isAutoLinkedMarkdownLink(link) ? "linkify" : "authored";
|
||||
}
|
||||
|
||||
/** Renderer hooks for converting Markdown IR into a marker-based target format. */
|
||||
export type RenderOptions = {
|
||||
styleMarkers: RenderStyleMap;
|
||||
annotationMarkers?: RenderAnnotationMap;
|
||||
escapeText: (text: string) => string;
|
||||
buildLink?: (link: MarkdownLinkSpan, text: string) => RenderLink | null;
|
||||
buildLink?: (
|
||||
link: MarkdownLinkSpan,
|
||||
text: string,
|
||||
context: { origin: MarkdownLinkOrigin },
|
||||
) => RenderLink | null;
|
||||
};
|
||||
|
||||
const STYLE_ORDER: MarkdownStyle[] = [
|
||||
@@ -253,15 +267,21 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
|
||||
const linkStarts = new Map<number, RenderLink[]>();
|
||||
if (options.buildLink) {
|
||||
const links = ir.links.flatMap((span) =>
|
||||
subtractRanges(span, dominantAnnotationRanges).flatMap((piece) =>
|
||||
splitAtBoundaries(piece, annotationBoundaries),
|
||||
),
|
||||
subtractRanges(span, dominantAnnotationRanges)
|
||||
.flatMap((piece) => splitAtBoundaries(piece, annotationBoundaries))
|
||||
.map((piece) =>
|
||||
copyMarkdownLinkSpan(span, {
|
||||
start: piece.start,
|
||||
end: piece.end,
|
||||
href: piece.href,
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const link of links) {
|
||||
if (link.start === link.end) {
|
||||
continue;
|
||||
}
|
||||
const rendered = options.buildLink(link, text);
|
||||
const rendered = options.buildLink(link, text, { origin: getMarkdownLinkOrigin(link) });
|
||||
if (!rendered) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user