PronounsPage/shared/parseMarkdown.ts
2025-08-25 21:01:11 +02:00

187 lines
7.9 KiB
TypeScript

import { fetchJson } from './fetchJson.ts';
import type { Translator } from './translator.ts';
let census_groups: Record<string, string> = {};
let census_comparisons: Record<string, string> = {};
const replaceQuoteEntity = (string: string): string => string.replace(/&quot;/g, '"');
const mainPlusDetails = (dict: Record<string, string>, wide: boolean) => (_: string, keys: string, content: string) => {
let selectedDict: Record<string, string> = {};
if (keys === undefined) {
selectedDict = dict;
} else {
for (const key of keys.substring(1).split(',')) {
selectedDict[key] = dict[key];
}
}
return `
<div class="${wide ? 'wide-escape' : ''}">
${content.replace(/%group%/g, 'general').replace(/<iframe class="graph" /g, '<iframe class="graph border" ')}${
Object.keys(selectedDict).map((group) => `
<details class="border mb-3">
<summary class="bg-light px-2 py-1" onclick="this.parentElement.querySelector('iframe.graph').contentDocument.location.reload()">${selectedDict[group]}</summary>
<div class="border-top p-md-3 bg-white">${content.replace(/%group%/g, group)}</div>
</details>`)
.join('\n')
}</div>`;
};
async function replaceAsync(str: string, regex: RegExp, asyncFn: (...args: string[]) => Promise<string>): Promise<string> {
const promises = [...str.matchAll(regex)].map((match) => asyncFn(...match));
const data = await Promise.all(promises);
return str.replace(regex, () => data.shift()!);
}
const generateToC = (content: string, translator: Translator) => (_: string) => {
const tags = [];
let curentLevel = 2;
let needsClosing = false;
for (const [, levelString, id, title] of content.matchAll(/<h([2-6]) id="([^"]+)">([^<]+)<\/h\1>/g)) {
const level = parseInt(levelString);
while (level < curentLevel) {
tags.push('</li>');
tags.push('</ul>');
curentLevel--;
}
while (level > curentLevel) {
tags.push('<ul>');
curentLevel++;
needsClosing = false;
}
if (needsClosing) {
tags.push('</li>');
}
tags.push('<li>');
tags.push(`<a href="#${id}">`);
tags.push(title);
tags.push('</a>');
needsClosing = true;
}
while (curentLevel < 2) {
tags.push('</li>');
tags.push('</ul>');
curentLevel--;
needsClosing = false;
}
if (needsClosing) {
tags.push('</li>');
}
return `
<div class="alert alert-light border">
<h2 class="h4"><span class="fal fa-list"></span> ${translator.translate('links.blogTOC')}</h2>
<ul class="mb-0">${tags.join('')}</ul>
</div>
`;
};
const generateGallery = (_: string, itemsString: string) => {
const items: Record<string, string> = JSON.parse(`{${replaceQuoteEntity(itemsString).replace(/,\s*$/, '')}}`);
const label = (alt: string): string => {
if (!alt.startsWith('! ')) {
return '';
}
return `<p class="small mt-2">${alt.substring(2)}</p>`;
};
const cells = Object.entries(items).map(([src, alt]) => `
<div class="col-6 col-lg-4 columnist-column mb-3">
<a href="${src}" target="_blank" rel="noopener">
<img src="${src}" alt="${alt.startsWith('! ') ? alt.substring(2) : alt}">
</a>
${label(alt)}
</div>
`);
return `<div class="row columnist-wall--disabled">${cells.join('')}</div>`;
};
export interface MarkdownInfo {
title: string | null;
img: string | null;
intro: string | null;
content: string | null;
}
export default async function parseMarkdown(markdown: string, translator: Translator): Promise<MarkdownInfo> {
let content = `<div>${
markdown
.replace(/<table>(.+?)<\/table>/gs, '<div class="table-responsive"><table class="table table-striped small">$1</table></div>')
.replace(/align="([^"]+)"/g, 'style="text-align:$1"')
.replace(/<a (href="http[^>]+)>/g, (_match, attributes) => {
return `<a ${attributes.includes('target=') ? '' : 'target="_blank" '}${attributes.includes('rel=') ? '' : 'rel="noopener" '}${attributes}>`;
})
.replace(/<p>{details=(.+?)}<\/p>(.+?)<p>{\/details}<\/p>/gms, '<details class="border mb-3"><summary class="bg-light p-3">$1</summary><div class="border-top p-3 bg-white">$2</div></details>')
.replace(/<p><img (.*?)><\/p>/g, (_, attrs) => {
let classNames = 'border';
const m = attrs.match(/alt="\{(.*)\}/);
if (m) {
classNames = m[1];
attrs = attrs.replace(/alt="{(.*)}/, 'alt="');
}
return `<div class="mb-3 text-center"><img ${attrs} class="${classNames}" loading="lazy"></div>`;
})
.replace(/{favicon=(.+?)}/g, '<img src="https://$1" alt="Favicon" style="width: 1em; height: 1em;">')
.replace(/<p>{embed=\/\/(.+?)=(.+?)}<\/p>/g, '<div style="position: relative;height: 0;padding-bottom: 56.25%;"><iframe src="https://$1" title="$2" allowfullscreen sandbox="allow-same-origin allow-scripts allow-popups" style="position: absolute;top: 0; left: 0;width: 100%;height: 100%;border:0;"></iframe></div>')
.replace(/<p>{graph=([^}]+)}<\/p>/g, '<iframe class="graph" src="$1.html" loading="lazy"></iframe>')
.replace(/<p>{set_census_groups=(.+?)}<\/p>/gms, (_, value) => {
census_groups = JSON.parse(replaceQuoteEntity(value));
return '';
})
.replace(/<p>{set_census_comparisons=(.+?)}<\/p>/gms, (_, value) => {
census_comparisons = JSON.parse(replaceQuoteEntity(value));
return '';
})
.replace(/<p>{census_groups(:.+?)?}<\/p>(.+?)<p>{\/census_groups}<\/p>/gms, mainPlusDetails(census_groups, false))
.replace(/<p>{census_comparisons(:.+?)?}<\/p>(.+?)<p>{\/census_comparisons}<\/p>/gms, mainPlusDetails(census_comparisons, true))
.replace(/<h1 id="🏳️🌈-/g, '<h1 id="') // license header
.replace(/ id=""/g, '')
.replace(/<p>{wide_table}<\/p>/g, '<div class="table-wide table-responsive my-5 headers-nowrap">')
.replace(/<p>{\/wide_table}<\/p>/g, '</div>')
.replace(/<p>{gallery={(.*?)}}<\/p>/gms, generateGallery)
.replace(/(?<![\w!])!!([^<>]*?)!!(?![\w!])/g, (_: string, spoilerString: string): string => {
return `<span class="spoiler">${spoilerString}</span>`;
})
}</div>`;
content = await replaceAsync(content, /{json=([^=}]+)=([^=}]+)}/g, async (_, filename, key) => {
try {
return await fetchJson(translator.config.locale, filename, key);
} catch (error) {
return `<span class="badge bg-danger text-white">${error}</span>`;
}
});
content = content.replace(/<p>{table_of_contents}<\/p>/g, generateToC(content, translator));
content = content.replace(/{optional}(.+?){\/optional}/gms, (_, content) => {
if (content.includes('badge bg-danger')) {
return '';
}
return content;
});
const titleMatch = content.match('<h1[^>]*>(.+?)</h1>');
const title = titleMatch ? replaceQuoteEntity(titleMatch[1]) : null;
const imgMatch = content.match('<img src="([^"]+)"[^>]*>');
const img = imgMatch ? imgMatch[1] : null;
let intro: string[] = [];
for (const introMatch of content.matchAll(/<p[^>]*>(.+?)<\/p>/gms)) {
const p = introMatch[1].replace(/(<([^>]+)>)/ig, '').replace(/\s+/g, ' ');
intro = [...intro, ...p.split(' ')];
}
return {
title,
img,
intro: intro.length ? intro.slice(0, 24).join(' ') : null,
content,
};
}