feat: add GitHub URL formatting with enterprise support

- Implement GitHub URL formatting feature in main plugin logic
- Create new `replace-urls` module with comprehensive URL parsing and formatting
- Add settings for GitHub URL formatting and enterprise URL configuration
- Include comprehensive test suite for GitHub URL formatting scenarios
- Support both standard GitHub and GitHub Enterprise URL transformations
This commit is contained in:
Kodai Nakamura 2025-02-16 13:52:53 +09:00
parent 89df0c36ee
commit 4c0f95e0de
6 changed files with 345 additions and 43 deletions

View file

@ -1,3 +1,5 @@
import AsyncLock from "async-lock";
import throttle from "just-throttle";
import {
App,
getFrontMatterInfo,
@ -6,16 +8,12 @@ import {
Plugin,
PluginManifest,
} from "obsidian";
import AsyncLock from "async-lock";
import { replaceLinks } from "./replace-links";
import {
AutomaticLinkerPluginSettingsTab,
AutomaticLinkerSettings,
DEFAULT_SETTINGS,
} from "./settings";
import throttle from "just-throttle";
import { buildCandidateTrie, CandidateData, TrieNode } from "./trie";
import { PathAndAliases } from "./path-and-aliases.types";
import { replaceLinks } from "./replace-links";
import { formatGitHubURL } from "./replace-urls";
import { AutomaticLinkerPluginSettingsTab } from "./settings";
import { buildCandidateTrie, CandidateData, TrieNode } from "./trie";
import { AutomaticLinkerSettings, DEFAULT_SETTINGS } from "./settings-info";
export default class AutomaticLinkerPlugin extends Plugin {
settings: AutomaticLinkerSettings;
@ -29,7 +27,6 @@ export default class AutomaticLinkerPlugin extends Plugin {
super(app, pluginManifest);
}
// Cancelable function to modify links in the active file
async modifyLinks() {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
@ -38,12 +35,21 @@ export default class AutomaticLinkerPlugin extends Plugin {
try {
// Read the current file content
const fileContent = (
await this.app.vault.read(activeFile)
).normalize("NFC");
let fileContent = (await this.app.vault.read(activeFile)).normalize(
"NFC",
);
console.log(new Date().toISOString(), "modifyLinks started");
// Format GitHub URLs if enabled
if (this.settings.formatGitHubURLs) {
// Find GitHub URLs using a regex pattern
const githubUrlPattern = /(https?:\/\/[^\s\]]+)/g;
fileContent = fileContent.replace(githubUrlPattern, (match) => {
return formatGitHubURL(match, this.settings);
});
}
// Use the pre-built trie and candidateMap to replace links.
// Fallback to an empty trie if not built.
const { contentStart } = getFrontMatterInfo(fileContent);

View file

@ -207,11 +207,18 @@ export const replaceLinks = async ({
}
// Remove base/ prefix when in base directory
if (settings.baseDir && linkPath.startsWith(settings.baseDir + "/")) {
linkPath = linkPath.slice((settings.baseDir + "/").length);
if (
settings.baseDir &&
linkPath.startsWith(settings.baseDir + "/")
) {
linkPath = linkPath.slice(
(settings.baseDir + "/").length,
);
}
result += hasAlias ? `[[${linkPath}|${alias}]]` : `[[${linkPath}]]`;
result += hasAlias
? `[[${linkPath}|${alias}]]`
: `[[${linkPath}]]`;
i += candidate.length;
continue outer;
}
@ -265,13 +272,21 @@ export const replaceLinks = async ({
const candidateData = filteredCandidates[0][1];
let linkPath = candidateData.canonical;
// Remove pages/ prefix when baseDir is set
if (settings.baseDir && linkPath.startsWith("pages/")) {
if (
settings.baseDir &&
linkPath.startsWith("pages/")
) {
linkPath = linkPath.slice("pages/".length);
}
// Remove base/ prefix when in base directory
if (settings.baseDir && linkPath.startsWith(settings.baseDir + "/")) {
linkPath = linkPath.slice((settings.baseDir + "/").length);
if (
settings.baseDir &&
linkPath.startsWith(settings.baseDir + "/")
) {
linkPath = linkPath.slice(
(settings.baseDir + "/").length,
);
}
result += `[[${linkPath}]]`;
i += word.length;
@ -381,13 +396,21 @@ export const replaceLinks = async ({
if (bestCandidate !== null) {
let linkPath = bestCandidate[1].canonical;
// Remove pages/ prefix when baseDir is set
if (settings.baseDir && linkPath.startsWith("pages/")) {
if (
settings.baseDir &&
linkPath.startsWith("pages/")
) {
linkPath = linkPath.slice("pages/".length);
}
// Remove base/ prefix when in base directory
if (settings.baseDir && linkPath.startsWith(settings.baseDir + "/")) {
linkPath = linkPath.slice((settings.baseDir + "/").length);
if (
settings.baseDir &&
linkPath.startsWith(settings.baseDir + "/")
) {
linkPath = linkPath.slice(
(settings.baseDir + "/").length,
);
}
result += `[[${linkPath}]]`;
i += word.length;

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { AutomaticLinkerSettings, DEFAULT_SETTINGS } from "../../settings-info";
import { formatGitHubURL } from "../index";
describe("formatGitHubURL", () => {
const baseSettings: AutomaticLinkerSettings = {
...DEFAULT_SETTINGS,
githubEnterpriseURLs: ["github.enterprise.com", "github.company.com"],
};
describe("Basic repository URL formatting", () => {
it("should format basic repository URL", () => {
const input = "https://github.com/kdnk/obsidian-automatic-linker";
const expected =
"[[kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)";
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
});
it("should format repository URL with trailing slash", () => {
const input = "https://github.com/kdnk/obsidian-automatic-linker/";
const expected =
"[[kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)";
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
});
it("should not modify non-GitHub URLs", () => {
const input = "https://example.com/some-path/";
expect(formatGitHubURL(input, baseSettings)).toBe(input);
});
it("should handle invalid URLs", () => {
const input = "not-a-url";
expect(formatGitHubURL(input, baseSettings)).toBe(input);
});
});
describe("Pull Request and Issue URL formatting", () => {
it("should format pull request URLs", () => {
const input =
"https://github.com/kdnk/obsidian-automatic-linker/pull/123?diff=split";
const expected =
"[[kdnk/obsidian-automatic-linker/pull/123]] [🔗](https://github.com/kdnk/obsidian-automatic-linker/pull/123)";
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
});
it("should format issue URLs", () => {
const input =
"https://github.com/kdnk/obsidian-automatic-linker/issues/456#issuecomment-1234567";
const expected =
"[[kdnk/obsidian-automatic-linker/issues/456]] [🔗](https://github.com/kdnk/obsidian-automatic-linker/issues/456)";
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
});
});
describe("GitHub Enterprise URL formatting", () => {
it("should format enterprise repository URLs", () => {
const input = "https://github.enterprise.com/kdnk/project/";
const expected =
"[[kdnk/project]] [🔗](https://github.enterprise.com/kdnk/project)";
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
});
it("should format enterprise pull request URLs", () => {
const input =
"https://github.enterprise.com/kdnk/project/pull/789?diff=split";
const expected =
"[[enterprise/kdnk/project/pull/789]] [🔗](https://github.enterprise.com/kdnk/project/pull/789)";
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
});
it("should handle custom enterprise URLs", () => {
const input = "https://github.company.com/team/project/issues/123";
const expected =
"[[company/team/project/issues/123]] [🔗](https://github.company.com/team/project/issues/123)";
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
});
it("should not format URLs from non-configured enterprise domains", () => {
const input = "https://github.other-company.com/team/project/";
expect(formatGitHubURL(input, baseSettings)).toBe(input);
});
});
describe("URL with query parameters", () => {
it("should remove query parameters from repository URLs", () => {
const input =
"https://github.com/kdnk/obsidian-automatic-linker?tab=repositories";
const expected =
"[[kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)";
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
});
it("should remove hash from URLs", () => {
const input =
"https://github.com/kdnk/obsidian-automatic-linker#readme";
const expected =
"[[kdnk/obsidian-automatic-linker]] [🔗](https://github.com/kdnk/obsidian-automatic-linker)";
expect(formatGitHubURL(input, baseSettings)).toBe(expected);
});
});
});

129
src/replace-urls/index.ts Normal file
View file

@ -0,0 +1,129 @@
import { AutomaticLinkerSettings } from "../settings-info";
type GitHubURLInfo = {
owner: string;
repository: string;
type?: "pull" | "issues";
id?: string;
};
/**
* Format a GitHub URL by normalizing the format and converting to Obsidian link format:
* [[github_id/repository/{pull or issue}/{id}]] [🔗](url)
* @param url The URL to format
* @param settings Plugin settings
* @returns The formatted URL in Obsidian link format
*/
export function formatGitHubURL(
url: string,
settings: AutomaticLinkerSettings,
): string {
try {
const githubURL = new URL(url);
// Check if it's a GitHub URL (including Enterprise)
if (!isGitHubURL(githubURL, settings.githubEnterpriseURLs)) {
return url;
}
const urlInfo = parseGitHubURL(githubURL);
if (!urlInfo) {
return url;
}
const cleanURL = getCleanURL(githubURL, urlInfo);
return formatToObsidianLink(urlInfo, cleanURL);
} catch (e) {
// If URL is invalid, return original string
return url;
}
}
/**
* Parse GitHub URL into its components
*/
function parseGitHubURL(url: URL): GitHubURLInfo | null {
const parts = url.pathname.split("/").filter(Boolean);
if (parts.length < 2) {
return null;
}
const [owner, repository, type, id] = parts;
const urlInfo: GitHubURLInfo = {
owner,
repository,
};
if (isPullRequestOrIssueURL(url)) {
urlInfo.type = type as "pull" | "issues";
urlInfo.id = id;
}
return urlInfo;
}
/**
* Get clean URL without query parameters and trailing slashes
*/
function getCleanURL(url: URL, urlInfo: GitHubURLInfo): string {
if (urlInfo.type && urlInfo.id) {
const basePath = `/${urlInfo.owner}/${urlInfo.repository}/${urlInfo.type}/${urlInfo.id}`;
return `${url.origin}${basePath}`;
}
return `${url.origin}/${urlInfo.owner}/${urlInfo.repository}`.replace(
/\/$/,
"",
);
}
/**
* Format URL info into Obsidian link format
*/
function formatToObsidianLink(
urlInfo: GitHubURLInfo,
cleanURL: string,
): string {
const url = new URL(cleanURL);
const isEnterpriseURL = url.hostname !== "github.com";
let domainPrefix = "";
if (isEnterpriseURL && (urlInfo.type || urlInfo.id)) {
// For github.enterprise.com -> enterprise
// For github.company.com -> company
domainPrefix = url.hostname.split(".")[1] + "/";
}
let wikiLink = `[[${domainPrefix}${urlInfo.owner}/${urlInfo.repository}`;
if (urlInfo.type && urlInfo.id) {
wikiLink += `/${urlInfo.type}/${urlInfo.id}`;
}
wikiLink += `]] [🔗](${cleanURL})`;
return wikiLink;
}
/**
* Check if the URL is a GitHub URL (including Enterprise)
*/
function isGitHubURL(url: URL, enterpriseURLs: string[]): boolean {
// Check if it's github.com
if (url.hostname === "github.com") {
return true;
}
// Check if it matches any of the configured enterprise URLs
return enterpriseURLs.some((enterpriseURL) => {
// Remove any protocol and trailing slashes from the enterprise URL
const cleanEnterpriseURL = enterpriseURL
.replace(/^https?:\/\//, "")
.replace(/\/$/, "");
return url.hostname === cleanEnterpriseURL;
});
}
/**
* Check if the URL is a pull request or issue URL
*/
function isPullRequestOrIssueURL(url: URL): boolean {
const path = url.pathname.toLowerCase();
return path.includes("/pull/") || path.includes("/issues/");
}

23
src/settings-info.ts Normal file
View file

@ -0,0 +1,23 @@
export type AutomaticLinkerSettings = {
formatOnSave: boolean;
baseDir: string;
showNotice: boolean;
minCharCount: number; // Minimum character count setting
considerAliases: boolean; // Consider aliases when linking
namespaceResolution: boolean; // Automatically resolve namespaces for shorthand links
ignoreDateFormats: boolean; // Ignore date formatted links (e.g. 2025-02-10)
formatGitHubURLs: boolean; // Format GitHub URLs on save
githubEnterpriseURLs: string[]; // List of GitHub Enterprise URLs
};
export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
formatOnSave: false,
baseDir: "pages",
showNotice: false,
minCharCount: 0, // Default value: 0 (always replace links)
considerAliases: false, // Default: do not consider aliases
namespaceResolution: false, // Default: disable automatic namespace resolution
ignoreDateFormats: true, // Default: ignore date formats (e.g. 2025-02-10)
formatGitHubURLs: true, // Default: format GitHub URLs
githubEnterpriseURLs: [], // Default: empty list
};

View file

@ -1,26 +1,6 @@
import { PluginSettingTab, App, Setting } from "obsidian";
import { App, PluginSettingTab, Setting } from "obsidian";
import AutomaticLinkerPlugin from "./main";
export type AutomaticLinkerSettings = {
formatOnSave: boolean;
baseDir: string;
showNotice: boolean;
minCharCount: number; // Minimum character count setting
considerAliases: boolean; // Consider aliases when linking
namespaceResolution: boolean; // Automatically resolve namespaces for shorthand links
ignoreDateFormats: boolean; // Ignore date formatted links (e.g. 2025-02-10)
};
export const DEFAULT_SETTINGS: AutomaticLinkerSettings = {
formatOnSave: false,
baseDir: "pages",
showNotice: false,
minCharCount: 0, // Default value: 0 (always replace links)
considerAliases: false, // Default: do not consider aliases
namespaceResolution: false, // Default: disable automatic namespace resolution
ignoreDateFormats: true, // Default: ignore date formats (e.g. 2025-02-10)
};
export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab {
plugin: AutomaticLinkerPlugin;
constructor(app: App, plugin: AutomaticLinkerPlugin) {
@ -138,5 +118,45 @@ export class AutomaticLinkerPluginSettingsTab extends PluginSettingTab {
await this.plugin.saveData(this.plugin.settings);
});
});
// Toggle for formatting GitHub URLs on save
new Setting(containerEl)
.setName("Format GitHub URLs on Save")
.setDesc(
"When enabled, GitHub URLs will be formatted when saving the file.",
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.formatGitHubURLs)
.onChange(async (value) => {
this.plugin.settings.formatGitHubURLs = value;
await this.plugin.saveData(this.plugin.settings);
});
});
// Add GitHub Enterprise URLs setting
new Setting(containerEl)
.setName("GitHub Enterprise URLs")
.setDesc(
"Enter GitHub Enterprise URLs (one per line). Example: github.enterprise.com",
)
.addTextArea((text) => {
text.setPlaceholder("github.enterprise.com\ngithub.company.com")
.setValue(
this.plugin.settings.githubEnterpriseURLs.join("\n"),
)
.onChange(async (value) => {
// Split by newlines and filter out empty lines
const urls = value
.split("\n")
.map((url) => url.trim())
.filter(Boolean);
this.plugin.settings.githubEnterpriseURLs = urls;
await this.plugin.saveData(this.plugin.settings);
});
// Make the text area taller
text.inputEl.rows = 4;
text.inputEl.cols = 50;
});
}
}