From 8c4e8073abb3ed80714666628735018caf710f26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 29 May 2026 12:34:54 +0800 Subject: [PATCH] feat(desktop): add classifyPreviewLink classifier Co-Authored-By: Claude Opus 4.8 (1M context) --- desktop/src/lib/previewLinkRouter.test.ts | 29 +++++++++++++++ desktop/src/lib/previewLinkRouter.ts | 45 +++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 desktop/src/lib/previewLinkRouter.test.ts create mode 100644 desktop/src/lib/previewLinkRouter.ts diff --git a/desktop/src/lib/previewLinkRouter.test.ts b/desktop/src/lib/previewLinkRouter.test.ts new file mode 100644 index 00000000..e18248b7 --- /dev/null +++ b/desktop/src/lib/previewLinkRouter.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { classifyPreviewLink } from './previewLinkRouter' + +describe('classifyPreviewLink', () => { + it('classifies loopback urls as browser-localhost', () => { + expect(classifyPreviewLink('http://localhost:5173/').kind).toBe('browser-localhost') + expect(classifyPreviewLink('http://127.0.0.1:8080/x').kind).toBe('browser-localhost') + }) + it('classifies html file paths as browser-file', () => { + expect(classifyPreviewLink('file:///Users/x/index.html').kind).toBe('browser-file') + expect(classifyPreviewLink('/Users/x/page.htm').kind).toBe('browser-file') + expect(classifyPreviewLink('./out/index.html').kind).toBe('browser-file') + }) + it('classifies relative previewable docs as file-preview', () => { + expect(classifyPreviewLink('docs/report.md').kind).toBe('file-preview') + expect(classifyPreviewLink('src/app.ts').kind).toBe('file-preview') + }) + it('classifies remote http(s) as remote', () => { + expect(classifyPreviewLink('https://example.com').kind).toBe('remote') + }) + it('ignores anchors and empty', () => { + expect(classifyPreviewLink('#section').kind).toBe('ignored') + expect(classifyPreviewLink('').kind).toBe('ignored') + }) + it('exposes a normalized path for file kinds', () => { + expect(classifyPreviewLink('file:///Users/x/index.html').path).toBe('/Users/x/index.html') + expect(classifyPreviewLink('docs/report.md').path).toBe('docs/report.md') + }) +}) diff --git a/desktop/src/lib/previewLinkRouter.ts b/desktop/src/lib/previewLinkRouter.ts new file mode 100644 index 00000000..8e9e4432 --- /dev/null +++ b/desktop/src/lib/previewLinkRouter.ts @@ -0,0 +1,45 @@ +import { isLoopbackHostname } from './desktopRuntime' + +export type PreviewLinkKind = + | 'browser-localhost' + | 'browser-file' + | 'file-preview' + | 'remote' + | 'ignored' + +export type PreviewLinkClass = { kind: PreviewLinkKind; path?: string; url?: string } + +const PREVIEWABLE_EXT = new Set(['md', 'markdown', 'txt', 'ts', 'tsx', 'js', 'jsx', 'json', 'css', 'py', 'rs', 'go', 'java', 'sh', 'yml', 'yaml', 'png', 'jpg', 'jpeg', 'gif', 'svg']) + +function extOf(p: string): string { + const m = /\.([a-z0-9]+)(?:[?#].*)?$/i.exec(p) + const g = m?.[1] + return g ? g.toLowerCase() : '' +} + +export function classifyPreviewLink(href: string): PreviewLinkClass { + const raw = href.trim() + if (!raw || raw.startsWith('#')) return { kind: 'ignored' } + + try { + const u = new URL(raw) + if (u.protocol === 'http:' || u.protocol === 'https:') { + return isLoopbackHostname(u.hostname) + ? { kind: 'browser-localhost', url: u.toString() } + : { kind: 'remote', url: u.toString() } + } + if (u.protocol === 'file:') { + return { kind: 'browser-file', path: decodeURIComponent(u.pathname) } + } + return { kind: 'ignored' } + } catch { + // not an absolute URL → treat as a path + } + + const ext = extOf(raw) + if (ext === 'html' || ext === 'htm') return { kind: 'browser-file', path: raw } + if (PREVIEWABLE_EXT.has(ext)) { + return raw.startsWith('/') ? { kind: 'browser-file', path: raw } : { kind: 'file-preview', path: raw } + } + return { kind: 'ignored' } +}