feat(ui): add switch between formats when previewing CITATION.{cff,bib} files (#9103)

See #8222 for context (loosely related to #4595).

## Implemented changes

The conversion logic is kept in the frontend and the related npm libraries are lazy-loaded (unchanged).

### Show some tabs on the preview of the `CITATION.*` file to switch between the formats:

![image](/attachments/be02656f-d906-4191-aa84-d666ee5a90ba)
![image](/attachments/240384e3-dec8-4f02-94e6-261143193541)

### Convert the "Cite repository" to a simple link to the citation file

So that this change can be considered non-breaking

## Current state (before this PR)

The last non-test call of `git.Blob.GetBlobContent` is made to retrieve the content of an eventual CITATION file.
This is available in the `...` menu near the clone URL:
![image](/attachments/ef79128d-ee3f-4e43-a74d-a00e4dcfe6b4)
And is displayed as a popup:

![image](/attachments/7aa930f9-0766-47b9-8145-cbebb5b051b0)

Co-authored-by: 0ko <0ko@noreply.codeberg.org>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/9103
Reviewed-by: Gusted <gusted@noreply.codeberg.org>
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
Co-authored-by: oliverpool <git@olivier.pfad.fr>
Co-committed-by: oliverpool <git@olivier.pfad.fr>
This commit is contained in:
oliverpool 2025-11-14 14:39:20 +01:00 committed by 0ko
parent 0737196842
commit 8f28cdefe0
23 changed files with 225 additions and 195 deletions

View file

@ -157,22 +157,6 @@ func (b *Blob) NewTruncatedReader(limit int64) (rc io.ReadCloser, fullSize int64
}, fullSize, nil
}
// GetBlobContent Gets the truncated content of the blob as raw text
func (b *Blob) GetBlobContent(limit int64) (string, error) {
if limit <= 0 {
return "", nil
}
rc, fullSize, err := b.NewTruncatedReader(limit)
if err != nil {
return "", err
}
defer rc.Close()
buf := make([]byte, min(fullSize, limit))
_, err = io.ReadFull(rc, buf)
return string(buf), err
}
type BlobTooLargeError struct {
Size, Limit int64
}

View file

