From 1e6d9cab7ee27540f1fde671eed15293d8dfa5eb Mon Sep 17 00:00:00 2001 From: Scott <5895214+ScotTFO@users.noreply.github.com> Date: Mon, 26 Jan 2026 07:32:34 +0000 Subject: [PATCH 1/2] feat: add repoUrl field to skills for GitHub/repository links - Add repoUrl (optional string) to skills schema - Populate from frontmatter homepage/repository/repo on publish (create + update) - Display as clickable link on SkillDetailPage (GitHub-aware label) - Existing skills get populated on next publish --- convex/schema.ts | 1 + convex/skills.ts | 12 ++++++++++++ src/components/SkillDetailPage.tsx | 7 +++++++ 3 files changed, 20 insertions(+) diff --git a/convex/schema.ts b/convex/schema.ts index 3485e61..a7fb50c 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -37,6 +37,7 @@ const skills = defineTable({ at: v.number(), }), ), + repoUrl: v.optional(v.string()), latestVersionId: v.optional(v.id('skillVersions')), tags: v.record(v.string(), v.id('skillVersions')), softDeletedAt: v.optional(v.number()), diff --git a/convex/skills.ts b/convex/skills.ts index 9ea5a36..3892043 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -681,10 +681,15 @@ export const insertVersion = internalMutation({ } const summary = getFrontmatterValue(args.parsed.frontmatter, 'description') + const repoUrl = + getFrontmatterValue(args.parsed.frontmatter, 'homepage') ?? + getFrontmatterValue(args.parsed.frontmatter, 'repository') ?? + getFrontmatterValue(args.parsed.frontmatter, 'repo') const skillId = await ctx.db.insert('skills', { slug: args.slug, displayName: args.displayName, summary: summary ?? undefined, + repoUrl: repoUrl ?? undefined, ownerUserId: userId, canonicalSkillId, forkOf, @@ -741,9 +746,16 @@ export const insertVersion = internalMutation({ const latestBefore = skill.latestVersionId + const updatedRepoUrl = + getFrontmatterValue(args.parsed.frontmatter, 'homepage') ?? + getFrontmatterValue(args.parsed.frontmatter, 'repository') ?? + getFrontmatterValue(args.parsed.frontmatter, 'repo') ?? + skill.repoUrl + await ctx.db.patch(skill._id, { displayName: args.displayName, summary: getFrontmatterValue(args.parsed.frontmatter, 'description') ?? skill.summary, + repoUrl: updatedRepoUrl, latestVersionId: versionId, tags: nextTags, stats: { ...skill.stats, versions: skill.stats.versions + 1 }, diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index 0fc6022..d3bd3d2 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -202,6 +202,13 @@ export function SkillDetailPage({ by @{owner.handle} ) : null} + {skill.repoUrl ? ( +
+ + {skill.repoUrl.includes('github.com') ? '🔗 GitHub' : '🔗 Repository'} + +
+ ) : null} {forkOf && forkOfHref ? (
{forkOfLabel}{' '} From 3c4316a58f298e03c3974f020ce4fc118722340b Mon Sep 17 00:00:00 2001 From: Scott <5895214+ScotTFO@users.noreply.github.com> Date: Mon, 26 Jan 2026 07:38:59 +0000 Subject: [PATCH 2/2] validate repoUrl: only accept GitHub repo URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add parseGitHubRepoUrl() that validates against https://github.com/owner/repo pattern - Non-GitHub URLs are silently ignored (repoUrl stays undefined) - Simplify UI label to always show '🔗 GitHub' since only GitHub URLs are accepted --- convex/skills.ts | 29 ++++++++++++++++++++++++----- src/components/SkillDetailPage.tsx | 2 +- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/convex/skills.ts b/convex/skills.ts index 3892043..2873e0b 100644 --- a/convex/skills.ts +++ b/convex/skills.ts @@ -681,10 +681,11 @@ export const insertVersion = internalMutation({ } const summary = getFrontmatterValue(args.parsed.frontmatter, 'description') - const repoUrl = + const repoUrl = parseGitHubRepoUrl( getFrontmatterValue(args.parsed.frontmatter, 'homepage') ?? getFrontmatterValue(args.parsed.frontmatter, 'repository') ?? - getFrontmatterValue(args.parsed.frontmatter, 'repo') + getFrontmatterValue(args.parsed.frontmatter, 'repo'), + ) const skillId = await ctx.db.insert('skills', { slug: args.slug, displayName: args.displayName, @@ -746,11 +747,11 @@ export const insertVersion = internalMutation({ const latestBefore = skill.latestVersionId - const updatedRepoUrl = + const updatedRepoUrl = parseGitHubRepoUrl( getFrontmatterValue(args.parsed.frontmatter, 'homepage') ?? getFrontmatterValue(args.parsed.frontmatter, 'repository') ?? - getFrontmatterValue(args.parsed.frontmatter, 'repo') ?? - skill.repoUrl + getFrontmatterValue(args.parsed.frontmatter, 'repo'), + ) ?? skill.repoUrl await ctx.db.patch(skill._id, { displayName: args.displayName, @@ -866,6 +867,24 @@ function clampInt(value: number, min: number, max: number) { return Math.min(max, Math.max(min, rounded)) } +const GITHUB_REPO_RE = /^https:\/\/github\.com\/[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+\/?$/ + +/** + * Validate and normalize a repo URL. Only accepts GitHub repository URLs + * (https://github.com/owner/repo) to prevent malicious links. + * Returns the validated URL or undefined if invalid/missing. + */ +function parseGitHubRepoUrl(raw: string | undefined): string | undefined { + if (!raw) return undefined + const trimmed = raw.trim().replace(/\/+$/, '') + if (!GITHUB_REPO_RE.test(trimmed + '/') && !GITHUB_REPO_RE.test(trimmed)) { + // Not a valid GitHub repo URL — silently ignore + return undefined + } + // Normalize: strip trailing slash + return trimmed +} + async function findCanonicalSkillForFingerprint( ctx: { db: MutationCtx['db'] }, fingerprint: string, diff --git a/src/components/SkillDetailPage.tsx b/src/components/SkillDetailPage.tsx index d3bd3d2..bcb4c1e 100644 --- a/src/components/SkillDetailPage.tsx +++ b/src/components/SkillDetailPage.tsx @@ -205,7 +205,7 @@ export function SkillDetailPage({ {skill.repoUrl ? (
- {skill.repoUrl.includes('github.com') ? '🔗 GitHub' : '🔗 Repository'} + 🔗 GitHub
) : null}