UNPKG

@zeix/ui-element

Version:

UIElement - a HTML-first library for reactive Web Components

276 lines (232 loc) โ€ข 8.04 kB
import { join } from 'path' import { mkdir, readFile, readdir, stat, writeFile } from 'fs/promises' import matter from 'gray-matter' import { marked } from 'marked' import { INCLUDES_DIR, LAYOUT_FILE, MENU_FILE, OUTPUT_DIR, PAGES_DIR, } from './config' import { generateMenu } from './generate-menu' import { generateSitemap } from './generate-sitemap' import { generateSlug } from './generate-slug' import { generateTOC } from './generate-toc' import { replaceAsync } from './replace-async' import { transformCodeBlocks } from './transform-codeblocks' marked.setOptions({ gfm: true, // Enables tables, task lists, and strikethroughs breaks: true, // Allows line breaks without needing double spaces }) type PageInfo = { filename: string title: string emoji: string description: string url: string section?: string relativePath: string depth: number } // Recursively find all .md files in a directory const findMarkdownFiles = async ( dir: string, basePath: string = '', ): Promise<string[]> => { const files: string[] = [] const entries = await readdir(dir) for (const entry of entries) { const fullPath = join(dir, entry) const relativePath = basePath ? join(basePath, entry) : entry const stats = await stat(fullPath) if (stats.isDirectory()) { // Recursively process subdirectories const subFiles = await findMarkdownFiles(fullPath, relativePath) files.push(...subFiles) } else if (entry.endsWith('.md')) { files.push(relativePath) } } return files } // Utility to ensure directory exists const ensureDir = async (filePath: string): Promise<void> => { const dir = join(filePath, '..') try { await mkdir(dir, { recursive: true }) } catch (error) { // Directory might already exist, which is fine if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { throw error } } } // Function to load HTML includes const loadIncludes = async (html: string): Promise<string> => { return await replaceAsync( html, /{{ include '(.+?)' }}/g, async (_, filename) => { const includePath = join(INCLUDES_DIR, filename) try { // console.log(`๐Ÿ“„ Loading include: ${filename}`); return await readFile(includePath, 'utf8') } catch { console.warn(`โš ๏ธ Warning: Missing include file: ${filename}`) return '' } }, ) } // Enhanced processMarkdownFile to handle nested paths const processMarkdownFile = async (relativePath: string): Promise<PageInfo> => { const filePath = join(PAGES_DIR, relativePath) const mdContent = await readFile(filePath, 'utf8') console.log(`๐Ÿ“‚ Processing: ${relativePath}`) // Parse frontmatter and Markdown content const { data: frontmatter, content } = matter(mdContent) // console.log(`๐Ÿ“ Frontmatter:`, frontmatter); // Determine section and depth early for API processing const pathParts = relativePath.split(/[/\\]/) // Handle both Unix and Windows paths const section = pathParts.length > 1 ? pathParts[0] : undefined const depth = pathParts.length - 1 // Clean up API content: remove everything above the first H1 heading let processedContent = content if (section === 'api') { const h1Match = content.match(/^(#\s+.+)$/m) if (h1Match) { const h1Index = content.indexOf(h1Match[0]) processedContent = content.substring(h1Index) console.log( `๐Ÿงน Cleaned API content: removed ${h1Index} characters before H1`, ) } } // Convert Markdown content to HTML and process code blocks const { processedMarkdown, codeBlockMap } = await transformCodeBlocks(processedContent) // Convert Markdown to HTML first let htmlContent = await marked.parse(processedMarkdown) // console.log(`๐Ÿ“œ Converted Markdown to HTML:`, htmlContent); // Post-process HTML to add permalinks to headings htmlContent = htmlContent.replace( /<h([1-6])>(.+?)<\/h[1-6]>/g, (_match, level, text) => { // For slug generation, decode common HTML entities to match TOC slugs const textForSlug = text .replace(/&quot;/g, '"') .replace(/&#39;/g, "'") .replace(/&amp;/g, '&') const slug = generateSlug(textForSlug) return `<h${level} id="${slug}"> <a name="${slug}" class="anchor" href="#${slug}"> <span class="permalink">๐Ÿ”—</span> </a> ${text} </h${level}>` }, ) // Generate TOC from markdown content (before HTML conversion) const tocHtml = await generateTOC(processedContent) // Fix internal .md links to .html htmlContent = htmlContent.replace( /href="([^"]*\.md)"/g, (_match, href) => `href="${href.replace(/\.md$/, '.html')}"`, ) // Replace placeholders with actual Shiki code blocks codeBlockMap.forEach((code, key) => { htmlContent = htmlContent.replace( new RegExp(`(<p>\\s*${key}\\s*</p>)`, 'g'), code, ) }) // Wrap API pages in a section tag for layout purposes if (section === 'api') { htmlContent = `<section class="api-content">\n${htmlContent}\n</section>` } // Generate output URL (preserve directory structure) const url = relativePath.replace('.md', '.html') // Calculate base href for assets and navigation const basePath = depth > 0 ? '../'.repeat(depth) : './' // Extract title from first heading if no frontmatter title (common for API docs) let title = frontmatter.title if (!title && section === 'api') { const headingMatch = processedContent.match( /^#\s+(Function|Type Alias|Variable):\s*(.+?)(?:\(\))?$/m, ) if (headingMatch) { title = headingMatch[2].trim() // Extract the actual function/type name } else { // Fallback to generic heading extraction const fallbackMatch = processedContent.match(/^#\s+(.+)$/m) if (fallbackMatch) { title = fallbackMatch[1].replace(/\(.*?\)$/, '').trim() } } } // Load layout template let layout = await readFile(LAYOUT_FILE, 'utf8') // console.log(`๐Ÿ“„ Layout before processing:`, layout); // Use regex to match the correct <li> by href and add class="active" let menuHtml = await readFile(MENU_FILE, 'utf8') // Fix menu links to be relative to current page depth if (depth > 0) { menuHtml = menuHtml.replace( /href="([^"]*\.html)"/g, `href="${basePath}$1"`, ) } // Mark active page in main menu const activeUrl = depth > 0 ? url.split('/').pop() : url menuHtml = menuHtml.replace( new RegExp(`(<a href="${basePath}${activeUrl}")`, 'g'), '$1 class="active"', ) layout = layout.replace("{{ include 'menu.html' }}", menuHtml) // 1๏ธโƒฃ Process remaining includes layout = await loadIncludes(layout) // console.log(`๐Ÿ“Ž After Includes Processing:`, layout); // 2๏ธโƒฃ Replace {{ content }} SECOND layout = layout.replace('{{ content }}', htmlContent) // console.log(`โœ… After Content Injection:`, layout); // 3๏ธโƒฃ Replace frontmatter placeholders LAST layout = layout.replace(/{{ (.*?) }}/g, (_, key) => { if (key === 'url') return url if (key === 'section') return section || '' if (key === 'base-path') return basePath if (key === 'title') return title || '' if (key === 'toc') return tocHtml return frontmatter[key] || '' }) // Ensure output directory exists and save output file const outputPath = join(OUTPUT_DIR, url) await ensureDir(outputPath) await writeFile(outputPath, layout, 'utf8') console.log(`โœ… Generated: ${url}`) return { filename: url, title: title || 'Untitled', emoji: frontmatter.emoji || '๐Ÿ“„', description: frontmatter.description || '', url, section, relativePath, depth, } } // Main function const run = async () => { console.log('๐Ÿ”„ Discovering markdown files...') // Find all .md files recursively const markdownFiles = await findMarkdownFiles(PAGES_DIR) console.log(`๐Ÿ“ Found ${markdownFiles.length} markdown files`) // Process all Markdown files const pages = await Promise.all(markdownFiles.map(processMarkdownFile)) // Generate main menu (only root pages) await generateMenu(pages.filter(p => !p.section)) // Generate sitemap with all pages await generateSitemap(pages) console.log('โœจ All pages generated!') } run()