gitea source for verification 2026-05-22
Some checks are pending
release-nightly / nightly-binary (push) Waiting to run
release-nightly / nightly-docker-rootful (push) Waiting to run
release-nightly / nightly-docker-rootless (push) Waiting to run

This commit is contained in:
2026-05-22 16:44:59 +08:00
commit 7a61cd3abc
5650 changed files with 690128 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
import {svg} from '../svg.ts';
const addPrefix = (str: string): string => `user-content-${str}`;
const removePrefix = (str: string): string => str.replace(/^user-content-/, '');
const hasPrefix = (str: string): boolean => str.startsWith('user-content-');
// scroll to anchor while respecting the `user-content` prefix that exists on the target
function scrollToAnchor(encodedId?: string): void {
// FIXME: need to rewrite this function with new a better markup anchor generation logic, too many tricks here
let elemId: string;
try {
elemId = decodeURIComponent(encodedId ?? '');
} catch {} // ignore the errors, since the "encodedId" is from user's input
if (!elemId) return;
const prefixedId = addPrefix(elemId);
// eslint-disable-next-line unicorn/prefer-query-selector
let el = document.getElementById(prefixedId);
// check for matching user-generated `a[name]`
el = el ?? document.querySelector(`a[name="${CSS.escape(prefixedId)}"]`);
// compat for links with old 'user-content-' prefixed hashes
// eslint-disable-next-line unicorn/prefer-query-selector
el = (!el && hasPrefix(elemId)) ? document.getElementById(elemId) : el;
el?.scrollIntoView();
}
export function initMarkupAnchors(): void {
const markupEls = document.querySelectorAll('.markup');
if (!markupEls.length) return;
for (const markupEl of markupEls) {
// create link icons for markup headings, the resulting link href will remove `user-content-`
for (const heading of markupEl.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
const a = document.createElement('a');
a.classList.add('anchor');
a.setAttribute('href', `#${encodeURIComponent(removePrefix(heading.id))}`);
a.innerHTML = svg('octicon-link');
heading.prepend(a);
}
// remove `user-content-` prefix from links so they don't show in url bar when clicked
for (const a of markupEl.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')) {
const href = a.getAttribute('href');
if (!href.startsWith('#user-content-')) continue;
a.setAttribute('href', `#${removePrefix(href.substring(1))}`);
}
// add `user-content-` prefix to user-generated `a[name]` link targets
// TODO: this prefix should be added in backend instead
for (const a of markupEl.querySelectorAll<HTMLAnchorElement>('a[name]')) {
const name = a.getAttribute('name');
if (!name) continue;
a.setAttribute('name', addPrefix(name));
}
for (const a of markupEl.querySelectorAll<HTMLAnchorElement>('a[href^="#"]')) {
a.addEventListener('click', (e) => {
scrollToAnchor((e.currentTarget as HTMLAnchorElement).getAttribute('href')?.substring(1));
});
}
}
// scroll to anchor unless the browser has already scrolled somewhere during page load
if (!document.querySelector(':target')) {
scrollToAnchor(window.location.hash?.substring(1));
}
}

View File

@@ -0,0 +1,17 @@
import {queryElems} from '../utils/dom.ts';
export async function initMarkupRenderAsciicast(elMarkup: HTMLElement): Promise<void> {
queryElems(elMarkup, '.asciinema-player-container', async (el) => {
const [player] = await Promise.all([
// @ts-expect-error: module exports no types
import(/* webpackChunkName: "asciinema-player" */'asciinema-player'),
import(/* webpackChunkName: "asciinema-player" */'asciinema-player/dist/bundle/asciinema-player.css'),
]);
player.create(el.getAttribute('data-asciinema-player-src'), el, {
// poster (a preview frame) to display until the playback is started.
// Set it to 1 hour (also means the end if the video is shorter) to make the preview frame show more.
poster: 'npt:1:0:0',
});
});
}

View File

@@ -0,0 +1,22 @@
import {svg} from '../svg.ts';
import {queryElems} from '../utils/dom.ts';
export function makeCodeCopyButton(): HTMLButtonElement {
const button = document.createElement('button');
button.classList.add('code-copy', 'ui', 'button');
button.innerHTML = svg('octicon-copy');
return button;
}
export function initMarkupCodeCopy(elMarkup: HTMLElement): void {
// .markup .code-block code
queryElems(elMarkup, '.code-block code', (el) => {
if (!el.textContent) return;
const btn = makeCodeCopyButton();
// remove final trailing newline introduced during HTML rendering
btn.setAttribute('data-clipboard-text', el.textContent.replace(/\r?\n$/, ''));
// we only want to use `.code-block-container` if it exists, no matter `.code-block` exists or not.
const btnContainer = el.closest('.code-block-container') ?? el.closest('.code-block');
btnContainer.append(btn);
});
}

