diff --git a/.changeset/eighty-falcons-tease.md b/.changeset/eighty-falcons-tease.md new file mode 100644 index 000000000000..7ea106f1e86b --- /dev/null +++ b/.changeset/eighty-falcons-tease.md @@ -0,0 +1,24 @@ +--- +"@astrojs/sitemap": minor +--- + +Adds a new configuration option `prefix` that allows you to change the default `sitemap-*.xml` file name. + +By default, running `astro build` creates both `sitemap-index.xml` and `sitemap-0.xml` in your output directory. + +To change the names of these files (e.g. to `astrosite-index.xml` and `astrosite-0.xml`), set the `prefix` option in your `sitemap` integration configuration: + +``` +import { defineConfig } from 'astro/config'; +import sitemap from '@astrojs/sitemap'; +export default defineConfig({ + site: 'https://example.com', + integrations: [ + sitemap({ + prefix: 'astrosite-', + }), + ], +}); +``` + +This option is useful when Google Search Console is unable to fetch your default sitemap files, but can read renamed files. diff --git a/packages/integrations/sitemap/src/index.ts b/packages/integrations/sitemap/src/index.ts index c254fb662bcf..4c049df9170b 100644 --- a/packages/integrations/sitemap/src/index.ts +++ b/packages/integrations/sitemap/src/index.ts @@ -1,12 +1,14 @@ import type { AstroConfig, AstroIntegration } from 'astro'; -import path from 'node:path'; +import path, { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { EnumChangefreq, LinkItem as LinkItemBase, SitemapItemLoose } from 'sitemap'; -import { simpleSitemapAndIndex } from 'sitemap'; +import { SitemapAndIndexStream, SitemapStream, streamToPromise } from 'sitemap'; import { ZodError } from 'zod'; import { generateSitemap } from './generate-sitemap.js'; import { validateOptions } from './validate-options.js'; +import { createWriteStream } from 'node:fs'; +import { Readable } from 'node:stream'; export { EnumChangefreq as ChangeFreqEnum } from 'sitemap'; export type ChangeFreq = `${EnumChangefreq}`; @@ -33,6 +35,8 @@ export type SitemapOptions = lastmod?: Date; priority?: number; + prefix?: string; + // called for each sitemap item just before to save them on disk, sync or async serialize?(item: SitemapItem): SitemapItem | Promise | undefined; } @@ -44,7 +48,6 @@ function formatConfigErrorMessage(err: ZodError) { } const PKG_NAME = '@astrojs/sitemap'; -const OUTFILE = 'sitemap-index.xml'; const STATUS_CODE_PAGES = new Set(['404', '500']); function isStatusCodePage(pathname: string): boolean { @@ -77,7 +80,8 @@ const createPlugin = (options?: SitemapOptions): AstroIntegration => { const opts = validateOptions(config.site, options); - const { filter, customPages, serialize, entryLimit } = opts; + const { filter, customPages, serialize, entryLimit, prefix = 'sitemap-' } = opts; + const OUTFILE = `${prefix}index.xml`; let finalSiteUrl: URL; if (config.site) { @@ -166,13 +170,22 @@ const createPlugin = (options?: SitemapOptions): AstroIntegration => { } } const destDir = fileURLToPath(dir); - await simpleSitemapAndIndex({ - hostname: finalSiteUrl.href, - destinationDir: destDir, - sourceData: urlData, + + const sms = new SitemapAndIndexStream({ limit: entryLimit, - gzip: false, + getSitemapStream: (i) => { + const sitemapStream = new SitemapStream({ hostname: finalSiteUrl.href }); + const fileName = `${prefix}${i}.xml`; + + const ws = sitemapStream.pipe(createWriteStream(resolve(destDir + fileName))); + + return [new URL(fileName, finalSiteUrl.href).toString(), sitemapStream, ws]; + }, }); + + sms.pipe(createWriteStream(resolve(destDir + OUTFILE))); + await streamToPromise(Readable.from(urlData).pipe(sms)); + sms.end(); logger.info(`\`${OUTFILE}\` created at \`${path.relative(process.cwd(), destDir)}\``); } catch (err) { if (err instanceof ZodError) { diff --git a/packages/integrations/sitemap/src/schema.ts b/packages/integrations/sitemap/src/schema.ts index a7682e881a05..da629fc0c690 100644 --- a/packages/integrations/sitemap/src/schema.ts +++ b/packages/integrations/sitemap/src/schema.ts @@ -34,6 +34,13 @@ export const SitemapOptionsSchema = z changefreq: z.nativeEnum(ChangeFreq).optional(), lastmod: z.date().optional(), priority: z.number().min(0).max(1).optional(), + + prefix: z + .string() + .regex(/^[a-zA-Z\-_]+$/gm, { + message: 'Only English alphabet symbols, hyphen and underscore allowed', + }) + .optional(), }) .strict() .default(SITEMAP_CONFIG_DEFAULTS); diff --git a/packages/integrations/sitemap/test/prefix.test.js b/packages/integrations/sitemap/test/prefix.test.js new file mode 100644 index 000000000000..fdd538d0f3cd --- /dev/null +++ b/packages/integrations/sitemap/test/prefix.test.js @@ -0,0 +1,72 @@ +import { loadFixture, readXML } from './test-utils.js'; +import { sitemap } from './fixtures/static/deps.mjs'; +import assert from 'node:assert/strict'; +import { before, describe, it } from 'node:test'; + +describe('Prefix support', () => { + /** @type {import('./test-utils.js').Fixture} */ + let fixture; + const prefix = 'test-'; + + describe('Static', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/static/', + integrations: [ + sitemap(), + sitemap({ + prefix, + }), + ], + }); + await fixture.build(); + }); + + it('Content is same', async () => { + const data = await readXML(fixture.readFile('/sitemap-0.xml')); + const prefixData = await readXML(fixture.readFile(`/${prefix}0.xml`)); + assert.deepEqual(prefixData, data); + }); + + it('Index file load correct sitemap', async () => { + const data = await readXML(fixture.readFile('/sitemap-index.xml')); + const sitemapUrl = data.sitemapindex.sitemap[0].loc[0]; + assert.strictEqual(sitemapUrl, 'http://example.com/sitemap-0.xml'); + + const prefixData = await readXML(fixture.readFile(`/${prefix}index.xml`)); + const prefixSitemapUrl = prefixData.sitemapindex.sitemap[0].loc[0]; + assert.strictEqual(prefixSitemapUrl, `http://example.com/${prefix}0.xml`); + }); + }); + + describe('SSR', () => { + before(async () => { + fixture = await loadFixture({ + root: './fixtures/ssr/', + integrations: [ + sitemap(), + sitemap({ + prefix, + }), + ], + }); + await fixture.build(); + }); + + it('Content is same', async () => { + const data = await readXML(fixture.readFile('/client/sitemap-0.xml')); + const prefixData = await readXML(fixture.readFile(`/client/${prefix}0.xml`)); + assert.deepEqual(prefixData, data); + }); + + it('Index file load correct sitemap', async () => { + const data = await readXML(fixture.readFile('/client/sitemap-index.xml')); + const sitemapUrl = data.sitemapindex.sitemap[0].loc[0]; + assert.strictEqual(sitemapUrl, 'http://example.com/sitemap-0.xml'); + + const prefixData = await readXML(fixture.readFile(`/client/${prefix}index.xml`)); + const prefixSitemapUrl = prefixData.sitemapindex.sitemap[0].loc[0]; + assert.strictEqual(prefixSitemapUrl, `http://example.com/${prefix}0.xml`); + }); + }); +});