Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions lib/editor/links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
agentnoteLinks,
findBareLinksInText,
hrefAtPos,
looksLikeWebHost,
noteIdFromInAppHref,
openHref,
resolveHref,
Expand All @@ -26,16 +27,40 @@ describe("resolveHref", () => {
expect(resolveHref("/?n=abc123")).toBe("/?n=abc123");
});

it("prefixes https for scheme-less web hosts", () => {
expect(resolveHref("docs.sume.com/enterprise/mobidoo")).toBe(
"https://docs.sume.com/enterprise/mobidoo",
);
expect(resolveHref("www.example.com")).toBe("https://www.example.com");
expect(resolveHref("//docs.sume.com/x")).toBe("https://docs.sume.com/x");
});

it("rejects javascript: and unknown schemes / relative paths", () => {
expect(resolveHref("javascript:alert(1)")).toBeNull();
expect(resolveHref("data:text/html,hi")).toBeNull();
expect(resolveHref("/thoughts/foo")).toBeNull();
expect(resolveHref("../escape")).toBeNull();
expect(resolveHref("README.md")).toBeNull();
expect(resolveHref("")).toBeNull();
expect(resolveHref(" ")).toBeNull();
});
});

describe("looksLikeWebHost", () => {
it("accepts host.tld and www hosts", () => {
expect(looksLikeWebHost("docs.sume.com/enterprise/mobidoo")).toBe(true);
expect(looksLikeWebHost("example.com")).toBe(true);
expect(looksLikeWebHost("www.example.com/a")).toBe(true);
});

it("rejects file-like names and non-hosts", () => {
expect(looksLikeWebHost("README.md")).toBe(false);
expect(looksLikeWebHost("app.tsx")).toBe(false);
expect(looksLikeWebHost("/n/abc")).toBe(false);
expect(looksLikeWebHost("notaurl")).toBe(false);
});
});

describe("noteIdFromInAppHref", () => {
it("parses /n/{id} and legacy query forms", () => {
expect(noteIdFromInAppHref("/n/note-1")).toBe("note-1");
Expand All @@ -62,6 +87,29 @@ describe("findBareLinksInText", () => {
]);
});

it("finds www and scheme-less host urls", () => {
const www = "see www.example.com/x end";
expect(findBareLinksInText(www)).toEqual([
{
from: "see ".length,
to: "see www.example.com/x".length,
url: "www.example.com/x",
},
]);
const host = "see docs.sume.com/enterprise/mobidoo end";
expect(findBareLinksInText(host)).toEqual([
{
from: "see ".length,
to: "see docs.sume.com/enterprise/mobidoo".length,
url: "docs.sume.com/enterprise/mobidoo",
},
]);
});

it("does not treat README.md as a bare link", () => {
expect(findBareLinksInText("open README.md please")).toEqual([]);
});

it("skips urls inside ]( destinations of links/images", () => {
const link = "[label](https://inside.example/link)";
const image = "![alt](https://inside.example/img.png)";
Expand Down Expand Up @@ -132,6 +180,17 @@ describe("hrefAtPos", () => {
);
expect(hrefAtPos(state, 0)).toBeNull(); // list marker
});

it("opens scheme-less [label](host/path) as https", () => {
const doc = "- [mobidoo docs](docs.sume.com/enterprise/mobidoo)\n";
const state = EditorState.create({
doc,
extensions: [markdown(), agentnoteLinks()],
});
expect(hrefAtPos(state, 10)).toBe(
"https://docs.sume.com/enterprise/mobidoo",
);
});
});

describe("agentnoteLinks decorations", () => {
Expand Down
108 changes: 102 additions & 6 deletions lib/editor/links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,61 @@ const hideMark = Decoration.replace({});
const linkLabelMark = Decoration.mark({ class: "cm-md-link" });
const bareLinkMark = Decoration.mark({ class: "cm-md-link cm-md-link--bare" });

/** Bare http(s) URLs in the buffer (not inside `](…)` destinations). */
const BARE_LINK_RE = /https?:\/\/[^\s)<>]+/g;
/**
* Bare URLs in the buffer (not inside `](…)` destinations):
* - http(s)://…
* - www.…
* - host.tld[/path] (scheme-less; resolved to https://)
*/
const BARE_LINK_RE =
/(?:https?:\/\/|www\.)[^\s)<>]+|(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z]{2,}(?::\d{1,5})?(?:\/[^\s)<>]*)?/gi;