View File

@@ -0,0 +1,8 @@
export function displayError(el: Element, err: Error): void {
el.classList.remove('is-loading');
const errorNode = document.createElement('pre');
errorNode.setAttribute('class', 'ui message error markup-block-error');
errorNode.textContent = err.message || String(err);
el.before(errorNode);
el.setAttribute('data-render-done', 'true');
}

View File

@@ -0,0 +1,21 @@
import {initMarkupCodeMermaid} from './mermaid.ts';
import {initMarkupCodeMath} from './math.ts';
import {initMarkupCodeCopy} from './codecopy.ts';
import {initMarkupRenderAsciicast} from './asciicast.ts';
import {initMarkupTasklist} from './tasklist.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initMarkupRenderIframe} from './render-iframe.ts';
import {initMarkupRefIssue} from './refissue.ts';
// code that runs for all markup content
export function initMarkupContent(): void {
registerGlobalSelectorFunc('.markup', (el: HTMLElement) => {
initMarkupCodeCopy(el);
initMarkupTasklist(el);
initMarkupCodeMermaid(el);
initMarkupCodeMath(el);
initMarkupRenderAsciicast(el);
initMarkupRenderIframe(el);
initMarkupRefIssue(el);
});
}

View File

@@ -0,0 +1,24 @@
import {convertHtmlToMarkdown} from './html2markdown.ts';
import {createElementFromHTML} from '../utils/dom.ts';
const h = createElementFromHTML;
test('convertHtmlToMarkdown', () => {
expect(convertHtmlToMarkdown(h(`<h1>h</h1>`))).toBe('# h');
expect(convertHtmlToMarkdown(h(`<strong>txt</strong>`))).toBe('**txt**');
expect(convertHtmlToMarkdown(h(`<em>txt</em>`))).toBe('_txt_');
expect(convertHtmlToMarkdown(h(`<del>txt</del>`))).toBe('~~txt~~');
expect(convertHtmlToMarkdown(h(`<a href="link">txt</a>`))).toBe('[txt](link)');
expect(convertHtmlToMarkdown(h(`<a href="https://link">https://link</a>`))).toBe('https://link');
expect(convertHtmlToMarkdown(h(`<img src="link">`))).toBe('![image](link)');
expect(convertHtmlToMarkdown(h(`<img src="link" alt="name">`))).toBe('![name](link)');
expect(convertHtmlToMarkdown(h(`<img src="link" width="1" height="1">`))).toBe('<img alt="image" width="1" height="1" src="link">');
expect(convertHtmlToMarkdown(h(`<p>txt</p>`))).toBe('txt\n');
expect(convertHtmlToMarkdown(h(`<blockquote>a\nb</blockquote>`))).toBe('> a\n> b\n');
expect(convertHtmlToMarkdown(h(`<ol><li>a<ul><li>b</li></ul></li></ol>`))).toBe('1. a\n * b\n\n');
expect(convertHtmlToMarkdown(h(`<ol><li><input checked>a</li></ol>`))).toBe('1. [x] a\n');
});

View File

