---
title: "I Shipped My First Obsidian Plugin"
description: "Everything I learned building and publishing YouTube Links to the Obsidian community directory, mistakes included."
date: 2026-06-27
tags:
  - obsidian
  - typescript
  - guide
image: '/blog/youtube-links-demo.gif'
---

I use Obsidian for everything. Notes, bookmarks, research, random thoughts at 2AM. And one thing that always bugged me was pasting YouTube links. You get this ugly raw URL sitting in your note and you have to manually rename it or it stays there looking wrong forever.

Notion handles this automatically. Paste a YouTube link, it fetches the title, the link becomes readable. I wanted that in Obsidian.

So I built it. And then I spent the rest of the day trying to get it published.

This is everything I learned, including the parts that weren't in any guide.

![YouTube Links demo](/blog/youtube-links-demo.gif)

---

## The Plugin Idea

The feature is simple: intercept paste events, detect YouTube URLs, fetch the video title and channel via YouTube's oEmbed endpoint (no API key required, it's public), and replace the raw URL with a markdown link that shows `Channel: Video Title`.

Exactly how Notion does it. No mystery.

---

## Getting Started

Obsidian has a sample plugin repo. Clone it into your vault's `.obsidian/plugins/` folder and start from there. The structure is:

```
your-plugin/
  src/
    main.ts       # your plugin logic
    settings.ts   # settings tab
  manifest.json
  package.json
  esbuild.config.mjs
  tsconfig.json
```

The build system is esbuild. `bun run dev` watches for changes and rebuilds, `bun run build` does a production build. To test, symlink your plugin folder into a test vault's `.obsidian/plugins/` and reload the plugin in Obsidian settings.

---

## The Paste Handler

Obsidian fires an `editor-paste` event on the workspace whenever you paste in a markdown view. The handler gets the clipboard text, the editor instance, and the event itself.

```typescript
this.registerEvent(
    this.app.workspace.on('editor-paste', (evt: ClipboardEvent, editor: Editor, _info: MarkdownView | MarkdownFileInfo) => {
        if (evt.defaultPrevented) return;

        const text = evt.clipboardData?.getData('text/plain')?.trim();
        if (!text) return;

        const url = extractYouTubeUrl(text);
        if (!url) return;

        evt.preventDefault();

        const cursor = editor.getCursor();
        const placeholder = `[Loading...](${url})`;
        editor.replaceRange(placeholder, cursor);
        editor.setCursor({ line: cursor.line, ch: cursor.ch + placeholder.length });

        void this.fetchAndReplace(editor, cursor, placeholder, url);
    }),
);
```

Two things worth noting:

Check `evt.defaultPrevented` at the top. If another plugin already handled this paste, bail out early. The Obsidian review will flag you if you don't.

The `void` in front of `fetchAndReplace` is intentional. The function is async and you're not awaiting it (you want the UI to feel instant), so you mark it explicitly with `void` to tell TypeScript you know what you're doing.

---

## The oEmbed Fetch

YouTube's oEmbed API is free and requires no authentication:

```
https://www.youtube.com/oembed?url=<encoded_url>&format=json
```

It returns the video title and the channel name (`author_name`). Use Obsidian's built-in `requestUrl` instead of `fetch`. It respects Obsidian's proxy settings and works on mobile.

```typescript
async function fetchVideoInfo(url: string): Promise<OEmbedResponse | null> {
    try {
        const endpoint = `https://www.youtube.com/oembed?url=${encodeURIComponent(url)}&format=json`;
        const response = await requestUrl({ url: endpoint });
        return response.json as OEmbedResponse;
    } catch {
        return null;
    }
}
```

If the fetch fails, just fall back to a plain link with the URL as both text and href. Don't crash.

---

## Replacing the Placeholder

After the fetch resolves, you need to find where the placeholder is in the document and swap it for the real link. The editor might have scrolled, the user might have typed more, so you search a few lines around the original cursor position:

```typescript
private findPlaceholderPosition(editor: Editor, hint: { line: number; ch: number }, placeholder: string) {
    for (let i = hint.line; i < Math.min(hint.line + 5, editor.lineCount()); i++) {
        const idx = editor.getLine(i).indexOf(placeholder);
        if (idx !== -1) return { line: i, ch: idx };
    }
    return null;
}
```

Then replace the range and move the cursor to the end of the new link so the user can keep typing without having to click anywhere.

---

## URL Formats

YouTube has three link formats. Make sure your regex handles all of them:

- `https://www.youtube.com/watch?v=VIDEO_ID`
- `https://youtu.be/VIDEO_ID`
- `https://www.youtube.com/shorts/VIDEO_ID`

The oEmbed API handles all three without any changes on your side.

---

## Manifest

`manifest.json` is what Obsidian reads for everything. A few rules that will save you time:

```json
{
    "id": "youtube-links",
    "name": "YouTube Links",
    "version": "1.0.0",
    "minAppVersion": "1.4.0",
    "description": "Paste YouTube links and they automatically expand into a readable link with the channel name and video title.",
    "author": "Your Name",
    "authorUrl": "https://github.com/you",
    "isDesktopOnly": false
}
```

