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 ? (
+
+ ) : 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 ? (
) : null}