import type { TelegraphNode } from "./telegraphPublisher"; // Import TelegraphNode interface
import { AnchorGenerator, type HeadingInfo } from "./utils/AnchorGenerator"; // Import unified anchor generator
/**
* Validates cleaned content to ensure it does not contain unsupported syntax, like raw HTML tags.
* @param content The content string (expected to be Markdown, potentially with HTML, which we want to disallow).
* @throws Error if unsupported syntax (HTML tags) is found.
*/
export function validateCleanedContent(content: string): void {
// This regex specifically looks for HTML-like tags, not Markdown syntax.
// It should detect things like
,
, , etc.
const htmlTagRegex = /<\/?\w+\b[^>]*>/;
if (htmlTagRegex.test(content)) {
throw new Error(
"Content contains unsupported HTML tags. Only Markdown formatting is allowed.",
);
}
}
/**
* Extracts the first heading from Markdown content and returns it as a title,
* along with the remaining content.
* @param markdown The raw Markdown content.
* @returns An object containing the extracted title (or null) and the modified content.
*/
export function extractTitleAndContent(markdown: string): {
title: string | null;
content: string;
} {
const lines = markdown.split(/\r?\n/);
let title: string | null = null;
let contentStartIndex = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]?.trim() || "";
if (line === "") {
contentStartIndex++;
continue; // Skip empty lines at the beginning
}
const headingMatch = line.match(/^(#+)\s*(.*)/);
// Check if the first non-empty line is a heading or bold/italic text that looks like a title
const boldItalicMatch = line.match(
/^(?:\*{2}|__)(.*?)(?:\*{2}|__)$|^\*(.*?)\*$|^_(.*?)_$/,
);
if (
headingMatch &&
headingMatch.length > 2 &&
headingMatch[2] !== undefined
) {
const headingText = headingMatch[2];
title = headingText.trim();
contentStartIndex = i + 1;
break;
} else if (boldItalicMatch) {
// If it's a bold/italic line, consider it a title if no heading was found yet
title = (
boldItalicMatch[1] ||
boldItalicMatch[2] ||
boldItalicMatch[3] ||
""
).trim();
contentStartIndex = i + 1;
break;
}
// If the first non-empty line is not a recognized title format, treat entire content as is
break;
}
const remainingContent = lines.slice(contentStartIndex).join("\n");
return { title, content: remainingContent };
}
/**
* Parses a Markdown table and converts it to a nested list structure.
* @param tableLines Array of lines that form the table
* @returns TelegraphNode representing the nested list structure
*/
function parseTable(tableLines: string[]): TelegraphNode {
if (tableLines.length < 3) {
// Not a valid table, return as paragraphs
return { tag: "p", children: [tableLines.join("\n")] };
}
// Parse header row
const headerLine = tableLines[0];
if (!headerLine) {
return { tag: "p", children: [tableLines.join("\n")] };
}
// Split by | but keep empty cells, then remove first and last empty cells
const allHeaders = headerLine.split("|").map((cell) => cell.trim());
const headers = allHeaders.slice(1, -1).filter((cell) => cell !== "");
// Skip separator line (tableLines[1])
// Parse data rows
const dataRows = tableLines.slice(2);
const listItems: TelegraphNode[] = [];
dataRows.forEach((row, index) => {
// Split by | but keep empty cells (don't filter them out)
const allCells = row.split("|").map((cell) => cell.trim());
// Remove first and last empty cells (they're from leading/trailing |)
const cells = allCells.slice(1, -1);
if (cells.length === 0) return; // Skip empty rows
// Create nested list for this row
const nestedItems: TelegraphNode[] = [];
// Use the number of headers as the reference
const numColumns = headers.length;
for (let i = 0; i < numColumns; i++) {
const header = headers[i] || `Колонка ${i + 1}`;
const value = cells[i] || "";
nestedItems.push({
tag: "li",
children: [`${header}: ${value}`],
});
}
// Create the main list item with number and nested list
listItems.push({
tag: "li",
children: [
`${index + 1}`,
{
tag: "ul",
children: nestedItems,
},
],
});
});
return {
tag: "ol",
children: listItems,
};
}
/**
* Creates children nodes for ToC links that handle formatting but prevent nested links
* @param heading The heading object with text and display information
* @returns Array of TelegraphNode children for the ToC link
*/
function createTocChildren(heading: { level: number; text: string; displayText: string; textForAnchor: string }): TelegraphNode[] {
// Check if this is a heading-link (e.g., "## [Link Text](./file.md)")
const linkInHeadingMatch = heading.text.match(/^\[(.*?)\]\((.*?)\)$/);
if (linkInHeadingMatch) {
// For heading-links, use only the plain text to avoid nested links
return [heading.textForAnchor];
}
// For normal headings with formatting, process inline Markdown to preserve bold, italic, etc.
return processInlineMarkdown(heading.displayText);
}
/**
* Creates children nodes for ToC links using HeadingInfo from AnchorGenerator.
* Handles heading-links by extracting only the text to avoid nested link elements.
* @param headingInfo The HeadingInfo object from AnchorGenerator.
* @returns Array of TelegraphNode elements for the link content.
*/
function createTocChildrenFromHeadingInfo(headingInfo: HeadingInfo): TelegraphNode[] {
// Check if this is a heading-link using metadata
if (headingInfo.metadata.hasLink && headingInfo.linkInfo) {
// For heading-links, use only the plain text to avoid nested links
return [headingInfo.linkInfo.text];
}
// For normal headings with formatting, process inline Markdown to preserve bold, italic, etc.
return processInlineMarkdown(headingInfo.displayText);
}
/**
* Generates a Table of Contents (ToC) as an aside element from Markdown content.
* Only generates ToC if there are 2 or more headings in the document.
* Uses the same heading processing logic as the main converter for consistency.
* @param markdown The raw Markdown content to scan for headings.
* @returns TelegraphNode for aside element with ToC, or null if insufficient headings.
*/
function generateTocAside(markdown: string, tocTitle: string = ''): TelegraphNode | null {
const headings: { level: number; text: string; displayText: string; textForAnchor: string }[] = [];
const lines = markdown.split(/\r?\n/);
// 1. Scan for all headings using the same regex as main converter
for (const line of lines) {
const headingMatch = line.match(/^(#+)\s+(.*)/);
if (headingMatch?.[1] && headingMatch[2] !== undefined) {
const level = headingMatch[1].length;
const originalText = headingMatch[2].trim();
let textForAnchor = originalText;
// NEW: Check if the heading text is a Markdown link
const linkInHeadingMatch = originalText.match(/^\[(.*?)\]\((.*?)\)$/);
if (linkInHeadingMatch) {
// If it's a link, use only its text part for the anchor
textForAnchor = linkInHeadingMatch[1] || '';
}
let displayText = originalText;
// 2. Apply the same heading strategy logic as main converter
switch (level) {
case 1:
case 2:
case 3:
case 4:
displayText = originalText;
break;
case 5:
displayText = `> ${originalText}`;
textForAnchor = linkInHeadingMatch ? `> ${linkInHeadingMatch[1]}` : `> ${originalText}`;
break;
case 6:
displayText = `>> ${originalText}`;
textForAnchor = linkInHeadingMatch ? `>> ${linkInHeadingMatch[1]}` : `>> ${originalText}`;
break;
default:
// Handle edge case: levels > 6
displayText = `>>> ${originalText}`;
textForAnchor = linkInHeadingMatch ? `>>> ${linkInHeadingMatch[1]}` : `>>> ${originalText}`;
break;
}
headings.push({ level, text: originalText, displayText, textForAnchor });
}
}
// 3. Check if ToC should be generated (2+ headings required)
if (headings.length < 2) {
return null;
}
// 4. Build ToC structure as list items
const listItems: TelegraphNode[] = [];
for (const heading of headings) {
// IMPORTANT: Use textForAnchor for anchor generation to handle link headings properly
// Based on empirical research: remove only < and > characters, replace spaces with hyphens
const anchor = heading.textForAnchor
.trim()
.replace(/[<]/g, '') // 1. Remove < characters only (preserve > for H5/H6 prefixes)
.replace(/ /g, '-'); // 2. Replace spaces with hyphens
const linkNode: TelegraphNode = {
tag: 'a',
attrs: { href: `#${anchor}` },
children: createTocChildren(heading)
};
listItems.push({
tag: 'li',
children: [linkNode],
});
}
// 5. Return aside element with optional heading and unordered list
const children: any[] = [];
// Add heading only if tocTitle is provided and not empty
if (tocTitle && tocTitle.trim()) {
children.push({
tag: 'h3',
children: [tocTitle]
});
}
// Always add the list
children.push({
tag: 'ul',
children: listItems
});
return {
tag: 'aside',
children
};
}
/**
* Generates a Table of Contents (ToC) using the unified AnchorGenerator.
* This ensures 100% consistency between TOC anchors and link validation anchors.
* Only generates ToC if there are 2 or more headings in the document.
* @param markdown The raw Markdown content to scan for headings.
* @returns TelegraphNode for aside element with ToC, or null if insufficient headings.
*/
function generateTocAsideWithAnchorGenerator(markdown: string, tocTitle: string = ''): TelegraphNode | null {
// Use AnchorGenerator to parse headings with unified logic
const headings = AnchorGenerator.parseHeadingsFromContent(markdown);
// Check if ToC should be generated (2+ headings required)
if (headings.length < 2) {
return null;
}
// Build ToC structure as list items
const listItems: TelegraphNode[] = [];
for (const headingInfo of headings) {
// Generate anchor using unified algorithm
const anchor = AnchorGenerator.generateAnchor(headingInfo);
const linkNode: TelegraphNode = {
tag: 'a',
attrs: { href: `#${anchor}` },
children: createTocChildrenFromHeadingInfo(headingInfo)
};
listItems.push({
tag: 'li',
children: [linkNode],
});
}
// Return aside element with optional heading and unordered list
const children: any[] = [];
// Add heading only if tocTitle is provided and not empty
if (tocTitle && tocTitle.trim()) {
children.push({
tag: 'h3',
children: [tocTitle]
});
}
// Always add the list
children.push({
tag: 'ul',
children: listItems
});
return {
tag: 'aside',
children
};
}
/**
* Converts Markdown content directly into an array of TelegraphNode objects.
* This function replaces the need for an intermediate HTML conversion step and 'mrkdwny' library.
* It directly parses Markdown elements into the structure expected by the Telegra.ph API.
* @param markdown The raw Markdown content.
* @returns An array of TelegraphNode objects representing the parsed content.
*/
export function convertMarkdownToTelegraphNodes(
markdown: string,
options: { generateToc?: boolean; tocTitle?: string; tocSeparators?: boolean } = { generateToc: true }
): TelegraphNode[] {
const nodes: TelegraphNode[] = [];
// Generate and add Table of Contents if enabled and there are 2+ headings
if (options.generateToc !== false) {
// Feature flag: Use unified AnchorGenerator for consistent anchor generation
const USE_UNIFIED_ANCHORS = process.env.USE_UNIFIED_ANCHORS === 'true' ||
process.env.NODE_ENV !== 'production';
const tocAside = USE_UNIFIED_ANCHORS
? generateTocAsideWithAnchorGenerator(markdown, options.tocTitle)
: generateTocAside(markdown, options.tocTitle);
if (tocAside) {
// Add HR before TOC (if separators enabled)
if (options.tocSeparators) {
nodes.push({ tag: 'hr' });
}
// Extract TOC elements from aside and add them separately for better Telegram compatibility
if (tocAside.children) {
for (const child of tocAside.children) {
nodes.push(child);
}
}
// Add HR after TOC (if separators enabled)
if (options.tocSeparators) {
nodes.push({ tag: 'hr' });
}
}
}
const lines = markdown.split(/\r?\n/);
let inCodeBlock = false;
let codeBlockContent: string[] = [];
let inList = false;
let currentListTag: "ul" | "ol" | "" = "";
let currentListItems: TelegraphNode[] = [];
let inBlockquote = false;
let blockquoteContent: string[] = [];
let inTable = false;
let tableLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i] || "";
// Handle code blocks first (they have highest priority)
if (line.startsWith("```")) {
if (inCodeBlock) {
// End of code block
// Close any open blocks first
if (inTable) {
nodes.push(parseTable(tableLines));
inTable = false;
tableLines = [];
}
if (inList) {
nodes.push({ tag: currentListTag, children: currentListItems });
inList = false;
currentListItems = [];
}
if (inBlockquote) {
nodes.push({
tag: "blockquote",
children: processInlineMarkdown(blockquoteContent.join("\n")),
});
inBlockquote = false;
blockquoteContent = [];
}
nodes.push({
tag: "pre",
children: [
{ tag: "code", children: [codeBlockContent.join("\n").trimEnd()] },
],
});
codeBlockContent = [];
inCodeBlock = false;
} else {
// Start of code block
// Close any open blocks first
if (inTable) {
nodes.push(parseTable(tableLines));
inTable = false;
tableLines = [];
}
if (inList) {
nodes.push({ tag: currentListTag, children: currentListItems });
inList = false;
currentListItems = [];
}
if (inBlockquote) {
nodes.push({
tag: "blockquote",
children: processInlineMarkdown(blockquoteContent.join("\n")),
});
inBlockquote = false;
blockquoteContent = [];
}
inCodeBlock = true;
}
continue;
}
if (inCodeBlock) {
codeBlockContent.push(line);
continue;
}
// Handle table detection and parsing
const isTableLine = line.includes("|") && line.trim() !== "";
const isTableSeparator = /^\s*\|?[\s\-|:]+\|?\s*$/.test(line);
if (isTableLine || isTableSeparator) {
if (!inTable) {
// Close any open blocks first
if (inList) {
nodes.push({ tag: currentListTag, children: currentListItems });
inList = false;
currentListItems = [];
}
if (inBlockquote) {
nodes.push({
tag: "blockquote",
children: processInlineMarkdown(blockquoteContent.join("\n")),
});
inBlockquote = false;
blockquoteContent = [];
}
inTable = true;
tableLines = [];
}
tableLines.push(line);
continue;
} else if (inTable) {
// End of table
nodes.push(parseTable(tableLines));
inTable = false;
tableLines = [];
// Continue processing current line
}
// Handle blockquotes
if (line.startsWith(">")) {
if (!inBlockquote) {
// If previously in a list, close it first
if (inList) {
nodes.push({ tag: currentListTag, children: currentListItems });
inList = false;
currentListItems = [];
}
inBlockquote = true;
blockquoteContent = [];
}
blockquoteContent.push(line.substring(1).trimStart()); // Remove '>' and leading space
continue;
} else if (inBlockquote) {
// If not a blockquote line, but was in blockquote, close it
nodes.push({
tag: "blockquote",
children: processInlineMarkdown(blockquoteContent.join("\n")),
});
inBlockquote = false;
blockquoteContent = [];
// Process current line as a new element
}
// Handle headings (MOVED UP - before lists to prevent numbered headings from being parsed as list items)
const headingMatch = line.match(/^(#+)\s*(.*)/);
if (headingMatch?.[1] && headingMatch[2] !== undefined) {
// Close any open blocks before adding a heading
if (inList) {
nodes.push({ tag: currentListTag, children: currentListItems });
inList = false;
currentListItems = [];
}
if (inBlockquote) {
nodes.push({
tag: "blockquote",
children: processInlineMarkdown(blockquoteContent.join("\n")),
});
inBlockquote = false;
blockquoteContent = [];
}
const level = headingMatch[1].length;
const originalText = headingMatch[2] || "";
let displayText = originalText;
let tag: 'h3' | 'h4' = 'h3';
// Map headings to Telegraph API compatible tags with visual hierarchy preservation
// Telegraph API only supports h3 and h4 tags for headings
switch (level) {
case 1:
case 2:
case 3:
// H1, H2, H3 → h3 (highest available level in Telegraph API)
tag = 'h3';
displayText = originalText;
break;
case 4:
// H4 → h4 (direct mapping, supported by Telegraph API)
tag = 'h4';
displayText = originalText;
break;
case 5:
// H5 → h4 with visual prefix to preserve hierarchy and enable anchors
tag = 'h4';
displayText = `> ${originalText}`;
break;
case 6:
// H6 → h4 with double visual prefix to preserve hierarchy and enable anchors
tag = 'h4';
displayText = `>> ${originalText}`;
break;
default:
// Handle edge case: levels > 6 as h4 with triple visual prefix
tag = 'h4';
displayText = `>>> ${originalText}`;
break;
}
const processedChildren = processInlineMarkdown(displayText);
nodes.push({ tag, children: processedChildren });
continue;
}
// Handle lists (NOW AFTER HEADINGS)
const listItemMatch = line.match(/^(-|\*)\s+(.*)|(\d+)\.\s+(.*)/);
if (listItemMatch) {
if (!inList) {
// If previously in a blockquote, close it first
if (inBlockquote) {
nodes.push({
tag: "blockquote",
children: processInlineMarkdown(blockquoteContent.join("\n")),
});
inBlockquote = false;
blockquoteContent = [];
}
inList = true;
currentListTag = listItemMatch[1] ? "ul" : "ol";
currentListItems = [];
}
let textContent = "";
if (listItemMatch[2] !== undefined) {
textContent = listItemMatch[2];
} else if (listItemMatch[4] !== undefined) {
textContent = listItemMatch[4];
}
if (textContent) {
currentListItems.push({
tag: "li",
children: processInlineMarkdown(textContent.trim()),
});
}
continue;
} else if (inList) {
// If not a list item, but was in a list, close it
if (line.trim() === "") {
// Empty line closes a list
nodes.push({ tag: currentListTag, children: currentListItems });
inList = false;
currentListItems = [];
continue; // Don't add empty paragraph for the empty line
} else {
// New content, close list and process as new paragraph
nodes.push({ tag: currentListTag, children: currentListItems });
inList = false;
currentListItems = [];
// Process current line as a new element (paragraph)
}
}
// Handle horizontal rules (simple check for now)
if (line.match(/^[*-]{3,}\s*$/)) {
// Close any open blocks before adding HR
if (inList) {
nodes.push({ tag: currentListTag, children: currentListItems });
inList = false;
currentListItems = [];
}
if (inBlockquote) {
nodes.push({
tag: "blockquote",
children: processInlineMarkdown(blockquoteContent.join("\n")),
});
inBlockquote = false;
blockquoteContent = [];
}
nodes.push({ tag: "hr" });
continue;
}
// Handle empty lines or plain paragraphs
if (line.trim() === "") {
// Do not add empty paragraphs if previous node was also an empty paragraph
const lastNode = nodes[nodes.length - 1];
if (
nodes.length > 0 &&
typeof lastNode === "object" &&
lastNode.tag === "p" &&
(!lastNode.children ||
lastNode.children.length === 0 ||
(lastNode.children.length === 1 && lastNode.children[0] === ""))
) {
continue; // Skip adding redundant empty paragraph
}
if (!inList && !inBlockquote && !inCodeBlock && !inTable) {
// Only add empty paragraph if not inside a block
nodes.push({ tag: "p", children: [""] });
}
continue;
}
// If we reach here, it's a plain paragraph line
// Close any open blocks (lists, blockquotes) if this line is not part of them
if (inList) {
nodes.push({ tag: currentListTag, children: currentListItems });
inList = false;
currentListItems = [];
}
if (inBlockquote) {
nodes.push({
tag: "blockquote",
children: processInlineMarkdown(blockquoteContent.join("\n")),
});
inBlockquote = false;
blockquoteContent = [];
}
nodes.push({ tag: "p", children: processInlineMarkdown(line) });
}
// After loop, close any open blocks
if (inCodeBlock) {
nodes.push({
tag: "pre",
children: [
{ tag: "code", children: [codeBlockContent.join("\n").trimEnd()] },
],
}); // Trim end for final code block
}
if (inList) {
nodes.push({ tag: currentListTag, children: currentListItems });
}
if (inBlockquote) {
nodes.push({
tag: "blockquote",
children: processInlineMarkdown(blockquoteContent.join("\n")),
});
}
if (inTable) {
nodes.push(parseTable(tableLines));
}
// Filter out any empty paragraph nodes that might have been created unnecessarily
return nodes.filter(
(node) =>
!(
node.tag === "p" &&
(!node.children ||
node.children.length === 0 ||
(node.children.length === 1 && node.children[0] === ""))
),
);
}
function processInlineMarkdown(text: string): (string | TelegraphNode)[] {
const result: (string | TelegraphNode)[] = [];
let currentIndex = 0;
// Define patterns for different inline elements
const patterns = [
{ regex: /\*\*(.*?)\*\*/g, tag: "strong" },
{ regex: /__(.*?)__/g, tag: "strong" },
{ regex: /\*(.*?)\*/g, tag: "em" },
{ regex: /_(.*?)_/g, tag: "em" },
{ regex: /`(.*?)`/g, tag: "code" },
{ regex: /\[(.*?)\]\(([^()]*(?:\([^()]*\)[^()]*)*)\)/g, tag: "a", isLink: true },
];
// Find all matches with their positions
const matches: Array<{
index: number;
length: number;
tag: string;
content: string;
href?: string;
}> = [];
for (const pattern of patterns) {
pattern.regex.lastIndex = 0; // Reset regex
let match: RegExpExecArray | null = null;
match = pattern.regex.exec(text);
while (match !== null) {
if (match.index !== undefined) {
if (pattern.isLink) {
matches.push({
index: match.index,
length: match[0].length,
tag: pattern.tag,
content: match[1] || "",
href: match[2] || "",
});
} else {
matches.push({
index: match.index,
length: match[0].length,
tag: pattern.tag,
content: match[1] || "",
});
}
}
match = pattern.regex.exec(text);
}
}
// Sort matches by index to process them in order
matches.sort((a, b) => a.index - b.index);
// Process matches and build result
for (const match of matches) {
// Add plain text before this match
if (match.index > currentIndex) {
const plainText = text.substring(currentIndex, match.index);
if (plainText) {
result.push(plainText);
}
}
// Skip if this match overlaps with previous processed content
if (match.index < currentIndex) {
continue;
}
// Add the formatted element
if (match.tag === "a" && match.href !== undefined) {
result.push({
tag: "a",
attrs: { href: match.href },
children: [match.content],
});
} else {
result.push({
tag: match.tag,
children: [match.content],
});
}
currentIndex = match.index + match.length;
}
// Add any remaining plain text
if (currentIndex < text.length) {
const remainingText = text.substring(currentIndex);
if (remainingText) {
result.push(remainingText);
}
}
// If no matches found, return the original text
if (result.length === 0) {
return [text];
}
return result;
}