diff --git a/modules/citation/cff.go b/modules/citation/cff.go
new file mode 100644
index 00000000000..3074a50da3d
--- /dev/null
+++ b/modules/citation/cff.go
@@ -0,0 +1,468 @@
+// Copyright 2026 The Gitea Authors.
+// Copyright 2018-2024 The Ruby Citation File Format Developers. Licensed under the Apache License, Version 2.0
+// SPDX-License-Identifier: Apache-2.0
+
+// Package citation formats CITATION.cff files, ported from the formatters of ruby-cff 1.3.0
+package citation
+
+import (
+ "cmp"
+ "maps"
+ "regexp"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+ "unicode"
+ "unicode/utf8"
+
+ "gitea.dev/modules/util"
+
+ "go.yaml.in/yaml/v4"
+ "golang.org/x/text/runes"
+ "golang.org/x/text/transform"
+ "golang.org/x/text/unicode/norm"
+)
+
+type date struct{ time.Time }
+
+var dateSeparators = strings.NewReplacer("/", "-", ". ", " ", ".", "-", ",", "")
+
+func (d *date) UnmarshalYAML(node *yaml.Node) error {
+ value := dateSeparators.Replace(node.Value)
+ for _, layout := range []string{"2006-1-2", "2-1-2006", "2 Jan 2006", "2 January 2006", "Jan 2 2006", "January 2 2006"} {
+ if parsed, err := time.Parse(layout, value); err == nil {
+ d.Time = parsed
+ break
+ }
+ }
+ return nil
+}
+
+type license string
+
+func (l *license) UnmarshalYAML(node *yaml.Node) error {
+ *l = license(node.Value)
+ if node.Kind != yaml.ScalarNode {
+ *l = license(inspectNode(node))
+ }
+ return nil
+}
+
+type actor struct {
+ Name string `yaml:"name"`
+ Alias string `yaml:"alias"`
+ FamilyNames string `yaml:"family-names"`
+ GivenNames string `yaml:"given-names"`
+ NameParticle string `yaml:"name-particle"`
+ NameSuffix string `yaml:"name-suffix"`
+ Affiliation string `yaml:"affiliation"`
+ City string `yaml:"city"`
+ Region string `yaml:"region"`
+ Country string `yaml:"country"`
+ DateStart date `yaml:"date-start"`
+ DateEnd date `yaml:"date-end"`
+}
+
+func (a *actor) UnmarshalYAML(node *yaml.Node) error {
+ switch node.Kind {
+ case yaml.ScalarNode:
+ a.Name = node.Value
+ return nil
+ case yaml.SequenceNode:
+ return nil
+ }
+ type plainActor actor
+ return node.Load((*plainActor)(a), yaml.WithUniqueKeys(false))
+}
+
+type metadata struct {
+ Type string `yaml:"type"`
+ Title string `yaml:"title"`
+ Authors []actor `yaml:"authors"`
+ Version string `yaml:"version"`
+ DOI string `yaml:"doi"`
+ URL string `yaml:"url"`
+ RepositoryCode string `yaml:"repository-code"`
+ License license `yaml:"license"`
+ DateReleased date `yaml:"date-released"`
+}
+
+type reference struct {
+ metadata `yaml:",inline"`
+ isTopLevel bool
+ Editors []actor `yaml:"editors"`
+ EditorsSeries []actor `yaml:"editors-series"`
+ DatePublished date `yaml:"date-published"`
+ Year string `yaml:"year"`
+ Month string `yaml:"month"`
+ Status string `yaml:"status"`
+ Journal string `yaml:"journal"`
+ Volume string `yaml:"volume"`
+ Issue string `yaml:"issue"`
+ Start string `yaml:"start"`
+ End string `yaml:"end"`
+ ISBN string `yaml:"isbn"`
+ Notes string `yaml:"notes"`
+ CollectionTitle string `yaml:"collection-title"`
+ ThesisType string `yaml:"thesis-type"`
+ Publisher actor `yaml:"publisher"`
+ Institution *actor `yaml:"institution"`
+ Conference actor `yaml:"conference"`
+}
+
+// FormatCFF returns the APA and BibTeX citations of a CITATION.cff file, both empty if it has no title or authors
+func FormatCFF(content string) (apa, bibtex string) {
+ var node yaml.Node
+ if yaml.Unmarshal([]byte(content), &node) != nil {
+ return "", ""
+ }
+ retagTimestamps(&node)
+ var file struct {
+ TopLevel metadata `yaml:",inline"`
+ PreferredCitation *reference `yaml:"preferred-citation"`
+ }
+ if node.Load(&file, yaml.WithUniqueKeys(false)) != nil {
+ return "", ""
+ }
+ ref := file.PreferredCitation
+ if ref == nil {
+ ref = &reference{metadata: file.TopLevel, isTopLevel: true}
+ }
+ if ref.Title == "" || len(ref.Authors) == 0 {
+ return "", ""
+ }
+ return ref.formatAPA(), ref.formatBibTeX()
+}
+
+func retagTimestamps(node *yaml.Node) {
+ if node.ShortTag() == "!!timestamp" {
+ node.Tag = "!!str"
+ }
+ for _, child := range node.Content {
+ retagTimestamps(child)
+ }
+}
+
+func inspectNode(node *yaml.Node) string {
+ var parts []string
+ switch node.Kind {
+ case yaml.AliasNode:
+ if node.Alias.Kind == yaml.ScalarNode { // collection aliases can be recursive
+ return inspectNode(node.Alias)
+ }
+ case yaml.SequenceNode:
+ for _, child := range node.Content {
+ parts = append(parts, inspectNode(child))
+ }
+ return "[" + strings.Join(parts, ", ") + "]"
+ case yaml.MappingNode:
+ for i := 0; i < len(node.Content); i += 2 {
+ parts = append(parts, inspectNode(node.Content[i])+" => "+inspectNode(node.Content[i+1]))
+ }
+ return "{" + strings.Join(parts, ", ") + "}"
+ }
+ if node.ShortTag() == "!!null" {
+ return "nil"
+ }
+ return strconv.Quote(node.Value)
+}
+
+var statusNotes = map[string]string{
+ "advance-online": "Advance online publication",
+ "in-preparation": "Manuscript in preparation.",
+ "submitted": "Manuscript submitted for publication.",
+}
+
+func joinNonEmpty(sep string, parts ...string) string {
+ return strings.Join(util.SliceRemoveAll(parts, ""), sep)
+}
+
+func (r *reference) conferenceDates() (start, end date) {
+ if r.Type == "conference-paper" {
+ return r.Conference.DateStart, r.Conference.DateEnd
+ }
+ return date{}, date{}
+}
+
+func (r *reference) monthAndYear() (month, year string) {
+ when, _ := r.conferenceDates()
+ if when.IsZero() {
+ if r.Status == "in-press" {
+ return "", "in press"
+ }
+ if r.Year != "" {
+ return r.Month, r.Year
+ }
+ when = cmp.Or(r.DateReleased, r.DatePublished)
+ }
+ if when.IsZero() {
+ return "", ""
+ }
+ return strconv.Itoa(int(when.Month())), strconv.Itoa(when.Year())
+}
+
+func (r *reference) pages(dash string) string {
+ if r.Start == "" || r.End == "" || r.Start == r.End {
+ return r.Start
+ }
+ return r.Start + dash + r.End
+}
+
+func (r *reference) volume() string {
+ if r.Volume == "" || r.Issue == "" {
+ return r.Volume
+ }
+ return r.Volume + "(" + r.Issue + ")"
+}
+
+func (r *reference) institution() string {
+ if r.Institution == nil {
+ return r.Authors[0].Affiliation
+ }
+ return r.Institution.Name
+}
+
+func (r *reference) formatAPA() string {
+ authors := make([]string, 0, len(r.Authors))
+ for _, author := range r.Authors {
+ authors = append(authors, apaAuthor(author))
+ }
+ date := r.apaDate()
+ if date != "" {
+ date = "(" + date + ")"
+ }
+ version := ""
+ if r.Version != "" {
+ version = " (Version " + r.Version + ")"
+ }
+ url := cmp.Or(r.RepositoryCode, r.URL)
+ if r.DOI != "" {
+ url = "https://doi.org/" + r.DOI
+ }
+ return joinNonEmpty(". ", combineAuthors(authors), date, r.Title+version+r.apaTypeLabel(), r.apaPublicationData(), url)
+}
+
+func apaAuthor(author actor) string {
+ if author.Name != "" {
+ return author.Name
+ }
+ name := cmp.Or(author.FamilyNames, author.GivenNames, author.Alias)
+ if author.FamilyNames != "" && author.GivenNames != "" {
+ name += ", " + initials(author.GivenNames) + "."
+ }
+ if author.NameParticle != "" {
+ name = author.NameParticle + " " + name
+ }
+ if author.NameSuffix != "" {
+ name += ", " + author.NameSuffix
+ }
+ return name
+}
+
+func initials(names string) string {
+ parts := splitWords(names)
+ for i, part := range parts {
+ first, _ := utf8.DecodeRuneInString(part)
+ parts[i] = util.ToTitleCase(string(first))
+ }
+ return strings.Join(parts, ". ")
+}
+
+func splitWords(text string) []string {
+ return strings.FieldsFunc(text, func(char rune) bool { return char <= unicode.MaxASCII && unicode.IsSpace(char) })
+}
+
+func combineAuthors(authors []string) string {
+ if len(authors) == 1 {
+ return strings.TrimSuffix(authors[0], ".")
+ }
+ return strings.TrimSuffix(strings.Join(authors[:len(authors)-1], ", ")+", & "+authors[len(authors)-1], ".")
+}
+
+func (r *reference) apaDate() string {
+ start, end := r.conferenceDates()
+ if start.IsZero() || end.IsZero() || !start.Before(end.Time) {
+ _, year := r.monthAndYear()
+ return year
+ }
+ endLayout := "2"
+ if end.Month() != start.Month() {
+ endLayout = "January 2"
+ }
+ if end.Year() != start.Year() {
+ endLayout = "2006, " + endLayout
+ }
+ return start.Format("2006, January 2") + "–" + end.Format(endLayout)
+}
+
+func (r *reference) apaTypeLabel() string {
+ switch {
+ case strings.Contains(r.Type, "data"):
+ return " [Data set]"
+ case strings.Contains(r.Type, "conference"):
+ return " [Conference paper]"
+ case !r.isTopLevel && !strings.Contains(r.Type, "software"):
+ return ""
+ }
+ return " [Computer software]"
+}
+
+func (r *reference) apaPublicationData() string {
+ switch r.Type {
+ case "article":
+ return joinNonEmpty(", ", r.Journal, r.volume(), r.pages("–"), statusNotes[r.Status])
+ case "book":
+ return r.Publisher.Name
+ case "conference-paper":
+ return joinNonEmpty(", ", r.CollectionTitle, r.volume(), r.pages("–"))
+ case "report":
+ return r.institution()
+ case "phdthesis":
+ return "[" + cmp.Or(r.ThesisType, "Doctoral dissertation") + ", " + r.institution() + "]"
+ case "mastersthesis":
+ return "[" + cmp.Or(r.ThesisType, "Master's thesis") + ", " + r.institution() + "]"
+ case "unpublished":
+ return statusNotes[r.Status]
+ }
+ return ""
+}
+
+var bibtexTypeFields = map[string][]string{
+ "article": {"journal", "note", "number", "pages", "volume"},
+ "book": {"address", "editor", "isbn", "number", "pages", "publisher", "volume"},
+ "booklet": {"address"},
+ "inproceedings": {"address", "booktitle", "editor", "pages", "publisher", "series"},
+ "manual": {"address"},
+ "mastersthesis": {"address", "school", "type"},
+ "misc": {"pages"},
+ "phdthesis": {"address", "school", "type"},
+ "proceedings": {"address", "booktitle", "editor", "pages", "publisher", "series"},
+ "software": {"license", "version"},
+ "techreport": {"address", "institution", "number"},
+ "unpublished": {"note"},
+}
+
+var (
+ bibtexEscaper = strings.NewReplacer("&", `\&`, "%", `\%`, "$", `\$`, "#", `\#`, "_", `\_`, "{", `\{`, "}", `\}`)
+ keyLetters = strings.NewReplacer(
+ "Æ", "AE", "æ", "ae", "Ð", "D", "ð", "d", "Ø", "O", "ø", "o", "Þ", "Th", "þ", "th", "ß", "ss", "×", "x",
+ "Đ", "D", "đ", "d", "Ħ", "H", "ħ", "h", "ı", "i", "IJ", "IJ", "ij", "ij", "ĸ", "k", "Ŀ", "L", "ŀ", "l",
+ "Ł", "L", "ł", "l", "ʼn", "'n", "Ŋ", "NG", "ŋ", "ng", "Œ", "OE", "œ", "oe", "Ŧ", "T", "ŧ", "t",
+ )
+ keyUnsafeChars = regexp.MustCompile(`[^a-zA-Z0-9-]+`)
+)
+
+func keyToASCII() transform.Transformer {
+ return transform.Chain(
+ runes.Remove(runes.Predicate(func(char rune) bool {
+ return char > unicode.MaxASCII && (char < 'À' || char > 'ž') && char != 'ệ'
+ })),
+ norm.NFD,
+ runes.Remove(runes.Predicate(func(char rune) bool { return char > unicode.MaxASCII })),
+ )
+}
+
+func (r *reference) formatBibTeX() string {
+ place := r.Publisher
+ if r.Type == "conference-paper" {
+ place = r.Conference
+ }
+ editors := r.Editors
+ if len(editors) == 0 {
+ editors = r.EditorsSeries
+ }
+ typeFields := map[string]string{
+ "address": joinNonEmpty(", ", place.City, place.Region, place.Country),
+ "booktitle": bibtexEscaper.Replace(r.CollectionTitle),
+ "editor": bibtexActors(editors),
+ "institution": bibtexEscaper.Replace(r.institution()),
+ "isbn": bibtexEscaper.Replace(r.ISBN),
+ "journal": bibtexEscaper.Replace(r.Journal),
+ "license": bibtexEscaper.Replace(string(r.License)),
+ "note": statusNotes[r.Status],
+ "number": r.Issue,
+ "pages": r.pages("--"),
+ "publisher": bibtexEscaper.Replace(r.Publisher.Name),
+ "school": bibtexEscaper.Replace(r.institution()),
+ "series": bibtexEscaper.Replace(r.Conference.Name),
+ "type": r.ThesisType,
+ "version": bibtexEscaper.Replace(r.Version),
+ "volume": bibtexEscaper.Replace(r.Volume),
+ }
+ entryType := bibtexType(r.Type)
+ fields := map[string]string{
+ "author": bibtexActors(r.Authors),
+ "title": "{" + bibtexEscaper.Replace(r.Title) + "}",
+ "doi": bibtexEscaper.Replace(r.DOI),
+ }
+ for _, name := range bibtexTypeFields[entryType] {
+ fields[name] = typeFields[name]
+ }
+ month, year := r.monthAndYear()
+ if num, _ := strconv.Atoi(month); num >= 1 && num <= 12 {
+ fields["month"] = strings.ToLower(time.Month(num).String()[:3])
+ }
+ fields["year"] = year
+ fields["url"] = cmp.Or(r.RepositoryCode, r.URL)
+ fields["note"] = cmp.Or(fields["note"], r.Notes)
+ maps.DeleteFunc(fields, func(_, value string) bool { return value == "" })
+
+ lines := []string{bibtexKey(fields)}
+ for _, name := range slices.Sorted(maps.Keys(fields)) {
+ value := fields[name]
+ if name != "month" {
+ value = "{" + value + "}"
+ }
+ lines = append(lines, name+" = "+value)
+ }
+ return "@" + entryType + "{" + strings.Join(lines, ",\n") + "\n}"
+}
+
+func bibtexType(cffType string) string {
+ if cffType == "" || strings.Contains(cffType, "software") {
+ return "software"
+ }
+ switch cffType {
+ case "article", "book", "manual", "unpublished", "phdthesis", "mastersthesis":
+ return cffType
+ case "conference", "proceedings":
+ return "proceedings"
+ case "conference-paper":
+ return "inproceedings"
+ case "magazine-article", "newspaper-article":
+ return "article"
+ case "pamphlet":
+ return "booklet"
+ case "report":
+ return "techreport"
+ }
+ return "misc"
+}
+
+func bibtexActors(actors []actor) string {
+ names := make([]string, 0, len(actors))
+ for _, entry := range actors {
+ switch {
+ case entry.Name != "":
+ names = append(names, "{"+bibtexEscaper.Replace(entry.Name)+"}")
+ case entry.FamilyNames == "" && entry.GivenNames == "":
+ names = append(names, bibtexEscaper.Replace(entry.Alias))
+ default:
+ family := entry.FamilyNames
+ if entry.NameParticle != "" {
+ family = entry.NameParticle + " " + family
+ }
+ names = append(names, joinNonEmpty(", ", family, entry.NameSuffix, entry.GivenNames))
+ }
+ }
+ return strings.Join(names, " and ")
+}
+
+func bibtexKey(fields map[string]string) string {
+ author, _, _ := strings.Cut(fields["author"], ",")
+ titleWords := splitWords(fields["title"])
+ key := joinNonEmpty("_", author, strings.Join(titleWords[:min(3, len(titleWords))], "_"), fields["year"])
+ key, _, _ = transform.String(keyToASCII(), keyLetters.Replace(key))
+ return strings.Trim(keyUnsafeChars.ReplaceAllString(key, "_"), "_")
+}
diff --git a/modules/citation/cff_test.go b/modules/citation/cff_test.go
new file mode 100644
index 00000000000..077736a2554
--- /dev/null
+++ b/modules/citation/cff_test.go
@@ -0,0 +1,137 @@
+// Copyright 2026 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package citation
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFormatCFF(t *testing.T) {
+ cases := []struct{ cff, apa, bibtex string }{
+ {
+ cff: `title: Overridden
+message: &mit MIT
+authors: [{family-names: Haines, given-names: Robert}, {name: "The {curly_braces} Collective"}]
+title: "Software that uses the following symbols: &, %, $, #"
+version: 2024-01-16
+license: [*mit, Apache-2.0]
+date-released: 2024-01-16
+`,
+ apa: "Haines, R., & The {curly_braces} Collective. (2024). Software that uses the following symbols: &, %, $, # (Version 2024-01-16) [Computer software]",
+ bibtex: `@software{Haines_Software_that_uses_2024,
+author = {Haines, Robert and {The \{curly\_braces\} Collective}},
+license = {["MIT", "Apache-2.0"]},
+month = jan,
+title = {{Software that uses the following symbols: \&, \%, \$, \#}},
+version = {2024-01-16},
+year = {2024}
+}`,
+ },
+ {
+ cff: `authors:
+ - family-names: Smith
+ given-names: Arfon M.
+title: "Software citation principles"
+license: MIT
+preferred-citation:
+ authors:
+ - family-names: Smith
+ given-names: A. M.
+ - name: "FORCE11 Software Citation Working Group"
+ doi: "10.7717/peerj-cs.86"
+ journal: "PeerJ Computer Science"
+ month: 9
+ start: e86
+ title: "Software citation principles"
+ type: article
+ volume: 2
+ issue: 123
+ year: 2016
+ publisher:
+ - name: The Open Journal
+`,
+ apa: "Smith, A. M., & FORCE11 Software Citation Working Group. (2016). Software citation principles. PeerJ Computer Science, 2(123), e86. https://doi.org/10.7717/peerj-cs.86",
+ bibtex: `@article{Smith_Software_citation_principles_2016,
+author = {Smith, A. M. and {FORCE11 Software Citation Working Group}},
+doi = {10.7717/peerj-cs.86},
+journal = {PeerJ Computer Science},
+month = sep,
+number = {123},
+pages = {e86},
+title = {{Software citation principles}},
+volume = {2},
+year = {2016}
+}`,
+ },
+ {
+ cff: `preferred-citation:
+ type: conference-paper
+ version: 1.10
+ title: "Über tools"
+ authors:
+ - family-names: Ørsted
+ given-names: ǰan christian
+ name-particle: van
+ name-suffix: Jr.
+ collection-title: "Proceedings of X & Y"
+ conference:
+ name: "Conf_2020"
+ city: Berlin
+ country: DE
+ date-start: 30.06.2020
+ date-end: 2020-07-02
+ start: 10
+ end: 20
+ editors:
+ - affiliation: Press
+ editors-series:
+ - family-names: Editor
+ given-names: Eve
+`,
+ apa: "van Ørsted, J̌. C., Jr. (2020, June 30–July 2). Über tools (Version 1.10) [Conference paper]. Proceedings of X & Y, 10–20",
+ bibtex: `@inproceedings{van_Orsted_Uber_tools_2020,
+address = {Berlin, DE},
+author = {van Ørsted, Jr., ǰan christian},
+booktitle = {Proceedings of X \& Y},
+month = jun,
+pages = {10--20},
+series = {Conf\_2020},
+title = {{Über tools}},
+year = {2020}
+}`,
+ },
+ {
+ cff: `thesis: &thesis {type: phdthesis, title: Thesis}
+preferred-citation:
+ <<: *thesis
+ authors:
+ - family-names: Nguyễn
+ given-names: Sam
+ affiliation: "Uni_A"
+ institution: {city: Hanoi}
+ license: &loop [*loop]
+ status: in-press
+ notes: A note
+`,
+ apa: "Nguyễn, S. (in press). Thesis. [Doctoral dissertation, ]",
+ bibtex: `@phdthesis{Nguyn_Thesis_in_press,
+author = {Nguyễn, Sam},
+note = {A note},
+title = {{Thesis}},
+year = {in press}
+}`,
+ },
+ {cff: "title: No authors\nauthor:\n - name: Typo\n"},
+ }
+ for _, tc := range cases {
+ t.Run("", func(t *testing.T) {
+ t.Parallel()
+ apa, bibtex := FormatCFF(tc.cff)
+ assert.Equal(t, tc.apa, apa)
+ assert.Equal(t, tc.bibtex, bibtex)
+ })
+ }
+}
diff --git a/package.json b/package.json
index ee4a0416e24..ea46af56331 100644
--- a/package.json
+++ b/package.json
@@ -6,10 +6,6 @@
"pnpm": ">= 11.0.0"
},
"dependencies": {
- "@citation-js/core": "0.7.21",
- "@citation-js/plugin-bibtex": "0.7.21",
- "@citation-js/plugin-csl": "0.7.22",
- "@citation-js/plugin-software-formats": "0.6.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-json": "6.0.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2021be74443..cc407c6051b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -166,18 +166,6 @@ importers:
.:
dependencies:
- '@citation-js/core':
- specifier: 0.7.21
- version: 0.7.21
- '@citation-js/plugin-bibtex':
- specifier: 0.7.21
- version: 0.7.21(@citation-js/core@0.7.21)
- '@citation-js/plugin-csl':
- specifier: 0.7.22
- version: 0.7.22(@citation-js/core@0.7.21)
- '@citation-js/plugin-software-formats':
- specifier: 0.6.2
- version: 0.6.2
'@codemirror/autocomplete':
specifier: 6.20.3
version: 6.20.3
@@ -535,54 +523,6 @@ packages:
'@chevrotain/utils@11.1.2':
resolution: {integrity: sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==}
- '@citation-js/core@0.7.21':
- resolution: {integrity: sha512-Vobv2/Yfnn6C6BVO/pvj7madQ7Mfzl83/jAWwixbemGF6ZThhGMz8++FD9hWHyHXDMYuLGa6fK68c2VsolZmTA==}
- engines: {node: '>=16.0.0'}
-
- '@citation-js/date@0.5.1':
- resolution: {integrity: sha512-1iDKAZ4ie48PVhovsOXQ+C6o55dWJloXqtznnnKy6CltJBQLIuLLuUqa8zlIvma0ZigjVjgDUhnVaNU1MErtZw==}
- engines: {node: '>=10.0.0'}
-
- '@citation-js/name@0.4.2':
- resolution: {integrity: sha512-brSPsjs2fOVzSnARLKu0qncn6suWjHVQtrqSUrnqyaRH95r/Ad4wPF5EsoWr+Dx8HzkCGb/ogmoAzfCsqlTwTQ==}
- engines: {node: '>=6'}
-
- '@citation-js/plugin-bibtex@0.7.21':
- resolution: {integrity: sha512-O008pSsJgiYKn4+7gAWrbNpNdUH++aMeYmZaJ2oFQ8X1tcY5jNBxJcr0zZojNtUi5CVOaXXHQ0yIifoUhuF2Vg==}
- engines: {node: '>=16.0.0'}
- peerDependencies:
- '@citation-js/core': ^0.7.0
-
- '@citation-js/plugin-cff@0.6.2':
- resolution: {integrity: sha512-jvERDFbtQQOBb9s+E8VbRIYsEIb2YEbcLH3yVDxXK0xqBGQDE5m8JZAYUkENy4FmbaD979l0+xJTWAsYN1pV/w==}
- engines: {node: '>=14.0.0'}
-
- '@citation-js/plugin-csl@0.7.22':
- resolution: {integrity: sha512-/rGdtbeP3nS4uZDdEbQUHT8PrUcIs0da2t+sWMKYXoOhXQqfw3oJJ7p4tUD+R8lptyIR5Eq20/DFk/kQDdLpYg==}
- engines: {node: '>=16.0.0'}
- peerDependencies:
- '@citation-js/core': ^0.7.0
-
- '@citation-js/plugin-github@0.6.2':
- resolution: {integrity: sha512-NKq/1Ja060o4II1Z4p1+utwpvMsx+XIWdNiFvnJDfR2Z9E1xGETjByPpdobGBsteUTpJPEe9OVfF8Dee/Q7zLQ==}
- engines: {node: '>=14.0.0'}
-
- '@citation-js/plugin-npm@0.6.2':
- resolution: {integrity: sha512-mbQg/N9HM+gOqHJCdDZEElSW+h/oM94snKCl3llXuZ4MEH3tHraElS6CYRW/vW7s8KUTTHhgE62Q6ua5aRml8g==}
- engines: {node: '>=14.0.0'}
-
- '@citation-js/plugin-software-formats@0.6.2':
- resolution: {integrity: sha512-x1IG0LBKglBU6SuiiKfvOtn7g7o7s+YhQhB44o7zrFaKEO8jkyQ5qMKtM5VFdCBL7teLfzZLjpjNkdJXtZ6XZw==}
- engines: {node: '>=14.0.0'}
-
- '@citation-js/plugin-yaml@0.6.2':
- resolution: {integrity: sha512-qw53Uy2fDekKAzNhv8pkAWpIccIxyKQ3nQuClMgzDPdyeWg34ElIs4bDub9ZZup15fy+X//2gP8k12RJqNo4lA==}
- engines: {node: '>=14.0.0'}
-
- '@citation-js/plugin-zenodo@0.6.2':
- resolution: {integrity: sha512-3XQOO3u4WXY/7AWZyQ+9SuBzS8bYTlJ+NF1uCgrZO64g36nK5iIc5YV9cBl2TL2QhHF6S36nvAsXsj5fX9FeHw==}
- engines: {node: '>=14.0.0'}
-
'@codemirror/autocomplete@6.20.3':
resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==}
@@ -1958,9 +1898,6 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22}
- base64-js@1.5.1:
- resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
-
baseline-browser-mapping@2.11.21:
resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==}
engines: {node: '>=6.0.0'}
@@ -1993,9 +1930,6 @@ packages:
resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==}
engines: {node: '>=4.0'}
- buffer@5.7.1:
- resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
-
builtin-modules@5.2.0:
resolution: {integrity: sha512-02yxLeyxF4dNl6SlY6/5HfRSrSdZ/sCPoxy2kZNP5dZZX8LSAD9aE2gtJIUgWrsQTiMPl3mxESyrobSwvRGisQ==}
engines: {node: '>=18.20'}
@@ -2073,9 +2007,6 @@ packages:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
- citeproc@2.4.63:
- resolution: {integrity: sha512-68F95Bp4UbgZU/DBUGQn0qV3HDZLCdI9+Bb2ByrTaNJDL5VEm9LqaiNaxljsvoaExSLEXe1/r6n2Z06SCzW3/Q==}
-
clippie@4.2.1:
resolution: {integrity: sha512-+Shk4WLaaBtxTwgaspGnFm/WrtjZCVCKsl66mPglNRj6B+OzgIYnp4LX7EjW1UTG5NfJsW60jxwV8EvqtyrDUA==}
@@ -2789,9 +2720,6 @@ packages:
picomatch:
optional: true
- fetch-ponyfill@7.1.0:
- resolution: {integrity: sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==}
-
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
@@ -2985,9 +2913,6 @@ packages:
idiomorph@0.8.0:
resolution: {integrity: sha512-R47JBL+6jHDQvcnbppIUtXXcxi5h6+wuCEEZXnRhUltsgzPFKzThL5PuHppP9ypJuy+7WawcY1TsSBD/iQbMhw==}
- ieee754@1.2.1:
- resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
-
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -3565,9 +3490,6 @@ packages:
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
- moo@0.5.3:
- resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
-
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -3601,24 +3523,6 @@ packages:
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
engines: {node: '>= 0.4'}
- node-fetch@2.6.13:
- resolution: {integrity: sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==}
- engines: {node: 4.x || >=6.0.0}
- peerDependencies:
- encoding: ^0.1.0
- peerDependenciesMeta:
- encoding:
- optional: true
-
- node-fetch@2.7.0:
- resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
- engines: {node: 4.x || >=6.0.0}
- peerDependencies:
- encoding: ^0.1.0
- peerDependenciesMeta:
- encoding:
- optional: true
-
node-releases@2.0.54:
resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==}
engines: {node: '>=18'}
@@ -4236,10 +4140,6 @@ packages:
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
- sync-fetch@0.4.5:
- resolution: {integrity: sha512-esiWJ7ixSKGpd9DJPBTC4ckChqdOjIwJfYhVHkcQ2Gnm41323p1TRmEI+esTQ9ppD+b5opps2OTEGTCGX5kF+g==}
- engines: {node: '>=14'}
-
table@6.9.0:
resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==}
engines: {node: '>=10.0.0'}
@@ -4293,9 +4193,6 @@ packages:
resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==}
engines: {node: '>=6'}
- tr46@0.0.3:
- resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
-
tr46@3.0.0:
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
engines: {node: '>=12'}
@@ -4523,9 +4420,6 @@ packages:
resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==}
engines: {node: '>=14'}
- webidl-conversions@3.0.1:
- resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
-
webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
@@ -4543,9 +4437,6 @@ packages:
resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==}
engines: {node: '>=12'}
- whatwg-url@5.0.0:
- resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
-
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -4679,64 +4570,6 @@ snapshots:
'@chevrotain/utils@11.1.2': {}
- '@citation-js/core@0.7.21':
- dependencies:
- '@citation-js/date': 0.5.1
- '@citation-js/name': 0.4.2
- fetch-ponyfill: 7.1.0
- sync-fetch: 0.4.5
- transitivePeerDependencies:
- - encoding
-
- '@citation-js/date@0.5.1': {}
-
- '@citation-js/name@0.4.2': {}
-
- '@citation-js/plugin-bibtex@0.7.21(@citation-js/core@0.7.21)':
- dependencies:
- '@citation-js/core': 0.7.21
- '@citation-js/date': 0.5.1
- '@citation-js/name': 0.4.2
- moo: 0.5.3
-
- '@citation-js/plugin-cff@0.6.2':
- dependencies:
- '@citation-js/date': 0.5.1
- '@citation-js/plugin-yaml': 0.6.2
-
- '@citation-js/plugin-csl@0.7.22(@citation-js/core@0.7.21)':
- dependencies:
- '@citation-js/core': 0.7.21
- '@citation-js/date': 0.5.1
- citeproc: 2.4.63
-
- '@citation-js/plugin-github@0.6.2':
- dependencies:
- '@citation-js/date': 0.5.1
- '@citation-js/name': 0.4.2
-
- '@citation-js/plugin-npm@0.6.2':
- dependencies:
- '@citation-js/date': 0.5.1
- '@citation-js/name': 0.4.2
-
- '@citation-js/plugin-software-formats@0.6.2':
- dependencies:
- '@citation-js/plugin-cff': 0.6.2
- '@citation-js/plugin-github': 0.6.2
- '@citation-js/plugin-npm': 0.6.2
- '@citation-js/plugin-yaml': 0.6.2
- '@citation-js/plugin-zenodo': 0.6.2
-
- '@citation-js/plugin-yaml@0.6.2':
- dependencies:
- js-yaml: 4.2.0
-
- '@citation-js/plugin-zenodo@0.6.2':
- dependencies:
- '@citation-js/date': 0.5.1
- '@citation-js/name': 0.4.2
-
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
@@ -6309,8 +6142,6 @@ snapshots:
balanced-match@4.0.4: {}
- base64-js@1.5.1: {}
-
baseline-browser-mapping@2.11.21: {}
binary-extensions@2.3.0: {}
@@ -6344,11 +6175,6 @@ snapshots:
'@types/node': 26.6.2
optional: true
- buffer@5.7.1:
- dependencies:
- base64-js: 1.5.1
- ieee754: 1.2.1
-
builtin-modules@5.2.0: {}
cacheable@2.5.0:
@@ -6434,8 +6260,6 @@ snapshots:
ci-info@4.4.0: {}
- citeproc@2.4.63: {}
-
clippie@4.2.1: {}
codemirror-lang-elixir@4.0.1:
@@ -7285,12 +7109,6 @@ snapshots:
optionalDependencies:
picomatch: 4.0.7
- fetch-ponyfill@7.1.0:
- dependencies:
- node-fetch: 2.6.13
- transitivePeerDependencies:
- - encoding
-
fflate@0.8.2: {}
fflate@0.8.3: {}
@@ -7507,8 +7325,6 @@ snapshots:
idiomorph@0.8.0: {}
- ieee754@1.2.1: {}
-
ignore@5.3.2: {}
ignore@7.0.6: {}
@@ -8200,8 +8016,6 @@ snapshots:
minimist@1.2.8: {}
- moo@0.5.3: {}
-
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -8230,14 +8044,6 @@ snapshots:
semver: 6.3.1
optional: true
- node-fetch@2.6.13:
- dependencies:
- whatwg-url: 5.0.0
-
- node-fetch@2.7.0:
- dependencies:
- whatwg-url: 5.0.0
-
node-releases@2.0.54: {}
normalize-path@3.0.0: {}
@@ -8926,13 +8732,6 @@ snapshots:
symbol-tree@3.2.4: {}
- sync-fetch@0.4.5:
- dependencies:
- buffer: 5.7.1
- node-fetch: 2.7.0
- transitivePeerDependencies:
- - encoding
-
table@6.9.0:
dependencies:
ajv: 8.20.0
@@ -9009,8 +8808,6 @@ snapshots:
universalify: 0.2.0
url-parse: 1.5.10
- tr46@0.0.3: {}
-
tr46@3.0.0:
dependencies:
punycode: 2.3.1
@@ -9231,8 +9028,6 @@ snapshots:
dependencies:
xml-name-validator: 4.0.0
- webidl-conversions@3.0.1: {}
-
webidl-conversions@7.0.0: {}
whatwg-encoding@2.0.0:
@@ -9246,11 +9041,6 @@ snapshots:
tr46: 3.0.0
webidl-conversions: 7.0.0
- whatwg-url@5.0.0:
- dependencies:
- tr46: 0.0.3
- webidl-conversions: 3.0.1
-
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
diff --git a/renovate.json5 b/renovate.json5
index ba9a258c82e..12a89b7a2b2 100644
--- a/renovate.json5
+++ b/renovate.json5
@@ -78,10 +78,6 @@
"matchPackageNames": ["tailwindcss"],
"allowedVersions": "^3", // need to migrate
},
- {
- "matchPackageNames": ["@citation-js/core", "@citation-js/plugin-bibtex", "@citation-js/plugin-csl"],
- "allowedVersions": "<0.8", // https://github.com/citation-js/citation-js/pull/277
- },
{
"matchPackageNames": ["typescript"],
"allowedVersions": "^6", // typescript-eslint is not yet compatible with typescript 7
diff --git a/routers/web/repo/view_home.go b/routers/web/repo/view_home.go
index e829520ce62..25a1ac07db5 100644
--- a/routers/web/repo/view_home.go
+++ b/routers/web/repo/view_home.go
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"net/http"
+ "slices"
"strconv"
"strings"
"time"
@@ -17,9 +18,11 @@ import (
repo_model "gitea.dev/models/repo"
unit_model "gitea.dev/models/unit"
user_model "gitea.dev/models/user"
+ "gitea.dev/modules/citation"
"gitea.dev/modules/git"
"gitea.dev/modules/htmlutil"
"gitea.dev/modules/httplib"
+ "gitea.dev/modules/lfs"
"gitea.dev/modules/log"
repo_module "gitea.dev/modules/repository"
"gitea.dev/modules/setting"
@@ -93,32 +96,43 @@ func prepareClonePanel(ctx *context.Context) {
}
}
-func prepareHomeSidebarCitationFile(entry *git.TreeEntry) func(ctx *context.Context) {
- return func(ctx *context.Context) {
- if entry.Name() != "" {
- return
+func prepareHomeSidebarCitationFile(ctx *context.Context) {
+ allEntries, err := ctx.Repo.Commit.Tree().ListEntries(ctx, ctx.Repo.GitRepo)
+ if err != nil {
+ ctx.ServerError("ListEntries", err)
+ return
+ }
+ isBlob := func(entry *git.TreeEntry) bool { return !entry.IsDir() && !entry.IsSubModule() }
+ for _, name := range []string{"CITATION.cff", "CITATION.bib"} {
+ idx := slices.IndexFunc(allEntries, func(entry *git.TreeEntry) bool { return isBlob(entry) && strings.EqualFold(entry.Name(), name) })
+ if idx == -1 {
+ continue
}
- tree, err := ctx.Repo.Commit.SubTree(ctx, ctx.Repo.GitRepo, ctx.Repo.TreePath)
- if err != nil {
- HandleGitError(ctx, "Repo.Commit.SubTree", err)
- return
- }
- allEntries, err := tree.ListEntries(ctx, ctx.Repo.GitRepo)
- if err != nil {
- ctx.ServerError("ListEntries", err)
- return
- }
- for _, entry := range allEntries {
- if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" {
- // Read Citation file contents
- if content, err := entry.Blob(ctx.Repo.GitRepo).GetBlobContent(ctx, setting.UI.MaxDisplayFileSize); err != nil {
- log.Error("checkCitationFile: GetBlobContent: %v", err)
- } else {
- ctx.Data["CitiationExist"] = true
- ctx.PageData["citationFileContent"] = content
- break
- }
+ entry := allEntries[idx]
+ if entry.IsLink() {
+ res, err := git.EntryFollowLinks(ctx, ctx.Repo.GitRepo, ctx.Repo.Commit, entry.Name(), entry)
+ if err != nil || !isBlob(res.TargetEntry) {
+ continue
}
+ entry = res.TargetEntry
+ }
+ content, err := entry.Blob(ctx.Repo.GitRepo).GetBlobContent(ctx, setting.UI.MaxDisplayFileSize)
+ if err != nil {
+ log.Error("prepareHomeSidebarCitationFile: GetBlobContent: %v", err)
+ continue
+ }
+ if pointer, _ := lfs.ReadPointerFromBuffer([]byte(content)); pointer.IsValid() {
+ continue
+ }
+ apa, bibtex := "", content
+ if name == "CITATION.cff" {
+ apa, bibtex = citation.FormatCFF(content)
+ }
+ if bibtex != "" {
+ ctx.Data["CitationFileName"] = allEntries[idx].Name()
+ ctx.Data["CitationAPA"] = apa
+ ctx.Data["CitationBibTeX"] = bibtex
+ return
}
}
}
@@ -470,7 +484,7 @@ func Home(ctx *context.Context) {
checkOutdatedBranch,
prepareUpstreamDivergingInfo,
prepareHomeSidebarLicenses,
- prepareHomeSidebarCitationFile(entry),
+ prepareHomeSidebarCitationFile,
prepareHomeSidebarLanguageStats,
prepareHomeSidebarLatestRelease,
)
diff --git a/templates/repo/cite/cite_buttons.tmpl b/templates/repo/cite/cite_buttons.tmpl
deleted file mode 100644
index b80fc47e05a..00000000000
--- a/templates/repo/cite/cite_buttons.tmpl
+++ /dev/null
@@ -1,11 +0,0 @@
-
-
-
-
-
diff --git a/templates/repo/cite/cite_modal.tmpl b/templates/repo/cite/cite_modal.tmpl
index 59da3b5ae54..4496b33cc70 100644
--- a/templates/repo/cite/cite_modal.tmpl
+++ b/templates/repo/cite/cite_modal.tmpl
@@ -4,9 +4,20 @@