@@ -0,0 +1,117 @@
import {html, htmlRaw} from '../utils/html.ts';
type Processor = (el: HTMLElement) => string | HTMLElement | void;
type Processors = {
[tagName: string]: Processor;
};
type ProcessorContext = {
elementIsFirst: boolean;
elementIsLast: boolean;
listNestingLevel: number;
};
function prepareProcessors(ctx:ProcessorContext): Processors {
const processors: Processors = {
H1(el: HTMLElement) {
const level = parseInt(el.tagName.slice(1));
el.textContent = `${'#'.repeat(level)} ${el.textContent.trim()}`;
},
STRONG(el: HTMLElement) {
return `**${el.textContent}**`;
},
EM(el: HTMLElement) {
return `_${el.textContent}_`;
},
DEL(el: HTMLElement) {
return `~~${el.textContent}~~`;
},
A(el: HTMLElement) {
const text = el.textContent || 'link';
const href = el.getAttribute('href');
if (/^https?:/.test(text) && text === href) {
return text;
}
return href ? `[${text}](${href})` : text;
},
IMG(el: HTMLElement) {
const alt = el.getAttribute('alt') || 'image';
const src = el.getAttribute('src');
const widthAttr = el.hasAttribute('width') ? htmlRaw` width="${el.getAttribute('width') || ''}"` : '';
const heightAttr = el.hasAttribute('height') ? htmlRaw` height="${el.getAttribute('height') || ''}"` : '';
if (widthAttr || heightAttr) {
return html`<img alt="${alt}"${widthAttr}${heightAttr} src="${src}">`;
}
return `![${alt}](${src})`;
},
P(el: HTMLElement) {
el.textContent = `${el.textContent}\n`;
},
BLOCKQUOTE(el: HTMLElement) {
el.textContent = `${el.textContent.replace(/^/mg, '> ')}\n`;
},
OL(el: HTMLElement) {
const preNewLine = ctx.listNestingLevel ? '\n' : '';
el.textContent = `${preNewLine}${el.textContent}\n`;
},
LI(el: HTMLElement) {
const parent = el.parentNode as HTMLElement;
const bullet = parent.tagName === 'OL' ? `1. ` : '* ';
const nestingIdentLevel = Math.max(0, ctx.listNestingLevel - 1);
el.textContent = `${' '.repeat(nestingIdentLevel * 4)}${bullet}${el.textContent}${ctx.elementIsLast ? '' : '\n'}`;
return el;
},
INPUT(el: HTMLElement) {
return (el as HTMLInputElement).checked ? '[x] ' : '[ ] ';
},
CODE(el: HTMLElement) {
const text = el.textContent;
if (el.parentNode && (el.parentNode as HTMLElement).tagName === 'PRE') {
el.textContent = `\`\`\`\n${text}\n\`\`\`\n`;
return el;
}
if (text.includes('`')) {
return `\`\` ${text} \`\``;
}
return `\`${text}\``;
},
};
processors['UL'] = processors.OL;
for (let level = 2; level <= 6; level++) {
processors[`H${level}`] = processors.H1;
}
return processors;
}
function processElement(ctx :ProcessorContext, processors: Processors, el: HTMLElement): string | void {
if (el.hasAttribute('data-markdown-generated-content')) return el.textContent;
if (el.tagName === 'A' && el.children.length === 1 && el.children[0].tagName === 'IMG') {
return processElement(ctx, processors, el.children[0] as HTMLElement);
}
const isListContainer = el.tagName === 'OL' || el.tagName === 'UL';
if (isListContainer) ctx.listNestingLevel++;
for (let i = 0; i < el.children.length; i++) {
ctx.elementIsFirst = i === 0;
ctx.elementIsLast = i === el.children.length - 1;
processElement(ctx, processors, el.children[i] as HTMLElement);
}
if (isListContainer) ctx.listNestingLevel--;
if (processors[el.tagName]) {
const ret = processors[el.tagName](el);
if (ret && ret !== el) {
el.replaceWith(typeof ret === 'string' ? document.createTextNode(ret) : ret);
}
}
}
export function convertHtmlToMarkdown(el: HTMLElement): string {
const div = document.createElement('div');
div.append(el);
const ctx = {} as ProcessorContext;
ctx.listNestingLevel = 0;
processElement(ctx, prepareProcessors(ctx), el);
return div.textContent;
}

47
web_src/js/markup/math.ts Normal file
View File

@@ -0,0 +1,47 @@
import {displayError} from './common.ts';
import {queryElems} from '../utils/dom.ts';
function targetElement(el: Element): {target: Element, displayAsBlock: boolean} {
// The target element is either the parent "code block with loading indicator", or itself
// It is designed to work for 2 cases (guaranteed by backend code):
// * <pre class="code-block is-loading"><code class="language-math display">...</code></pre>
// * <code class="language-math">...</code>
return {
target: el.closest('.code-block.is-loading') ?? el,
displayAsBlock: el.classList.contains('display'),
};
}
export async function initMarkupCodeMath(elMarkup: HTMLElement): Promise<void> {
// .markup code.language-math'
queryElems(elMarkup, 'code.language-math', async (el) => {
const [{default: katex}] = await Promise.all([
import(/* webpackChunkName: "katex" */'katex'),
import(/* webpackChunkName: "katex" */'katex/dist/katex.css'),
]);
const MAX_CHARS = 1000;
const MAX_SIZE = 25;
const MAX_EXPAND = 1000;
const {target, displayAsBlock} = targetElement(el);
if (target.hasAttribute('data-render-done')) return;
const source = el.textContent;
if (source.length > MAX_CHARS) {
displayError(target, new Error(`Math source of ${source.length} characters exceeds the maximum allowed length of ${MAX_CHARS}.`));
return;
}
try {
const tempEl = document.createElement(displayAsBlock ? 'p' : 'span');
katex.render(source, tempEl, {
maxSize: MAX_SIZE,
maxExpand: MAX_EXPAND,
displayMode: displayAsBlock, // katex: true for display (block) mode, false for inline mode
});
target.replaceWith(tempEl);
} catch (error) {
displayError(target, error);
}
});
}

