-
Notifications
You must be signed in to change notification settings - Fork 0
Mirror: feat(docs): add dynamic sitemap.xml generation (#5728) #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
beef6ed
3e43248
52145b9
a7476f1
7c4b6f0
1118a19
7cdd014
0687932
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,21 +1,12 @@ | ||
| # To get started with Dependabot version updates, you'll need to specify which | ||
| # package ecosystems to update and where the package manifests are located. | ||
| # Please see the documentation for all configuration options: | ||
| # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file | ||
|
|
||
| version: 2 | ||
| updates: | ||
| - package-ecosystem: "npm" | ||
| directory: "/" | ||
| schedule: | ||
| interval: "weekly" | ||
|
|
||
| - package-ecosystem: "npm" | ||
| directory: "/cli" | ||
| schedule: | ||
| interval: "weekly" | ||
|
|
||
| open-pull-requests-limit: 5 | ||
| - package-ecosystem: "github-actions" | ||
| directory: "/" | ||
| schedule: | ||
| interval: "weekly" | ||
| open-pull-requests-limit: 3 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| name: CodeQL | ||
| on: | ||
| pull_request: | ||
| branches: [main] | ||
|
|
||
| permissions: | ||
| security-events: write | ||
| contents: read | ||
|
|
||
| jobs: | ||
| analyze: | ||
| name: Analyze | ||
| runs-on: ubuntu-latest | ||
| strategy: | ||
| fail-fast: false | ||
| matrix: | ||
| language: [javascript-typescript] | ||
| steps: | ||
| - name: Checkout | ||
| uses: actions/checkout@v4 | ||
| - name: Initialize CodeQL | ||
| uses: github/codeql-action/init@v3 | ||
| with: | ||
| languages: ${{ matrix.language }} | ||
| - name: Autobuild | ||
| uses: github/codeql-action/autobuild@v3 | ||
| - name: Perform CodeQL Analysis | ||
| uses: github/codeql-action/analyze@v3 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| name: PR Agent | ||
| on: | ||
| pull_request: | ||
| types: [opened, reopened, ready_for_review] | ||
| issue_comment: | ||
| types: [created] | ||
|
|
||
| permissions: | ||
| issues: write | ||
| pull-requests: write | ||
| contents: read | ||
|
|
||
| jobs: | ||
| pr_agent: | ||
| if: ${{ github.event.sender.type != 'Bot' }} | ||
| runs-on: ubuntu-latest | ||
| name: Run PR Agent | ||
| steps: | ||
| - name: PR Agent action step | ||
| id: pragent | ||
| uses: qodo-ai/pr-agent@main | ||
| env: | ||
| OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }} | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,162 @@ | ||
| /** | ||
| * Tests for the sitemap.xml API endpoint | ||
| * | ||
| * This test suite verifies that the sitemap generation works correctly: | ||
| * 1. Generates valid XML structure | ||
| * 2. Includes all markdown pages | ||
| * 3. Uses correct URL format with the docs basePath | ||
| */ | ||
|
|
||
| import { expect, describe, it, vi, beforeEach, afterEach } from "vitest" | ||
| import type { NextApiRequest, NextApiResponse } from "next" | ||
|
|
||
| // Mock fs module | ||
| vi.mock("fs", () => ({ | ||
| default: { | ||
| readdirSync: vi.fn(), | ||
| statSync: vi.fn(), | ||
| }, | ||
| readdirSync: vi.fn(), | ||
| statSync: vi.fn(), | ||
| })) | ||
|
|
||
| import fs from "fs" | ||
| import handler from "../pages/api/sitemap.xml" | ||
|
|
||
| describe("sitemap.xml API", () => { | ||
| let mockReq: Partial<NextApiRequest> | ||
| let mockRes: Partial<NextApiResponse> | ||
| let responseData: string | ||
| let responseHeaders: Record<string, string> | ||
| let responseStatus: number | ||
|
|
||
| beforeEach(() => { | ||
| responseHeaders = {} | ||
| responseStatus = 200 | ||
| responseData = "" | ||
|
|
||
| mockReq = { | ||
| method: "GET", | ||
| } | ||
|
|
||
| mockRes = { | ||
| status: vi.fn().mockReturnThis(), | ||
| send: vi.fn((data) => { | ||
| responseData = data | ||
| return mockRes | ||
| }), | ||
| json: vi.fn().mockReturnThis(), | ||
| setHeader: vi.fn((key, value) => { | ||
| responseHeaders[key as string] = value as string | ||
| return mockRes | ||
| }), | ||
| } | ||
|
|
||
| // Mock fs.readdirSync to return test files | ||
| vi.mocked(fs.readdirSync).mockImplementation((dir: any) => { | ||
| const dirStr = dir.toString() | ||
| if (dirStr.endsWith("pages")) { | ||
| return [ | ||
| { name: "index.md", isDirectory: () => false }, | ||
| { name: "getting-started", isDirectory: () => true }, | ||
| { name: "api", isDirectory: () => true }, | ||
| ] as any | ||
| } | ||
| if (dirStr.includes("getting-started")) { | ||
| return [ | ||
| { name: "index.md", isDirectory: () => false }, | ||
| { name: "quickstart.md", isDirectory: () => false }, | ||
| ] as any | ||
| } | ||
| return [] | ||
| }) | ||
|
|
||
| // Mock fs.statSync to return a fixed date | ||
| vi.mocked(fs.statSync).mockReturnValue({ | ||
| mtime: new Date("2025-01-15T10:00:00Z"), | ||
| } as any) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| vi.clearAllMocks() | ||
| }) | ||
|
|
||
| describe("HTTP method handling", () => { | ||
| it("should return 405 for non-GET requests", async () => { | ||
| mockReq.method = "POST" | ||
|
|
||
| await handler(mockReq as NextApiRequest, mockRes as NextApiResponse) | ||
|
|
||
| expect(mockRes.status).toHaveBeenCalledWith(405) | ||
| expect(mockRes.json).toHaveBeenCalledWith({ error: "Method not allowed" }) | ||
| }) | ||
|
|
||
| it("should accept GET requests", async () => { | ||
| await handler(mockReq as NextApiRequest, mockRes as NextApiResponse) | ||
|
|
||
| expect(mockRes.status).toHaveBeenCalledWith(200) | ||
| }) | ||
| }) | ||
|
|
||
| describe("sitemap generation", () => { | ||
| it("should generate valid XML with correct headers", async () => { | ||
| await handler(mockReq as NextApiRequest, mockRes as NextApiResponse) | ||
|
|
||
| expect(responseHeaders["Content-Type"]).toBe("application/xml; charset=utf-8") | ||
| expect(responseHeaders["Cache-Control"]).toBe("public, max-age=3600, s-maxage=3600") | ||
| }) | ||
|
|
||
| it("should include XML declaration and urlset", async () => { | ||
| await handler(mockReq as NextApiRequest, mockRes as NextApiResponse) | ||
|
|
||
| expect(responseData).toContain('<?xml version="1.0" encoding="UTF-8"?>') | ||
| expect(responseData).toContain('<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">') | ||
| expect(responseData).toContain("</urlset>") | ||
| }) | ||
|
|
||
| it("should include homepage with priority 1.0", async () => { | ||
| await handler(mockReq as NextApiRequest, mockRes as NextApiResponse) | ||
|
|
||
| expect(responseData).toContain("<loc>https://kilo.ai/docs</loc>") | ||
| expect(responseData).toContain("<priority>1.0</priority>") | ||
| }) | ||
|
|
||
| it("should include markdown pages with correct URLs", async () => { | ||
| await handler(mockReq as NextApiRequest, mockRes as NextApiResponse) | ||
|
|
||
| expect(responseData).toContain("<loc>https://kilo.ai/docs/getting-started</loc>") | ||
| expect(responseData).toContain("<loc>https://kilo.ai/docs/getting-started/quickstart</loc>") | ||
| }) | ||
|
|
||
| it("should include lastmod dates", async () => { | ||
| await handler(mockReq as NextApiRequest, mockRes as NextApiResponse) | ||
|
|
||
| expect(responseData).toContain("<lastmod>2025-01-15</lastmod>") | ||
| }) | ||
|
|
||
| it("should include changefreq", async () => { | ||
| await handler(mockReq as NextApiRequest, mockRes as NextApiResponse) | ||
|
|
||
| expect(responseData).toContain("<changefreq>weekly</changefreq>") | ||
| }) | ||
|
|
||
| it("should skip api directory", async () => { | ||
| await handler(mockReq as NextApiRequest, mockRes as NextApiResponse) | ||
|
|
||
| expect(responseData).not.toContain("/api/") | ||
| }) | ||
| }) | ||
|
|
||
| describe("error handling", () => { | ||
| it("should return 500 on filesystem errors", async () => { | ||
| vi.mocked(fs.readdirSync).mockImplementation(() => { | ||
| throw new Error("Filesystem error") | ||
| }) | ||
|
|
||
| await handler(mockReq as NextApiRequest, mockRes as NextApiResponse) | ||
|
|
||
| expect(mockRes.status).toHaveBeenCalledWith(500) | ||
| expect(mockRes.json).toHaveBeenCalledWith({ error: "Internal server error" }) | ||
| }) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,112 @@ | ||||||||||||||||||||||||
| import type { NextApiRequest, NextApiResponse } from "next" | ||||||||||||||||||||||||
| import fs from "fs" | ||||||||||||||||||||||||
| import path from "path" | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const SITE_URL = "https://kilo.ai/docs" | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Recursively finds all markdown files in a directory | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| function findMarkdownFiles(dir: string, baseDir: string = dir): string[] { | ||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||||||||||||||||||||
| const files: string[] = [] | ||||||||||||||||||||||||
| const entries = fs.readdirSync(dir, { withFileTypes: true }) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| for (const entry of entries) { | ||||||||||||||||||||||||
| const fullPath = path.join(dir, entry.name) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| if (entry.isDirectory()) { | ||||||||||||||||||||||||
| // Skip api directory | ||||||||||||||||||||||||
| if (entry.name === "api") continue | ||||||||||||||||||||||||
| files.push(...findMarkdownFiles(fullPath, baseDir)) | ||||||||||||||||||||||||
| } else if (entry.name.endsWith(".md")) { | ||||||||||||||||||||||||
| files.push(fullPath) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| return files | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Converts a file path to a URL path | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| function filePathToUrlPath(filePath: string, pagesDir: string): string { | ||||||||||||||||||||||||
| let relativePath = path.relative(pagesDir, filePath) | ||||||||||||||||||||||||
| // Remove .md extension | ||||||||||||||||||||||||
| relativePath = relativePath.replace(/\.md$/, "") | ||||||||||||||||||||||||
| // Handle index files | ||||||||||||||||||||||||
| relativePath = relativePath.replace(/(^|\/)index$/, "") | ||||||||||||||||||||||||
| // Convert to URL path | ||||||||||||||||||||||||
| return "/" + relativePath.split(path.sep).join("/") | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Gets the last modified date of a file | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| function getLastModified(filePath: string): string { | ||||||||||||||||||||||||
| const stats = fs.statSync(filePath) | ||||||||||||||||||||||||
| return stats.mtime.toISOString().split("T")[0] | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Escapes special XML characters | ||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||
| function escapeXml(str: string): string { | ||||||||||||||||||||||||
| return str | ||||||||||||||||||||||||
| .replace(/&/g, "&") | ||||||||||||||||||||||||
| .replace(/</g, "<") | ||||||||||||||||||||||||
| .replace(/>/g, ">") | ||||||||||||||||||||||||
| .replace(/"/g, """) | ||||||||||||||||||||||||
| .replace(/'/g, "'") | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export default async function handler(req: NextApiRequest, res: NextApiResponse) { | ||||||||||||||||||||||||
| if (req.method !== "GET") { | ||||||||||||||||||||||||
| return res.status(405).json({ error: "Method not allowed" }) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| const pagesDir = path.join(process.cwd(), "pages") | ||||||||||||||||||||||||
| const markdownFiles = findMarkdownFiles(pagesDir) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Sort files for consistent output | ||||||||||||||||||||||||
| markdownFiles.sort() | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const urls: string[] = [] | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Add homepage | ||||||||||||||||||||||||
| urls.push(` <url> | ||||||||||||||||||||||||
| <loc>${SITE_URL}</loc> | ||||||||||||||||||||||||
| <changefreq>weekly</changefreq> | ||||||||||||||||||||||||
| <priority>1.0</priority> | ||||||||||||||||||||||||
| </url>`) | ||||||||||||||||||||||||
|
Comment on lines
+77
to
+81
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The homepage entry in the sitemap is missing the You can get the modification date from the
Suggested change
|
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| for (const filePath of markdownFiles) { | ||||||||||||||||||||||||
| const urlPath = filePathToUrlPath(filePath, pagesDir) | ||||||||||||||||||||||||
| const lastMod = getLastModified(filePath) | ||||||||||||||||||||||||
| const fullUrl = `${SITE_URL}${urlPath}` | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // Determine priority based on path depth | ||||||||||||||||||||||||
| const depth = urlPath.split("/").filter(Boolean).length | ||||||||||||||||||||||||
| const priority = Math.max(0.5, 1.0 - depth * 0.1).toFixed(1) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| urls.push(` <url> | ||||||||||||||||||||||||
| <loc>${escapeXml(fullUrl)}</loc> | ||||||||||||||||||||||||
| <lastmod>${lastMod}</lastmod> | ||||||||||||||||||||||||
| <changefreq>weekly</changefreq> | ||||||||||||||||||||||||
| <priority>${priority}</priority> | ||||||||||||||||||||||||
| </url>`) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| const sitemap = `<?xml version="1.0" encoding="UTF-8"?> | ||||||||||||||||||||||||
| <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> | ||||||||||||||||||||||||
| ${urls.join("\n")} | ||||||||||||||||||||||||
| </urlset>` | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| res.setHeader("Content-Type", "application/xml; charset=utf-8") | ||||||||||||||||||||||||
| res.setHeader("Cache-Control", "public, max-age=3600, s-maxage=3600") // Cache for 1 hour | ||||||||||||||||||||||||
| res.status(200).send(sitemap) | ||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||
| console.error("Error generating sitemap:", error) | ||||||||||||||||||||||||
| res.status(500).json({ error: "Internal server error" }) | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test correctly verifies the homepage's
locandpriority, but it doesn't check for the presence of the<lastmod>tag. To ensure the sitemap is complete and effective for SEO, the test should also assert that the homepage entry includes alastmoddate.