初始提交: Gitea 项目代码
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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('');
|
||||
expect(convertHtmlToMarkdown(h(`<img src="link" alt="name">`))).toBe('');
|
||||
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');
|
||||
});
|
||||
@@ -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 ``;
|
||||
},
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user