From 30776baef7ff8808d2a9bb1fcb2ec85d3ad1843e Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 14 May 2026 15:47:50 +0800 Subject: [PATCH] fix: update obsidian community routes --- lib/obsidian-routes.test.ts | 122 +++++++++++++++++++++++++++++++++ lib/routes/obsidian/plugins.ts | 34 ++------- lib/routes/obsidian/themes.ts | 16 +++++ lib/routes/obsidian/utils.ts | 98 ++++++++++++++++++++++++-- vitest.config.ts | 2 +- 5 files changed, 237 insertions(+), 35 deletions(-) create mode 100644 lib/obsidian-routes.test.ts create mode 100644 lib/routes/obsidian/themes.ts 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: {