#!/usr/bin/env node /** * Graph Data Generation Script * * This script generates graph data for the local graph feature by analyzing * post connections (both wikilinks and standard links) and tag relationships. * * The generated data includes: * - Post nodes with metadata (title, slug, date, tags) * - Tag nodes with metadata (name, color) * - Connections between posts (direct links) * - Connections between posts and tags (shared tags) * * This data is used by the LocalGraph component to render an Obsidian-like graph view. * * ID Generation Strategy: * - Uses path-based IDs (no frontmatter required) * - Single files: "my-post.md" → ID: "my-post" * - Folder-based: "my-folder/index.md" → ID: "my-folder" * - Nested content: "category/my-post.md" → ID: "category-my-post" */ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync, } from "fs"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const projectRoot = join(__dirname, ".."); // Configuration const OUTPUT_DIR = join(projectRoot, "public", "graph"); const OUTPUT_FILE = join(OUTPUT_DIR, "graph-data.json"); /** * Read maxNodes from config file */ function getMaxNodesFromConfig() { try { const configPath = join(projectRoot, "src", "config.ts"); const configContent = readFileSync(configPath, "utf-8"); // Extract maxNodes value from config const maxNodesMatch = configContent.match(/maxNodes:\s*(\d+)/); if (maxNodesMatch) { return parseInt(maxNodesMatch[1], 10); } // Default fallback return 100; } catch (error) { log.warn("Could not read config file, using default maxNodes: 100"); return 100; } } // Simple logging utility const isDev = process.env.NODE_ENV !== "production" && !process.argv.includes("--production"); const log = { info: (...args) => isDev && console.log(...args), error: (...args) => console.error(...args), warn: (...args) => console.warn(...args), }; // Ensure output directory exists if (!existsSync(OUTPUT_DIR)) { mkdirSync(OUTPUT_DIR, { recursive: true }); } /** * Generate a stable ID from file path * @param {string} filePath - The file path * @param {string} collectionType - The collection type (e.g., 'posts') * @returns {string} - The generated ID */ function generateNodeId(filePath, collectionType) { // Remove collection prefix and extension let id = filePath.replace(`src/content/${collectionType}/`, ""); id = id.replace(".md", ""); id = id.replace("/index", ""); // Handle folder-based posts // Clean up the ID: lowercase, replace spaces/special chars with hyphens id = id.toLowerCase().replace(/[^a-z0-9-]/g, "-"); // Remove multiple consecutive hyphens id = id.replace(/-+/g, "-"); // Remove leading/trailing hyphens id = id.replace(/^-+|-+$/g, ""); return id; } /** * Extract wikilinks from content (Obsidian-style) */ function extractWikilinks(content) { const matches = []; const wikilinkRegex = /!?\[\[([^\]]+)\]\]/g; let match; while ((match = wikilinkRegex.exec(content)) !== null) { const [fullMatch, linkContent] = match; const isImageWikilink = fullMatch.startsWith("!"); // Skip image wikilinks, only process link wikilinks if (!isImageWikilink) { const [link, displayText] = linkContent.includes("|") ? linkContent.split("|", 2) : [linkContent, linkContent]; // Parse anchor if present const anchorIndex = link.indexOf("#"); const baseLink = anchorIndex === -1 ? link : link.substring(0, anchorIndex); // Generate target ID from the link const targetId = generateNodeId(baseLink, "posts"); matches.push({ link: baseLink, display: displayText.trim(), slug: targetId, }); } } return matches; } /** * Extract standard markdown links from content */ function extractStandardLinks(content) { const matches = []; const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g; let match; while ((match = markdownLinkRegex.exec(content)) !== null) { const [fullMatch, displayText, url] = match; // Check if this is an internal link if (isInternalLink(url)) { const { linkText } = extractLinkTextFromUrl(url); if (linkText) { // Only include posts in graph data - this includes: // - posts/ prefixed links // - /posts/ relative links // - .md files (assumed to be posts) // - Simple slugs (assumed to be posts for backward compatibility) const isPostLink = linkText.startsWith("posts/") || url.startsWith("/posts/") || url.startsWith("posts/") || url.endsWith(".md") || (!linkText.includes("/") && !url.startsWith("/")); if (isPostLink) { // Generate target ID from the link const targetId = generateNodeId(linkText, "posts"); matches.push({ link: linkText, display: displayText.trim(), slug: targetId, }); } } } } return matches; } /** * Check if a URL is an internal link */ function isInternalLink(url) { url = url.trim(); // Skip external URLs if (url.startsWith("http://") || url.startsWith("https://")) { return false; } // Skip email links if (url.startsWith("mailto:")) { return false; } // Skip anchors only if (url.startsWith("#")) { return false; } // Check if it's an internal link: // - Ends with .md (markdown files) // - Starts with /posts/ or posts/ (post relative URLs) // - Is just a slug (no slashes) - assumes posts for backward compatibility const isInternal = url.endsWith(".md") || url.startsWith("/posts/") || url.startsWith("posts/") || !url.includes("/"); return isInternal; } /** * Extract link text from URL */ function extractLinkTextFromUrl(url) { url = url.trim(); // Parse anchor if present const anchorIndex = url.indexOf("#"); const link = anchorIndex === -1 ? url : url.substring(0, anchorIndex); const anchor = anchorIndex === -1 ? null : url.substring(anchorIndex + 1); // Handle posts/ prefixed links if (link.startsWith("posts/")) { let linkText = link.replace("posts/", "").replace(/\.md$/, ""); // Remove /index for folder-based posts if (linkText.endsWith("/index") && linkText.split("/").length === 2) { linkText = linkText.replace("/index", ""); } return { linkText: linkText, anchor: anchor, }; } // Handle /posts/ URLs (relative links) if (link.startsWith("/posts/")) { let linkText = link.replace("/posts/", "").replace(/\.md$/, ""); // Remove /index for folder-based posts if (linkText.endsWith("/index") && linkText.split("/").length === 2) { linkText = linkText.replace("/index", ""); } return { linkText: linkText, anchor: anchor, }; } // Handle .md files if (link.endsWith(".md")) { let linkText = link.replace(/\.md$/, ""); // Remove /index for folder-based posts if (linkText.endsWith("/index") && linkText.split("/").length === 1) { linkText = linkText.replace("/index", ""); } return { linkText: linkText, anchor: anchor, }; } // If it's just a slug (no slashes), use it directly if (!link.includes("/")) { return { linkText: link, anchor: anchor, }; } return { linkText: null, anchor: null }; } /** * Read and parse markdown files from content directory */ function readContentFiles(dirPath) { const posts = []; try { const items = readdirSync(dirPath); for (const item of items) { const itemPath = join(dirPath, item); const stat = statSync(itemPath); if (stat.isDirectory()) { // Handle folder-based posts const indexPath = join(itemPath, "index.md"); if (existsSync(indexPath)) { const content = readFileSync(indexPath, "utf-8"); const parsed = parseMarkdownFile(content, item); if (parsed) { posts.push(parsed); } } } else if (item.endsWith(".md")) { // Handle single-file posts const content = readFileSync(itemPath, "utf-8"); const slug = item.replace(".md", ""); const parsed = parseMarkdownFile(content, slug); if (parsed) { posts.push(parsed); } } } } catch (error) { log.error("Error reading content directory:", error); } return posts; } /** * Parse markdown file and extract frontmatter and content */ function parseMarkdownFile(content, slug) { try { // Extract frontmatter (handle both \n and \r\n line endings) const frontmatterMatch = content.match( /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/ ); if (!frontmatterMatch) { return null; } const [, frontmatter, body] = frontmatterMatch; const lines = frontmatter.split(/\r?\n/); const data = {}; // Parse frontmatter (improved YAML parser) let currentKey = null; let currentArray = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmedLine = line.trim(); // Skip empty lines if (!trimmedLine) continue; // Check if this is a key-value pair const colonIndex = line.indexOf(":"); if (colonIndex > 0 && !line.startsWith(" ")) { // Save previous array if we have one if (currentKey && currentArray.length > 0) { data[currentKey] = [...currentArray]; currentArray = []; } const key = line.substring(0, colonIndex).trim(); let value = line.substring(colonIndex + 1).trim(); // Remove quotes if present if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } // Check if this is an array key (next line starts with dash) if (i + 1 < lines.length && lines[i + 1].trim().startsWith("- ")) { currentKey = key; currentArray = []; } else { // Single value if (key === "date") { data[key] = new Date(value); } else if (key === "draft") { data[key] = value === "true"; } else if ( key === "imageOG" || key === "hideCoverImage" || key === "noIndex" || key === "featured" ) { data[key] = value === "true"; } else { data[key] = value; } } } else if (trimmedLine.startsWith("- ")) { // This is an array item const item = trimmedLine.substring(2).trim(); currentArray.push(item); } } // Save final array if we have one if (currentKey && currentArray.length > 0) { data[currentKey] = [...currentArray]; } return { id: slug, data, body, }; } catch (error) { log.warn(`Error parsing file ${slug}:`, error.message); return null; } } /** * Generate graph data from posts */ async function generateGraphData() { log.info("🔍 Analyzing post connections..."); try { // Get configuration values const maxNodes = getMaxNodesFromConfig(); // Read all posts from the content directory const postsDir = join(projectRoot, "src", "content", "posts"); log.info("📁 Reading posts from:", postsDir); const posts = readContentFiles(postsDir); log.info(`📄 Found ${posts.length} posts`); // Filter out draft posts in production const isDev = process.env.NODE_ENV !== "production" && !process.argv.includes("--production"); const visiblePosts = posts.filter((post) => isDev || !post.data.draft); log.info(`📄 Processing ${visiblePosts.length} visible posts`); // Generate nodes and connections const nodes = []; const connections = []; // Process each post for (const post of visiblePosts) { // Add post node const postNode = { id: post.id, type: "post", title: post.data.title, slug: post.id, date: post.data.date ? post.data.date.toISOString() : new Date().toISOString(), connections: 0, }; nodes.push(postNode); // Extract links from post content const wikilinks = extractWikilinks(post.body); const standardLinks = extractStandardLinks(post.body); const allLinks = [...wikilinks, ...standardLinks]; // Process links to other posts for (const link of allLinks) { const targetPost = visiblePosts.find((p) => p.id === link.slug); if (targetPost && targetPost.id !== post.id) { // Add post-to-post connection connections.push({ source: post.id, target: targetPost.id, type: "link", }); // Update connection counts postNode.connections++; const targetNode = nodes.find((n) => n.id === targetPost.id); if (targetNode) { targetNode.connections++; } } } } // Apply maxNodes filtering if configured let filteredNodes = nodes; let filteredConnections = connections; if (maxNodes && nodes.length > maxNodes) { // Sort posts by connection count (descending), then by date (descending) const sortedPosts = nodes.sort((a, b) => { if (b.connections !== a.connections) { return b.connections - a.connections; } return new Date(b.date) - new Date(a.date); }); filteredNodes = sortedPosts.slice(0, maxNodes); // Filter connections to only include those between selected nodes const selectedNodeIds = new Set(filteredNodes.map((n) => n.id)); filteredConnections = connections.filter( (conn) => selectedNodeIds.has(conn.source) && selectedNodeIds.has(conn.target) ); } // Remove date field from nodes before exporting (only needed for sorting) const nodesForExport = filteredNodes.map(({ date, ...node }) => node); // Generate graph data const graphData = { nodes: nodesForExport, connections: filteredConnections, metadata: { totalPosts: filteredNodes.length, totalConnections: filteredConnections.length, maxNodesApplied: maxNodes && nodes.length > maxNodes, originalNodeCount: nodes.length, }, }; // Write graph data to file writeFileSync(OUTPUT_FILE, JSON.stringify(graphData, null, 2)); log.info("✅ Graph data generated successfully!"); if (graphData.metadata.maxNodesApplied) { log.info( `📊 Stats: ${graphData.metadata.totalPosts} posts, ${graphData.metadata.totalConnections} connections (filtered from ${graphData.metadata.originalNodeCount} total nodes)` ); } else { log.info( `📊 Stats: ${graphData.metadata.totalPosts} posts, ${graphData.metadata.totalConnections} connections` ); } log.info(`💾 Saved to: ${OUTPUT_FILE}`); } catch (error) { log.error("❌ Error generating graph data:", error); process.exit(1); } } // Run the script generateGraphData();