View File

@@ -0,0 +1,89 @@
import {isDarkTheme} from '../utils.ts';
import {makeCodeCopyButton} from './codecopy.ts';
import {displayError} from './common.ts';
import {queryElems} from '../utils/dom.ts';
import {html, htmlRaw} from '../utils/html.ts';
const {mermaidMaxSourceCharacters} = window.config;
// margin removal is for https://github.com/mermaid-js/mermaid/issues/4907
const iframeCss = `:root {color-scheme: normal}
body {margin: 0; padding: 0; overflow: hidden}
#mermaid {display: block; margin: 0 auto}
blockquote, dd, dl, figure, h1, h2, h3, h4, h5, h6, hr, p, pre {margin: 0}`;
export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> {
// .markup code.language-mermaid
queryElems(elMarkup, 'code.language-mermaid', async (el) => {
const {default: mermaid} = await import(/* webpackChunkName: "mermaid" */'mermaid');
mermaid.initialize({
startOnLoad: false,
theme: isDarkTheme() ? 'dark' : 'neutral',
securityLevel: 'strict',
suppressErrorRendering: true,
});
const pre = el.closest('pre');
if (pre.hasAttribute('data-render-done')) return;
const source = el.textContent;
if (mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters) {
displayError(pre, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`));
return;
}
try {
await mermaid.parse(source);
} catch (err) {
displayError(pre, err);
return;
}
try {
// can't use bindFunctions here because we can't cross the iframe boundary. This
// means js-based interactions won't work but they aren't intended to work either
const {svg} = await mermaid.render('mermaid', source);
const iframe = document.createElement('iframe');
iframe.classList.add('markup-content-iframe', 'tw-invisible');
iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg)}</body></html>`;
const mermaidBlock = document.createElement('div');
mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden');
mermaidBlock.append(iframe);
const btn = makeCodeCopyButton();
btn.setAttribute('data-clipboard-text', source);
mermaidBlock.append(btn);
const updateIframeHeight = () => {
const body = iframe.contentWindow?.document?.body;
if (body) {
iframe.style.height = `${body.clientHeight}px`;
}
};
iframe.addEventListener('load', () => {
pre.replaceWith(mermaidBlock);
mermaidBlock.classList.remove('tw-hidden');
updateIframeHeight();
setTimeout(() => { // avoid flash of iframe background
mermaidBlock.classList.remove('is-loading');
iframe.classList.remove('tw-invisible');
}, 0);
// update height when element's visibility state changes, for example when the diagram is inside
// a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
// would initially set a incorrect height and the correct height is set during this callback.
(new IntersectionObserver(() => {
updateIframeHeight();
}, {root: document.documentElement})).observe(iframe);
});
document.body.append(mermaidBlock);
} catch (err) {
displayError(pre, err);
}
});
}

View File

@@ -0,0 +1,41 @@
import {queryElems} from '../utils/dom.ts';
import {parseIssueHref} from '../utils.ts';
import {createApp} from 'vue';
import ContextPopup from '../components/ContextPopup.vue';
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
export function initMarkupRefIssue(el: HTMLElement) {
queryElems(el, '.ref-issue', (el) => {
el.addEventListener('mouseenter', showMarkupRefIssuePopup);
el.addEventListener('focus', showMarkupRefIssuePopup);
});
}
export function showMarkupRefIssuePopup(e: MouseEvent | FocusEvent) {
const refIssue = e.currentTarget as HTMLElement;
if (getAttachedTippyInstance(refIssue)) return;
if (refIssue.classList.contains('ref-external-issue')) return;
const issuePathInfo = parseIssueHref(refIssue.getAttribute('href'));
if (!issuePathInfo.ownerName) return;
const el = document.createElement('div');
const tippy = createTippy(refIssue, {
theme: 'default',
content: el,
trigger: 'mouseenter focus',
placement: 'top-start',
interactive: true,
role: 'dialog',
interactiveBorder: 5,
// onHide() { return false }, // help to keep the popup and debug the layout
onShow: () => {
const view = createApp(ContextPopup, {
// backend: GetIssueInfo
loadIssueInfoUrl: `${window.config.appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`,
});
view.mount(el);
},
});
tippy.show();
}

View File

@@ -0,0 +1,32 @@
import {generateElemId, queryElemChildren} from '../utils/dom.ts';
import {isDarkTheme} from '../utils.ts';
export async function loadRenderIframeContent(iframe: HTMLIFrameElement) {
const iframeSrcUrl = iframe.getAttribute('data-src');
if (!iframe.id) iframe.id = generateElemId('gitea-iframe-');
window.addEventListener('message', (e) => {
if (!e.data?.giteaIframeCmd || e.data?.giteaIframeId !== iframe.id) return;
const cmd = e.data.giteaIframeCmd;
if (cmd === 'resize') {
iframe.style.height = `${e.data.iframeHeight}px`;
} else if (cmd === 'open-link') {
if (e.data.anchorTarget === '_blank') {
window.open(e.data.openLink, '_blank');
} else {
window.location.href = e.data.openLink;
}
} else {
throw new Error(`Unknown gitea iframe cmd: ${cmd}`);
}
});
const u = new URL(iframeSrcUrl, window.location.origin);
u.searchParams.set('gitea-is-dark-theme', String(isDarkTheme()));
u.searchParams.set('gitea-iframe-id', iframe.id);
iframe.src = u.href;
}
export function initMarkupRenderIframe(el: HTMLElement) {
queryElemChildren(el, 'iframe.external-render-iframe', loadRenderIframeContent);
}

View File

@@ -0,0 +1,90 @@
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
const preventListener = (e: Event) => e.preventDefault();
/**
* Attaches `input` handlers to markdown rendered tasklist checkboxes in comments.
*
* When a checkbox value changes, the corresponding [ ] or [x] in the markdown string
* is set accordingly and sent to the server. On success, it updates the raw-content on
* error it resets the checkbox to its original value.
*/
export function initMarkupTasklist(elMarkup: HTMLElement): void {
if (!elMarkup.matches('[data-can-edit=true]')) return;
const container = elMarkup.parentNode;
const checkboxes = elMarkup.querySelectorAll<HTMLInputElement>(`.task-list-item input[type=checkbox]`);
for (const checkbox of checkboxes) {
if (checkbox.hasAttribute('data-editable')) {
return;
}
checkbox.setAttribute('data-editable', 'true');
checkbox.addEventListener('input', async () => {
const checkboxCharacter = checkbox.checked ? 'x' : ' ';
const position = parseInt(checkbox.getAttribute('data-source-position')) + 1;
const rawContent = container.querySelector('.raw-content');
const oldContent = rawContent.textContent;
const encoder = new TextEncoder();
const buffer = encoder.encode(oldContent);
// Indexes may fall off the ends and return undefined.
if (buffer[position - 1] !== '['.codePointAt(0) ||
buffer[position] !== ' '.codePointAt(0) && buffer[position] !== 'x'.codePointAt(0) ||
buffer[position + 1] !== ']'.codePointAt(0)) {
// Position is probably wrong. Revert and don't allow change.
checkbox.checked = !checkbox.checked;
throw new Error(`Expected position to be space or x and surrounded by brackets, but it's not: position=${position}`);
}
buffer.set(encoder.encode(checkboxCharacter), position);
const newContent = new TextDecoder().decode(buffer);
if (newContent === oldContent) {
return;
}
// Prevent further inputs until the request is done. This does not use the
// `disabled` attribute because it causes the border to flash on click.
for (const checkbox of checkboxes) {
checkbox.addEventListener('click', preventListener);
}
try {
const editContentZone = container.querySelector<HTMLDivElement>('.edit-content-zone');
const updateUrl = editContentZone.getAttribute('data-update-url');
const context = editContentZone.getAttribute('data-context');
const contentVersion = editContentZone.getAttribute('data-content-version');
const requestBody = new FormData();
requestBody.append('ignore_attachments', 'true');
requestBody.append('content', newContent);
requestBody.append('context', context);
requestBody.append('content_version', contentVersion);
const response = await POST(updateUrl, {data: requestBody});
const data = await response.json();
if (response.status === 400) {
showErrorToast(data.errorMessage);
return;
}
editContentZone.setAttribute('data-content-version', data.contentVersion);
rawContent.textContent = newContent;
} catch (err) {
checkbox.checked = !checkbox.checked;
console.error(err);
}
// Enable input on checkboxes again
for (const checkbox of checkboxes) {
checkbox.removeEventListener('click', preventListener);
}
});
// Enable the checkboxes as they are initially disabled by the markdown renderer
for (const checkbox of checkboxes) {
checkbox.disabled = false;
}
}
}