Complete Forgejo discussion MVP

This commit is contained in:
kacper 2026-04-13 18:19:50 -04:00
parent d84a885fdb
commit 51706d2d11
17 changed files with 1708 additions and 127 deletions

View file

@ -20,7 +20,7 @@ function escapeHtml(value: string): string {
.replace(/'/g, "'");
}
function normalizeLinkTarget(value: string): string | null {
function normalizeLinkTarget(value: string, baseUrl?: string): string | null {
const trimmed = value.trim();
if (!trimmed) {
return null;
@ -31,7 +31,7 @@ function normalizeLinkTarget(value: string): string | null {
}
try {
const url = new URL(trimmed);
const url = new URL(trimmed, baseUrl || undefined);
if (url.protocol === "http:" || url.protocol === "https:") {
return escapeHtml(url.toString());
}
@ -42,7 +42,7 @@ function normalizeLinkTarget(value: string): string | null {
return null;
}
function renderInline(markdown: string): string {
function renderInline(markdown: string, baseUrl?: string): string {
const codeTokens: string[] = [];
let rendered = escapeHtml(markdown);
@ -51,10 +51,21 @@ function renderInline(markdown: string): string {
codeTokens.push(`<code>${code}</code>`);
return token;
});
rendered = rendered.replace(
/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(_match, label: string, href: string) => {
const safeHref = normalizeLinkTarget(href, baseUrl);
if (!safeHref) {
return label;
}
return `<img src="${safeHref}" alt="${label}" loading="lazy" />`;
},
);
rendered = rendered.replace(
/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g,
(_match, label: string, href: string) => {
const safeHref = normalizeLinkTarget(href);
const safeHref = normalizeLinkTarget(href, baseUrl);
if (!safeHref) {
return label;
}
@ -83,12 +94,12 @@ function createParserState(): ParserState {
};
}
function flushParagraph(state: ParserState) {
function flushParagraph(state: ParserState, baseUrl?: string) {
if (state.paragraphLines.length === 0) {
return;
}
state.output.push(`<p>${renderInline(state.paragraphLines.join(" "))}</p>`);
state.output.push(`<p>${renderInline(state.paragraphLines.join(" "), baseUrl)}</p>`);
state.paragraphLines.length = 0;
}
@ -104,13 +115,13 @@ function flushList(state: ParserState) {
state.listType = null;
}
function flushBlockquote(state: ParserState) {
function flushBlockquote(state: ParserState, baseUrl?: string) {
if (state.blockquoteLines.length === 0) {
return;
}
state.output.push(
`<blockquote><p>${renderInline(state.blockquoteLines.join(" "))}</p></blockquote>`,
`<blockquote><p>${renderInline(state.blockquoteLines.join(" "), baseUrl)}</p></blockquote>`,
);
state.blockquoteLines.length = 0;
}
@ -131,10 +142,10 @@ function flushCodeBlock(state: ParserState) {
state.codeLines.length = 0;
}
function flushInlineBlocks(state: ParserState) {
flushParagraph(state);
function flushInlineBlocks(state: ParserState, baseUrl?: string) {
flushParagraph(state, baseUrl);
flushList(state);
flushBlockquote(state);
flushBlockquote(state, baseUrl);
}
function handleCodeBlockLine(state: ParserState, line: string): boolean {
@ -151,124 +162,129 @@ function handleCodeBlockLine(state: ParserState, line: string): boolean {
return true;
}
function handleFenceStart(state: ParserState, line: string): boolean {
function handleFenceStart(state: ParserState, line: string, baseUrl?: string): boolean {
if (!line.trim().startsWith("```")) {
return false;
}
flushInlineBlocks(state);
flushInlineBlocks(state, baseUrl);
state.inCodeBlock = true;
state.codeLanguage = line.trim().slice(3).trim();
return true;
}
function handleBlankLine(state: ParserState, line: string): boolean {
function handleBlankLine(state: ParserState, line: string, baseUrl?: string): boolean {
if (line.trim()) {
return false;
}
flushInlineBlocks(state);
flushInlineBlocks(state, baseUrl);
return true;
}
function handleHeadingLine(state: ParserState, line: string): boolean {
function handleHeadingLine(state: ParserState, line: string, baseUrl?: string): boolean {
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (!headingMatch) {
return false;
}
flushInlineBlocks(state);
flushInlineBlocks(state, baseUrl);
const level = headingMatch[1].length;
state.output.push(`<h${level}>${renderInline(headingMatch[2].trim())}</h${level}>`);
state.output.push(`<h${level}>${renderInline(headingMatch[2].trim(), baseUrl)}</h${level}>`);
return true;
}
function handleRuleLine(state: ParserState, line: string): boolean {
function handleRuleLine(state: ParserState, line: string, baseUrl?: string): boolean {
if (!/^(-{3,}|\*{3,})$/.test(line.trim())) {
return false;
}
flushInlineBlocks(state);
flushInlineBlocks(state, baseUrl);
state.output.push("<hr />");
return true;
}
function handleListLine(state: ParserState, line: string, listType: ListType): boolean {
function handleListLine(
state: ParserState,
line: string,
listType: ListType,
baseUrl?: string,
): boolean {
const pattern = listType === "ul" ? /^[-*+]\s+(.*)$/ : /^\d+\.\s+(.*)$/;
const match = line.match(pattern);
if (!match) {
return false;
}
flushParagraph(state);
flushBlockquote(state);
flushParagraph(state, baseUrl);
flushBlockquote(state, baseUrl);
if (state.listType !== listType) {
flushList(state);
state.listType = listType;
}
state.listItems.push(renderInline(match[1].trim()));
state.listItems.push(renderInline(match[1].trim(), baseUrl));
return true;
}
function handleBlockquoteLine(state: ParserState, line: string): boolean {
function handleBlockquoteLine(state: ParserState, line: string, baseUrl?: string): boolean {
const match = line.match(/^>\s?(.*)$/);
if (!match) {
return false;
}
flushParagraph(state);
flushParagraph(state, baseUrl);
flushList(state);
state.blockquoteLines.push(match[1].trim());
return true;
}
function handleParagraphLine(state: ParserState, line: string) {
function handleParagraphLine(state: ParserState, line: string, baseUrl?: string) {
flushList(state);
flushBlockquote(state);
flushBlockquote(state, baseUrl);
state.paragraphLines.push(line.trim());
}
function processMarkdownLine(state: ParserState, line: string) {
function processMarkdownLine(state: ParserState, line: string, baseUrl?: string) {
if (handleCodeBlockLine(state, line)) {
return;
}
if (handleFenceStart(state, line)) {
if (handleFenceStart(state, line, baseUrl)) {
return;
}
if (handleBlankLine(state, line)) {
if (handleBlankLine(state, line, baseUrl)) {
return;
}
if (handleHeadingLine(state, line)) {
if (handleHeadingLine(state, line, baseUrl)) {
return;
}
if (handleRuleLine(state, line)) {
if (handleRuleLine(state, line, baseUrl)) {
return;
}
if (handleListLine(state, line, "ul") || handleListLine(state, line, "ol")) {
if (handleListLine(state, line, "ul", baseUrl) || handleListLine(state, line, "ol", baseUrl)) {
return;
}
if (handleBlockquoteLine(state, line)) {
if (handleBlockquoteLine(state, line, baseUrl)) {
return;
}
handleParagraphLine(state, line);
handleParagraphLine(state, line, baseUrl);
}
function markdownToHtml(markdown: string): string {
function markdownToHtml(markdown: string, baseUrl?: string): string {
const state = createParserState();
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
for (const line of lines) {
processMarkdownLine(state, line);
processMarkdownLine(state, line, baseUrl);
}
flushInlineBlocks(state);
flushInlineBlocks(state, baseUrl);
flushCodeBlock(state);
return state.output.join("");
}
@ -288,8 +304,8 @@ export function stripLeadingTitleHeading(markdown: string, title: string): strin
return markdown;
}
export function MarkdownContent(props: { markdown: string; className?: string }) {
const html = markdownToHtml(props.markdown);
export function MarkdownContent(props: { markdown: string; className?: string; baseUrl?: string }) {
const html = markdownToHtml(props.markdown, props.baseUrl);
const className = props.className ? `markdown-content ${props.className}` : "markdown-content";
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;