**The `id` cannot contain the word "obsidian"** and cannot end with "plugin". I named mine `obsidian-youtube-links` and the submission was rejected immediately. `youtube-links` is fine.

**The `id` must match your plugin's folder name** in the vault. If they don't match, some API methods like `onExternalSettingsChange` will silently not fire.

**`description` should end with a period** and be under 250 characters. Keep it plain, no emoji.

If you're accepting donations, add `fundingUrl`:

```json
"fundingUrl": {
    "Ko-fi": "https://ko-fi.com/you",
    "GitHub Sponsors": "https://github.com/sponsors/you"
}
```

---

## Settings

If your plugin has settings, add a settings tab. The Obsidian style guide says use sentence case in the UI, so "Show loading text" not "Show Loading Text". It's a small thing but the automated review checks for it.

```typescript
new Setting(containerEl)
    .setName('Show loading text')
    .setDesc('Show "Loading..." as a placeholder while fetching video info.')
    .addToggle(toggle =>
        toggle
            .setValue(this.plugin.settings.showLoadingText)
            .onChange(async value => {
                this.plugin.settings.showLoadingText = value;
                await this.plugin.saveSettings();
            })
    );
```

---

## The Release Workflow

Use GitHub Actions to build and publish releases automatically. Tag a version, the workflow builds, attests, and creates a draft release with `main.js` and `manifest.json` attached. You review and publish the draft.

**Artifact attestations are required** by the Obsidian review system. Add this step before creating the release:

```yaml
- name: Attest build provenance
  uses: actions/attest@v4
  with:
      subject-path: main.js
```

Without attestations, the review will flag it as a recommendation failure. It won't block publishing but it looks bad.

---

## The Review

After you submit at community.obsidian.md, an automated review runs. Here's what it checks and what will get you:

**ESLint errors.** The review runs the Obsidian ESLint plugin against your source. A few things it specifically catches:

- `as any` casts are not allowed. Use proper types. If you're accessing a workspace event that isn't in the type definitions, check again, because `editor-paste` actually is typed correctly in the Obsidian API.
- Undescribed `eslint-disable` comments are flagged as errors. If you're suppressing a rule, you have to explain why in the comment.
- Floating promises need `void` or an explicit handler.

**Network requests.** It scans for suspicious patterns. `requestUrl` to YouTube's oEmbed endpoint is obviously fine.

**Build verification.** It rebuilds your plugin from source and compares `main.js` byte-for-byte against what you uploaded to the release. If they don't match, it fails. This means your build has to be deterministic. Esbuild handles this fine.

**Artifact attestations.** The release asset needs a verified attestation from the GitHub Actions workflow that built it.

The review is pretty fast. If something fails, fix it, bump the version, push a new tag, and resubmit. The listing on community.obsidian.md updates automatically when you push a new release.

---

## Test Setup

Don't test in your real vault. Create a separate one:

```bash
mkdir ~/ObsidianTestVault
mkdir -p ~/ObsidianTestVault/.obsidian/plugins
ln -s ~/your-plugin ~/ObsidianTestVault/.obsidian/plugins/your-plugin-id
```

Add a `community-plugins.json` file listing your plugin ID so Obsidian loads it automatically without you having to enable it every time:

```json
["your-plugin-id"]
```

Run `bun run dev` so the build is watching. To pick up changes in Obsidian, toggle the plugin off and on in settings or use the "Reload app without saving" command palette option.

---

## Things I'd Do Differently

**Check the ESLint plugin early.** Run `bun run lint` before you submit anything. The Obsidian ESLint plugin (`eslint-plugin-obsidianmd`) catches exactly what the automated review catches. If lint passes locally, the review will pass.

**Name your plugin correctly from the start.** I went through `youtube-link-enhanced`, `obsidian-youtube-links`, and finally `youtube-links`. Every rename is a new release and new version bump. The repo name can be whatever you want on GitHub (I kept it `obsidian-youtube-links`), but the `id` in manifest is what Obsidian uses internally, and you want that stable.

**Don't skip the test vault setup.** Symlink, not copy. You want live rebuilds to reflect instantly.

---

## Was It Worth It

The plugin took a few hours to write and most of a day to navigate the submission process. The actual code is maybe 120 lines. Obsidian's plugin system is genuinely well-designed: `registerEvent` auto-cleans up listeners on unload, `requestUrl` handles proxies, the esbuild setup just works.

The submission friction is mostly self-inflicted. Naming mistakes, missing attestations, ESLint issues that lint would have caught. None of it is hard once you know what the review is looking for.

After publishing, I added one more thing: an embed option. If you set link format to "Embed video", the paste becomes `![Channel: Video Title](url)` instead of a plain link, and Obsidian renders it as an inline player. One character difference in the output, one extra dropdown option in settings.

Plugin is live: search **YouTube Links** in Settings > Community plugins > Browse.

Repo: [github.com/xevrion/obsidian-youtube-links](https://github.com/xevrion/obsidian-youtube-links)
