diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json
index aa327fb6..415bf6dc 100644
--- a/npm-shrinkwrap.json
+++ b/npm-shrinkwrap.json
@@ -1068,7 +1068,6 @@
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
- "peer": true,
"engines": {
"node": "^14.21.3 || >=16"
},
@@ -1169,7 +1168,6 @@
"resolved": "https://registry.npmjs.org/@orama/cuid2/-/cuid2-2.2.3.tgz",
"integrity": "sha512-Lcak3chblMejdlSHgYU2lS2cdOhDpU6vkfIJH4m+YKvqQyLqs1bB8+w6NT1MG5bO12NUK2GFc34Mn2xshMIQ1g==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@noble/hashes": "^1.1.5"
}
@@ -1187,8 +1185,7 @@
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/@orama/oramacore-events-parser/-/oramacore-events-parser-0.0.5.tgz",
"integrity": "sha512-yAuSwog+HQBAXgZ60TNKEwu04y81/09mpbYBCmz1RCxnr4ObNY2JnPZI7HmALbjAhLJ8t5p+wc2JHRK93ubO4w==",
- "license": "AGPL-3.0",
- "peer": true
+ "license": "AGPL-3.0"
},
"node_modules/@orama/stopwords": {
"version": "3.1.16",
@@ -3495,6 +3492,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -4030,8 +4028,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
@@ -4231,6 +4228,7 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7133,6 +7131,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -7182,6 +7181,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-11.0.0-beta.0.tgz",
"integrity": "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg==",
"license": "MIT",
+ "peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -7681,8 +7681,7 @@
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
@@ -8271,6 +8270,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -8636,6 +8636,7 @@
"integrity": "sha512-VUyWiTNQD7itdiMuJy+EuLEErLj3uwX/EpHQF8EOf33Dq3Ju6VW1GXm+swk6+1h7a49uv9fKZ+dft9jU7esdLA==",
"dev": true,
"hasInstallScript": true,
+ "peer": true,
"dependencies": {
"napi-postinstall": "^0.2.4"
},
@@ -9062,7 +9063,6 @@
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz",
"integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==",
"license": "ISC",
- "peer": true,
"peerDependencies": {
"zod": "^3.24.1"
}
diff --git a/src/generators/index.mjs b/src/generators/index.mjs
index 09a4c1a0..24294f92 100644
--- a/src/generators/index.mjs
+++ b/src/generators/index.mjs
@@ -14,6 +14,7 @@ import llmsTxt from './llms-txt/index.mjs';
import manPage from './man-page/index.mjs';
import metadata from './metadata/index.mjs';
import oramaDb from './orama-db/index.mjs';
+import sitemap from './sitemap/index.mjs';
import web from './web/index.mjs';
export const publicGenerators = {
@@ -27,6 +28,7 @@ export const publicGenerators = {
'api-links': apiLinks,
'orama-db': oramaDb,
'llms-txt': llmsTxt,
+ sitemap,
web,
};
diff --git a/src/generators/llms-txt/utils/buildApiDocLink.mjs b/src/generators/llms-txt/utils/buildApiDocLink.mjs
index 1177e6be..d7714c6d 100644
--- a/src/generators/llms-txt/utils/buildApiDocLink.mjs
+++ b/src/generators/llms-txt/utils/buildApiDocLink.mjs
@@ -1,5 +1,5 @@
-import { BASE_URL } from '../../../constants.mjs';
import { transformNodeToString } from '../../../utils/unist.mjs';
+import { buildApiDocURL } from '../../../utils/url.mjs';
/**
* Retrieves the description of a given API doc entry. It first checks whether
@@ -38,8 +38,7 @@ export const getEntryDescription = entry => {
export const buildApiDocLink = entry => {
const title = entry.heading.data.name;
- const path = entry.api_doc_source.replace(/^doc\//, '/docs/latest/');
- const url = new URL(path, BASE_URL);
+ const url = buildApiDocURL(entry);
const link = `[${title}](${url})`;
diff --git a/src/generators/sitemap/entry-template.xml b/src/generators/sitemap/entry-template.xml
new file mode 100644
index 00000000..d646ff63
--- /dev/null
+++ b/src/generators/sitemap/entry-template.xml
@@ -0,0 +1,6 @@
+
+ __LOC__
+ __LASTMOD__
+ __CHANGEFREQ__
+ __PRIORITY__
+
diff --git a/src/generators/sitemap/index.mjs b/src/generators/sitemap/index.mjs
new file mode 100644
index 00000000..0ed7156b
--- /dev/null
+++ b/src/generators/sitemap/index.mjs
@@ -0,0 +1,79 @@
+import { readFile, writeFile } from 'node:fs/promises';
+import { join } from 'node:path';
+
+import { BASE_URL } from '../../constants.mjs';
+import { createPageSitemapEntry } from './utils/createPageSitemapEntry.mjs';
+
+/**
+ * This generator generates a sitemap.xml file for search engine optimization
+ *
+ * @typedef {Array} Input
+ *
+ * @type {GeneratorMetadata}
+ */
+export default {
+ name: 'sitemap',
+
+ version: '1.0.0',
+
+ description: 'Generates a sitemap.xml file for search engine optimization',
+
+ dependsOn: 'metadata',
+
+ /**
+ * Generates a sitemap.xml file
+ *
+ * @param {Input} entries
+ * @param {Partial} options
+ * @returns {Promise}
+ */
+ async generate(entries, { output }) {
+ const template = await readFile(
+ join(import.meta.dirname, 'template.xml'),
+ 'utf-8'
+ );
+
+ const entryTemplate = await readFile(
+ join(import.meta.dirname, 'entry-template.xml'),
+ 'utf-8'
+ );
+
+ const lastmod = new Date().toISOString().split('T')[0];
+
+ const apiPages = entries
+ .filter(entry => entry.heading.depth === 1)
+ .map(entry => createPageSitemapEntry(entry, lastmod));
+
+ const { href: loc } = new URL('/docs/latest/api/', BASE_URL);
+
+ /**
+ * @typedef {import('./types').SitemapEntry}
+ */
+ const mainPage = {
+ loc,
+ lastmod,
+ changefreq: 'daily',
+ priority: '1.0',
+ };
+
+ apiPages.push(mainPage);
+
+ const urlset = apiPages
+ .map(page =>
+ entryTemplate
+ .replace('__LOC__', page.loc)
+ .replace('__LASTMOD__', page.lastmod)
+ .replace('__CHANGEFREQ__', page.changefreq)
+ .replace('__PRIORITY__', page.priority)
+ )
+ .join('');
+
+ const sitemap = template.replace('__URLSET__', urlset);
+
+ if (output) {
+ await writeFile(join(output, 'sitemap.xml'), sitemap, 'utf-8');
+ }
+
+ return sitemap;
+ },
+};
diff --git a/src/generators/sitemap/template.xml b/src/generators/sitemap/template.xml
new file mode 100644
index 00000000..84771792
--- /dev/null
+++ b/src/generators/sitemap/template.xml
@@ -0,0 +1,4 @@
+
+
+__URLSET__
+
diff --git a/src/generators/sitemap/types.d.ts b/src/generators/sitemap/types.d.ts
new file mode 100644
index 00000000..0353c9cc
--- /dev/null
+++ b/src/generators/sitemap/types.d.ts
@@ -0,0 +1,13 @@
+export interface SitemapEntry {
+ loc: string;
+ lastmod?: string;
+ changefreq?:
+ | 'always'
+ | 'hourly'
+ | 'daily'
+ | 'weekly'
+ | 'monthly'
+ | 'yearly'
+ | 'never';
+ priority?: string;
+}
diff --git a/src/generators/sitemap/utils/createPageSitemapEntry.mjs b/src/generators/sitemap/utils/createPageSitemapEntry.mjs
new file mode 100644
index 00000000..cbd8ff46
--- /dev/null
+++ b/src/generators/sitemap/utils/createPageSitemapEntry.mjs
@@ -0,0 +1,14 @@
+import { buildApiDocURL } from '../../../utils/url.mjs';
+
+/**
+ * Builds an API doc sitemap url.
+ *
+ * @param {ApiDocMetadataEntry} entry
+ * @param {string} lastmod
+ * @returns {import('../types').SitemapEntry}
+ */
+export const createPageSitemapEntry = (entry, lastmod) => {
+ const { href } = buildApiDocURL(entry, true);
+
+ return { loc: href, lastmod, changefreq: 'weekly', priority: '0.8' };
+};
diff --git a/src/utils/__tests__/url.test.mjs b/src/utils/__tests__/url.test.mjs
new file mode 100644
index 00000000..4b43b75e
--- /dev/null
+++ b/src/utils/__tests__/url.test.mjs
@@ -0,0 +1,32 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import { buildApiDocURL } from '../url.mjs';
+
+const BASE = 'https://nodejs.org/';
+
+describe('buildApiDocURL', () => {
+ it('builds markdown doc URLs from doc/ sources', () => {
+ const entry = { api_doc_source: 'doc/api/fs.md' };
+
+ const result = buildApiDocURL(entry);
+
+ assert.equal(result.href, `${BASE}docs/latest/api/fs.md`);
+ });
+
+ it('builds html doc URLs when requested', () => {
+ const entry = { api_doc_source: 'doc/api/path.md' };
+
+ const result = buildApiDocURL(entry, true);
+
+ assert.equal(result.href, `${BASE}docs/latest/api/path.html`);
+ });
+
+ it('leaves non doc/ sources untouched', () => {
+ const entry = { api_doc_source: 'api/crypto.md' };
+
+ const result = buildApiDocURL(entry);
+
+ assert.equal(result.href, `${BASE}api/crypto.md`);
+ });
+});
diff --git a/src/utils/url.mjs b/src/utils/url.mjs
new file mode 100644
index 00000000..6a03d305
--- /dev/null
+++ b/src/utils/url.mjs
@@ -0,0 +1,18 @@
+import { BASE_URL } from '../constants.mjs';
+
+/**
+ * Builds the url of a api doc entry.
+ *
+ * @param {ApiDocMetadataEntry} entry
+ * @param {boolean} [useHtml]
+ * @returns {URL}
+ */
+export const buildApiDocURL = (entry, useHtml = false) => {
+ const path = entry.api_doc_source.replace(/^doc\//, '/docs/latest/');
+
+ if (useHtml) {
+ return URL.parse(path.replace(/\.md$/, '.html'), BASE_URL);
+ }
+
+ return URL.parse(path, BASE_URL);
+};