diff --git a/lib/obsidian-routes.test.ts b/lib/obsidian-routes.test.ts
new file mode 100644
index 000000000..2332501e7
--- /dev/null
+++ b/lib/obsidian-routes.test.ts
@@ -0,0 +1,122 @@
+import { http, HttpResponse } from 'msw';
+import { describe, expect, it } from 'vitest';
+
+import { route as pluginsRoute } from './routes/obsidian/plugins';
+import { route as themesRoute } from './routes/obsidian/themes';
+
+const searchConfigHtml = '';
+
+describe('/obsidian', () => {
+ it('builds the plugins feed from the community search API sorted by creation time', async () => {
+ const { default: server } = await import('@/setup.test');
+
+ server.use(
+ http.get('https://community.obsidian.md/search', ({ request }) => {
+ const url = new URL(request.url);
+
+ expect(url.searchParams.get('type')).toBe('plugin');
+ expect(url.searchParams.get('sort')).toBe('created');
+
+ return HttpResponse.html(searchConfigHtml);
+ }),
+ http.get('https://community.obsidian.md/api/search/collections/entries/documents/search', ({ request }) => {
+ const url = new URL(request.url);
+
+ expect(request.headers.get('x-typesense-api-key')).toBe('test-search-key');
+ expect(url.searchParams.get('q')).toBe('*');
+ expect(url.searchParams.get('query_by')).toBe('name,authors,short_desc');
+ expect(url.searchParams.get('filter_by')).toBe('type:=plugin');
+ expect(url.searchParams.get('sort_by')).toBe('github_created_at:desc');
+ expect(url.searchParams.get('per_page')).toBe('54');
+
+ return HttpResponse.json({
+ hits: [
+ {
+ document: {
+ authors: ['slaymish'],
+ downloads: 2,
+ github_created_at: '2026-05-14T05:08:50Z',
+ id: '5757',
+ name: 'Synod',
+ short_desc: 'Run a council of LLM value agents over your journal.',
+ slug: 'synod',
+ tags: ['ai', 'import'],
+ type: 'plugin',
+ },
+ },
+ ],
+ });
+ })
+ );
+
+ const feed = await pluginsRoute.handler();
+
+ expect(feed.title).toBe('Obsidian Plugins');
+ expect(feed.link).toBe('https://community.obsidian.md/search?type=plugin&sort=created');
+ expect(feed.item).toHaveLength(1);
+ expect(feed.item[0]).toMatchObject({
+ title: 'Synod',
+ description: 'Run a council of LLM value agents over your journal.',
+ link: 'https://community.obsidian.md/plugins/synod',
+ guid: 'plugin:5757',
+ author: 'slaymish',
+ category: ['ai', 'import'],
+ });
+ expect(feed.item[0].pubDate?.toISOString()).toBe('2026-05-14T05:08:50.000Z');
+ });
+
+ it('builds the themes feed from the community search API sorted by creation time', async () => {
+ const { default: server } = await import('@/setup.test');
+
+ server.use(
+ http.get('https://community.obsidian.md/search', ({ request }) => {
+ const url = new URL(request.url);
+
+ expect(url.searchParams.get('type')).toBe('theme');
+ expect(url.searchParams.get('sort')).toBe('created');
+
+ return HttpResponse.html(searchConfigHtml);
+ }),
+ http.get('https://community.obsidian.md/api/search/collections/entries/documents/search', ({ request }) => {
+ const url = new URL(request.url);
+
+ expect(request.headers.get('x-typesense-api-key')).toBe('test-search-key');
+ expect(url.searchParams.get('filter_by')).toBe('type:=theme');
+ expect(url.searchParams.get('sort_by')).toBe('github_created_at:desc');
+
+ return HttpResponse.json({
+ hits: [
+ {
+ document: {
+ authors: ['jshuntley'],
+ downloads: 0,
+ github_created_at: '2026-05-14T00:28:26Z',
+ id: '5749',
+ name: 'Fjord',
+ short_desc: 'Fjord colorscheme for Obsidian.',
+ slug: 'fjord',
+ tags: [],
+ type: 'theme',
+ },
+ },
+ ],
+ });
+ })
+ );
+
+ const feed = await themesRoute.handler();
+
+ expect(feed.title).toBe('Obsidian Themes');
+ expect(feed.link).toBe('https://community.obsidian.md/search?type=theme&sort=created');
+ expect(feed.item).toHaveLength(1);
+ expect(feed.item[0]).toMatchObject({
+ title: 'Fjord',
+ description: 'Fjord colorscheme for Obsidian.',
+ link: 'https://community.obsidian.md/themes/fjord',
+ guid: 'theme:5749',
+ author: 'jshuntley',
+ category: [],
+ });
+ expect(feed.item[0].pubDate?.toISOString()).toBe('2026-05-14T00:28:26.000Z');
+ });
+});
diff --git a/lib/routes/obsidian/plugins.ts b/lib/routes/obsidian/plugins.ts
index a2caa5fdc..e8a96abb9 100644
--- a/lib/routes/obsidian/plugins.ts
+++ b/lib/routes/obsidian/plugins.ts
@@ -1,40 +1,16 @@
import type { Route } from '@/types';
-import ofetch from '@/utils/ofetch';
+
+import { buildCommunityFeed } from './utils';
export const route: Route = {
path: '/plugins',
- name: 'Obsidian Plugins',
+ name: 'Plugins',
maintainers: ['DIYgod'],
categories: ['program-update'],
example: '/obsidian/plugins',
handler,
};
-async function handler() {
- const data = JSON.parse(await ofetch('https://raw.githubusercontent.com/obsidianmd/obsidian-releases/refs/heads/master/community-plugins.json')) as Array<{
- id: string;
- name: string;
- author: string;
- description: string;
- repo: string;
- }>;
- const stats = JSON.parse(await ofetch('https://raw.githubusercontent.com/obsidianmd/obsidian-releases/HEAD/community-plugin-stats.json')) as {
- [key: string]: {
- downloads: number;
- updated: number;
- };
- };
-
- return {
- title: 'Obsidian Plugins',
- link: 'https://obsidian.md/plugins',
- item: data.map((item) => ({
- title: item.name,
- description: `${item.description}
Downloads: ${stats[item.id].downloads}`,
- link: `https://github.com/${item.repo}`,
- guid: item.id,
- pubDate: new Date(stats[item.id].updated),
- author: item.author,
- })),
- };
+function handler() {
+ return buildCommunityFeed('plugin');
}
diff --git a/lib/routes/obsidian/themes.ts b/lib/routes/obsidian/themes.ts
new file mode 100644
index 000000000..5d3241ae0
--- /dev/null
+++ b/lib/routes/obsidian/themes.ts
@@ -0,0 +1,16 @@
+import type { Route } from '@/types';
+
+import { buildCommunityFeed } from './utils';
+
+export const route: Route = {
+ path: '/themes',
+ name: 'Themes',
+ maintainers: ['DIYgod'],
+ categories: ['program-update'],
+ example: '/obsidian/themes',
+ handler,
+};
+
+function handler() {
+ return buildCommunityFeed('theme');
+}
diff --git a/lib/routes/obsidian/utils.ts b/lib/routes/obsidian/utils.ts
index 39dfdee83..06e191766 100644
--- a/lib/routes/obsidian/utils.ts
+++ b/lib/routes/obsidian/utils.ts
@@ -1,8 +1,96 @@
-const regex = /([^/]+)\.md$/;
+import type { Data, DataItem } from '@/types';
+import ofetch from '@/utils/ofetch';
+import { parseDate } from '@/utils/parse-date';
-const getTitle = (path: string): string => {
- const match = path.match(regex);
- return match ? match[1] : '';
+type CommunityEntryType = 'plugin' | 'theme';
+
+type CommunitySearchConfig = {
+ apiKey: string;
+ url: string;
};
-export { getTitle };
+type CommunitySearchHit = {
+ document: {
+ authors?: string[];
+ github_created_at?: string;
+ github_updated_at?: string;
+ id: string;
+ latest_release_at?: string;
+ name: string;
+ short_desc?: string;
+ slug: string;
+ tags?: string[];
+ type: CommunityEntryType;
+ };
+};
+
+type CommunitySearchResponse = {
+ hits?: CommunitySearchHit[];
+};
+
+const hitsPerPage = 54;
+const searchPageBaseUrl = 'https://community.obsidian.md/search';
+const titleRegex = /([^/]+)\.md$/;
+
+export async function buildCommunityFeed(type: CommunityEntryType): Promise {
+ const pageUrl = getSearchPageUrl(type);
+ const searchConfig = await getSearchConfig(pageUrl);
+ const data = await ofetch(`${searchConfig.url}/collections/entries/documents/search`, {
+ headers: {
+ 'X-TYPESENSE-API-KEY': searchConfig.apiKey,
+ },
+ query: {
+ filter_by: `type:=${type}`,
+ per_page: hitsPerPage,
+ q: '*',
+ query_by: 'name,authors,short_desc',
+ sort_by: 'github_created_at:desc',
+ },
+ });
+
+ return {
+ title: `Obsidian ${type === 'plugin' ? 'Plugins' : 'Themes'}`,
+ link: pageUrl,
+ item: data.hits?.map(({ document }) => buildItem(document)) ?? [],
+ };
+}
+
+export function getTitle(path: string): string {
+ const match = path.match(titleRegex);
+ return match ? match[1] : '';
+}
+
+function getSearchPageUrl(type: CommunityEntryType) {
+ const url = new URL(searchPageBaseUrl);
+ url.searchParams.set('type', type);
+ url.searchParams.set('sort', 'created');
+
+ return url.toString();
+}
+
+async function getSearchConfig(pageUrl: string): Promise {
+ const html = await ofetch(pageUrl);
+ const match = html.match(/apiKey\\":\\"([^"\\]+)\\"[\s\S]*?url\\":\\"([^"\\]+)\\"/) ?? html.match(/"apiKey":"([^"]+)"[\s\S]*?"url":"([^"]+)"/);
+
+ if (!match) {
+ throw new Error('Unable to locate Obsidian community search API config');
+ }
+
+ return {
+ apiKey: match[1],
+ url: match[2].replaceAll(String.raw`\/`, '/'),
+ };
+}
+
+function buildItem(document: CommunitySearchHit['document']): DataItem {
+ return {
+ title: document.name,
+ description: document.short_desc,
+ link: `https://community.obsidian.md/${document.type}s/${document.slug}`,
+ guid: `${document.type}:${document.id}`,
+ pubDate: document.github_created_at ? parseDate(document.github_created_at) : undefined,
+ updated: document.latest_release_at ? parseDate(document.latest_release_at) : document.github_updated_at ? parseDate(document.github_updated_at) : undefined,
+ author: document.authors?.join(', '),
+ category: document.tags,
+ };
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index 95bc7391a..668b66916 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -2,7 +2,7 @@ import tsconfigPaths from 'vite-tsconfig-paths';
import { configDefaults, defineConfig } from 'vitest/config';
export default defineConfig({
- plugins: [tsconfigPaths()],
+ plugins: [tsconfigPaths({ root: '.' })],
test: {
watch: false,
coverage: {