/** In-app note deep link: `/n/{id}`. */
const IN_APP_NOTE_PATH_RE = /^\/n\/([^/?#]+)\/?$/;

/**
* Filename-ish "TLDs" — reject bare `README.md` / `foo.ts` when there is no
* path/query (still allow `docs.sume.com/…`).
*/
const FILE_LIKE_TLDS = new Set([
"md",
"mdx",
"ts",
"tsx",
"js",
"jsx",
"mjs",
"cjs",
"json",
"css",
"scss",
"png",
"jpg",
"jpeg",
"gif",
"svg",
"webp",
"ico",
"pdf",
"txt",
"html",
"htm",
"yml",
"yaml",
"toml",
"lock",
"map",
"py",
"rb",
"go",
"rs",
"java",
"kt",
"swift",
"sh",
"env",
]);

export type LinkHit = { from: number; to: number; url: string };

function collectMarkdownLinks(state: EditorState): {
Expand Down Expand Up @@ -101,6 +150,43 @@ export function collectBareLinks(
return findBareLinksInText(state.doc.toString(), occupied);
}

/**
* True for scheme-less strings that look like a web host (+ optional path).
* Examples: `docs.sume.com/enterprise/mobidoo`, `www.example.com`, `example.com:443/a`.
*/
export function looksLikeWebHost(raw: string): boolean {
const url = raw.trim();
if (!url || /\s/.test(url)) return false;
if (url.startsWith("/") || url.startsWith(".") || url.startsWith("#")) {
return false;
}
if (url.includes("://")) return false;
// Reject other schemes (`javascript:`, `mailto:`, …) — handled separately.
if (/^[a-z][a-z0-9+.-]*:/i.test(url)) return false;

const hostPart = url.split(/[/?#]/, 1)[0] ?? "";
if (!hostPart) return false;

const hostMatch = /^(.+?)(?::(\d{1,5}))?$/.exec(hostPart);
if (!hostMatch?.[1]) return false;
const host = hostMatch[1];
if (
!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+$/i.test(
host,
)
) {
return false;
}

const labels = host.split(".");
const tld = labels[labels.length - 1]?.toLowerCase() ?? "";
if (tld.length < 2 || /^\d+$/.test(tld)) return false;
// `README.md` / `app.tsx` without a path should not become https links.
if (FILE_LIKE_TLDS.has(tld) && !/[/?#]/.test(url)) return false;

return true;
}

/** Pure helper for unit tests — same rules as the editor bare-link scanner. */
export function findBareLinksInText(
doc: string,
Expand All @@ -109,11 +195,14 @@ export function findBareLinksInText(
const hits: LinkHit[] = [];
for (const match of doc.matchAll(BARE_LINK_RE)) {
const from = match.index ?? 0;
const to = from + match[0].length;
const raw = match[0];
const to = from + raw.length;
if (occupied.some((hit) => !(to <= hit.from || from >= hit.to))) continue;
// Skip destinations inside markdown image / link markup.
if (from >= 2 && doc.slice(from - 2, from) === "](") continue;
hits.push({ from, to, url: match[0] });
// Drop false positives the regex alone cannot filter (e.g. `README.md`).
if (!resolveHref(raw)) continue;
hits.push({ from, to, url: raw });
}
return hits;
}
Expand Down Expand Up @@ -171,8 +260,9 @@ const visibleLinkMarks = StateField.define<DecorationSet>({
});

/**
* Allow only http(s), mailto, in-app `/n/{id}`, and legacy `?n=` / `/?n=`
* query forms. Rejects `javascript:` and other unknown schemes.
* Allow http(s), mailto, in-app `/n/{id}`, legacy `?n=` / `/?n=`,
* protocol-relative `//host…`, and scheme-less web hosts (→ `https://…`).
* Rejects `javascript:` and other unknown schemes.
*/
export function resolveHref(raw: string): string | null {
const url = raw.trim();
Expand All @@ -183,6 +273,12 @@ export function resolveHref(raw: string): string | null {
}
if (/^https?:\/\//i.test(url)) return url;
if (/^mailto:/i.test(url)) return url;
if (url.startsWith("//") && looksLikeWebHost(url.slice(2))) {
return `https:${url}`;
}
if (looksLikeWebHost(url)) {
return `https://${url}`;
}
return null;
}

Expand Down