diff --git a/.gitignore b/.gitignore index 5982b35a6..1cfcfb76d 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ plugins/*/compiled .cache-loader static/llms.txt static/reference-full.md +static/rss.xml static/web-console/*.json # Files generated by script validate_queries.py diff --git a/documentation/changelog.mdx b/documentation/changelog.mdx new file mode 100644 index 000000000..daf07da80 --- /dev/null +++ b/documentation/changelog.mdx @@ -0,0 +1,15 @@ +--- +title: Documentation Changelog +description: Recently updated documentation pages for QuestDB +sidebar_label: Changelog +changelog: false +--- + +import Changelog from "@site/src/components/Changelog" + +# Documentation Changelog + +This page lists the most recently updated documentation pages, helping you stay +informed about new content and improvements. + + diff --git a/documentation/sidebars.js b/documentation/sidebars.js index 020a9c6f2..5bdbc9b80 100644 --- a/documentation/sidebars.js +++ b/documentation/sidebars.js @@ -911,5 +911,14 @@ module.exports = { type: "link", href: "https://questdb.com/release-notes", }, + + // =================== + // CHANGELOG + // =================== + { + id: "changelog", + type: "doc", + label: "Documentation Changelog", + }, ].filter(Boolean), } diff --git a/docusaurus.config.js b/docusaurus.config.js index cadce8bd4..3231d9acf 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -41,6 +41,17 @@ const config = { crossorigin: "anonymous", }, ], + headTags: [ + { + tagName: "link", + attributes: { + rel: "alternate", + type: "application/rss+xml", + title: "QuestDB Documentation RSS Feed", + href: "/docs/rss.xml", + }, + }, + ], scripts: [ { src: "https://widget.kapa.ai/kapa-widget.bundle.js", @@ -161,6 +172,7 @@ const config = { require.resolve("./plugins/raw-markdown/index"), require.resolve("./plugins/tailwind/index"), + require.resolve("./plugins/docs-rss/index"), [ "@docusaurus/plugin-pwa", { diff --git a/netlify.toml b/netlify.toml new file mode 100644 index 000000000..b7602dac2 --- /dev/null +++ b/netlify.toml @@ -0,0 +1,6 @@ +[build] + command = "yarn build" + publish = "build" + +[build.environment] + NODE_VERSION = "18" diff --git a/plugins/docs-rss/index.js b/plugins/docs-rss/index.js new file mode 100644 index 000000000..551893197 --- /dev/null +++ b/plugins/docs-rss/index.js @@ -0,0 +1,289 @@ +const fs = require("fs") +const path = require("path") +const { execSync } = require("child_process") +const matter = require("gray-matter") + +const FEED_ITEMS_COUNT = 20 +const GITHUB_REPO = "questdb/documentation" + +/** + * Check if running in a shallow git clone + */ +function isShallowClone() { + try { + const result = execSync("git rev-parse --is-shallow-repository", { + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + }).trim() + return result === "true" + } catch { + return false + } +} + +/** + * Get the last commit date for a file using GitHub API + */ +async function getGitHubLastModified(relativePath) { + const url = `https://api.github.com/repos/${GITHUB_REPO}/commits?path=${encodeURIComponent(relativePath)}&per_page=1` + + try { + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "questdb-docs-rss", + }, + }) + + if (!response.ok) { + return null + } + + const commits = await response.json() + if (commits?.length > 0 && commits[0].commit) { + return new Date(commits[0].commit.committer.date) + } + } catch { + // Ignore errors + } + + return null +} + +/** + * Get the last git commit date for a file + */ +function getGitLastModified(filePath) { + try { + const timestamp = execSync(`git log -1 --format=%cI -- "${filePath}"`, { + encoding: "utf-8", + stdio: ["pipe", "pipe", "ignore"], + }).trim() + return timestamp ? new Date(timestamp) : null + } catch { + return null + } +} + +/** + * Extract excerpt from markdown content + */ +function extractExcerpt(content, maxLength = 200) { + let text = content + .replace(/^import\s+.*$/gm, "") + .replace(/<[^>]+>/g, "") + .replace(/```[\s\S]*?```/g, "") + .replace(/`[^`]+`/g, "") + .replace(/^#{1,6}\s+.*$/gm, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/!\[[^\]]*\]\([^)]+\)/g, "") + .replace(/^:::\w+[\s\S]*?^:::/gm, "") + .replace(//g, "") + .replace(/\s+/g, " ") + .trim() + + if (text.length > maxLength) { + text = text.substring(0, maxLength).replace(/\s+\S*$/, "") + "..." + } + + return text +} + +/** + * Escape XML special characters + */ +function escapeXml(str) { + if (!str) return "" + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +/** + * Recursively get all markdown files + */ +function getAllMarkdownFiles(dir, files = []) { + const entries = fs.readdirSync(dir, { withFileTypes: true }) + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + + if (entry.isDirectory()) { + getAllMarkdownFiles(fullPath, files) + } else if ( + (entry.name.endsWith(".md") || entry.name.endsWith(".mdx")) && + !entry.name.includes(".partial.") + ) { + files.push(fullPath) + } + } + + return files +} + +/** + * Generate RSS XML + */ +function generateRssXml(items, siteConfig) { + const siteUrl = siteConfig.url + siteConfig.baseUrl + const now = new Date().toUTCString() + + const itemsXml = items + .map( + (item) => ` + ${escapeXml(item.title)} + ${escapeXml(item.url)} + ${escapeXml(item.url)} + ${item.date.toUTCString()} + ${escapeXml(item.excerpt)} + ` + ) + .join("\n") + + return ` + + + ${escapeXml(siteConfig.title)} Documentation + ${escapeXml(siteUrl)} + ${escapeXml(siteConfig.tagline)} + en + ${now} + +${itemsXml} + +` +} + +/** + * Generate RSS feed items from documentation + */ +async function generateFeedItems(docsDir, siteConfig, useGitHubApi) { + const markdownFiles = getAllMarkdownFiles(docsDir) + const fileData = [] + + // Collect file metadata + for (const filePath of markdownFiles) { + try { + const fileContent = fs.readFileSync(filePath, "utf-8") + const { data: frontmatter, content } = matter(fileContent) + + if (frontmatter.draft === true || frontmatter.changelog === false) { + continue + } + + let title = frontmatter.title + if (!title) { + const headingMatch = content.match(/^#\s+(.+)$/m) + title = headingMatch + ? headingMatch[1] + : path.basename(filePath, path.extname(filePath)) + } + + const excerpt = frontmatter.description || extractExcerpt(content) + const relativePath = path.relative(docsDir, filePath).replace(/\\/g, "/") + const repoPath = "documentation/" + relativePath + const dirPath = path.dirname(relativePath) + + let urlPath + if (frontmatter.slug) { + if (frontmatter.slug.startsWith("/")) { + urlPath = frontmatter.slug.slice(1) + } else { + urlPath = + dirPath === "." ? frontmatter.slug : `${dirPath}/${frontmatter.slug}` + } + } else { + urlPath = relativePath.replace(/\.mdx?$/, "").replace(/\/index$/, "") + } + + if (urlPath && !urlPath.endsWith("/")) { + urlPath += "/" + } + + const baseUrl = siteConfig.baseUrl.endsWith("/") + ? siteConfig.baseUrl + : siteConfig.baseUrl + "/" + const url = siteConfig.url + baseUrl + urlPath + + fileData.push({ filePath, repoPath, title, url, excerpt }) + } catch (err) { + console.warn(`[docs-rss] Error processing ${filePath}:`, err.message) + } + } + + // Get dates + if (useGitHubApi) { + console.log("[docs-rss] Using GitHub API for file dates...") + const CONCURRENCY = 10 + for (let i = 0; i < fileData.length; i += CONCURRENCY) { + const batch = fileData.slice(i, i + CONCURRENCY) + const dates = await Promise.all( + batch.map((f) => getGitHubLastModified(f.repoPath)) + ) + batch.forEach((f, idx) => { + f.date = dates[idx] + }) + } + } else { + for (const f of fileData) { + f.date = getGitLastModified(f.filePath) + } + } + + // Build final items + const items = fileData + .filter((f) => f.date) + .map((f) => ({ + title: f.title, + url: f.url, + date: f.date, + excerpt: f.excerpt, + })) + + items.sort((a, b) => b.date - a.date) + return items.slice(0, FEED_ITEMS_COUNT) +} + +module.exports = function docsRssPlugin(context) { + return { + name: "docs-rss", + + async loadContent() { + const { siteConfig } = context + const docsDir = path.join(context.siteDir, "documentation") + const staticDir = path.join(context.siteDir, "static") + + if (!fs.existsSync(docsDir)) { + console.warn("[docs-rss] Documentation directory not found") + return [] + } + + console.log("[docs-rss] Generating RSS feed...") + + const useGitHubApi = isShallowClone() + if (useGitHubApi) { + console.log("[docs-rss] Shallow clone detected, using GitHub API") + } + + const recentItems = await generateFeedItems(docsDir, siteConfig, useGitHubApi) + const rssXml = generateRssXml(recentItems, siteConfig) + const rssPath = path.join(staticDir, "rss.xml") + fs.writeFileSync(rssPath, rssXml, "utf-8") + + console.log(`[docs-rss] Generated RSS feed with ${recentItems.length} items`) + + return recentItems.map((item) => ({ + ...item, + date: item.date.toISOString(), + })) + }, + + async contentLoaded({ content, actions }) { + const { setGlobalData } = actions + setGlobalData({ changelog: content || [] }) + }, + } +} diff --git a/src/components/Changelog/index.tsx b/src/components/Changelog/index.tsx new file mode 100644 index 000000000..a6a3151dc --- /dev/null +++ b/src/components/Changelog/index.tsx @@ -0,0 +1,120 @@ +import { usePluginData } from "@docusaurus/useGlobalData" +import Link from "@docusaurus/Link" + +function RssIcon({ className }: { className?: string }) { + return ( + + + + + ) +} + +type ChangelogItem = { + title: string + url: string + date: string + excerpt: string +} + +type ChangelogData = { + changelog: ChangelogItem[] +} + +function formatDate(dateString: string): string { + const date = new Date(dateString) + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }) +} + +function getRelativeUrl(fullUrl: string): string { + // Extract path from full URL for internal linking + try { + const url = new URL(fullUrl) + return url.pathname + } catch { + return fullUrl + } +} + +function groupByDate(items: ChangelogItem[]): Map { + const groups = new Map() + + for (const item of items) { + const date = new Date(item.date) + const dateKey = date.toISOString().split("T")[0] + + if (!groups.has(dateKey)) { + groups.set(dateKey, []) + } + groups.get(dateKey)!.push(item) + } + + return groups +} + +export default function Changelog() { + const data = usePluginData("docs-rss") as ChangelogData | undefined + + if (!data?.changelog?.length) { + return ( + + No recent documentation updates found. + + ) + } + + const groupedItems = groupByDate(data.changelog) + + return ( + + + + + Subscribe to RSS Feed + + + + {Array.from(groupedItems.entries()).map(([dateKey, items]) => ( + + + {formatDate(items[0].date)} + + + {items.map((item, index) => ( + + + {item.title} + + {item.excerpt && ( + + {item.excerpt} + + )} + + ))} + + + ))} + + + ) +}
+ {item.excerpt} +