53
stars.exists?(user_id: user.id)
54
end
55
56
+ # ── SEO / social-card metadata ──────────────────────────────────────────
57
+ #
58
+ # Common source-file extensions → { name, color }. Colors mirror GitHub
59
+ # Linguist so the language dot reads as expected. Intentionally small: the
60
+ # goal is a good guess for the share card, not exhaustive coverage.
61
+ LANGUAGE_BY_EXT = {
62
+ "rb" => [ "Ruby", "#701516" ], "py" => [ "Python", "#3572A5" ],
63
+ "js" => [ "JavaScript", "#f1e05a" ], "mjs" => [ "JavaScript", "#f1e05a" ],
64
+ "ts" => [ "TypeScript", "#3178c6" ], "tsx" => [ "TypeScript", "#3178c6" ],
65
+ "jsx" => [ "JavaScript", "#f1e05a" ], "go" => [ "Go", "#00ADD8" ],
66
+ "rs" => [ "Rust", "#dea584" ], "java" => [ "Java", "#b07219" ],
67
+ "kt" => [ "Kotlin", "#A97BFF" ], "swift" => [ "Swift", "#F05138" ],
68
+ "c" => [ "C", "#555555" ], "h" => [ "C", "#555555" ],
69
+ "cpp" => [ "C++", "#f34b7d" ], "cc" => [ "C++", "#f34b7d" ], "hpp" => [ "C++", "#f34b7d" ],
70
+ "cs" => [ "C#", "#178600" ], "php" => [ "PHP", "#4F5D95" ],
71
+ "rb.erb" => [ "Ruby", "#701516" ], "erb" => [ "HTML", "#e34c26" ],
72
+ "html" => [ "HTML", "#e34c26" ], "css" => [ "CSS", "#563d7c" ],
73
+ "scss" => [ "SCSS", "#c6538c" ], "vue" => [ "Vue", "#41b883" ],
74
+ "ex" => [ "Elixir", "#6e4a7e" ], "exs" => [ "Elixir", "#6e4a7e" ],
75
+ "scala" => [ "Scala", "#c22d40" ], "sh" => [ "Shell", "#89e051" ],
76
+ "lua" => [ "Lua", "#000080" ], "dart" => [ "Dart", "#00B4AB" ],
77
+ "r" => [ "R", "#198CE7" ], "jl" => [ "Julia", "#a270ba" ],
78
+ "ipynb" => [ "Jupyter Notebook", "#DA5B0B" ], "sql" => [ "SQL", "#e38c00" ],
79
+ "md" => [ "Markdown", "#083fa1" ]
80
+ }.freeze
81
+
82
+ WEIGHT_EXTS = %w[safetensors gguf bin onnx pt pth h5 ckpt].freeze
83
+
84
+ # A short, human description for titles/social cards, with sane fallbacks so
85
+ # tags are never empty even when the repo has no description.
86
+ def seo_description
87
+ return description.strip if description.present?
88
+
89
+ if model?
90
+ "#{name} — an open-weights model by @#{user.username}, with a full model " \
91
+ "card and LFS-backed weights on siGit."
92
+ else
93
+ "#{name} — a Git repository by @#{user.username} on siGit, Git hosting " \
94
+ "built for AI workflows."
95
+ end
96
+ end
97
+
98
+ # Best-guess primary language as [name, color], or nil. For model repos that
99
+ # carry weight files we surface the weight format instead of a code language.
100
+ # Memoized; walks the tree once on first call.
101
+ def primary_language
102
+ return @primary_language if defined?(@primary_language)
103
+ @primary_language = detect_primary_language
104
+ end
105
+
106
+ # Opaque content version for the repo's social card — changes whenever the
107
+ # tip commit, name, description, or star count changes, so cached cards stay
108
+ # correct without manual busting.
109
+ def og_version
110
+ head = initialized? ? GitRepositoryService.head_sha(disk_path, default_branch) : nil
111
+ Digest::SHA256.hexdigest(
112
+ [ id, name, description, stars_count, head, updated_at.to_i ].join("|")
113
+ )[0, 16]
114
+ end
115
+
116
# Parses the Hugging Face-style YAML front-matter from the README (the "model
117
# card") on the default branch. Memoized per instance; returns a ModelCard
118
# even when there is no front-matter so callers can use it unconditionally.
126
127
private
128
129
+ def detect_primary_language
130
+ return nil unless initialized?
131
+ files = GitRepositoryService.tree_filenames(disk_path, default_branch)
132
+ return nil if files.empty?
133
+
134
+ counts = Hash.new(0)
135
+ files.each do |f|
136
+ ext = File.extname(f).delete_prefix(".").downcase
137
+ next if ext.empty?
138
+ if WEIGHT_EXTS.include?(ext)
139
+ counts[[ "#{ext.upcase} weights", "#92D3A2" ]] += 1
140
+ elsif (lang = LANGUAGE_BY_EXT[ext])
141
+ counts[lang] += 1
142
+ end
143
+ end
144
+ counts.max_by { |_lang, n| n }&.first
145
+ end
146
+
147
def no_consecutive_dots_in_name
148
return if name.blank?
149
errors.add(:name, "must not contain consecutive dots") if name.include?("..")