Show a Hugging Face repository's model card on the mirror page

Opening a repository shows its model card, rendered from markdown, with
its base model, license and languages above it; the listing names each
repository's base model and license under its name. The card is a
stranger's text: raw HTML and dangerous links are dropped, images are
shown as links rather than loaded, and relative links point into the
repository.

Adds github.com/yuin/goldmark for the markdown.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-09-28 22:03:19 +02:00
1 parent 02fa639657
commit 3fea39feb9
10 files changed
+375 -27

No files matched your search

+1
View File
@@ -15,6 +15,7 @@ require (
github.com/prometheus/client_model v0.6.3
github.com/prometheus/common v0.71.0
github.com/spf13/cobra v1.10.2
github.com/yuin/goldmark v1.8.6
golang.org/x/crypto v0.57.0
golang.org/x/net v0.59.0
golang.org/x/sync v0.23.0
+2
View File
@@ -77,6 +77,8 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
+135 -25
View File
@@ -267,6 +267,16 @@ type Repo struct {
// repository's weights, empty for a repository with none. What decides
// whether llama.cpp can load it, known before anything is downloaded.
Architecture string
// From the model card's header: what somebody asks before reading the
// rest of it.
License string
BaseModel []string
Languages []string
// Card is the model card's text, markdown, without its header. Empty when
// the repository has none or it could not be read — a repository is
// still worth fetching from without its README.
Card string
}
// ShortRevision is the revision as a person would write it.
@@ -291,10 +301,15 @@ func (c *Client) Repo(ctx context.Context, id string) (Repo, error) {
GGUF *struct {
Architecture string `json:"architecture"`
} `json:"gguf"`
CardData struct {
License string `json:"license"`
BaseModel stringish `json:"base_model"`
Language stringish `json:"language"`
} `json:"cardData"`
}
// Expanded to what this reads: asking for gguf alone would leave out the
// revision and the gate.
if err := c.get(ctx, "/api/models/"+id+"?expand[]=sha&expand[]=gated&expand[]=gguf", &meta); err != nil {
if err := c.get(ctx, "/api/models/"+id+"?expand[]=sha&expand[]=gated&expand[]=gguf&expand[]=cardData", &meta); err != nil {
return Repo{}, err
}
@@ -317,26 +332,112 @@ func (c *Client) Repo(ctx context.Context, id string) (Repo, error) {
return Repo{}, err
}
r := Repo{ID: meta.ID, Revision: meta.SHA, Gated: meta.Gated}
r := Repo{ID: meta.ID, Revision: meta.SHA, Gated: meta.Gated,
License: meta.CardData.License, BaseModel: meta.CardData.BaseModel,
Languages: meta.CardData.Language}
if meta.GGUF != nil {
r.Architecture = meta.GGUF.Architecture
}
if r.ID == "" {
r.ID = id
}
hasCard := false
for _, e := range tree {
if e.Type != "file" {
continue
}
if e.Path == "README.md" {
hasCard = true
}
f := File{Path: e.Path, Bytes: e.Size}
if e.LFS != nil {
f.Bytes, f.SHA256 = e.LFS.Size, e.LFS.OID
}
r.Files = append(r.Files, f)
}
if hasCard {
// Best effort: the files are what the page is for.
if b, err := c.raw(ctx, "/"+r.ID+"/raw/"+rev+"/README.md", maxCard); err == nil {
r.Card = stripFrontMatter(string(b))
}
}
return r, nil
}
// maxCard is as much of a model card as is read. Most are a few kilobytes;
// the cap is for the one that is not.
const maxCard = 256 << 10
// stripFrontMatter drops a card's YAML header, which the registry already
// hands over as cardData and which reads as noise at the top of the text.
func stripFrontMatter(s string) string {
s = strings.TrimPrefix(s, "\ufeff")
if !strings.HasPrefix(s, "---") {
return strings.TrimSpace(s)
}
rest := s[3:]
if i := strings.Index(rest, "\n---"); i >= 0 {
rest = rest[i+4:]
if j := strings.IndexByte(rest, '\n'); j >= 0 {
rest = rest[j+1:]
} else {
rest = ""
}
return strings.TrimSpace(rest)
}
return strings.TrimSpace(s)
}
// stringish is a card field written as one string or a list of them.
type stringish []string
// UnmarshalJSON accepts a string, a list of strings, or anything else as
// nothing: a card header is free-form YAML, and one odd field should not
// cost the page its file list.
func (v *stringish) UnmarshalJSON(b []byte) error {
var one string
if json.Unmarshal(b, &one) == nil {
if one != "" {
*v = []string{one}
}
return nil
}
var many []string
if json.Unmarshal(b, &many) == nil {
*v = many
}
return nil
}
// BaseModel is the model a repository was made from, read from its tags:
// "base_model:quantized:Qwen/Qwen3-8B" and its kin. Empty when not said.
func (m Model) BaseModel() string {
for _, t := range m.Tags {
if !strings.HasPrefix(t, "base_model:") {
continue
}
rest := strings.TrimPrefix(t, "base_model:")
// The relation is optional: base_model:X or base_model:quantized:X.
if i := strings.Index(rest, ":"); i >= 0 && !strings.Contains(rest[:i], "/") {
rest = rest[i+1:]
}
if rest != "" {
return rest
}
}
return ""
}
// License is the license tag, empty when there is none.
func (m Model) License() string {
for _, t := range m.Tags {
if strings.HasPrefix(t, "license:") {
return strings.TrimPrefix(t, "license:")
}
}
return ""
}
// DownloadURL is where the mirror should fetch a file from.
//
// Pinned to the revision the listing came from. "main" would mean the mirror
@@ -354,38 +455,47 @@ func (r Repo) DownloadURL(base, path string) string {
}
func (c *Client) get(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
body, err := c.raw(ctx, path, 4<<20)
if err != nil {
return err
}
resp, err := c.hc.Do(req)
if err != nil {
return fmt.Errorf("reaching the model registry: %w", err)
}
defer resp.Body.Close()
// Capped: this is a third party, and a page that renders what it sends
// should not be handed a gigabyte of it.
body, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if err != nil {
return err
}
switch {
case resp.StatusCode == http.StatusNotFound:
return fmt.Errorf("the registry has nothing at %s", path)
case resp.StatusCode == http.StatusUnauthorized, resp.StatusCode == http.StatusForbidden:
// Said plainly, because the next thing somebody does is check their
// network. It is the repository, not the connection.
return fmt.Errorf("the registry refused this anonymously; the repository is gated or private")
case resp.StatusCode >= 300:
return fmt.Errorf("the registry answered %d", resp.StatusCode)
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("the registry's answer was not the JSON expected: %w", err)
}
return nil
}
// raw reads a path from the registry, at most limit bytes of it.
func (c *Client) raw(ctx context.Context, path string, limit int64) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
if err != nil {
return nil, err
}
resp, err := c.hc.Do(req)
if err != nil {
return nil, fmt.Errorf("reaching the model registry: %w", err)
}
defer resp.Body.Close()
// Capped: this is a third party, and a page that renders what it sends
// should not be handed a gigabyte of it.
body, err := io.ReadAll(io.LimitReader(resp.Body, limit))
if err != nil {
return nil, err
}
switch {
case resp.StatusCode == http.StatusNotFound:
return nil, fmt.Errorf("the registry has nothing at %s", path)
case resp.StatusCode == http.StatusUnauthorized, resp.StatusCode == http.StatusForbidden:
// Said plainly, because the next thing somebody does is check their
// network. It is the repository, not the connection.
return nil, fmt.Errorf("the registry refused this anonymously; the repository is gated or private")
case resp.StatusCode >= 300:
return nil, fmt.Errorf("the registry answered %d", resp.StatusCode)
}
return body, nil
}
// shardPattern matches the "-00002-of-00004" a split file carries before its
// extension. Five digits in practice, but not required: the count is what
// matters and a repository that used three would otherwise list every part as
+36
View File
@@ -230,3 +230,39 @@ func TestExploringAsksForWhatThePageFilters(t *testing.T) {
t.Errorf("the registry was asked %d times", calls)
}
}
func TestARepositoryCarriesItsCardWithoutTheHeader(t *testing.T) {
c := registry(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/api/models/q/m/tree/"):
_, _ = w.Write([]byte(`[{"type":"file","path":"README.md","size":40},{"type":"file","path":"m.gguf","size":1}]`))
case r.URL.Path == "/api/models/q/m":
_, _ = w.Write([]byte(`{"id":"q/m","sha":"abc","cardData":{"license":"apache-2.0","base_model":"Qwen/Qwen2.5-0.5B","language":["en","tr"]}}`))
case r.URL.Path == "/q/m/raw/abc/README.md":
_, _ = w.Write([]byte("---\nlicense: apache-2.0\n---\n\n# M\n\nA model.\n"))
default:
http.NotFound(w, r)
}
})
got, err := c.Repo(t.Context(), "q/m")
if err != nil {
t.Fatal(err)
}
if got.Card != "# M\n\nA model." {
t.Errorf("card = %q", got.Card)
}
if got.License != "apache-2.0" || len(got.BaseModel) != 1 || got.BaseModel[0] != "Qwen/Qwen2.5-0.5B" ||
len(got.Languages) != 2 {
t.Errorf("card header = %+v", got)
}
}
func TestAListingNamesTheBaseModelAndLicenseFromItsTags(t *testing.T) {
m := Model{Tags: []string{"gguf", "base_model:quantized:Qwen/Qwen3-8B", "base_model:Qwen/Qwen3-8B", "license:apache-2.0"}}
if m.BaseModel() != "Qwen/Qwen3-8B" || m.License() != "apache-2.0" {
t.Errorf("base %q, license %q", m.BaseModel(), m.License())
}
if (Model{Tags: []string{"base_model:org/x"}}).BaseModel() != "org/x" {
t.Error("a base model with no relation should read as itself")
}
}
+14 -1
View File
@@ -122,7 +122,11 @@ type adminMirrorData struct {
Query string
Explore *explorer
// Back is the listing an open repository was reached from.
Back template.URL
Back template.URL
// Card is the open repository's model card, rendered.
Card template.HTML
// RepoURL is the open repository on the registry's own site.
RepoURL string
Repo *hf.Repo
RepoSets []repoSet
RegistryErr string
@@ -466,6 +470,8 @@ func (p *AccountPages) explore(r *http.Request, d *adminMirrorData) {
return
}
d.Repo = &got
d.RepoURL = strings.TrimSuffix(got.DownloadURL("", ""), "/resolve/"+revOrMain(got.Revision)+"/")
d.Card = renderCard(got.Card, d.RepoURL+"/blob/"+revOrMain(got.Revision)+"/")
var weights []hf.File
for _, f := range got.Files {
if !f.IsWeights() {
@@ -532,3 +538,10 @@ func (p *AccountPages) checkSource(ctx context.Context, source string) gguf.Verd
}
return gguf.Check(ctx, url, p.llamaVersion)
}
func revOrMain(rev string) string {
if rev == "" {
return "main"
}
return rev
}
+17
View File
@@ -97,3 +97,20 @@ func TestAnAdapterIsNotListedAsAModel(t *testing.T) {
t.Errorf("adapters should be left out and small models kept: %s", body)
}
}
func TestARepositoryShowsItsCard(t *testing.T) {
reg := &fakeRegistry{repo: hf.Repo{
ID: "Qwen/X-GGUF", Revision: "abc123def456", License: "apache-2.0",
BaseModel: []string{"Qwen/X"}, Card: "# X\n\nA **small** model. See [the license](LICENSE).",
Files: []hf.File{{Path: "x.gguf", Bytes: 10}},
}}
body := mirrorGet(t, mirrorPageLlama(t, MirrorDeps{Mirror: &fakeMirror{}, Registry: reg}),
"/admin/mirror?repo=Qwen/X-GGUF").Body.String()
for _, want := range []string{"<strong>small</strong>", "license apache-2.0", "<code>Qwen/X</code>",
`href="https://huggingface.co/Qwen/X-GGUF/blob/abc123def456/LICENSE"`,
`href="https://huggingface.co/Qwen/X-GGUF"`} {
if !strings.Contains(body, want) {
t.Errorf("missing %s", want)
}
}
}
+114
View File
@@ -0,0 +1,114 @@
package web
import (
"bytes"
"html/template"
"net/url"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// renderCard turns a model card into HTML for the mirror page.
//
// The card is a stranger's text, so what it may do is narrow:
// - raw HTML is dropped, which goldmark does unless told otherwise, and
// links to javascript: and the like are emptied, which it also does;
// - images are not loaded but shown as links, because an admin page that
// fetches whatever a README points at tells a stranger who is looking;
// - relative links, to the repository's own LICENSE and files, are made to
// point at the repository rather than at this page.
func renderCard(card, repoBase string) template.HTML {
if strings.TrimSpace(card) == "" {
return ""
}
md := goldmark.New(
goldmark.WithExtensions(extension.GFM),
goldmark.WithParserOptions(parser.WithASTTransformers(
util.Prioritized(cardLinks{base: repoBase}, 100))),
goldmark.WithRendererOptions(
renderer.WithNodeRenderers(util.Prioritized(imageAsLink{}, 100))),
)
var out bytes.Buffer
if err := md.Convert([]byte(card), &out); err != nil {
return ""
}
// Safe by construction: goldmark in its default, safe mode, with the two
// changes above. Not something a template may escape again.
return template.HTML(out.String()) //nolint:gosec
}
// cardLinks resolves a card's relative links against its repository.
type cardLinks struct{ base string }
func (c cardLinks) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) {
base, err := url.Parse(c.base)
if err != nil || c.base == "" {
return
}
_ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
var dest *[]byte
switch l := n.(type) {
case *ast.Link:
dest = &l.Destination
case *ast.Image:
dest = &l.Destination
default:
return ast.WalkContinue, nil
}
u, err := url.Parse(string(*dest))
if err != nil || u.IsAbs() || strings.HasPrefix(string(*dest), "#") {
return ast.WalkContinue, nil
}
*dest = []byte(base.ResolveReference(u).String())
return ast.WalkContinue, nil
})
}
// imageAsLink renders an image as a link to it, named by its alt text.
type imageAsLink struct{}
func (imageAsLink) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindImage, func(w util.BufWriter, src []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
img := n.(*ast.Image)
alt := strings.TrimSpace(string(nodeText(img, src)))
if alt == "" {
alt = "image"
}
dest := img.Destination
if html.IsDangerousURL(dest) {
_, _ = w.WriteString(`<span class="img">[` + template.HTMLEscapeString(alt) + `]</span>`)
return ast.WalkSkipChildren, nil
}
_, _ = w.WriteString(`<a class="img" rel="noopener noreferrer" href="` +
template.HTMLEscapeString(string(util.URLEscape(dest, true))) + `">[` +
template.HTMLEscapeString(alt) + `]</a>`)
return ast.WalkSkipChildren, nil
})
}
// nodeText is the plain text beneath a node.
func nodeText(n ast.Node, src []byte) []byte {
var b bytes.Buffer
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
if t, ok := c.(*ast.Text); ok {
b.Write(t.Segment.Value(src))
continue
}
b.Write(nodeText(c, src))
}
return b.Bytes()
}
+30
View File
@@ -0,0 +1,30 @@
package web
import (
"strings"
"testing"
)
func TestAModelCardCannotRunOrLoadAnything(t *testing.T) {
card := "# Qwen\n\nA **small** model.\n\n<script>alert(1)</script>\n\n" +
"<img src=x onerror=alert(1)>\n\n[click](javascript:alert(1))\n\n" +
"![benchmark](https://tracker.example/pixel.png)\n\n[license](LICENSE)\n\n" +
"| a | b |\n|---|---|\n| 1 | 2 |\n"
got := string(renderCard(card, "https://huggingface.co/Qwen/X/blob/abc/"))
for _, bad := range []string{"<script", "onerror", "javascript:", "<img"} {
if strings.Contains(got, bad) {
t.Errorf("the card rendered %q: %s", bad, got)
}
}
// Shown as a link to the picture, not fetched by the page.
if !strings.Contains(got, `href="https://tracker.example/pixel.png">[benchmark]</a>`) {
t.Errorf("an image should become a link: %s", got)
}
if !strings.Contains(got, `href="https://huggingface.co/Qwen/X/blob/abc/LICENSE"`) {
t.Errorf("a relative link should point into the repository: %s", got)
}
if !strings.Contains(got, "<strong>small</strong>") || !strings.Contains(got, "<table>") {
t.Errorf("the markdown should render, tables included: %s", got)
}
}
+12
View File
@@ -476,3 +476,15 @@ table.explore .cannot { color: var(--bad); }
.tag { display: inline-block; font-size: .7rem; font-weight: 600; color: var(--muted);
border: 1px solid var(--line); border-radius: 999px; padding: 0 .4rem; margin-left: .3rem; vertical-align: 1px; }
.tag.new { color: var(--accent); border-color: var(--accent); }
table.explore .from { font-size: .75rem; color: var(--muted); margin-top: .15rem; overflow-wrap: anywhere; }
.repo-meta { display: flex; flex-wrap: wrap; gap: .25rem 1rem; font-size: .85rem; color: var(--muted); margin: .25rem 0 .75rem; }
/* A README is written for its own site. Held to a box so the file list, which
is what this page is for, stays within reach below it. */
details.model-card { border: 1px solid var(--line); border-radius: 8px; background: var(--card); margin: 0 0 1rem; }
details.model-card > summary { cursor: pointer; padding: .5rem .8rem; font-weight: 600; font-size: .9rem; }
details.model-card .card-body { max-height: 28rem; overflow: auto; padding: 0 1rem 1rem; font-size: .88rem; line-height: 1.55; }
.card-body h1 { font-size: 1.25rem; } .card-body h2 { font-size: 1.1rem; } .card-body h3, .card-body h4 { font-size: .95rem; }
.card-body pre { overflow-x: auto; padding: .6rem .75rem; background: var(--bg); border-radius: 6px; font-size: .8rem; }
.card-body table { width: auto; max-width: 100%; display: block; overflow-x: auto; font-size: .8rem; }
.card-body td, .card-body th { padding: .25rem .5rem; }
.card-body a.img { font-size: .8rem; }
+14 -1
View File
@@ -216,7 +216,8 @@ document.addEventListener('click', function (e) {
<th class="num">Downloads</th><th class="num">Likes</th><th class="num">Updated</th></tr>
{{range .Rows}}<tr>
<td><a href="{{.Href}}"><code>{{.ID}}</code></a>
{{if .New}}<span class="tag new">new</span>{{end}}{{if .Gated.Is}}<span class="tag">gated</span>{{end}}</td>
{{if .New}}<span class="tag new">new</span>{{end}}{{if .Gated.Is}}<span class="tag">gated</span>{{end}}
{{if or .BaseModel .License}}<div class="from">{{with .BaseModel}}from {{.}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}</div>{{end}}</td>
<td class="num">{{.ParamsLabel}}</td>
<td>{{if .Architecture}}<span{{if not .Verdict.Servable}} class="cannot"{{end}} title="{{.Verdict.Reason}}">{{.Architecture}}{{if .Verdict.Checked}}{{if .Verdict.Servable}} ✓{{else}} ✗{{end}}{{end}}</span>{{else}}—{{end}}</td>
<td class="num">{{.ContextLabel}}</td>
@@ -239,6 +240,18 @@ directory, or give the mirror a token.</p>
{{if .Repo}}
<p><a href="{{.Back}}">← back to the list</a></p>
<h3>{{.Repo.ID}} <span class="opt">at {{.Repo.ShortRevision}}</span></h3>
<p class="repo-meta">
{{with .Repo.BaseModel}}<span>from {{range $i, $b := .}}{{if $i}}, {{end}}<code>{{$b}}</code>{{end}}</span>{{end}}
{{with .Repo.License}}<span>license {{.}}</span>{{end}}
{{with .Repo.Languages}}<span>{{join .}}</span>{{end}}
<span><a href="{{.RepoURL}}" rel="noopener noreferrer">on Hugging Face ↗</a></span>
</p>
{{if .Card}}
<details class="model-card" open>
<summary>Model card</summary>
<div class="card-body">{{.Card}}</div>
</details>
{{end}}
{{if .RepoSets}}
<table>
<tr><th>What</th><th>Size</th><th>Published checksum</th><th></th></tr>