Auto-link raw webexteams URIs (#8560)

* feat: auto-link webexteams URIs

* test: expect escaped webexteams URIs

---------

Co-authored-by: Aamer Akhter <aamer_akhter@users.noreply.bitbucket.org>
This commit is contained in:
aakhter 2026-06-24 05:07:56 -04:00 committed by GitHub
parent 8eda115468
commit 677d4aa57f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 85 additions and 4 deletions

View file

@ -31,6 +31,7 @@ export const ALLOWED_EXTERNAL_URL_SCHEMES = [
'vscode-insiders:',
'zotero:',
'logseq:',
'webexteams:',
];
const LOCAL_FILE_URL_PREFIX = 'file:///';

View file

@ -22,6 +22,7 @@ describe('isExternalUrlSchemeAllowed', () => {
'vscode-insiders://file/home/user/x.ts',
'zotero://select/items/0_ABCD1234',
'logseq://graph/Notes?page=Today',
'webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&message=f4f13440-6b50-11f1-8868-03e71232fa87',
];
allowed.forEach((url) => {
it(`allows "${url}"`, () => {
@ -42,6 +43,7 @@ describe('isExternalUrlSchemeAllowed', () => {
'vscode-insiders:',
'zotero:',
'logseq:',
'webexteams:',
]);
});
});

View file

@ -390,13 +390,14 @@ describe('markedOptionsFactory', () => {
// #8429: app deep-links must stay clickable
'obsidian://open?vault=Notes',
'vscode://file/home/user/x.ts',
'webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&message=f4f13440-6b50-11f1-8868-03e71232fa87',
].forEach((href) => {
const result = linkRenderer({
href,
title: '',
tokens: [{ type: 'text', raw: 'L', text: 'L' }],
} as any);
expect(result).toContain(`href="${href}"`);
expect(result).toContain(`href="${escapeHtmlAttr(href)}"`);
});
});
@ -414,6 +415,17 @@ describe('markedOptionsFactory', () => {
);
});
});
it('auto-links raw webexteams:// URIs with their query string intact', () => {
const uri =
'webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&message=f4f13440-6b50-11f1-8868-03e71232fa87';
const result = parseWithFactory(`Open ${uri}`);
expect(result).toContain(`href="${uri.replace(/&/g, '&amp;')}"`);
expect(result).toContain(
'webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&amp;message=f4f13440-6b50-11f1-8868-03e71232fa87',
);
});
});
describe('image renderer', () => {

View file

@ -1,5 +1,10 @@
import { MarkedOptions, MarkedRenderer } from 'ngx-markdown';
import { Hooks, Token } from 'marked';
import {
Hooks,
type Token,
type TokenizerExtensionFunction,
type TokenizerStartFunction,
} from 'marked';
import {
isExternalUrlSchemeAllowed,
isPathSafeToOpen,
@ -62,6 +67,48 @@ export const preprocessMarkdown = (markdown: string): string => {
);
};
const WEBEX_TEAMS_URI_PREFIX = 'webexteams://';
const WEBEX_TEAMS_URI_RE = /^webexteams:\/\/\S{1,2000}/i;
const WEBEX_TRAILING_PUNCT_RE = /[.,;!?]+$/;
const stripWebexTeamsUriTrailing = (raw: string): string => {
const uri = raw.replace(WEBEX_TRAILING_PUNCT_RE, '');
let opens = 0;
let closes = 0;
for (let i = 0; i < uri.length; i++) {
const c = uri.charCodeAt(i);
if (c === 40) opens++;
else if (c === 41) closes++;
}
let end = uri.length;
while (end > 0 && uri.charCodeAt(end - 1) === 41 && closes > opens) {
end--;
closes--;
}
return end < uri.length ? uri.substring(0, end) : uri;
};
const startWebexTeamsAutoLink: TokenizerStartFunction = (src: string): number | void => {
const index = src.toLowerCase().indexOf(WEBEX_TEAMS_URI_PREFIX);
return index >= 0 ? index : undefined;
};
const tokenizeWebexTeamsAutoLink: TokenizerExtensionFunction = (src: string) => {
const match = WEBEX_TEAMS_URI_RE.exec(src);
if (!match) {
return undefined;
}
const href = stripWebexTeamsUriTrailing(match[0]);
return {
type: 'link',
raw: href,
href,
text: href,
tokens: [{ type: 'text', raw: href, text: href }],
};
};
export const markedOptionsFactory = (): MarkedOptions => {
const renderer = new MarkedRenderer();
@ -196,6 +243,12 @@ export const markedOptionsFactory = (): MarkedOptions => {
gfm: true,
breaks: true,
pedantic: false,
extensions: {
renderers: {},
childTokens: {},
inline: [tokenizeWebexTeamsAutoLink],
startInline: [startWebexTeamsAutoLink],
},
};
// Add preprocessing hook to handle image sizing syntax

View file

@ -240,6 +240,18 @@ describe('RenderLinksPipe', () => {
expect((result.match(/<a /g) || []).length).toBe(2);
});
it('should auto-link plain webexteams:// URIs and preserve query params', () => {
const uri =
'webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&message=f4f13440-6b50-11f1-8868-03e71232fa87';
const result = html(pipe.transform(`Open ${uri}`));
expect(result).toContain(`href="${uri.replace(/&/g, '&amp;')}"`);
expect(result).toContain(
'>webexteams://im?space=ff135070-68f8-11f1-9229-c7e6cca7a7cd&amp;message=f4f13440-6b50-11f1-8868-03e71232fa87</a>',
);
expect(result).not.toContain('http://webexteams');
});
it('should reject empty URLs in markdown links', () => {
const result = html(pipe.transform('[text]()'));
expect(result).toContain('text');

View file

@ -13,11 +13,12 @@ export const hasLinkHints = (text: string): boolean =>
text.includes(LINK_HINT_WWW) ||
text.includes(LINK_HINT_MARKDOWN);
// URL regex matching URLs with protocol (http, https, file) or www prefix.
// URL regex matching URLs with protocol (http, https, file, webexteams) or www prefix.
// ftp://, ssh://, blob:, etc. are intentionally excluded — they are either
// non-browsable or handled by _isUrlSchemeSafe's denylist for markdown links.
// Limit URL length to 2000 chars to prevent ReDoS attacks.
const URL_REGEX = /(?:(?:https?|file):\/\/\S{1,2000}(?=\s|$)|www\.\S{1,2000}(?=\s|$))/gi;
const URL_REGEX =
/(?:(?:https?|file|webexteams):\/\/\S{1,2000}(?=\s|$)|www\.\S{1,2000}(?=\s|$))/gi;
// Markdown link regex: [title](url)
// The URL group allows one level of balanced parentheses so that links like