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:
1 parent
02fa639657
commit
3fea39feb9
10 files changed
+375
-27
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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" +
|
||||
"\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)
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user