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); +};