初始提交: Gitea 项目代码

This commit is contained in:
root
2026-05-30 22:47:36 +08:00
commit f288f76350
6116 changed files with 776822 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import {svg} from '../svg.ts';
// Rendered content from users have IDs prefixed with `user-content-` to avoid conflicts with other IDs on the page.
// - security concern: elements with IDs can affect frontend logic, for example: sending requests.
// To make end users have better experience, the prefixes are stripped from the href attributes of links.
// The same as GitHub: backend generates anchor `id="user-content-faq"` but the link shown to users is `href="#faq"`.
//
// At the moment, the anchor processing works like this:
// - backend adds `user-content-` prefix for elements like `<h1 id>` and `<a href>`
// - js adds the `user-content-` prefix to user-generated `<a name>` targets
// - js intercepts the hash navigation on page load and whenever a link is clicked
// to add the prefix so the correct prefixed `id`/`name` element is focused
//
// TODO: ideally, backend should be able to generate elements with necessary anchors,
// backend doesn't need to add the prefix to `href`, then frontend doesn't need to spend
// time on adding new elements or removing the prefixes.
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 | undefined;
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));
}
}
+16
View File
@@ -0,0 +1,16 @@
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([
import('asciinema-player'),
import('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',
});
});
}
+25
View File
@@ -0,0 +1,25 @@
import {svg} from '../svg.ts';
import {createElementFromAttrs, queryElems} from '../utils/dom.ts';
export function makeCodeCopyButton(attrs: Record<string, string> = {}): HTMLButtonElement {
const btn = createElementFromAttrs<HTMLButtonElement>('button', {
class: 'ui compact icon button code-copy auto-hide-control',
...attrs,
});
btn.innerHTML = svg('octicon-copy');
return btn;
}
export function initMarkupCodeCopy(elMarkup: HTMLElement): void {
// .markup .code-block code
queryElems(elMarkup, '.code-block code', (el) => {
if (!el.textContent) return;
// remove final trailing newline introduced during HTML rendering
const btn = makeCodeCopyButton({
'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);
});
}
+10
View File
@@ -0,0 +1,10 @@
import {errorMessage} from '../modules/errors.ts';
export function displayError(el: Element, err: unknown): void {
el.classList.remove('is-loading');
const errorNode = document.createElement('pre');
errorNode.setAttribute('class', 'ui message error markup-block-error');
errorNode.textContent = errorMessage(err);
el.before(errorNode);
el.setAttribute('data-render-done', 'true');
}
+29
View File
@@ -0,0 +1,29 @@
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 {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initExternalRenderIframe} from './render-iframe.ts';
import {toggleElemClass} from '../utils/dom.ts';
// code that runs for all markup content
export function initMarkupContent(): void {
registerGlobalInitFunc('initExternalRenderIframe', initExternalRenderIframe);
registerGlobalSelectorFunc('.markup', (el: HTMLElement) => {
if (el.matches('.truncated-markup')) {
// when the rendered markup is truncated (e.g.: user's home activity feed)
// we should not initialize any of the features (e.g.: code copy button), due to:
// * truncated markup already means that the container doesn't want to show complex contents
// * truncated markup may contain incomplete HTML/mermaid elements
// so the only thing we need to do is to remove the "is-loading" class added by the backend render.
toggleElemClass(el.querySelectorAll('.is-loading'), 'is-loading', false);
return;
}
initMarkupCodeCopy(el);
initMarkupTasklist(el);
initMarkupCodeMermaid(el);
initMarkupCodeMath(el);
initMarkupRenderAsciicast(el);
});
}
+24
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');
});
+117
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
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('katex'),
import('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);
}
});
}
+59
View File
@@ -0,0 +1,59 @@
import {sourceNeedsElk} from './mermaid.ts';
import {dedent} from '../utils/testhelper.ts';
test('MermaidConfigLayoutCheck', () => {
expect(sourceNeedsElk(dedent(`
flowchart TB
elk --> B
`))).toEqual(false);
expect(sourceNeedsElk(dedent(`
---
config:
layout : elk
---
flowchart TB
A --> B
`))).toEqual(true);
expect(sourceNeedsElk(dedent(`
---
config:
layout: elk.layered
---
flowchart TB
A --> B
`))).toEqual(true);
expect(sourceNeedsElk(`
%%{ init : { "flowchart": { "defaultRenderer": "elk" } } }%%
flowchart TB
A --> B
`)).toEqual(true);
expect(sourceNeedsElk(dedent(`
---
config:
layout: 123
---
%%{ init : { "class": { "defaultRenderer": "elk.any" } } }%%
flowchart TB
A --> B
`))).toEqual(true);
expect(sourceNeedsElk(`
%%{init:{
"layout" : "elk.layered"
}}%%
flowchart TB
A --> B
`)).toEqual(true);
expect(sourceNeedsElk(`
%%{ initialize: {
'layout' : 'elk.layered'
}}%%
flowchart TB
A --> B
`)).toEqual(true);
});
+250
View File
@@ -0,0 +1,250 @@
import {isDarkTheme} from '../utils.ts';
import {displayError} from './common.ts';
import {createElementFromAttrs, createElementFromHTML, queryElems} from '../utils/dom.ts';
import {html, htmlRaw} from '../utils/html.ts';
import {load as loadYaml} from 'js-yaml';
import type {MermaidConfig} from 'mermaid';
import {svg} from '../svg.ts';
const {mermaidMaxSourceCharacters} = window.config;
function getIframeCss(): string {
return `
html, body { height: 100%; }
body { margin: 0; padding: 0; overflow: hidden; }
#mermaid { display: block; margin: 0 auto; }
`;
}
function isSourceTooLarge(source: string) {
return mermaidMaxSourceCharacters >= 0 && source.length > mermaidMaxSourceCharacters;
}
function parseYamlInitConfig(source: string): MermaidConfig | null {
// ref: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/diagram-api/regexes.ts
const yamlFrontMatterRegex = /^---\s*[\n\r](.*?)[\n\r]---\s*[\n\r]+/s;
const frontmatter = (yamlFrontMatterRegex.exec(source) || [])[1];
if (!frontmatter) return null;
try {
return (loadYaml(frontmatter) as {config: MermaidConfig})?.config;
} catch {
console.error('invalid or unsupported mermaid init YAML config', frontmatter);
}
return null;
}
function parseJsonInitConfig(source: string): MermaidConfig | null {
// https://mermaid.js.org/config/directives.html#declaring-directives
// Do as dirty as mermaid does: https://github.com/mermaid-js/mermaid/blob/develop/packages/mermaid/src/utils.ts
// It can even accept invalid JSON string like:
// %%{initialize: { 'logLevel': 'fatal', "theme":'dark', 'startOnLoad': true } }%%
const jsonInitConfigRegex = /%%\{\s*(init|initialize)\s*:\s*(.*?)\}%%/s;
const jsonInitText = (jsonInitConfigRegex.exec(source) || [])[2];
if (!jsonInitText) return null;
try {
const processed = jsonInitText.trim().replace(/'/g, '"');
return JSON.parse(processed);
} catch {
console.error('invalid or unsupported mermaid init JSON config', jsonInitText);
}
return null;
}
function configValueIsElk(layoutOrRenderer: string | undefined) {
if (typeof layoutOrRenderer !== 'string') return false;
return layoutOrRenderer === 'elk' || layoutOrRenderer.startsWith('elk.');
}
function configContainsElk(config: MermaidConfig | null) {
if (!config) return false;
// Check the layout from the following properties:
// * config.layout
// * config.{any-diagram-config}.defaultRenderer
// Although only a few diagram types like "flowchart" support "defaultRenderer",
// as long as there is no side effect, here do a general check for all properties of "config", for ease of maintenance
return configValueIsElk(config.layout) || Object.values(config).some((diagCfg) => configValueIsElk(diagCfg?.defaultRenderer));
}
export function sourceNeedsElk(source: string) {
if (isSourceTooLarge(source)) return false;
const configYaml = parseYamlInitConfig(source), configJson = parseJsonInitConfig(source);
return configContainsElk(configYaml) || configContainsElk(configJson);
}
async function loadMermaid(needElkRender: boolean) {
const mermaidPromise = import('mermaid');
const elkPromise = needElkRender ? import('@mermaid-js/layout-elk') : null;
const results = await Promise.all([mermaidPromise, elkPromise]);
return {
mermaid: results[0].default,
elkLayouts: results[1]?.default,
};
}
function initMermaidViewController(viewController: Element, dragElement: SVGSVGElement) {
let inited = false, isDragging = false;
let currentScale = 1, initLeft = 0, lastLeft = 0, lastTop = 0, lastPageX = 0, lastPageY = 0;
const resetView = () => {
currentScale = 1;
lastLeft = initLeft;
lastTop = 0;
dragElement.style.left = `${lastLeft}px`;
dragElement.style.top = `${lastTop}px`;
dragElement.style.position = 'absolute';
dragElement.style.margin = '0';
};
const initAbsolutePosition = () => {
if (inited) return;
// if we need to drag or zoom, use absolute position and get the current "left" from the "margin: auto" layout.
inited = true;
const container = dragElement.parentElement!;
initLeft = container.getBoundingClientRect().width / 2 - dragElement.getBoundingClientRect().width / 2;
resetView();
};
for (const el of viewController.querySelectorAll('[data-control-action]')) {
el.addEventListener('click', () => {
initAbsolutePosition();
switch (el.getAttribute('data-control-action')) {
case 'zoom-in':
currentScale *= 1.2;
break;
case 'zoom-out':
currentScale /= 1.2;
break;
case 'reset':
resetView();
break;
}
dragElement.style.transform = `scale(${currentScale})`;
});
}
dragElement.addEventListener('mousedown', (e) => {
if (e.button !== 0 || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return; // only left mouse button can drag
const target = e.target as Element;
// don't start the drag if the click is on an interactive element (e.g.: link, button) or text element
if (target.closest('div, p, a, span, button, input, text')) return;
initAbsolutePosition();
isDragging = true;
lastPageX = e.pageX;
lastPageY = e.pageY;
dragElement.style.cursor = 'grabbing';
});
dragElement.ownerDocument.addEventListener('mousemove', (e) => {
if (!isDragging) return;
lastLeft = e.pageX - lastPageX + lastLeft;
lastTop = e.pageY - lastPageY + lastTop;
dragElement.style.left = `${lastLeft}px`;
dragElement.style.top = `${lastTop}px`;
lastPageX = e.pageX;
lastPageY = e.pageY;
});
dragElement.ownerDocument.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
dragElement.style.removeProperty('cursor');
});
}
let elkLayoutsRegistered = false;
export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void> {
// .markup code.language-mermaid
const mermaidBlocks: Array<{source: string, parentContainer: HTMLElement}> = [];
const attrMermaidRendered = 'data-markup-mermaid-rendered';
let needElkRender = false;
for (const elCodeBlock of queryElems(elMarkup, 'code.language-mermaid')) {
const parentContainer = elCodeBlock.closest('pre')!; // it must exist, if no, there must be a bug
if (parentContainer.hasAttribute(attrMermaidRendered)) continue;
parentContainer.setAttribute(attrMermaidRendered, 'true');
const source = elCodeBlock.textContent ?? '';
needElkRender = needElkRender || sourceNeedsElk(source);
mermaidBlocks.push({source, parentContainer});
}
if (!mermaidBlocks.length) return;
const {mermaid, elkLayouts} = await loadMermaid(needElkRender);
if (elkLayouts && !elkLayoutsRegistered) {
mermaid.registerLayoutLoaders(elkLayouts);
elkLayoutsRegistered = true;
}
mermaid.initialize({
startOnLoad: false,
theme: isDarkTheme() ? 'dark' : 'neutral', // TODO: maybe it should use "darkMode" to adopt more user-specified theme instead of just "dark" or "neutral"
securityLevel: 'strict',
suppressErrorRendering: true,
});
const iframeStyleText = getIframeCss();
const applyMermaidIframeHeight = (iframe: HTMLIFrameElement, height: number) => {
if (!height) return;
// use a min-height to make sure the buttons won't overlap.
iframe.style.height = `${Math.max(height, 85)}px`;
};
// mermaid is a globally shared instance, its document also says "Multiple calls to this function will be enqueued to run serially."
// so here we just simply render the mermaid blocks one by one, no need to do "Promise.all" concurrently
for (const block of mermaidBlocks) {
const {source, parentContainer} = block;
if (isSourceTooLarge(source)) {
displayError(parentContainer, new Error(`Mermaid source of ${source.length} characters exceeds the maximum allowed length of ${mermaidMaxSourceCharacters}.`));
continue;
}
try {
// render the mermaid diagram to svg text, and parse it to a DOM node
const {svg: svgText, bindFunctions} = await mermaid.render('mermaid', source, parentContainer);
const svgNode = createElementFromHTML<SVGSVGElement>(svgText);
const viewControllerHtml = html`
<div class="view-controller auto-hide-control flex-text-block">
<button type="button" class="ui tiny compact icon button" data-control-action="zoom-in">${htmlRaw(svg('octicon-zoom-in', 12))}</button>
<button type="button" class="ui tiny compact icon button" data-control-action="reset">${htmlRaw(svg('octicon-sync', 12))}</button>
<button type="button" class="ui tiny compact icon button" data-control-action="zoom-out">${htmlRaw(svg('octicon-zoom-out', 12))}</button>
</div>
`;
const viewController = createElementFromHTML(viewControllerHtml);
// create an iframe to sandbox the svg with styles, and set correct height by reading svg's viewBox height
const iframe = document.createElement('iframe');
iframe.classList.add('markup-content-iframe', 'is-loading');
// the styles are not ready, so don't really render anything before the "load" event, to avoid flicker of unstyled content
iframe.srcdoc = html`<html><head></head><body></body></html>`;
// although the "viewBox" is optional, mermaid's output should always have a correct viewBox with width and height
const iframeHeightFromViewBox = Math.ceil(svgNode.viewBox?.baseVal?.height ?? 0);
applyMermaidIframeHeight(iframe, iframeHeightFromViewBox);
// the iframe will be fully reloaded if its DOM context is changed (e.g.: moved in the DOM tree).
// to avoid unnecessary reloading, we should insert the iframe to its final position only once.
iframe.addEventListener('load', () => {
// same origin, so we can operate "iframe head/body" and all elements directly
const style = document.createElement('style');
style.textContent = iframeStyleText;
iframe.contentDocument!.head.append(style);
const iframeBody = iframe.contentDocument!.body;
iframeBody.append(svgNode);
bindFunctions?.(iframeBody); // follow "mermaid.render" doc, attach event handlers to the svg's container
// according to mermaid, the viewBox height should always exist, here just a fallback for unknown cases.
// and keep in mind: clientHeight can be 0 if the element is hidden (display: none).
if (!iframeHeightFromViewBox) applyMermaidIframeHeight(iframe, iframeBody.clientHeight);
iframe.classList.remove('is-loading');
initMermaidViewController(viewController, svgNode);
});
const container = createElementFromAttrs('div', {class: 'mermaid-block'}, iframe, viewController);
parentContainer.replaceWith(container);
} catch (err) {
displayError(parentContainer, err);
}
}
}
+48
View File
@@ -0,0 +1,48 @@
import {navigateToIframeLink} from './render-iframe.ts';
describe('navigateToIframeLink', () => {
const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
const assignSpy = vi.spyOn(window.location, 'assign').mockImplementation(() => undefined);
test('safe links', () => {
navigateToIframeLink('http://example.com', '_blank');
expect(openSpy).toHaveBeenCalledWith('http://example.com/', '_blank', 'noopener,noreferrer');
vi.clearAllMocks();
navigateToIframeLink('https://example.com', '_self');
expect(assignSpy).toHaveBeenCalledWith('https://example.com/');
vi.clearAllMocks();
navigateToIframeLink('https://example.com', null);
expect(assignSpy).toHaveBeenCalledWith('https://example.com/');
vi.clearAllMocks();
navigateToIframeLink('/path', '');
expect(assignSpy).toHaveBeenCalledWith('http://localhost:3000/path');
vi.clearAllMocks();
// input can be any type & any value, keep the same behavior as `window.location.href = 0`
navigateToIframeLink(0, {});
expect(assignSpy).toHaveBeenCalledWith('http://localhost:3000/0');
vi.clearAllMocks();
});
test('unsafe links', () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
window.location.href = 'http://localhost:3000/';
// eslint-disable-next-line no-script-url
navigateToIframeLink('javascript:void(0);', '_blank');
expect(openSpy).toHaveBeenCalledTimes(0);
expect(assignSpy).toHaveBeenCalledTimes(0);
expect(window.location.href).toBe('http://localhost:3000/');
vi.clearAllMocks();
navigateToIframeLink('data:image/svg+xml;utf8,<svg></svg>', '');
expect(openSpy).toHaveBeenCalledTimes(0);
expect(assignSpy).toHaveBeenCalledTimes(0);
expect(window.location.href).toBe('http://localhost:3000/');
errorSpy.mockRestore();
vi.clearAllMocks();
});
});
+73
View File
@@ -0,0 +1,73 @@
import {generateElemId} from '../utils/dom.ts';
import {errorMessage} from '../modules/errors.ts';
import {isDarkTheme} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
function safeRenderIframeLink(link: any): string | null {
try {
const url = new URL(`${link}`, window.location.href);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
console.error(`Unsupported link protocol: ${link}`);
return null;
}
return url.href;
} catch (e) {
console.error(`Failed to parse link: ${link}, error: ${errorMessage(e)}`);
return null;
}
}
// This function is only designed for "open-link" command from iframe, is not suitable for other contexts.
// Because other link protocols are directly handled by the iframe, but not here.
// Arguments can be any type & any value, they are from "message" event's data which is not controlled by us.
export function navigateToIframeLink(unsafeLink: any, target: any) {
const linkHref = safeRenderIframeLink(unsafeLink);
if (linkHref === null) return;
if (target === '_blank') {
window.open(linkHref, '_blank', 'noopener,noreferrer');
return;
}
// treat all other targets including ("_top", "_self", etc.) as same tab navigation
window.location.assign(linkHref);
}
function getRealBackgroundColor(el: HTMLElement) {
for (let n = el; n; n = n.parentElement!) {
const style = window.getComputedStyle(n);
const bgColor = style.backgroundColor;
// 'rgba(0, 0, 0, 0)' is how most browsers represent transparent
if (bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
return bgColor;
}
}
return '';
}
export async function initExternalRenderIframe(iframe: HTMLIFrameElement) {
const iframeSrcUrl = iframe.getAttribute('data-src')!;
if (!iframe.id) iframe.id = generateElemId('gitea-iframe-');
window.addEventListener('message', (e) => {
if (e.source !== iframe.contentWindow) return;
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') {
navigateToIframeLink(e.data.openLink, e.data.anchorTarget);
} 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);
u.searchParams.set('gitea-iframe-bgcolor', getRealBackgroundColor(iframe));
// It must use "srcdoc" here, because our backend always sends CSP sandbox directive for the rendered content
// (to protect from XSS risks), so we can't use "src" to load the content directly, otherwise there will be console errors like:
// Unsafe attempt to load URL http://localhost:3000/test from frame with URL http://localhost:3000/test
const resp = await GET(u.href);
iframe.srcdoc = await resp.text();
}
+9
View File
@@ -0,0 +1,9 @@
import {toggleTasklistCheckbox} from './tasklist.ts';
test('toggleTasklistCheckbox', () => {
expect(toggleTasklistCheckbox('- [ ] task', 3, true)).toEqual('- [x] task');
expect(toggleTasklistCheckbox('- [x] task', 3, false)).toEqual('- [ ] task');
expect(toggleTasklistCheckbox('- [ ] task', 0, true)).toBeNull();
expect(toggleTasklistCheckbox('- [ ] task', 99, true)).toBeNull();
expect(toggleTasklistCheckbox('😀 - [ ] task', 8, true)).toEqual('😀 - [x] task');
});
+100
View File
@@ -0,0 +1,100 @@
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
const preventListener = (e: Event) => e.preventDefault();
/**
* Toggle a task list checkbox in markdown content.
* `position` is the byte offset of the space or `x` character inside `[ ]`.
* Returns the updated content, or null if the position is invalid.
*/
export function toggleTasklistCheckbox(content: string, position: number, checked: boolean): string | null {
const buffer = new TextEncoder().encode(content);
// Indexes may fall off the ends and return undefined.
if (buffer[position - 1] !== '['.charCodeAt(0) ||
buffer[position] !== ' '.charCodeAt(0) && buffer[position] !== 'x'.charCodeAt(0) ||
buffer[position + 1] !== ']'.charCodeAt(0)) {
return null;
}
buffer[position] = checked ? 'x'.charCodeAt(0) : ' '.charCodeAt(0);
return new TextDecoder().decode(buffer);
}
/**
* 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 position = parseInt(checkbox.getAttribute('data-source-position')!) + 1;
const rawContent = container.querySelector('.raw-content')!;
const oldContent = rawContent.textContent;
const newContent = toggleTasklistCheckbox(oldContent, position, checkbox.checked);
if (newContent === null) {
// 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}`);
}
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;
}
}
}