This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
export async function fetchJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export function formatDate(value) {
|
||||
const date = new Date(value);
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function readFileAsDataUrl(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.addEventListener('load', () => resolve(reader.result));
|
||||
reader.addEventListener('error', () => reject(reader.error));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { estimateReadingMinutes } from './markdown/estimateReadingMinutes.js';
|
||||
export { htmlToMarkdown } from './markdown/htmlToMarkdown.js';
|
||||
export { markdownToHtml } from './markdown/markdownToHtml.js';
|
||||
export { previewStyles } from './markdown/previewStyles.js';
|
||||
@@ -0,0 +1,12 @@
|
||||
export function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export function escapeAttribute(value) {
|
||||
return escapeHtml(value).replace(/`/g, '`');
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function estimateReadingMinutes(markdown) {
|
||||
const text = markdown
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, ' ')
|
||||
.replace(/\[[^\]]+\]\([^)]+\)/g, ' ')
|
||||
.replace(/[#>*_`-]/g, ' ');
|
||||
const words = text.match(/[a-zа-я0-9]+/giu) || [];
|
||||
|
||||
return Math.max(1, Math.ceil(words.length / 180));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { nodeToMarkdown } from './nodeToMarkdown.js';
|
||||
import { normalizeMarkdown } from './normalizeMarkdown.js';
|
||||
|
||||
export function htmlToMarkdown(html) {
|
||||
const template = document.createElement('template');
|
||||
template.innerHTML = html;
|
||||
|
||||
return normalizeMarkdown(Array.from(template.content.childNodes).map(nodeToMarkdown).join(''));
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { escapeAttribute, escapeHtml } from './escapeHtml.js';
|
||||
import { normalizeAssetUrl } from './normalizeAssetUrl.js';
|
||||
|
||||
export function inlineToHtml(value) {
|
||||
const codeTokens = [];
|
||||
let html = escapeHtml(value).replace(/`([^`]+)`/g, (_match, code) => {
|
||||
const token = `@@CODE${codeTokens.length}@@`;
|
||||
codeTokens.push(`<code>${code}</code>`);
|
||||
return token;
|
||||
});
|
||||
|
||||
html = html
|
||||
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (match, alt, url) => renderImage(match, alt, url))
|
||||
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (match, text, url) => renderLink(match, text, url))
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||
|
||||
codeTokens.forEach((code, index) => {
|
||||
html = html.replace(`@@CODE${index}@@`, code);
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderImage(match, alt, url) {
|
||||
const safeUrl = normalizeAssetUrl(url);
|
||||
return safeUrl ? `<img src="${escapeAttribute(safeUrl)}" alt="${escapeAttribute(alt)}" />` : escapeHtml(match);
|
||||
}
|
||||
|
||||
function renderLink(match, text, url) {
|
||||
const safeUrl = normalizeAssetUrl(url);
|
||||
return safeUrl ? `<a href="${escapeAttribute(safeUrl)}">${text}</a>` : escapeHtml(match);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { escapeHtml } from './escapeHtml.js';
|
||||
import { inlineToHtml } from './inlineToHtml.js';
|
||||
|
||||
export function markdownToHtml(markdown) {
|
||||
const lines = markdown.replace(/\r\n?/g, '\n').split('\n');
|
||||
const html = [];
|
||||
const paragraph = [];
|
||||
let listType = null;
|
||||
let codeLines = null;
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraph.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
html.push(`<p>${inlineToHtml(paragraph.join(' '))}</p>`);
|
||||
paragraph.length = 0;
|
||||
};
|
||||
|
||||
const closeList = () => {
|
||||
if (!listType) {
|
||||
return;
|
||||
}
|
||||
|
||||
html.push(`</${listType}>`);
|
||||
listType = null;
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
if (/^```/.test(line)) {
|
||||
codeLines = toggleCodeBlock({ codeLines, flushParagraph, closeList, html });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (codeLines) {
|
||||
codeLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
flushParagraph();
|
||||
closeList();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (renderHeading({ closeList, flushParagraph, html, line })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (renderQuote({ closeList, flushParagraph, html, line })) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const renderedListType = renderListItem({ closeList, flushParagraph, html, line, listType });
|
||||
if (renderedListType) {
|
||||
listType = renderedListType;
|
||||
continue;
|
||||
}
|
||||
|
||||
paragraph.push(line.trim());
|
||||
}
|
||||
|
||||
if (codeLines) {
|
||||
html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
closeList();
|
||||
|
||||
return html.join('\n');
|
||||
}
|
||||
|
||||
function toggleCodeBlock({ closeList, codeLines, flushParagraph, html }) {
|
||||
if (codeLines) {
|
||||
html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
|
||||
return null;
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
closeList();
|
||||
return [];
|
||||
}
|
||||
|
||||
function renderHeading({ closeList, flushParagraph, html, line }) {
|
||||
const heading = line.match(/^(#{1,3})\s+(.+)$/);
|
||||
if (!heading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
closeList();
|
||||
html.push(`<h${heading[1].length}>${inlineToHtml(heading[2])}</h${heading[1].length}>`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderQuote({ closeList, flushParagraph, html, line }) {
|
||||
const quote = line.match(/^>\s?(.+)$/);
|
||||
if (!quote) {
|
||||
return false;
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
closeList();
|
||||
html.push(`<blockquote>${inlineToHtml(quote[1])}</blockquote>`);
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderListItem({ closeList, flushParagraph, html, line, listType }) {
|
||||
const unordered = line.match(/^[-*]\s+(.+)$/);
|
||||
const ordered = line.match(/^\d+\.\s+(.+)$/);
|
||||
if (!unordered && !ordered) {
|
||||
return null;
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
const nextListType = unordered ? 'ul' : 'ol';
|
||||
|
||||
if (listType !== nextListType) {
|
||||
closeList();
|
||||
html.push(`<${nextListType}>`);
|
||||
}
|
||||
|
||||
html.push(`<li>${inlineToHtml((unordered || ordered)[1])}</li>`);
|
||||
return nextListType;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
export function nodeToMarkdown(node) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent.replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return elementToMarkdown(node);
|
||||
}
|
||||
|
||||
function elementToMarkdown(node) {
|
||||
const tag = node.tagName.toLowerCase();
|
||||
const children = () => Array.from(node.childNodes).map(nodeToMarkdown).join('').trim();
|
||||
|
||||
const renderers = {
|
||||
a: () => renderLink(node, children),
|
||||
b: () => `**${children()}**`,
|
||||
blockquote: () => renderBlockquote(children()),
|
||||
br: () => '\n',
|
||||
code: () => renderCode(node),
|
||||
em: () => `*${children()}*`,
|
||||
figcaption: () => `\n*${children()}*`,
|
||||
figure: () => `\n\n${children()}\n\n`,
|
||||
i: () => `*${children()}*`,
|
||||
img: () => renderImage(node),
|
||||
li: () => `- ${children()}\n`,
|
||||
ol: () => renderList(node),
|
||||
p: () => `\n\n${children()}\n\n`,
|
||||
pre: () => `\n\n\`\`\`\n${node.textContent.trim()}\n\`\`\`\n\n`,
|
||||
strong: () => `**${children()}**`,
|
||||
ul: () => renderList(node),
|
||||
};
|
||||
|
||||
if (/^h[1-6]$/.test(tag)) {
|
||||
return renderHeading(tag, children());
|
||||
}
|
||||
|
||||
return renderers[tag]?.() || children();
|
||||
}
|
||||
|
||||
function renderBlockquote(content) {
|
||||
return `\n\n${content
|
||||
.split('\n')
|
||||
.map((line) => (line.trim() ? `> ${line.trim()}` : '>'))
|
||||
.join('\n')}\n\n`;
|
||||
}
|
||||
|
||||
function renderCode(node) {
|
||||
return node.parentElement?.tagName.toLowerCase() === 'pre' ? node.textContent : `\`${node.textContent.trim()}\``;
|
||||
}
|
||||
|
||||
function renderHeading(tag, content) {
|
||||
const level = Math.min(Number(tag.slice(1)), 3);
|
||||
return `\n\n${'#'.repeat(level)} ${content}\n\n`;
|
||||
}
|
||||
|
||||
function renderImage(node) {
|
||||
const alt = node.getAttribute('alt') || '';
|
||||
const src = node.getAttribute('src') || '';
|
||||
return src ? `` : '';
|
||||
}
|
||||
|
||||
function renderLink(node, children) {
|
||||
const text = children() || node.getAttribute('href') || '';
|
||||
const href = node.getAttribute('href') || '';
|
||||
return href ? `[${text}](${href})` : text;
|
||||
}
|
||||
|
||||
function renderList(node) {
|
||||
return `\n${Array.from(node.children).map(nodeToMarkdown).join('')}\n`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function normalizeAssetUrl(url) {
|
||||
const normalized = url.trim();
|
||||
|
||||
if (/^(https?:|\/|#)/i.test(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function normalizeMarkdown(markdown) {
|
||||
return markdown
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export const previewStyles = `
|
||||
body { margin: 0; padding: 24px; color: #1e252d; font: 18px/1.65 Arial, Helvetica, sans-serif; }
|
||||
h1 { margin: 38px 0 14px; font-size: 34px; line-height: 1.15; }
|
||||
h2 { margin: 36px 0 12px; font-size: 30px; line-height: 1.2; }
|
||||
h3 { margin: 30px 0 10px; font-size: 24px; line-height: 1.2; }
|
||||
p, ul, ol, blockquote { margin: 0 0 20px; }
|
||||
blockquote { border-left: 3px solid #cfd7df; padding-left: 16px; color: #617080; }
|
||||
a { color: #176b5d; }
|
||||
img { max-width: 100%; border: 1px solid #d8d0c2; border-radius: 8px; }
|
||||
pre { overflow-x: auto; border-radius: 8px; padding: 18px 20px; background: #111827; color: #e5e7eb; font-size: 15px; }
|
||||
code { font-family: SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
:not(pre) > code { border-radius: 4px; padding: 2px 5px; background: #eef1f4; }
|
||||
`;
|
||||
@@ -0,0 +1,24 @@
|
||||
export function slugify(value) {
|
||||
return (
|
||||
value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/ё/g, 'е')
|
||||
.replace(/[^a-zа-я0-9]+/giu, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 90) || `post-${Date.now()}`
|
||||
);
|
||||
}
|
||||
|
||||
export function uniqueDraftSlug(slug, articles) {
|
||||
const used = new Set(articles.map((article) => article.slug));
|
||||
let candidate = slug;
|
||||
let index = 2;
|
||||
|
||||
while (used.has(candidate)) {
|
||||
candidate = `${slug}-${index}`;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
}
|
||||
Reference in New Issue
Block a user