I Shipped My First Obsidian Plugin

Everything I learned building and publishing YouTube Links to the Obsidian community directory, mistakes included.

June 27, 2026·5 min read··view raw

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.


The Plugin IdeaCopied!

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 StartedCopied!

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 HandlerCopied!

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.

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 FetchCopied!

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.

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 PlaceholderCopied!

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:

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 FormatsCopied!

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.


ManifestCopied!

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

{
    "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:

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

SettingsCopied!

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.

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 WorkflowCopied!

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:

- 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 ReviewCopied!

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 SetupCopied!

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

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:

["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 DifferentlyCopied!

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 ItCopied!

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

Comments