@ -45,24 +45,6 @@ func TestBlob(t *testing.T) {
testBlob, err := repo.GetBlob("6c493ff740f9380390d5c9ddef4af18697ac9375")
require.NoError(t, err)
t.Run("GetBlobContent", func(t *testing.T) {
r, err := testBlob.GetBlobContent(100)
require.NoError(t, err)
require.Equal(t, "file2\n", r)
r, err = testBlob.GetBlobContent(-1)
require.NoError(t, err)
require.Empty(t, r)
r, err = testBlob.GetBlobContent(4)
require.NoError(t, err)
require.Equal(t, "file", r)
r, err = testBlob.GetBlobContent(6)
require.NoError(t, err)
require.Equal(t, "file2\n", r)
})
t.Run("GetContentBase64", func(t *testing.T) {
r, err := testBlob.GetContentBase64(100)
require.NoError(t, err)
@ -140,11 +122,6 @@ func TestBlob(t *testing.T) {
nonExistingBlob, err := repo.GetBlob("00003ff740f9380390d5c9ddef4af18690000000")
require.NoError(t, err)
r, err := nonExistingBlob.GetBlobContent(100)
require.Error(t, err)
require.IsType(t, ErrNotExist{}, err)
require.Empty(t, r)
rc, size, err := nonExistingBlob.NewTruncatedReader(100)
require.Error(t, err)
require.IsType(t, ErrNotExist{}, err)

View file

@ -601,6 +601,7 @@ func renderFile(ctx *context.Context, entry *git.TreeEntry) {
ctx.Data["EscapeStatus"] = status
ctx.Data["FileContent"] = fileContent
ctx.Data["LineEscapeStatus"] = statuses
ctx.Data["IsCitationFile"] = isCitationFile(entry)
}
if !fInfo.isLFSFile {
if ctx.Repo.CanEnableEditor(ctx, ctx.Doer) {
@ -778,6 +779,10 @@ func checkHomeCodeViewable(ctx *context.Context) {
ctx.NotFound("Home", errors.New(ctx.Locale.TrString("units.error.no_unit_allowed_repo")))
}
func isCitationFile(entry *git.TreeEntry) bool {
return entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib"
}
func checkCitationFile(ctx *context.Context, entry *git.TreeEntry) {
if entry.Name() != "" {
return
@ -793,16 +798,9 @@ func checkCitationFile(ctx *context.Context, entry *git.TreeEntry) {
return
}
for _, entry := range allEntries {
if entry.Name() == "CITATION.cff" || entry.Name() == "CITATION.bib" {
// Read Citation file contents
if content, err := entry.Blob().GetBlobContent(setting.UI.MaxDisplayFileSize); err != nil {
log.Error("checkCitationFile: GetBlobContent: %v", err)
} else {
ctx.Data["CitationExist"] = true
ctx.Data["CitationFile"] = entry.Name()
ctx.PageData["citationFileContent"] = content
break
}
if isCitationFile(entry) {
ctx.Data["CitationFile"] = entry.Name()
break
}
}
}

View file

@ -1,8 +0,0 @@
<span class="ui citation label primary" id="citation-copy-bibtex" data-text="">
BibTeX
</span>
<!-- the value will be updated by initCitationFileCopyContent, the code below is used to avoid UI flicking -->
<input id="citation-copy-content" value="" size="1" readonly>
<button class="ui icon button" id="citation-clipboard-btn" data-tooltip-content="{{ctx.Locale.Tr "copy"}}" data-clipboard-target="#citation-copy-content">
{{svg "octicon-copy"}}
</button>

View file

@ -1,20 +0,0 @@
<div class="ui small modal" id="cite-repo-modal">
<div class="header">
{{ctx.Locale.Tr "repo.cite_this_repo"}}
</div>
<div class="content">
<div class="ui stackable secondary menu">
<div class="ui action input" id="citation-panel">
{{template "repo/cite/cite_buttons" .}}
<a id="goto-citation-btn" class="ui basic jump icon button" href="{{$.RepoLink}}/src/{{$.BranchName}}/{{$.CitationFile}}" data-tooltip-content="{{ctx.Locale.Tr "repo.find_file.go_to_file"}}">
{{svg "octicon-file-moved"}}
</a>
</div>
</div>
</div>
<div class="actions">
<button class="ui cancel button">
{{ctx.Locale.Tr "cancel"}}
</button>
</div>
</div>

View file

@ -130,8 +130,8 @@
<a class="item archive-link" href="{{$.RepoLink}}/archive/{{PathEscapeSegments $.RefName}}.tar.gz" rel="nofollow">{{svg "octicon-file-zip" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.download_tar"}}</a>
<a class="item archive-link" href="{{$.RepoLink}}/archive/{{PathEscapeSegments $.RefName}}.bundle" rel="nofollow">{{svg "octicon-package" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.download_bundle"}}</a>
{{end}}
{{if .CitationExist}}
<a class="item" id="cite-repo-button">{{svg "octicon-cross-reference" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.cite_this_repo"}}</a>
{{if .CitationFile}}
<a class="item citation-link" href="{{$.RepoLink}}/src/branch/{{$.BranchName}}/{{$.CitationFile}}">{{svg "octicon-cross-reference" 16 "tw-mr-2"}}{{ctx.Locale.Tr "repo.cite_this_repo"}}</a>
{{end}}
{{range .OpenWithEditorApps}}
<a class="item js-clone-url-editor" data-href-template="{{.OpenURL}}">{{.IconHTML}}{{ctx.Locale.Tr "repo.open_with_editor" .DisplayName}}</a>
@ -140,7 +140,6 @@
</button>
{{template "repo/clone_script" .}}{{/* the script will update `.js-clone-url` and related elements */}}
</div>
{{template "repo/cite/cite_modal" .}}
{{end}}
{{if and (not $isHomepage) (not .IsViewFile) (not .IsBlame)}}{{/* IsViewDirectory (not home), TODO: split the templates, avoid using "if" tricks */}}
<div class="button-sequence folder-actions">

View file

@ -26,9 +26,9 @@
{{end}}
<h4 class="file-header ui top attached header tw-flex tw-items-center tw-justify-between tw-flex-wrap">
<div class="file-header-left tw-flex tw-items-center tw-py-2 tw-pr-4">
<div class="file-header-left tw-flex tw-items-center tw-pr-4 tw-flex-wrap {{if .IsCitationFile}}tw-gap-4{{else}}tw-gap-2{{end}}">
{{if .ReadmeInList}}
{{svg "octicon-book" 16 "tw-mr-2"}}
{{svg "octicon-book"}}
<strong><a class="default-link muted" href="#readme">{{.FileName}}</a></strong>
{{else}}
{{template "repo/file_info" .}}
@ -150,6 +150,9 @@
</tbody>
</table>
{{else}}
{{if .IsCitationFile}}
<lazy-webc tag="citation-information">
{{end}}
<table>
<tbody>
{{range $idx, $code := .FileContent}}
@ -164,6 +167,9 @@
{{end}}
</tbody>
</table>
{{if .IsCitationFile}}
</lazy-webc>
{{end}}
<div class="code-line-menu tippy-target">
{{if $.Permission.CanRead $.UnitTypeIssues}}
<a class="item ref-in-new-issue" role="menuitem" data-url-issue-new="{{.RepoLink}}/issues/new" data-url-param-body-link="{{.Repository.Link}}/src/commit/{{PathEscape .CommitID}}/{{PathEscapeSegments .TreePath}}{{if $.HasSourceRenderedToggle}}?display=source{{end}}" rel="nofollow noindex">{{ctx.Locale.Tr "repo.issues.context.reference_issue"}}</a>

View file

@ -0,0 +1,47 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
// @watch start
// web_src/js/webcomponents/citation-information.js
// @watch end
import {expect} from '@playwright/test';
import {test} from './utils_e2e.ts';
test('CITATION.cff switch', async ({page}) => {
const previewPath = '/user2/rendering-test/src/branch/master/CITATION.cff';
const response = await page.goto(previewPath);
expect(response?.status()).toBe(200);
await expect(page.getByText('cff-version: 1.2.0')).toBeVisible();
await page.getByRole('button', {name: 'BibTeX'}).click();
await expect(page.getByText('cff-version: 1.2.0')).toBeHidden();
await expect(
page.getByText('howpublished = {https://forgejo.org/},'),
).toBeVisible();
await page.getByRole('button', {name: 'Citation File Format'}).click();
await expect(page.getByText('cff-version: 1.2.0')).toBeVisible();
});
test('glb file with 3D rendering', async ({page}, workerInfo) => {
test.skip(
workerInfo.project.name !== 'chromium',
'needs some investigation to run on other platforms',
// https://codeberg.org/forgejo/forgejo/actions/runs/113344/jobs/3/attempt/1
);
const previewPath =
'/user2/rendering-test/src/branch/master/Unicode❤♻Test.glb';
const response = await page.goto(previewPath);
expect(response?.status()).toBe(200);
await page
.getByRole('img', {
name: '3D model. Use mouse, touch or arrow keys to move.',
})
.click();
});

View file

@ -1 +1 @@
fafaad77cb54665ac800d1bf77e6a55bd355eabc
e8196c874f13227602a2b680c30eef433036e213

View file

@ -89,10 +89,13 @@ func testPackageCargo(t *testing.T, _ *neturl.URL) {
blob, err := commit.GetBlobByPath(path)
require.NoError(t, err)
content, err := blob.GetBlobContent(1024)
rc, _, err := blob.NewTruncatedReader(1024)
require.NoError(t, err)
return content
content, err := io.ReadAll(rc)
require.NoError(t, err)
return string(content)
}
root := fmt.Sprintf("%sapi/packages/%s/cargo", setting.AppURL, user.Name)

View file

@ -31,49 +31,58 @@ func TestCitation(t *testing.T) {
repo, _, f := tests.CreateDeclarativeRepo(t, user, "citation-no-citation", []unit_model.Type{unit_model.TypeCode}, nil, nil)
defer f()
testCitationButtonExists(t, session, repo, "", false)
testCitationButtonExists(t, session, repo, "")
})
t.Run("cff citation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
repo, f := createRepoWithEmptyFile(t, user, "citation-cff", "CITATION.cff")
repo, f := createRepoWithDummyFile(t, user, "citation-cff", "CITATION.cff")
defer f()
testCitationButtonExists(t, session, repo, "CITATION.cff", true)
testCitationButtonExists(t, session, repo, "CITATION.cff")
})
t.Run("bib citation", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
repo, f := createRepoWithEmptyFile(t, user, "citation-bib", "CITATION.bib")
repo, f := createRepoWithDummyFile(t, user, "citation-bib", "CITATION.bib")
defer f()
testCitationButtonExists(t, session, repo, "CITATION.bib", true)
testCitationButtonExists(t, session, repo, "CITATION.bib")
})
})
}
func testCitationButtonExists(t *testing.T, session *TestSession, repo *repo_model.Repository, file string, exists bool) {
func testCitationButtonExists(t *testing.T, session *TestSession, repo *repo_model.Repository, file string) {
req := NewRequest(t, "GET", repo.HTMLURL())
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
doc.AssertElement(t, "#cite-repo-button", exists)
if exists {
href, exists := doc.doc.Find("#goto-citation-btn").Attr("href")
assert.True(t, exists)
assert.True(t, strings.HasSuffix(href, file))
links := doc.Find("a.citation-link")
if file == "" {
assert.Equal(t, 0, links.Length())
return
}
assert.Equal(t, 1, links.Length())
href, exists := links.Attr("href")
assert.True(t, exists)
assert.True(t, strings.HasSuffix(href, file))
// request the citation file to check for webcomponent presence
req = NewRequest(t, "GET", href)
resp = session.MakeRequest(t, req, http.StatusOK)
doc = NewHTMLParser(t, resp.Body)
doc.AssertElement(t, `lazy-webc[tag="citation-information"]`, true)
}
func createRepoWithEmptyFile(t *testing.T, user *user_model.User, repoName, fileName string) (*repo_model.Repository, func()) {
func createRepoWithDummyFile(t *testing.T, user *user_model.User, repoName, fileName string) (*repo_model.Repository, func()) {
repo, _, f := tests.CreateDeclarativeRepo(t, user, repoName, []unit_model.Type{unit_model.TypeCode}, nil, []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: fileName,
Operation: "create",
TreePath: fileName,
ContentReader: strings.NewReader("citation-content"), // viewer requires some content
},
})

View file

@ -3,6 +3,10 @@
100% { transform: translate(-50%, -50%) rotate(360deg); }
}
lazy-webc {
display: block;
}
lazy-webc,
.is-loading {
pointer-events: none !important;

View file

@ -438,6 +438,10 @@ pdf-object {
justify-content: center;
}
citation-information .tab:not(.active) {
display: none;
}
.repository.file.list .non-diff-file-content .plain-text {
padding: 1em 2em;
}
@ -1897,48 +1901,6 @@ details.repo-search-result summary::marker {
font-weight: var(--font-weight-medium);
}
#cite-repo-modal #citation-panel {
display: flex;
width: 100%;
}
#cite-repo-modal #citation-panel input {
border-radius: 0;
padding: 5px 10px;
width: 50%;
line-height: 1.4;
}
#cite-repo-modal #citation-panel #citation-copy-content {
border-radius: 0;
padding: 5px 10px;
font-size: 1.2em;
line-height: 1.4;
flex: 1;
}
#cite-repo-modal #citation-panel #citation-copy-bibtex {
font-size: 13px;
padding: 7.5px 5px;
border-right: none;
}
#cite-repo-modal #citation-panel #goto-citation-btn {
border-left: none;
}
#cite-repo-modal #citation-panel > :first-child {
border-radius: var(--border-radius) 0 0 var(--border-radius) !important;
}
#cite-repo-modal #citation-panel > :last-child {
border-radius: 0 var(--border-radius) var(--border-radius) 0 !important;
}
#cite-repo-modal #citation-panel .icon.button {
padding: 0 10px;
}
#search-user-box .results .result .image {
order: 0;
margin-right: 12px;
@ -2381,6 +2343,7 @@ tbody.commit-list {
overflow-x: auto;
padding: 6px 12px !important;
font-size: 13px !important;
min-height: 46px;
}
.file-info {

View file

@ -1,50 +0,0 @@
import $ from 'jquery';
import {getCurrentLocale} from '../utils.js';
const {pageData} = window.config;
async function initInputCitationValue(inputContent) {
const [{Cite, plugins}] = await Promise.all([
import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'),
import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'),
import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'),
]);
const {citationFileContent} = pageData;
const config = plugins.config.get('@bibtex');
config.constants.fieldTypes.doi = ['field', 'literal'];
config.constants.fieldTypes.version = ['field', 'literal'];
const citationFormatter = new Cite(citationFileContent);
const lang = getCurrentLocale() || 'en-US';
const bibtexOutput = citationFormatter.format('bibtex', {lang});
inputContent.value = bibtexOutput;
}
export async function initCitationFileCopyContent() {
if (!pageData.citationFileContent) return;
const inputContent = document.getElementById('citation-copy-content');
if (!inputContent) return;
document.getElementById('cite-repo-button')?.addEventListener('click', async (e) => {
const dropdownBtn = e.target.closest('.ui.dropdown.button');
dropdownBtn.classList.add('is-loading');
try {
try {
await initInputCitationValue(inputContent);
} catch (e) {
console.error(`initCitationFileCopyContent error: ${e}`, e);
return;
}
inputContent.addEventListener('click', () => {
inputContent.select();
});
} finally {
dropdownBtn.classList.remove('is-loading');
}
$('#cite-repo-modal').modal('show');
});
}

View file

@ -13,7 +13,6 @@ import {initRepoBranchTagSelector} from './repo-branch-tag-selector.js';
import {
initRepoCloneLink, initRepoCommonBranchOrTagDropdown, initRepoCommonFilterSearchDropdown,
} from './repo-common.js';
import {initCitationFileCopyContent} from './citation.js';
import {initCompLabelEdit} from './comp/LabelEdit.js';
import {initRepoDiffConversationNav} from './repo-diff.js';
import {showErrorToast} from '../modules/toast.js';
@ -457,7 +456,6 @@ export function initRepository() {
}
initRepoCloneLink();
initCitationFileCopyContent();
initRepoSettingBranches();
// Issues

View file

@ -0,0 +1,114 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
import '@citation-js/plugin-software-formats';
import '@citation-js/plugin-bibtex';
import {Cite, plugins} from '@citation-js/core';
import {getCurrentLocale} from '../utils.js';
import {initTab} from '../modules/tab.ts';
window.customElements.define(
'citation-information',
class extends HTMLElement {
connectedCallback() {
const children = this.children; // eslint-disable-line wc/no-child-traversal-in-connectedcallback
if (children.length !== 1) {
// developer error
throw new Error(
`<citation-information> expected one child, got ${children.length}`,
);
}
const lang = getCurrentLocale() || 'en-US';
const raw = children[0];
raw.dataset.tab = 'raw';
raw.classList.add('tab', 'active');
// like in copy-content
const lineEls = raw.querySelectorAll('.lines-code');
const code = Array.from(lineEls, (el) => el.textContent).join('');
const inputType = plugins.input.type(code);
let parsed;
try {
parsed = new Cite(code, {forceType: inputType});
} catch (err) {
const elContainer = document.createElement('div');
elContainer.classList.add('ui', 'warning', 'message');
const elHeader = document.createElement('div');
elHeader.classList.add('header');
elHeader.textContent = `Could not parse citation-information (format ${inputType})`; // ideally this message should be localized, however the error below will likely be in english
elContainer.append(elHeader);
const elParagraph = document.createElement('pre');
elParagraph.textContent = err;
elContainer.append(elParagraph);
this.prepend(elContainer);
return;
}
const toggleBar = document.createElement('div');
toggleBar.classList.add('switch');
const newButton = (txt, id, tooltip, active) => {
const el = document.createElement('button');
el.textContent = txt;
el.dataset.tab = id;
if (tooltip) {
el.dataset.tooltipContent = tooltip;
}
el.classList.add('item');
if (active) {
el.classList.add('active');
}
return el;
};
let originalText = 'Original';
let originalTooltip = '';
switch (inputType) {
case '@biblatex/text':
originalText = 'BibTeX';
break;
case '@else/yaml':
originalText = 'CFF';
originalTooltip = 'Citation File Format';
break;
}
toggleBar.append(newButton(originalText, 'raw', originalTooltip, true));
const appendTab = (id, btnLabel, btnTooltip, tabContent) => {
const el = document.createElement('pre');
el.textContent = tabContent;
el.dataset.tab = id;
el.classList.add('tab');
el.style.padding = '1rem';
el.style.margin = 0;
this.append(el);
toggleBar.append(newButton(btnLabel, id, btnTooltip));
};
if (inputType !== '@biblatex/text') {
appendTab(
'bibtex',
'BibTeX',
'',
parsed.format('bibtex', {lang}).trim(),
);
}
if (inputType !== '@else/yaml') {
appendTab(
'cff',
'CFF',
'Citation File Format',
parsed.format('cff', {lang}).trim(),
);
}
const toggleBarParent = document.querySelector('.file-header-left');
toggleBarParent.prepend(toggleBar);
initTab(toggleBarParent);
}
},
);

View file

@ -1,3 +1,6 @@
// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: MIT
import {onDomReady} from '../utils/dom.js';
/**
@ -22,6 +25,9 @@ const loadableComponents = {
'pdf-object': lazyPromise(() => {
return import(/* webpackChunkName: "pdf-object" */ './pdf-object.js');
}),
'citation-information': lazyPromise(() => {
return import(/* webpackChunkName: "citation-information" */ './citation-information.js');
}),
};
/**