Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions npm-shrinkwrap.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/generators/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -27,6 +28,7 @@ export const publicGenerators = {
'api-links': apiLinks,
'orama-db': oramaDb,
'llms-txt': llmsTxt,
sitemap,
web,
};

Expand Down
5 changes: 2 additions & 3 deletions src/generators/llms-txt/utils/buildApiDocLink.mjs
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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})`;

Expand Down
77 changes: 77 additions & 0 deletions src/generators/sitemap/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';

import dedent from 'dedent';

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<ApiDocMetadataEntry>} Input
*
* @type {GeneratorMetadata<Input, string>}
*/
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<GeneratorOptions>} options
* @returns {Promise<string>}
*/
async generate(entries, { output }) {
const lastmod = new Date().toISOString().split('T')[0];

const apiPages = entries
.filter(entry => entry.heading.depth === 1)
.map(entry => createPageSitemapEntry(entry, lastmod));

/**
* @typedef {import('./types').SitemapEntry}
*/
const mainPage = {
loc: new URL('/docs/latest/api/', BASE_URL).href,
lastmod,
changefreq: 'daily',
priority: '1.0',
};

apiPages.push(mainPage);

const template = await readFile(
join(import.meta.dirname, 'template.xml'),
'utf-8'
);

const urlset = apiPages
.map(
page => dedent`
<url>
Copy link
Member

@ovflowd ovflowd Dec 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use template files for this and then do simple key->value substitution. Or use proper rss/feed libraries OR xml libraries.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or use proper rss/feed libraries OR xml libraries.

You can probably use hast (but I'm also fine with it this way), seeing as the majority of it is a template

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we use hast, we are probably going to need https://github.com/syntax-tree/hast-util-to-xast too

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with whatever that uses the least amount of dependencies. We can also just use yet another template file, we can also simply use another dependency, like the xast one, or rss/feeds or whatever.

<loc>${page.loc}</loc>
<lastmod>${page.lastmod}</lastmod>
<changefreq>${page.changefreq}</changefreq>
<priority>${page.priority}</priority>
</url>
`
)
.join('\n');

const sitemap = template.replace('__URLSET__', urlset);

if (output) {
await writeFile(join(output, 'sitemap.xml'), sitemap, 'utf-8');
}

return sitemap;
},
};
4 changes: 4 additions & 0 deletions src/generators/sitemap/template.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
__URLSET__
</urlset>
13 changes: 13 additions & 0 deletions src/generators/sitemap/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export interface SitemapEntry {
loc: string;
lastmod?: string;
changefreq?:
| 'always'
| 'hourly'
| 'daily'
| 'weekly'
| 'monthly'
| 'yearly'
| 'never';
priority?: string;
}
14 changes: 14 additions & 0 deletions src/generators/sitemap/utils/createPageSitemapEntry.mjs
Original file line number Diff line number Diff line change
@@ -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' };
};
32 changes: 32 additions & 0 deletions src/utils/__tests__/url.test.mjs
Original file line number Diff line number Diff line change
@@ -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`);
});
});
19 changes: 19 additions & 0 deletions src/utils/url.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
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) {
const htmlPath = path.replace(/\.md$/, '.html');
return new URL(htmlPath, BASE_URL);
}

return new URL(path, BASE_URL);
};
Loading