aboutsummaryrefslogtreecommitdiff
path: root/packages/integrations/sitemap
diff options
context:
space:
mode:
Diffstat (limited to 'packages/integrations/sitemap')
-rw-r--r--packages/integrations/sitemap/CHANGELOG.md388
-rw-r--r--packages/integrations/sitemap/README.md38
-rw-r--r--packages/integrations/sitemap/package.json48
-rw-r--r--packages/integrations/sitemap/src/config-defaults.ts6
-rw-r--r--packages/integrations/sitemap/src/generate-sitemap.ts77
-rw-r--r--packages/integrations/sitemap/src/index.ts195
-rw-r--r--packages/integrations/sitemap/src/schema.ts41
-rw-r--r--packages/integrations/sitemap/src/utils/parse-i18n-url.ts42
-rw-r--r--packages/integrations/sitemap/src/validate-options.ts22
-rw-r--r--packages/integrations/sitemap/src/write-sitemap.ts75
-rw-r--r--packages/integrations/sitemap/test/base-path.test.js52
-rw-r--r--packages/integrations/sitemap/test/config.test.js125
-rw-r--r--packages/integrations/sitemap/test/dynamic-path.test.js24
-rw-r--r--packages/integrations/sitemap/test/fixtures/dynamic/astro.config.mjs7
-rw-r--r--packages/integrations/sitemap/test/fixtures/dynamic/package.json9
-rw-r--r--packages/integrations/sitemap/test/fixtures/dynamic/src/pages/[...slug].astro21
-rw-r--r--packages/integrations/sitemap/test/fixtures/ssr/astro.config.mjs12
-rw-r--r--packages/integrations/sitemap/test/fixtures/ssr/package.json10
-rw-r--r--packages/integrations/sitemap/test/fixtures/ssr/src/pages/one.astro8
-rw-r--r--packages/integrations/sitemap/test/fixtures/ssr/src/pages/two.astro8
-rw-r--r--packages/integrations/sitemap/test/fixtures/static/astro.config.mjs18
-rw-r--r--packages/integrations/sitemap/test/fixtures/static/deps.mjs1
-rw-r--r--packages/integrations/sitemap/test/fixtures/static/package.json9
-rw-r--r--packages/integrations/sitemap/test/fixtures/static/src/pages/123.astro8
-rw-r--r--packages/integrations/sitemap/test/fixtures/static/src/pages/404.astro8
-rw-r--r--packages/integrations/sitemap/test/fixtures/static/src/pages/[lang]/manifest.ts15
-rw-r--r--packages/integrations/sitemap/test/fixtures/static/src/pages/[slug].astro17
-rw-r--r--packages/integrations/sitemap/test/fixtures/static/src/pages/de/404.astro8
-rw-r--r--packages/integrations/sitemap/test/fixtures/static/src/pages/endpoint.json.ts6
-rw-r--r--packages/integrations/sitemap/test/fixtures/static/src/pages/products-by-id/[id].astro11
-rw-r--r--packages/integrations/sitemap/test/fixtures/trailing-slash/astro.config.mjs7
-rw-r--r--packages/integrations/sitemap/test/fixtures/trailing-slash/package.json9
-rw-r--r--packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/index.astro8
-rw-r--r--packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/one.astro8
-rw-r--r--packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/two.astro8
-rw-r--r--packages/integrations/sitemap/test/routes.test.js27
-rw-r--r--packages/integrations/sitemap/test/smoke.test.js3
-rw-r--r--packages/integrations/sitemap/test/ssr.test.js23
-rw-r--r--packages/integrations/sitemap/test/staticPaths.test.js48
-rw-r--r--packages/integrations/sitemap/test/test-utils.js29
-rw-r--r--packages/integrations/sitemap/test/trailing-slash.test.js127
-rw-r--r--packages/integrations/sitemap/test/units/generate-sitemap.test.js147
-rw-r--r--packages/integrations/sitemap/tsconfig.json7
43 files changed, 1760 insertions, 0 deletions
diff --git a/packages/integrations/sitemap/CHANGELOG.md b/packages/integrations/sitemap/CHANGELOG.md
new file mode 100644
index 000000000..ccb04f05f
--- /dev/null
+++ b/packages/integrations/sitemap/CHANGELOG.md
@@ -0,0 +1,388 @@
+# @astrojs/sitemap
+
+## 3.4.1
+
+### Patch Changes
+
+- [#13871](https://github.com/withastro/astro/pull/13871) [`8a1e849`](https://github.com/withastro/astro/commit/8a1e8499dbd1ed98e971635e86eb89f910f0ce78) Thanks [@blimmer](https://github.com/blimmer)! - Uncaught errors in the `filter` method will now bubble, causing the astro build to fail.
+
+## 3.4.0
+
+### Minor Changes
+
+- [#13753](https://github.com/withastro/astro/pull/13753) [`90293de`](https://github.com/withastro/astro/commit/90293de03320da51965f05cfa6923cbe5521f519) Thanks [@mattyoho](https://github.com/mattyoho)! - Customize the filenames of sitemap XML files generated by the `@astro/sitemap` integration by setting `filenameBase` in the integration configuration settings. This may be useful when deploying an Astro site at a path on a domain with preexisting sitemap files.
+
+ Generated sitemap files will appear at `/sitemap-0.xml` and `/sitemap-index.xml` by default, which may conflict with preexisting files. Set `filenameBase` to a custom value to avoid that if so:
+
+ ```js
+ import { defineConfig } from 'astro/config';
+ import sitemap from '@astrojs/sitemap';
+
+ export default defineConfig({
+ site: 'https://example.com',
+ integrations: [
+ sitemap({
+ filenameBase: 'astronomy-sitemap',
+ }),
+ ],
+ });
+ ```
+
+ This will yield sitemap and index files as `https://example.com/astronomy-sitemap-0.xml` and `https://example.com/astronomy-sitemap-index.xml`.
+
+## 3.3.1
+
+### Patch Changes
+
+- [#13591](https://github.com/withastro/astro/pull/13591) [`5dd2d3f`](https://github.com/withastro/astro/commit/5dd2d3fde8a138ed611dedf39ffa5dfeeed315f8) Thanks [@florian-lefebvre](https://github.com/florian-lefebvre)! - Removes unused code
+
+## 3.3.0
+
+### Minor Changes
+
+- [#13448](https://github.com/withastro/astro/pull/13448) [`91c9503`](https://github.com/withastro/astro/commit/91c95034e0d0bd450170623fd8aab4b56b5b1366) Thanks [@ematipico](https://github.com/ematipico)! - Add support for XSL in sitemap-index.xml
+
+## 3.2.1
+
+### Patch Changes
+
+- [#12156](https://github.com/withastro/astro/pull/12156) [`07754f5`](https://github.com/withastro/astro/commit/07754f5873b05ab4dae31ded7264fe4056c2dfc8) Thanks [@mingjunlu](https://github.com/mingjunlu)! - Adds missing `xslURL` property to `SitemapOptions` type.
+
+## 3.2.0
+
+### Minor Changes
+
+- [#11485](https://github.com/withastro/astro/pull/11485) [`fbe1bc5`](https://github.com/withastro/astro/commit/fbe1bc51d89994c4919c12768908658604513bd3) Thanks [@sondr3](https://github.com/sondr3)! - Adds new `xslURL` option to enable styling of sitemaps
+
+## 3.1.6
+
+### Patch Changes
+
+- [#11263](https://github.com/withastro/astro/pull/11263) [`7d59750`](https://github.com/withastro/astro/commit/7d597506615fa5a34327304e8321be7b9c4b799d) Thanks [@wackbyte](https://github.com/wackbyte)! - Refactor to use Astro's integration logger for logging
+
+## 3.1.5
+
+### Patch Changes
+
+- [#10779](https://github.com/withastro/astro/pull/10779) [`cefeadf`](https://github.com/withastro/astro/commit/cefeadf0a4a51420130445b6dc5ab1e5b331732b) Thanks [@adrianlyjak](https://github.com/adrianlyjak)! - Fixes false positives for status code routes like `404` and `500` when generating sitemaps.
+
+## 3.1.4
+
+### Patch Changes
+
+- [#10772](https://github.com/withastro/astro/pull/10772) [`0e22462d1534afc8f7bb6782f86db680c7a5f245`](https://github.com/withastro/astro/commit/0e22462d1534afc8f7bb6782f86db680c7a5f245) Thanks [@gislerro](https://github.com/gislerro)! - Fixes an issue where the root url does not follow the `trailingSlash` config option
+
+## 3.1.3
+
+### Patch Changes
+
+- [#10795](https://github.com/withastro/astro/pull/10795) [`1ce22881c657becf0397b83ac393fb5d2399104c`](https://github.com/withastro/astro/commit/1ce22881c657becf0397b83ac393fb5d2399104c) Thanks [@bluwy](https://github.com/bluwy)! - Improves performance when generating the sitemap data
+
+## 3.1.2
+
+### Patch Changes
+
+- [#10557](https://github.com/withastro/astro/pull/10557) [`5f7e9c47e01116f6ec74b33770f480404680956a`](https://github.com/withastro/astro/commit/5f7e9c47e01116f6ec74b33770f480404680956a) Thanks [@mingjunlu](https://github.com/mingjunlu)! - Fixes an issue where the base path is missing in `sitemap-index.xml`.
+
+## 3.1.1
+
+### Patch Changes
+
+- [#10179](https://github.com/withastro/astro/pull/10179) [`6343f6a438d790fa16a0dd268f4a51def4fa0f33`](https://github.com/withastro/astro/commit/6343f6a438d790fa16a0dd268f4a51def4fa0f33) Thanks [@ematipico](https://github.com/ematipico)! - Revert https://github.com/withastro/astro/pull/9846
+
+ The feature to customize the file name of the sitemap was reverted due to some internal issues with one of the dependencies. With an non-deterministic behaviour, the sitemap file was sometime emitted with incorrect syntax.
+
+- [#9975](https://github.com/withastro/astro/pull/9975) [`ec7d2ebbd96b8c2dfdadaf076bbf7953007536ed`](https://github.com/withastro/astro/commit/ec7d2ebbd96b8c2dfdadaf076bbf7953007536ed) Thanks [@moose96](https://github.com/moose96)! - Fixes URL generation for routes that rest parameters and start with `/`
+
+## 3.1.0
+
+### Minor Changes
+
+- [#9846](https://github.com/withastro/astro/pull/9846) [`9b78c992750cdb99c40a89a00ea2a0d1c00877d7`](https://github.com/withastro/astro/commit/9b78c992750cdb99c40a89a00ea2a0d1c00877d7) Thanks [@ktym4a](https://github.com/ktym4a)! - 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.
+
+## 3.0.5
+
+### Patch Changes
+
+- [#9704](https://github.com/withastro/astro/pull/9704) [`b325fada567892b63ecae87c1ff845c8514457ba`](https://github.com/withastro/astro/commit/b325fada567892b63ecae87c1ff845c8514457ba) Thanks [@andremralves](https://github.com/andremralves)! - Fixes generated URLs when using a `base` with a SSR adapter
+
+## 3.0.4
+
+### Patch Changes
+
+- [#9479](https://github.com/withastro/astro/pull/9479) [`1baf0b0d3cbd0564954c2366a7278794fad6726e`](https://github.com/withastro/astro/commit/1baf0b0d3cbd0564954c2366a7278794fad6726e) Thanks [@sarah11918](https://github.com/sarah11918)! - Updates README
+
+## 3.0.3
+
+### Patch Changes
+
+- [#8762](https://github.com/withastro/astro/pull/8762) [`35cd810f0`](https://github.com/withastro/astro/commit/35cd810f0f988010fbb8e6d7ab205de5d816e2b2) Thanks [@evadecker](https://github.com/evadecker)! - Upgrades Zod to 3.22.4
+
+## 3.0.2
+
+### Patch Changes
+
+- [#8824](https://github.com/withastro/astro/pull/8824) [`10b103820`](https://github.com/withastro/astro/commit/10b103820e22e51dcfb0592c542cdf2c5eeb2f52) Thanks [@silent1mezzo](https://github.com/silent1mezzo)! - Display output directory in the sitemap build result
+
+## 3.0.1
+
+### Patch Changes
+
+- [#8737](https://github.com/withastro/astro/pull/8737) [`6f60da805`](https://github.com/withastro/astro/commit/6f60da805e0014bc50dd07bef972e91c73560c3c) Thanks [@ematipico](https://github.com/ematipico)! - Add provenance statement when publishing the library from CI
+
+## 3.0.0
+
+### Major Changes
+
+- [#8188](https://github.com/withastro/astro/pull/8188) [`d0679a666`](https://github.com/withastro/astro/commit/d0679a666f37da0fca396d42b9b32bbb25d29312) Thanks [@ematipico](https://github.com/ematipico)! - Remove support for Node 16. The lowest supported version by Astro and all integrations is now v18.14.1. As a reminder, Node 16 will be deprecated on the 11th September 2023.
+
+- [#8179](https://github.com/withastro/astro/pull/8179) [`6011d52d3`](https://github.com/withastro/astro/commit/6011d52d38e43c3e3d52bc3bc41a60e36061b7b7) Thanks [@matthewp](https://github.com/matthewp)! - Astro 3.0 Release Candidate
+
+## 3.0.0-rc.1
+
+### Major Changes
+
+- [#8179](https://github.com/withastro/astro/pull/8179) [`6011d52d3`](https://github.com/withastro/astro/commit/6011d52d38e43c3e3d52bc3bc41a60e36061b7b7) Thanks [@matthewp](https://github.com/matthewp)! - Astro 3.0 Release Candidate
+
+## 3.0.0-beta.0
+
+### Major Changes
+
+- [`1eae2e3f7`](https://github.com/withastro/astro/commit/1eae2e3f7d693c9dfe91c8ccfbe606d32bf2fb81) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Remove support for Node 16. The lowest supported version by Astro and all integrations is now v18.14.1. As a reminder, Node 16 will be deprecated on the 11th September 2023.
+
+## 2.0.2
+
+### Patch Changes
+
+- [#8063](https://github.com/withastro/astro/pull/8063) [`bee284cb7`](https://github.com/withastro/astro/commit/bee284cb7741ee594e8b38b1a618763e9058740b) Thanks [@martrapp](https://github.com/martrapp)! - docs: fix github search link in README.md
+
+## 2.0.1
+
+### Patch Changes
+
+- [#7722](https://github.com/withastro/astro/pull/7722) [`77ffcc8f8`](https://github.com/withastro/astro/commit/77ffcc8f8b0ca9f8b9da29525f03028e666fd8df) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Ensure nested 404 and 500 pages are always excluded
+
+## 2.0.0
+
+### Major Changes
+
+- [#7656](https://github.com/withastro/astro/pull/7656) [`dd931a780`](https://github.com/withastro/astro/commit/dd931a78065a9f46ade0588b35dcc2ea7dbed974) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Sitemap only includes `page` routes (generated by `.astro` files) rather than all routes (pages, endpoints, or redirects). This behavior matches our existing documentation, but is a breaking change nonetheless.
+
+### Patch Changes
+
+- [#7656](https://github.com/withastro/astro/pull/7656) [`dd931a780`](https://github.com/withastro/astro/commit/dd931a78065a9f46ade0588b35dcc2ea7dbed974) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Ensure trailing slash is only added to page routes
+
+## 1.4.0
+
+### Minor Changes
+
+- [#7655](https://github.com/withastro/astro/pull/7655) [`c258492b7`](https://github.com/withastro/astro/commit/c258492b7218cc7e5b7be38f48ec1bb1296292d5) Thanks [@natemoo-re](https://github.com/natemoo-re)! - Ensure sitemap only excludes numerical pages matching `/404` and `/500` exactly
+
+## 1.3.3
+
+### Patch Changes
+
+- [#7263](https://github.com/withastro/astro/pull/7263) [`dff0d0dda`](https://github.com/withastro/astro/commit/dff0d0dda2f20c02901594739a654834d3451c8e) Thanks [@andremralves](https://github.com/andremralves)! - Fix sitemap does not filter pages
+
+## 1.3.2
+
+### Patch Changes
+
+- [#7028](https://github.com/withastro/astro/pull/7028) [`6ca3b5a9e`](https://github.com/withastro/astro/commit/6ca3b5a9e8b9aa19a9436043f8ead41e7938c32e) Thanks [@alexanderniebuhr](https://github.com/alexanderniebuhr)! - exported enum type to support typescript > 5.0
+
+## 1.3.1
+
+### Patch Changes
+
+- [#7029](https://github.com/withastro/astro/pull/7029) [`1b90a7a5d`](https://github.com/withastro/astro/commit/1b90a7a5d5f16e3e1fa0329b509c6c6e76248181) Thanks [@TheOtterlord](https://github.com/TheOtterlord)! - Fix generation for static dynamic routes
+
+## 1.3.0
+
+### Minor Changes
+
+- [#6534](https://github.com/withastro/astro/pull/6534) [`ad907196c`](https://github.com/withastro/astro/commit/ad907196cb42f21d9540ae0d77aa742bf7adf030) Thanks [@atilafassina](https://github.com/atilafassina)! - Adds support to SSR routes to sitemap generation.
+
+## 1.2.2
+
+### Patch Changes
+
+- [#6658](https://github.com/withastro/astro/pull/6658) [`1ec1df126`](https://github.com/withastro/astro/commit/1ec1df12641290ec8b3a417a6284fd8d752c02bf) Thanks [@andremralves](https://github.com/andremralves)! - Fix sitemap generation with a base path
+
+## 1.2.1
+
+### Patch Changes
+
+- [#6494](https://github.com/withastro/astro/pull/6494) [`a13e9d7e3`](https://github.com/withastro/astro/commit/a13e9d7e33baccf51e7d4815f99b481ad174bc57) Thanks [@Yan-Thomas](https://github.com/Yan-Thomas)! - Consistency improvements to several package descriptions
+
+## 1.2.0
+
+### Minor Changes
+
+- [#6213](https://github.com/withastro/astro/pull/6213) [`afbbc4d5b`](https://github.com/withastro/astro/commit/afbbc4d5bfafc1779bac00b41c2a1cb1c90f2808) Thanks [@Princesseuh](https://github.com/Princesseuh)! - Updated compilation settings to disable downlevelling for Node 14
+
+## 1.1.0
+
+### Minor Changes
+
+- [#6262](https://github.com/withastro/astro/pull/6262) [`4fcefa34f`](https://github.com/withastro/astro/commit/4fcefa34f979e23b8c48940b5a5da57fdabc32a4) Thanks [@vic1707](https://github.com/vic1707)! - update `ChangeFreq` to support typescript configurations with string literal or predefined value.
+
+## 1.0.1
+
+### Patch Changes
+
+- [#5478](https://github.com/withastro/astro/pull/5478) [`1c7eef308`](https://github.com/withastro/astro/commit/1c7eef308e808aa5ed4662b53e67ec8d1b814d1f) Thanks [@nemo0](https://github.com/nemo0)! - Update READMEs for consistency
+
+## 1.0.0
+
+### Major Changes
+
+- [`04ad44563`](https://github.com/withastro/astro/commit/04ad445632c67bdd60c1704e1e0dcbcaa27b9308) - > Astro v1.0 is out! Read the [official announcement post](https://astro.build/blog/astro-1/).
+
+ **No breaking changes**. This package is now officially stable and compatible with `astro@1.0.0`!
+
+## 0.3.0
+
+### Minor Changes
+
+- [#4015](https://github.com/withastro/astro/pull/4015) [`6fd161d76`](https://github.com/withastro/astro/commit/6fd161d7691cbf9d3ffa4646e46059dfd0940010) Thanks [@matthewp](https://github.com/matthewp)! - New `output` configuration option
+
+ This change introduces a new "output target" configuration option (`output`). Setting the output target lets you decide the format of your final build, either:
+
+ - `"static"` (default): A static site. Your final build will be a collection of static assets (HTML, CSS, JS) that you can deploy to any static site host.
+ - `"server"`: A dynamic server application. Your final build will be an application that will run in a hosted server environment, generating HTML dynamically for different requests.
+
+ If `output` is omitted from your config, the default value `"static"` will be used.
+
+ When using the `"server"` output target, you must also include a runtime adapter via the `adapter` configuration. An adapter will _adapt_ your final build to run on the deployed platform of your choice (Netlify, Vercel, Node.js, Deno, etc).
+
+ To migrate: No action is required for most users. If you currently define an `adapter`, you will need to also add `output: 'server'` to your config file to make it explicit that you are building a server. Here is an example of what that change would look like for someone deploying to Netlify:
+
+ ```diff
+ import { defineConfig } from 'astro/config';
+ import netlify from '@astrojs/netlify/functions';
+
+ export default defineConfig({
+ adapter: netlify(),
+ + output: 'server',
+ });
+ ```
+
+### Patch Changes
+
+- [#3978](https://github.com/withastro/astro/pull/3978) [`b37d7078a`](https://github.com/withastro/astro/commit/b37d7078a009869bf482912397a073dca490d3da) Thanks [@Chrissdroid](https://github.com/Chrissdroid)! - Update README to reflect `@astrojs/sitemap@0.2.0` changes
+
+* [#4004](https://github.com/withastro/astro/pull/4004) [`ef9c4152b`](https://github.com/withastro/astro/commit/ef9c4152b2b399e25bf4e8aa7b37adcf6d0d8f17) Thanks [@sarah11918](https://github.com/sarah11918)! - [READMEs] removed "experimental" from astro add instructions
+
+## 0.2.6
+
+### Patch Changes
+
+- [#3885](https://github.com/withastro/astro/pull/3885) [`bf5d1cc1e`](https://github.com/withastro/astro/commit/bf5d1cc1e71da38a14658c615e9481f2145cc6e7) Thanks [@delucis](https://github.com/delucis)! - Integration README fixes
+
+## 0.2.5
+
+### Patch Changes
+
+- [#3865](https://github.com/withastro/astro/pull/3865) [`1f9e4857`](https://github.com/withastro/astro/commit/1f9e4857ff2b2cb7db89d619618cdf546cd3b3dc) Thanks [@delucis](https://github.com/delucis)! - Small README fixes
+
+* [#3854](https://github.com/withastro/astro/pull/3854) [`b012ee55`](https://github.com/withastro/astro/commit/b012ee55b107dea0730286263b27d83e530fad5d) Thanks [@bholmesdev](https://github.com/bholmesdev)! - [astro add] Support adapters and third party packages
+
+## 0.2.4
+
+### Patch Changes
+
+- [#3677](https://github.com/withastro/astro/pull/3677) [`8045c8ad`](https://github.com/withastro/astro/commit/8045c8ade16fe4306448b7f98a4560ef0557d378) Thanks [@Jutanium](https://github.com/Jutanium)! - Update READMEs
+
+## 0.2.3
+
+### Patch Changes
+
+- [#3723](https://github.com/withastro/astro/pull/3723) [`52f75369`](https://github.com/withastro/astro/commit/52f75369efe5a0a1b320478984c90b6727d52159) Thanks [@alextim](https://github.com/alextim)! - fix: if `serialize` function returns `undefined` for the passed entry, such entry will be excluded from sitemap
+
+## 0.2.2
+
+### Patch Changes
+
+- [#3689](https://github.com/withastro/astro/pull/3689) [`3f8ee70e`](https://github.com/withastro/astro/commit/3f8ee70e2bc5b49c65a0444d9606232dadbc2fca) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Add warning log for sitemap + SSR adapter, with suggestion to use customPages configuration option
+
+## 0.2.1
+
+### Patch Changes
+
+- [#3661](https://github.com/withastro/astro/pull/3661) [`2ff11df4`](https://github.com/withastro/astro/commit/2ff11df438a6a901e72d1f1979c79deb0ad199f2) Thanks [@matthewp](https://github.com/matthewp)! - Fixes the last build
+
+## 0.2.0
+
+### Minor Changes
+
+- [#3579](https://github.com/withastro/astro/pull/3579) [`1031c06f`](https://github.com/withastro/astro/commit/1031c06f9c6794d9ee6fb18c145ca5614e6f0583) Thanks [@alextim](https://github.com/alextim)! - # Key features
+
+ - Split up your large sitemap into multiple sitemaps by custom limit.
+ - Ability to add sitemap specific attributes such as `lastmod` etc.
+ - Final output customization via JS function.
+ - Localization support.
+ - Reliability: all config options are validated.
+
+ ## Important changes
+
+ The integration always generates at least two files instead of one:
+
+ - `sitemap-index.xml` - index file;
+ - `sitemap-{i}.xml` - actual sitemap.
+
+## 0.1.2
+
+### Patch Changes
+
+- [#3563](https://github.com/withastro/astro/pull/3563) [`09803129`](https://github.com/withastro/astro/commit/098031294f4e25619d0ae5a6ffc871c7401d98ae) Thanks [@alextim](https://github.com/alextim)! - Remove unused dependency
+
+## 0.1.1
+
+### Patch Changes
+
+- [#3553](https://github.com/withastro/astro/pull/3553) [`c601ce59`](https://github.com/withastro/astro/commit/c601ce59b5740e7ff48c6575a6168d6a2408f7a3) Thanks [@caioferrarezi](https://github.com/caioferrarezi)! - Prevent sitemap URLs with trimmed paths
+
+## 0.1.0
+
+### Minor Changes
+
+- [`e425f896`](https://github.com/withastro/astro/commit/e425f896b668d98033ad3b998b50c1f28bc7f6ee) Thanks [@FredKSchott](https://github.com/FredKSchott)! - Update config options to respect [RFC0019](https://github.com/withastro/rfcs/blob/main/proposals/0019-config-finalization.md)
+
+### Patch Changes
+
+- [`e425f896`](https://github.com/withastro/astro/commit/e425f896b668d98033ad3b998b50c1f28bc7f6ee) Thanks [@FredKSchott](https://github.com/FredKSchott)! - Add new sitemap configuration options:
+ - `filter`: filter pages to include in your sitemap
+ - `canonicalURL`: override your astro.config `site` with a custom base URL
+
+## 0.0.2
+
+### Patch Changes
+
+- [#2885](https://github.com/withastro/astro/pull/2885) [`6b004363`](https://github.com/withastro/astro/commit/6b004363f99f27e581d1e2d53a2ebff39d7afb8a) Thanks [@bholmesdev](https://github.com/bholmesdev)! - Add README across Astro built-in integrations
+
+* [#2847](https://github.com/withastro/astro/pull/2847) [`3b621f7a`](https://github.com/withastro/astro/commit/3b621f7a613b45983b090794fa7c015f23ed6140) Thanks [@tony-sull](https://github.com/tony-sull)! - Adds keywords to the official integrations to support discoverability on Astro's Integrations site
+
+## 0.0.2-next.0
+
+### Patch Changes
+
+- [#2847](https://github.com/withastro/astro/pull/2847) [`3b621f7a`](https://github.com/withastro/astro/commit/3b621f7a613b45983b090794fa7c015f23ed6140) Thanks [@tony-sull](https://github.com/tony-sull)! - Adds keywords to the official integrations to support discoverability on Astro's Integrations site
diff --git a/packages/integrations/sitemap/README.md b/packages/integrations/sitemap/README.md
new file mode 100644
index 000000000..4fc5efd97
--- /dev/null
+++ b/packages/integrations/sitemap/README.md
@@ -0,0 +1,38 @@
+# @astrojs/sitemap 🗺
+
+This **[Astro integration][astro-integration]** generates a sitemap based on your pages when you build your Astro project.
+
+## Documentation
+
+Read the [`@astrojs/sitemap` docs][docs]
+
+## Support
+
+- Get help in the [Astro Discord][discord]. Post questions in our `#support` forum, or visit our dedicated `#dev` channel to discuss current development and more!
+
+- Check our [Astro Integration Documentation][astro-integration] for more on integrations.
+
+- Submit bug reports and feature requests as [GitHub issues][issues].
+
+## Contributing
+
+This package is maintained by Astro's Core team. You're welcome to submit an issue or PR! These links will help you get started:
+
+- [Contributor Manual][contributing]
+- [Code of Conduct][coc]
+- [Community Guide][community]
+
+## License
+
+MIT
+
+Copyright (c) 2023–present [Astro][astro]
+
+[astro]: https://astro.build/
+[docs]: https://docs.astro.build/en/guides/integrations-guide/sitemap/
+[contributing]: https://github.com/withastro/astro/blob/main/CONTRIBUTING.md
+[coc]: https://github.com/withastro/.github/blob/main/CODE_OF_CONDUCT.md
+[community]: https://github.com/withastro/.github/blob/main/COMMUNITY_GUIDE.md
+[discord]: https://astro.build/chat/
+[issues]: https://github.com/withastro/astro/issues
+[astro-integration]: https://docs.astro.build/en/guides/integrations-guide/
diff --git a/packages/integrations/sitemap/package.json b/packages/integrations/sitemap/package.json
new file mode 100644
index 000000000..d7ae0cf34
--- /dev/null
+++ b/packages/integrations/sitemap/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@astrojs/sitemap",
+ "description": "Generate a sitemap for your Astro site",
+ "version": "3.4.1",
+ "type": "module",
+ "types": "./dist/index.d.ts",
+ "author": "withastro",
+ "license": "MIT",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/withastro/astro.git",
+ "directory": "packages/integrations/sitemap"
+ },
+ "keywords": [
+ "astro-integration",
+ "astro-component",
+ "seo",
+ "sitemap"
+ ],
+ "bugs": "https://github.com/withastro/astro/issues",
+ "homepage": "https://docs.astro.build/en/guides/integrations-guide/sitemap/",
+ "exports": {
+ ".": "./dist/index.js",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "astro-scripts build \"src/**/*.ts\" && tsc",
+ "build:ci": "astro-scripts build \"src/**/*.ts\"",
+ "dev": "astro-scripts dev \"src/**/*.ts\"",
+ "test": "astro-scripts test \"test/**/*.test.js\""
+ },
+ "dependencies": {
+ "sitemap": "^8.0.0",
+ "stream-replace-string": "^2.0.0",
+ "zod": "^3.24.2"
+ },
+ "devDependencies": {
+ "astro": "workspace:*",
+ "astro-scripts": "workspace:*",
+ "xml2js": "0.6.2"
+ },
+ "publishConfig": {
+ "provenance": true
+ }
+}
diff --git a/packages/integrations/sitemap/src/config-defaults.ts b/packages/integrations/sitemap/src/config-defaults.ts
new file mode 100644
index 000000000..8d854c7a9
--- /dev/null
+++ b/packages/integrations/sitemap/src/config-defaults.ts
@@ -0,0 +1,6 @@
+import type { SitemapOptions } from './index.js';
+
+export const SITEMAP_CONFIG_DEFAULTS = {
+ filenameBase: 'sitemap',
+ entryLimit: 45000,
+} satisfies SitemapOptions;
diff --git a/packages/integrations/sitemap/src/generate-sitemap.ts b/packages/integrations/sitemap/src/generate-sitemap.ts
new file mode 100644
index 000000000..0fb096cc9
--- /dev/null
+++ b/packages/integrations/sitemap/src/generate-sitemap.ts
@@ -0,0 +1,77 @@
+import type { EnumChangefreq } from 'sitemap';
+import type { SitemapItem, SitemapOptions } from './index.js';
+import { parseI18nUrl } from './utils/parse-i18n-url.js';
+
+/** Construct sitemap.xml given a set of URLs */
+export function generateSitemap(pages: string[], finalSiteUrl: string, opts?: SitemapOptions) {
+ const { changefreq, priority, lastmod: lastmodSrc, i18n } = opts ?? {};
+ // TODO: find way to respect <link rel="canonical"> URLs here
+ const urls = [...pages];
+ urls.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); // sort alphabetically so sitemap is same each time
+
+ const lastmod = lastmodSrc?.toISOString();
+
+ // Parse URLs for i18n matching later
+ const { defaultLocale, locales } = i18n ?? {};
+ let getI18nLinks: GetI18nLinks | undefined;
+ if (defaultLocale && locales) {
+ getI18nLinks = createGetI18nLinks(urls, defaultLocale, locales, finalSiteUrl);
+ }
+
+ const urlData: SitemapItem[] = urls.map((url, i) => ({
+ url,
+ links: getI18nLinks?.(i),
+ lastmod,
+ priority,
+ changefreq: changefreq as EnumChangefreq,
+ }));
+
+ return urlData;
+}
+
+type GetI18nLinks = (urlIndex: number) => SitemapItem['links'] | undefined;
+
+function createGetI18nLinks(
+ urls: string[],
+ defaultLocale: string,
+ locales: Record<string, string>,
+ finalSiteUrl: string,
+): GetI18nLinks {
+ // `parsedI18nUrls` will have the same length as `urls`, matching correspondingly
+ const parsedI18nUrls = urls.map((url) => parseI18nUrl(url, defaultLocale, locales, finalSiteUrl));
+ // Cache as multiple i18n URLs with the same path will have the same links
+ const i18nPathToLinksCache = new Map<string, SitemapItem['links']>();
+
+ return (urlIndex) => {
+ const i18nUrl = parsedI18nUrls[urlIndex];
+ if (!i18nUrl) {
+ return undefined;
+ }
+
+ const cached = i18nPathToLinksCache.get(i18nUrl.path);
+ if (cached) {
+ return cached;
+ }
+
+ // Find all URLs with the same path (without the locale part), e.g. /en/foo and /es/foo
+ const links: NonNullable<SitemapItem['links']> = [];
+ for (let i = 0; i < parsedI18nUrls.length; i++) {
+ const parsed = parsedI18nUrls[i];
+ if (parsed?.path === i18nUrl.path) {
+ links.push({
+ url: urls[i],
+ lang: locales[parsed.locale],
+ });
+ }
+ }
+
+ // If 0 or 1 (which is itself), return undefined to not create any links.
+ // We also don't need to cache this as we know there's no other URLs that would've match this.
+ if (links.length <= 1) {
+ return undefined;
+ }
+
+ i18nPathToLinksCache.set(i18nUrl.path, links);
+ return links;
+ };
+}
diff --git a/packages/integrations/sitemap/src/index.ts b/packages/integrations/sitemap/src/index.ts
new file mode 100644
index 000000000..078f78abb
--- /dev/null
+++ b/packages/integrations/sitemap/src/index.ts
@@ -0,0 +1,195 @@
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import type { AstroConfig, AstroIntegration } from 'astro';
+import type { EnumChangefreq, LinkItem as LinkItemBase, SitemapItemLoose } from 'sitemap';
+import { ZodError } from 'zod';
+
+import { generateSitemap } from './generate-sitemap.js';
+import { validateOptions } from './validate-options.js';
+import { writeSitemap } from './write-sitemap.js';
+
+export { EnumChangefreq as ChangeFreqEnum } from 'sitemap';
+export type ChangeFreq = `${EnumChangefreq}`;
+export type SitemapItem = Pick<
+ SitemapItemLoose,
+ 'url' | 'lastmod' | 'changefreq' | 'priority' | 'links'
+>;
+export type LinkItem = LinkItemBase;
+
+export type SitemapOptions =
+ | {
+ filenameBase?: string;
+ filter?(page: string): boolean;
+ customPages?: string[];
+
+ i18n?: {
+ defaultLocale: string;
+ locales: Record<string, string>;
+ };
+ // number of entries per sitemap file
+ entryLimit?: number;
+
+ // sitemap specific
+ changefreq?: ChangeFreq;
+ lastmod?: Date;
+ priority?: number;
+
+ // called for each sitemap item just before to save them on disk, sync or async
+ serialize?(item: SitemapItem): SitemapItem | Promise<SitemapItem | undefined> | undefined;
+
+ xslURL?: string;
+ }
+ | undefined;
+
+function formatConfigErrorMessage(err: ZodError) {
+ const errorList = err.issues.map((issue) => ` ${issue.path.join('.')} ${issue.message + '.'}`);
+ return errorList.join('\n');
+}
+
+const PKG_NAME = '@astrojs/sitemap';
+const STATUS_CODE_PAGES = new Set(['404', '500']);
+
+const isStatusCodePage = (locales: string[]) => {
+ const statusPathNames = new Set(
+ locales
+ .flatMap((locale) => [...STATUS_CODE_PAGES].map((page) => `${locale}/${page}`))
+ .concat([...STATUS_CODE_PAGES]),
+ );
+
+ return (pathname: string): boolean => {
+ if (pathname.endsWith('/')) {
+ pathname = pathname.slice(0, -1);
+ }
+ if (pathname.startsWith('/')) {
+ pathname = pathname.slice(1);
+ }
+ return statusPathNames.has(pathname);
+ };
+};
+const createPlugin = (options?: SitemapOptions): AstroIntegration => {
+ let config: AstroConfig;
+
+ return {
+ name: PKG_NAME,
+
+ hooks: {
+ 'astro:config:done': async ({ config: cfg }) => {
+ config = cfg;
+ },
+
+ 'astro:build:done': async ({ dir, routes, pages, logger }) => {
+ try {
+ if (!config.site) {
+ logger.warn(
+ 'The Sitemap integration requires the `site` astro.config option. Skipping.',
+ );
+ return;
+ }
+
+ const opts = validateOptions(config.site, options);
+
+ const { filenameBase, filter, customPages, serialize, entryLimit } = opts;
+
+ const outFile = `${filenameBase}-index.xml`;
+ const finalSiteUrl = new URL(config.base, config.site);
+ const shouldIgnoreStatus = isStatusCodePage(Object.keys(opts.i18n?.locales ?? {}));
+ let pageUrls = pages
+ .filter((p) => !shouldIgnoreStatus(p.pathname))
+ .map((p) => {
+ if (p.pathname !== '' && !finalSiteUrl.pathname.endsWith('/'))
+ finalSiteUrl.pathname += '/';
+ if (p.pathname.startsWith('/')) p.pathname = p.pathname.slice(1);
+ const fullPath = finalSiteUrl.pathname + p.pathname;
+ return new URL(fullPath, finalSiteUrl).href;
+ });
+
+ const routeUrls = routes.reduce<string[]>((urls, r) => {
+ // Only expose pages, not endpoints or redirects
+ if (r.type !== 'page') return urls;
+
+ /**
+ * Dynamic URLs have entries with `undefined` pathnames
+ */
+ if (r.pathname) {
+ if (shouldIgnoreStatus(r.pathname ?? r.route)) return urls;
+
+ // `finalSiteUrl` may end with a trailing slash
+ // or not because of base paths.
+ let fullPath = finalSiteUrl.pathname;
+ if (fullPath.endsWith('/')) fullPath += r.generate(r.pathname).substring(1);
+ else fullPath += r.generate(r.pathname);
+
+ const newUrl = new URL(fullPath, finalSiteUrl).href;
+
+ if (config.trailingSlash === 'never') {
+ urls.push(newUrl);
+ } else if (config.build.format === 'directory' && !newUrl.endsWith('/')) {
+ urls.push(newUrl + '/');
+ } else {
+ urls.push(newUrl);
+ }
+ }
+
+ return urls;
+ }, []);
+
+ pageUrls = Array.from(new Set([...pageUrls, ...routeUrls, ...(customPages ?? [])]));
+
+ if (filter) {
+ pageUrls = pageUrls.filter(filter);
+ }
+
+ if (pageUrls.length === 0) {
+ logger.warn(`No pages found!\n\`${outFile}\` not created.`);
+ return;
+ }
+
+ let urlData = generateSitemap(pageUrls, finalSiteUrl.href, opts);
+
+ if (serialize) {
+ try {
+ const serializedUrls: SitemapItem[] = [];
+ for (const item of urlData) {
+ const serialized = await Promise.resolve(serialize(item));
+ if (serialized) {
+ serializedUrls.push(serialized);
+ }
+ }
+ if (serializedUrls.length === 0) {
+ logger.warn('No pages found!');
+ return;
+ }
+ urlData = serializedUrls;
+ } catch (err) {
+ logger.error(`Error serializing pages\n${(err as any).toString()}`);
+ return;
+ }
+ }
+ const destDir = fileURLToPath(dir);
+ const xslURL = opts.xslURL ? new URL(opts.xslURL, finalSiteUrl).href : undefined;
+ await writeSitemap(
+ {
+ filenameBase: filenameBase,
+ hostname: finalSiteUrl.href,
+ destinationDir: destDir,
+ publicBasePath: config.base,
+ sourceData: urlData,
+ limit: entryLimit,
+ xslURL: xslURL,
+ },
+ config,
+ );
+ logger.info(`\`${outFile}\` created at \`${path.relative(process.cwd(), destDir)}\``);
+ } catch (err) {
+ if (err instanceof ZodError) {
+ logger.warn(formatConfigErrorMessage(err));
+ } else {
+ throw err;
+ }
+ }
+ },
+ },
+ };
+};
+
+export default createPlugin;
diff --git a/packages/integrations/sitemap/src/schema.ts b/packages/integrations/sitemap/src/schema.ts
new file mode 100644
index 000000000..0ab9d672d
--- /dev/null
+++ b/packages/integrations/sitemap/src/schema.ts
@@ -0,0 +1,41 @@
+import { EnumChangefreq as ChangeFreq } from 'sitemap';
+import { z } from 'zod';
+import { SITEMAP_CONFIG_DEFAULTS } from './config-defaults.js';
+
+const localeKeySchema = z.string().min(1);
+
+export const SitemapOptionsSchema = z
+ .object({
+ filenameBase: z.string().optional().default(SITEMAP_CONFIG_DEFAULTS.filenameBase),
+ filter: z.function().args(z.string()).returns(z.boolean()).optional(),
+ customPages: z.string().url().array().optional(),
+ canonicalURL: z.string().url().optional(),
+ xslURL: z.string().optional(),
+
+ i18n: z
+ .object({
+ defaultLocale: localeKeySchema,
+ locales: z.record(
+ localeKeySchema,
+ z
+ .string()
+ .min(2)
+ .regex(/^[a-zA-Z\-]+$/gm, {
+ message: 'Only English alphabet symbols and hyphen allowed',
+ }),
+ ),
+ })
+ .refine((val) => !val || val.locales[val.defaultLocale], {
+ message: '`defaultLocale` must exist in `locales` keys',
+ })
+ .optional(),
+
+ entryLimit: z.number().nonnegative().optional().default(SITEMAP_CONFIG_DEFAULTS.entryLimit),
+ serialize: z.function().args(z.any()).returns(z.any()).optional(),
+
+ changefreq: z.nativeEnum(ChangeFreq).optional(),
+ lastmod: z.date().optional(),
+ priority: z.number().min(0).max(1).optional(),
+ })
+ .strict()
+ .default(SITEMAP_CONFIG_DEFAULTS);
diff --git a/packages/integrations/sitemap/src/utils/parse-i18n-url.ts b/packages/integrations/sitemap/src/utils/parse-i18n-url.ts
new file mode 100644
index 000000000..86221ca9d
--- /dev/null
+++ b/packages/integrations/sitemap/src/utils/parse-i18n-url.ts
@@ -0,0 +1,42 @@
+interface ParsedI18nUrl {
+ locale: string;
+ path: string;
+}
+
+// NOTE: The parameters have been schema-validated with Zod
+export function parseI18nUrl(
+ url: string,
+ defaultLocale: string,
+ locales: Record<string, string>,
+ base: string,
+): ParsedI18nUrl | undefined {
+ if (!url.startsWith(base)) {
+ return undefined;
+ }
+
+ let s = url.slice(base.length);
+
+ // Handle root URL
+ if (!s || s === '/') {
+ return { locale: defaultLocale, path: '/' };
+ }
+
+ if (s[0] !== '/') {
+ s = '/' + s;
+ }
+
+ // Get locale from path, e.g.
+ // "/en-US/" -> "en-US"
+ // "/en-US/foo" -> "en-US"
+ const locale = s.split('/')[1];
+ if (locale in locales) {
+ // "/en-US/foo" -> "/foo"
+ let path = s.slice(1 + locale.length);
+ if (!path) {
+ path = '/';
+ }
+ return { locale, path };
+ }
+
+ return { locale: defaultLocale, path: s };
+}
diff --git a/packages/integrations/sitemap/src/validate-options.ts b/packages/integrations/sitemap/src/validate-options.ts
new file mode 100644
index 000000000..f51750ff5
--- /dev/null
+++ b/packages/integrations/sitemap/src/validate-options.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod';
+import type { SitemapOptions } from './index.js';
+import { SitemapOptionsSchema } from './schema.js';
+
+// @internal
+export const validateOptions = (site: string | undefined, opts: SitemapOptions) => {
+ const result = SitemapOptionsSchema.parse(opts);
+
+ z.object({
+ site: z.string().optional(), // Astro takes care of `site`: how to validate, transform and refine
+ canonicalURL: z.string().optional(), // `canonicalURL` is already validated in prev step
+ })
+ .refine((options) => options.site || options.canonicalURL, {
+ message: 'Required `site` astro.config option or `canonicalURL` integration option',
+ })
+ .parse({
+ site,
+ canonicalURL: result.canonicalURL,
+ });
+
+ return result;
+};
diff --git a/packages/integrations/sitemap/src/write-sitemap.ts b/packages/integrations/sitemap/src/write-sitemap.ts
new file mode 100644
index 000000000..939bd91be
--- /dev/null
+++ b/packages/integrations/sitemap/src/write-sitemap.ts
@@ -0,0 +1,75 @@
+import { type WriteStream, createWriteStream } from 'node:fs';
+import { mkdir } from 'node:fs/promises';
+import { normalize, resolve } from 'node:path';
+import { Readable, pipeline } from 'node:stream';
+import { promisify } from 'node:util';
+import replace from 'stream-replace-string';
+
+import { SitemapAndIndexStream, SitemapStream } from 'sitemap';
+
+import type { AstroConfig } from 'astro';
+import type { SitemapItem } from './index.js';
+
+type WriteSitemapConfig = {
+ filenameBase: string;
+ hostname: string;
+ sitemapHostname?: string;
+ sourceData: SitemapItem[];
+ destinationDir: string;
+ publicBasePath?: string;
+ limit?: number;
+ xslURL?: string;
+};
+
+// adapted from sitemap.js/sitemap-simple
+export async function writeSitemap(
+ {
+ filenameBase,
+ hostname,
+ sitemapHostname = hostname,
+ sourceData,
+ destinationDir,
+ limit = 50000,
+ publicBasePath = './',
+ xslURL: xslUrl,
+ }: WriteSitemapConfig,
+ astroConfig: AstroConfig,
+) {
+ await mkdir(destinationDir, { recursive: true });
+
+ const sitemapAndIndexStream = new SitemapAndIndexStream({
+ limit,
+ xslUrl,
+ getSitemapStream: (i) => {
+ const sitemapStream = new SitemapStream({
+ hostname,
+ xslUrl,
+ });
+ const path = `./${filenameBase}-${i}.xml`;
+ const writePath = resolve(destinationDir, path);
+ if (!publicBasePath.endsWith('/')) {
+ publicBasePath += '/';
+ }
+ const publicPath = normalize(publicBasePath + path);
+
+ let stream: WriteStream;
+ if (astroConfig.trailingSlash === 'never' || astroConfig.build.format === 'file') {
+ // workaround for trailing slash issue in sitemap.js: https://github.com/ekalinin/sitemap.js/issues/403
+ const host = hostname.endsWith('/') ? hostname.slice(0, -1) : hostname;
+ const searchStr = `<loc>${host}/</loc>`;
+ const replaceStr = `<loc>${host}</loc>`;
+ stream = sitemapStream
+ .pipe(replace(searchStr, replaceStr))
+ .pipe(createWriteStream(writePath));
+ } else {
+ stream = sitemapStream.pipe(createWriteStream(writePath));
+ }
+
+ return [new URL(publicPath, sitemapHostname).toString(), sitemapStream, stream];
+ },
+ });
+
+ const src = Readable.from(sourceData);
+ const indexPath = resolve(destinationDir, `./${filenameBase}-index.xml`);
+ return promisify(pipeline)(src, sitemapAndIndexStream, createWriteStream(indexPath));
+}
diff --git a/packages/integrations/sitemap/test/base-path.test.js b/packages/integrations/sitemap/test/base-path.test.js
new file mode 100644
index 000000000..fee031ff4
--- /dev/null
+++ b/packages/integrations/sitemap/test/base-path.test.js
@@ -0,0 +1,52 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import { loadFixture, readXML } from './test-utils.js';
+
+describe('URLs with base path', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ describe('using node adapter', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/ssr/',
+ base: '/base',
+ });
+ await fixture.build();
+ });
+
+ it('Base path is concatenated correctly', async () => {
+ const [sitemapZero, sitemapIndex] = await Promise.all([
+ readXML(fixture.readFile('/client/sitemap-0.xml')),
+ readXML(fixture.readFile('/client/sitemap-index.xml')),
+ ]);
+ assert.equal(sitemapZero.urlset.url[0].loc[0], 'http://example.com/base/one/');
+ assert.equal(
+ sitemapIndex.sitemapindex.sitemap[0].loc[0],
+ 'http://example.com/base/sitemap-0.xml',
+ );
+ });
+ });
+
+ describe('static', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/static/',
+ base: '/base',
+ });
+ await fixture.build();
+ });
+
+ it('Base path is concatenated correctly', async () => {
+ const [sitemapZero, sitemapIndex] = await Promise.all([
+ readXML(fixture.readFile('/sitemap-0.xml')),
+ readXML(fixture.readFile('/sitemap-index.xml')),
+ ]);
+ assert.equal(sitemapZero.urlset.url[0].loc[0], 'http://example.com/base/123/');
+ assert.equal(
+ sitemapIndex.sitemapindex.sitemap[0].loc[0],
+ 'http://example.com/base/sitemap-0.xml',
+ );
+ });
+ });
+});
diff --git a/packages/integrations/sitemap/test/config.test.js b/packages/integrations/sitemap/test/config.test.js
new file mode 100644
index 000000000..f95333876
--- /dev/null
+++ b/packages/integrations/sitemap/test/config.test.js
@@ -0,0 +1,125 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import { sitemap } from './fixtures/static/deps.mjs';
+import { loadFixture, readXML } from './test-utils.js';
+
+describe('Config', () => {
+ /** @type {import('./test-utils.js').Fixture} */
+ let fixture;
+
+ describe('Static', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/static/',
+ integrations: [
+ sitemap({
+ filter: (page) => page === 'http://example.com/one/',
+ xslURL: '/sitemap.xsl',
+ }),
+ ],
+ });
+ await fixture.build();
+ });
+
+ it('filter: Just one page is added', async () => {
+ const data = await readXML(fixture.readFile('/sitemap-0.xml'));
+ const urls = data.urlset.url;
+ assert.equal(urls.length, 1);
+ });
+
+ it('xslURL: Includes xml-stylesheet', async () => {
+ const indexXml = await fixture.readFile('/sitemap-index.xml');
+ assert.ok(
+ indexXml.includes(
+ '<?xml-stylesheet type="text/xsl" href="http://example.com/sitemap.xsl"?>',
+ ),
+ indexXml,
+ );
+
+ const xml = await fixture.readFile('/sitemap-0.xml');
+ assert.ok(
+ xml.includes('<?xml-stylesheet type="text/xsl" href="http://example.com/sitemap.xsl"?>'),
+ xml,
+ );
+ });
+ });
+
+ describe('SSR', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/ssr/',
+ integrations: [
+ sitemap({
+ filter: (page) => page === 'http://example.com/one/',
+ xslURL: '/sitemap.xsl',
+ }),
+ ],
+ });
+ await fixture.build();
+ });
+
+ it('filter: Just one page is added', async () => {
+ const data = await readXML(fixture.readFile('/client/sitemap-0.xml'));
+ const urls = data.urlset.url;
+ assert.equal(urls.length, 1);
+ });
+
+ it('xslURL: Includes xml-stylesheet', async () => {
+ const indexXml = await fixture.readFile('/client/sitemap-index.xml');
+ assert.ok(
+ indexXml.includes(
+ '<?xml-stylesheet type="text/xsl" href="http://example.com/sitemap.xsl"?>',
+ ),
+ indexXml,
+ );
+
+ const xml = await fixture.readFile('/client/sitemap-0.xml');
+ assert.ok(
+ xml.includes('<?xml-stylesheet type="text/xsl" href="http://example.com/sitemap.xsl"?>'),
+ xml,
+ );
+ });
+ });
+
+ describe('Configuring the filename', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/static/',
+ integrations: [
+ sitemap({
+ filter: (page) => page === 'http://example.com/one/',
+ filenameBase: 'my-sitemap',
+ }),
+ ],
+ });
+ await fixture.build();
+ });
+
+ it('filenameBase: Sets the generated sitemap filename', async () => {
+ const data = await readXML(fixture.readFile('/my-sitemap-0.xml'));
+ const urls = data.urlset.url;
+ assert.equal(urls.length, 1);
+
+ const indexData = await readXML(fixture.readFile('/my-sitemap-index.xml'));
+ const sitemapUrls = indexData.sitemapindex.sitemap;
+ assert.equal(sitemapUrls.length, 1);
+ assert.equal(sitemapUrls[0].loc[0], 'http://example.com/my-sitemap-0.xml');
+ });
+ });
+
+ describe('Filtering pages - error handling', () => {
+ it('filter: uncaught errors are thrown', async () => {
+ fixture = await loadFixture({
+ root: './fixtures/static/',
+ integrations: [
+ sitemap({
+ filter: () => {
+ throw new Error('filter error');
+ },
+ }),
+ ],
+ });
+ await assert.rejects(fixture.build(), /^Error: filter error$/);
+ });
+ });
+});
diff --git a/packages/integrations/sitemap/test/dynamic-path.test.js b/packages/integrations/sitemap/test/dynamic-path.test.js
new file mode 100644
index 000000000..eab3b912c
--- /dev/null
+++ b/packages/integrations/sitemap/test/dynamic-path.test.js
@@ -0,0 +1,24 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import { loadFixture, readXML } from './test-utils.js';
+
+describe('Dynamic with rest parameter', () => {
+ /** @type {import('./test-utils.js').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/dynamic',
+ });
+ await fixture.build();
+ });
+
+ it('Should generate correct urls', async () => {
+ const data = await readXML(fixture.readFile('/sitemap-0.xml'));
+ const urls = data.urlset.url.map((url) => url.loc[0]);
+
+ assert.ok(urls.includes('http://example.com/'));
+ assert.ok(urls.includes('http://example.com/blog/'));
+ assert.ok(urls.includes('http://example.com/test/'));
+ });
+});
diff --git a/packages/integrations/sitemap/test/fixtures/dynamic/astro.config.mjs b/packages/integrations/sitemap/test/fixtures/dynamic/astro.config.mjs
new file mode 100644
index 000000000..82d25b854
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/dynamic/astro.config.mjs
@@ -0,0 +1,7 @@
+import sitemap from '@astrojs/sitemap';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ integrations: [sitemap()],
+ site: 'http://example.com'
+})
diff --git a/packages/integrations/sitemap/test/fixtures/dynamic/package.json b/packages/integrations/sitemap/test/fixtures/dynamic/package.json
new file mode 100644
index 000000000..1eac19a1b
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/dynamic/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/sitemap-dynamic",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*",
+ "@astrojs/sitemap": "workspace:*"
+ }
+}
diff --git a/packages/integrations/sitemap/test/fixtures/dynamic/src/pages/[...slug].astro b/packages/integrations/sitemap/test/fixtures/dynamic/src/pages/[...slug].astro
new file mode 100644
index 000000000..9622cb374
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/dynamic/src/pages/[...slug].astro
@@ -0,0 +1,21 @@
+---
+export async function getStaticPaths() {
+ return [
+ {
+ params: {
+ slug: undefined,
+ }
+ },
+ {
+ params: {
+ slug: '/blog'
+ }
+ },
+ {
+ params: {
+ slug: '/test'
+ }
+ }
+ ];
+}
+---
diff --git a/packages/integrations/sitemap/test/fixtures/ssr/astro.config.mjs b/packages/integrations/sitemap/test/fixtures/ssr/astro.config.mjs
new file mode 100644
index 000000000..ce84944bf
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/ssr/astro.config.mjs
@@ -0,0 +1,12 @@
+import nodeServer from '@astrojs/node'
+import sitemap from '@astrojs/sitemap';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ integrations: [sitemap()],
+ site: 'http://example.com',
+ output: 'server',
+ adapter: nodeServer({
+ mode: "standalone"
+ })
+})
diff --git a/packages/integrations/sitemap/test/fixtures/ssr/package.json b/packages/integrations/sitemap/test/fixtures/ssr/package.json
new file mode 100644
index 000000000..4b5e6848d
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/ssr/package.json
@@ -0,0 +1,10 @@
+{
+ "name": "@test/sitemap-ssr",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*",
+ "@astrojs/node": "workspace:*",
+ "@astrojs/sitemap": "workspace:*"
+ }
+}
diff --git a/packages/integrations/sitemap/test/fixtures/ssr/src/pages/one.astro b/packages/integrations/sitemap/test/fixtures/ssr/src/pages/one.astro
new file mode 100644
index 000000000..0c7fb90a7
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/ssr/src/pages/one.astro
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>One</title>
+ </head>
+ <body>
+ <h1>One</h1>
+ </body>
+</html>
diff --git a/packages/integrations/sitemap/test/fixtures/ssr/src/pages/two.astro b/packages/integrations/sitemap/test/fixtures/ssr/src/pages/two.astro
new file mode 100644
index 000000000..e7ba9910e
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/ssr/src/pages/two.astro
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>Two</title>
+ </head>
+ <body>
+ <h1>Two</h1>
+ </body>
+</html>
diff --git a/packages/integrations/sitemap/test/fixtures/static/astro.config.mjs b/packages/integrations/sitemap/test/fixtures/static/astro.config.mjs
new file mode 100644
index 000000000..ae53a9342
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/static/astro.config.mjs
@@ -0,0 +1,18 @@
+import sitemap from '@astrojs/sitemap';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ integrations: [sitemap({
+ i18n: {
+ defaultLocale: 'it',
+ locales: {
+ it: 'it-IT',
+ de: 'de-DE',
+ }
+ }
+ })],
+ site: 'http://example.com',
+ redirects: {
+ '/redirect': '/'
+ },
+})
diff --git a/packages/integrations/sitemap/test/fixtures/static/deps.mjs b/packages/integrations/sitemap/test/fixtures/static/deps.mjs
new file mode 100644
index 000000000..b24f26189
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/static/deps.mjs
@@ -0,0 +1 @@
+export { default as sitemap } from '@astrojs/sitemap';
diff --git a/packages/integrations/sitemap/test/fixtures/static/package.json b/packages/integrations/sitemap/test/fixtures/static/package.json
new file mode 100644
index 000000000..ed5f3670b
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/static/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/sitemap-static",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*",
+ "@astrojs/sitemap": "workspace:*"
+ }
+}
diff --git a/packages/integrations/sitemap/test/fixtures/static/src/pages/123.astro b/packages/integrations/sitemap/test/fixtures/static/src/pages/123.astro
new file mode 100644
index 000000000..115292de9
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/static/src/pages/123.astro
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>123</title>
+ </head>
+ <body>
+ <h1>123</h1>
+ </body>
+</html>
diff --git a/packages/integrations/sitemap/test/fixtures/static/src/pages/404.astro b/packages/integrations/sitemap/test/fixtures/static/src/pages/404.astro
new file mode 100644
index 000000000..9e307c5c2
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/static/src/pages/404.astro
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>404</title>
+ </head>
+ <body>
+ <h1>404</h1>
+ </body>
+</html>
diff --git a/packages/integrations/sitemap/test/fixtures/static/src/pages/[lang]/manifest.ts b/packages/integrations/sitemap/test/fixtures/static/src/pages/[lang]/manifest.ts
new file mode 100644
index 000000000..907b94a21
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/static/src/pages/[lang]/manifest.ts
@@ -0,0 +1,15 @@
+export const GET: APIRoute = async ({ params }) => {
+ const { lang } = params;
+
+ return new Response(`I'm a route in the "${lang}" language.`);
+};
+
+export async function getStaticPaths() {
+ return ['it', 'en'].map((language) => {
+ return {
+ params: {
+ lang: language,
+ },
+ };
+ });
+}
diff --git a/packages/integrations/sitemap/test/fixtures/static/src/pages/[slug].astro b/packages/integrations/sitemap/test/fixtures/static/src/pages/[slug].astro
new file mode 100644
index 000000000..205633c76
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/static/src/pages/[slug].astro
@@ -0,0 +1,17 @@
+---
+export function getStaticPaths() {
+ return [
+ { params: { slug: 'one' }, props: { title: 'One' } },
+ { params: { slug: 'two' }, props: { title: 'Two' } },
+ ]
+}
+---
+
+<html>
+ <head>
+ <title>{Astro.props.title}</title>
+ </head>
+ <body>
+ <h1>{Astro.props.title}</h1>
+ </body>
+</html>
diff --git a/packages/integrations/sitemap/test/fixtures/static/src/pages/de/404.astro b/packages/integrations/sitemap/test/fixtures/static/src/pages/de/404.astro
new file mode 100644
index 000000000..9e307c5c2
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/static/src/pages/de/404.astro
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>404</title>
+ </head>
+ <body>
+ <h1>404</h1>
+ </body>
+</html>
diff --git a/packages/integrations/sitemap/test/fixtures/static/src/pages/endpoint.json.ts b/packages/integrations/sitemap/test/fixtures/static/src/pages/endpoint.json.ts
new file mode 100644
index 000000000..2e088376f
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/static/src/pages/endpoint.json.ts
@@ -0,0 +1,6 @@
+export async function GET({}) {
+ return Response.json({
+ name: 'Astro',
+ url: 'https://astro.build/',
+ });
+}
diff --git a/packages/integrations/sitemap/test/fixtures/static/src/pages/products-by-id/[id].astro b/packages/integrations/sitemap/test/fixtures/static/src/pages/products-by-id/[id].astro
new file mode 100644
index 000000000..b10438a68
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/static/src/pages/products-by-id/[id].astro
@@ -0,0 +1,11 @@
+---
+export async function getStaticPaths() {
+ return [
+ { params: { id: '404' } },
+ { params: { id: '405' } },
+ ]
+}
+
+const { id } = Astro.params
+---
+<!DOCTYPE html><html> <head><title>Product #{id}</title></head> <body> <h1>Product #404</h1> <p>This is a product that just happens to have an ID of {id}. It is found!</p></body></html> \ No newline at end of file
diff --git a/packages/integrations/sitemap/test/fixtures/trailing-slash/astro.config.mjs b/packages/integrations/sitemap/test/fixtures/trailing-slash/astro.config.mjs
new file mode 100644
index 000000000..82d25b854
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/trailing-slash/astro.config.mjs
@@ -0,0 +1,7 @@
+import sitemap from '@astrojs/sitemap';
+import { defineConfig } from 'astro/config';
+
+export default defineConfig({
+ integrations: [sitemap()],
+ site: 'http://example.com'
+})
diff --git a/packages/integrations/sitemap/test/fixtures/trailing-slash/package.json b/packages/integrations/sitemap/test/fixtures/trailing-slash/package.json
new file mode 100644
index 000000000..980e02e73
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/trailing-slash/package.json
@@ -0,0 +1,9 @@
+{
+ "name": "@test/sitemap-trailing-slash",
+ "version": "0.0.0",
+ "private": true,
+ "dependencies": {
+ "astro": "workspace:*",
+ "@astrojs/sitemap": "workspace:*"
+ }
+}
diff --git a/packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/index.astro b/packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/index.astro
new file mode 100644
index 000000000..5a29cbdbe
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/index.astro
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>Index</title>
+ </head>
+ <body>
+ <h1>Index</h1>
+ </body>
+</html> \ No newline at end of file
diff --git a/packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/one.astro b/packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/one.astro
new file mode 100644
index 000000000..0c7fb90a7
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/one.astro
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>One</title>
+ </head>
+ <body>
+ <h1>One</h1>
+ </body>
+</html>
diff --git a/packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/two.astro b/packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/two.astro
new file mode 100644
index 000000000..e7ba9910e
--- /dev/null
+++ b/packages/integrations/sitemap/test/fixtures/trailing-slash/src/pages/two.astro
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>Two</title>
+ </head>
+ <body>
+ <h1>Two</h1>
+ </body>
+</html>
diff --git a/packages/integrations/sitemap/test/routes.test.js b/packages/integrations/sitemap/test/routes.test.js
new file mode 100644
index 000000000..00d6ccde3
--- /dev/null
+++ b/packages/integrations/sitemap/test/routes.test.js
@@ -0,0 +1,27 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import { loadFixture, readXML } from './test-utils.js';
+
+describe('routes', () => {
+ /** @type {import('./test-utils.js').Fixture} */
+ let fixture;
+ /** @type {string[]} */
+ let urls;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/static/',
+ });
+ await fixture.build();
+ const data = await readXML(fixture.readFile('/sitemap-0.xml'));
+ urls = data.urlset.url.map((url) => url.loc[0]);
+ });
+
+ it('does not include endpoints', async () => {
+ assert.equal(urls.includes('http://example.com/endpoint.json'), false);
+ });
+
+ it('does not include redirects', async () => {
+ assert.equal(urls.includes('http://example.com/redirect'), false);
+ });
+});
diff --git a/packages/integrations/sitemap/test/smoke.test.js b/packages/integrations/sitemap/test/smoke.test.js
new file mode 100644
index 000000000..d24c191ec
--- /dev/null
+++ b/packages/integrations/sitemap/test/smoke.test.js
@@ -0,0 +1,3 @@
+import '../dist/index.js';
+
+// Just a smoke test, this would fail if there's a problem.
diff --git a/packages/integrations/sitemap/test/ssr.test.js b/packages/integrations/sitemap/test/ssr.test.js
new file mode 100644
index 000000000..b5c92698b
--- /dev/null
+++ b/packages/integrations/sitemap/test/ssr.test.js
@@ -0,0 +1,23 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import { loadFixture, readXML } from './test-utils.js';
+
+describe('SSR support', () => {
+ /** @type {import('./test-utils.js').Fixture} */
+ let fixture;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/ssr/',
+ });
+ await fixture.build();
+ });
+
+ it('SSR pages require zero config', async () => {
+ const data = await readXML(fixture.readFile('/client/sitemap-0.xml'));
+ const urls = data.urlset.url;
+
+ assert.equal(urls[0].loc[0], 'http://example.com/one/');
+ assert.equal(urls[1].loc[0], 'http://example.com/two/');
+ });
+});
diff --git a/packages/integrations/sitemap/test/staticPaths.test.js b/packages/integrations/sitemap/test/staticPaths.test.js
new file mode 100644
index 000000000..7df9d5cb6
--- /dev/null
+++ b/packages/integrations/sitemap/test/staticPaths.test.js
@@ -0,0 +1,48 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import { loadFixture, readXML } from './test-utils.js';
+
+describe('getStaticPaths support', () => {
+ /** @type {import('./test-utils.js').Fixture} */
+ let fixture;
+ /** @type {string[]} */
+ let urls;
+
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/static/',
+ trailingSlash: 'always',
+ });
+ await fixture.build();
+
+ const data = await readXML(fixture.readFile('/sitemap-0.xml'));
+ urls = data.urlset.url.map((url) => url.loc[0]);
+ });
+
+ it('requires zero config for getStaticPaths', async () => {
+ assert.equal(urls.includes('http://example.com/one/'), true);
+ assert.equal(urls.includes('http://example.com/two/'), true);
+ });
+
+ it('does not include 404 pages', () => {
+ assert.equal(urls.includes('http://example.com/404/'), false);
+ });
+
+ it('does not include nested 404 pages', () => {
+ assert.equal(urls.includes('http://example.com/de/404/'), false);
+ });
+
+ it('includes numerical pages', () => {
+ assert.equal(urls.includes('http://example.com/123/'), true);
+ });
+
+ it('includes numerical 404 pages if not for i18n', () => {
+ assert.equal(urls.includes('http://example.com/products-by-id/405/'), true);
+ assert.equal(urls.includes('http://example.com/products-by-id/404/'), true);
+ });
+
+ it('should render the endpoint', async () => {
+ const page = await fixture.readFile('./it/manifest');
+ assert.match(page, /I'm a route in the "it" language./);
+ });
+});
diff --git a/packages/integrations/sitemap/test/test-utils.js b/packages/integrations/sitemap/test/test-utils.js
new file mode 100644
index 000000000..74bba6a44
--- /dev/null
+++ b/packages/integrations/sitemap/test/test-utils.js
@@ -0,0 +1,29 @@
+import * as xml2js from 'xml2js';
+import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js';
+
+/**
+ * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture
+ */
+
+export function loadFixture(inlineConfig) {
+ if (!inlineConfig?.root) throw new Error("Must provide { root: './fixtures/...' }");
+
+ // resolve the relative root (i.e. "./fixtures/tailwindcss") to a full filepath
+ // without this, the main `loadFixture` helper will resolve relative to `packages/astro/test`
+ return baseLoadFixture({
+ ...inlineConfig,
+ root: new URL(inlineConfig.root, import.meta.url).toString(),
+ });
+}
+
+export function readXML(fileOrPromise) {
+ const parseString = xml2js.parseString;
+ return Promise.resolve(fileOrPromise).then((xml) => {
+ return new Promise((resolve, reject) => {
+ parseString(xml, function (err, result) {
+ if (err) return reject(err);
+ resolve(result);
+ });
+ });
+ });
+}
diff --git a/packages/integrations/sitemap/test/trailing-slash.test.js b/packages/integrations/sitemap/test/trailing-slash.test.js
new file mode 100644
index 000000000..181f0def5
--- /dev/null
+++ b/packages/integrations/sitemap/test/trailing-slash.test.js
@@ -0,0 +1,127 @@
+import assert from 'node:assert/strict';
+import { before, describe, it } from 'node:test';
+import { loadFixture, readXML } from './test-utils.js';
+
+describe('Trailing slash', () => {
+ /** @type {import('./test-utils').Fixture} */
+ let fixture;
+
+ describe('trailingSlash: ignore', () => {
+ describe('build.format: directory', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ trailingSlash: 'ignore',
+ build: {
+ format: 'directory',
+ },
+ });
+ await fixture.build();
+ });
+
+ it('URLs end with trailing slash', async () => {
+ const data = await readXML(fixture.readFile('/sitemap-0.xml'));
+ const urls = data.urlset.url;
+
+ assert.equal(urls[0].loc[0], 'http://example.com/');
+ assert.equal(urls[1].loc[0], 'http://example.com/one/');
+ assert.equal(urls[2].loc[0], 'http://example.com/two/');
+ });
+ });
+
+ describe('build.format: file', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ trailingSlash: 'ignore',
+ build: {
+ format: 'file',
+ },
+ });
+ await fixture.build();
+ });
+
+ it('URLs do not end with trailing slash', async () => {
+ const data = await readXML(fixture.readFile('/sitemap-0.xml'));
+ const urls = data.urlset.url;
+
+ assert.equal(urls[0].loc[0], 'http://example.com');
+ assert.equal(urls[1].loc[0], 'http://example.com/one');
+ assert.equal(urls[2].loc[0], 'http://example.com/two');
+ });
+ });
+ });
+
+ describe('trailingSlash: never', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ trailingSlash: 'never',
+ });
+ await fixture.build();
+ });
+
+ it('URLs do not end with trailing slash', async () => {
+ const data = await readXML(fixture.readFile('/sitemap-0.xml'));
+ const urls = data.urlset.url;
+
+ assert.equal(urls[0].loc[0], 'http://example.com');
+ assert.equal(urls[1].loc[0], 'http://example.com/one');
+ assert.equal(urls[2].loc[0], 'http://example.com/two');
+ });
+ describe('with base path', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ trailingSlash: 'never',
+ base: '/base',
+ });
+ await fixture.build();
+ });
+
+ it('URLs do not end with trailing slash', async () => {
+ const data = await readXML(fixture.readFile('/sitemap-0.xml'));
+ const urls = data.urlset.url;
+ assert.equal(urls[0].loc[0], 'http://example.com/base');
+ assert.equal(urls[1].loc[0], 'http://example.com/base/one');
+ assert.equal(urls[2].loc[0], 'http://example.com/base/two');
+ });
+ });
+ });
+
+ describe('trailingSlash: always', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ trailingSlash: 'always',
+ });
+ await fixture.build();
+ });
+
+ it('URLs end with trailing slash', async () => {
+ const data = await readXML(fixture.readFile('/sitemap-0.xml'));
+ const urls = data.urlset.url;
+ assert.equal(urls[0].loc[0], 'http://example.com/');
+ assert.equal(urls[1].loc[0], 'http://example.com/one/');
+ assert.equal(urls[2].loc[0], 'http://example.com/two/');
+ });
+ describe('with base path', () => {
+ before(async () => {
+ fixture = await loadFixture({
+ root: './fixtures/trailing-slash/',
+ trailingSlash: 'always',
+ base: '/base',
+ });
+ await fixture.build();
+ });
+
+ it('URLs end with trailing slash', async () => {
+ const data = await readXML(fixture.readFile('/sitemap-0.xml'));
+ const urls = data.urlset.url;
+ assert.equal(urls[0].loc[0], 'http://example.com/base/');
+ assert.equal(urls[1].loc[0], 'http://example.com/base/one/');
+ assert.equal(urls[2].loc[0], 'http://example.com/base/two/');
+ });
+ });
+ });
+});
diff --git a/packages/integrations/sitemap/test/units/generate-sitemap.test.js b/packages/integrations/sitemap/test/units/generate-sitemap.test.js
new file mode 100644
index 000000000..fbf4e7858
--- /dev/null
+++ b/packages/integrations/sitemap/test/units/generate-sitemap.test.js
@@ -0,0 +1,147 @@
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+import { generateSitemap } from '../../dist/generate-sitemap.js';
+
+const site = 'http://example.com';
+
+describe('generateSitemap', () => {
+ describe('basic', () => {
+ it('works', () => {
+ const items = generateSitemap(
+ [
+ // All pages
+ `${site}/a`,
+ `${site}/b`,
+ `${site}/c`,
+ ],
+ site,
+ );
+ assert.equal(items.length, 3);
+ assert.equal(items[0].url, `${site}/a`);
+ assert.equal(items[1].url, `${site}/b`);
+ assert.equal(items[2].url, `${site}/c`);
+ });
+
+ it('sorts the items', () => {
+ const items = generateSitemap(
+ [
+ // All pages
+ `${site}/c`,
+ `${site}/a`,
+ `${site}/b`,
+ ],
+ site,
+ );
+ assert.equal(items.length, 3);
+ assert.equal(items[0].url, `${site}/a`);
+ assert.equal(items[1].url, `${site}/b`);
+ assert.equal(items[2].url, `${site}/c`);
+ });
+
+ it('sitemap props are passed to items', () => {
+ const now = new Date();
+ const items = generateSitemap(
+ [
+ // All pages
+ `${site}/a`,
+ `${site}/b`,
+ `${site}/c`,
+ ],
+ site,
+ {
+ changefreq: 'monthly',
+ lastmod: now,
+ priority: 0.5,
+ },
+ );
+
+ assert.equal(items.length, 3);
+
+ assert.equal(items[0].url, `${site}/a`);
+ assert.equal(items[0].changefreq, 'monthly');
+ assert.equal(items[0].lastmod, now.toISOString());
+ assert.equal(items[0].priority, 0.5);
+
+ assert.equal(items[1].url, `${site}/b`);
+ assert.equal(items[1].changefreq, 'monthly');
+ assert.equal(items[1].lastmod, now.toISOString());
+ assert.equal(items[1].priority, 0.5);
+
+ assert.equal(items[2].url, `${site}/c`);
+ assert.equal(items[2].changefreq, 'monthly');
+ assert.equal(items[2].lastmod, now.toISOString());
+ assert.equal(items[2].priority, 0.5);
+ });
+ });
+
+ describe('i18n', () => {
+ it('works', () => {
+ const items = generateSitemap(
+ [
+ // All pages
+ `${site}/a`,
+ `${site}/b`,
+ `${site}/c`,
+ `${site}/es/a`,
+ `${site}/es/b`,
+ `${site}/es/c`,
+ `${site}/fr/a`,
+ `${site}/fr/b`,
+ // `${site}/fr-CA/c`, (intentionally missing for testing)
+ ],
+ site,
+ {
+ i18n: {
+ defaultLocale: 'en',
+ locales: {
+ en: 'en-US',
+ es: 'es-ES',
+ fr: 'fr-CA',
+ },
+ },
+ },
+ );
+
+ assert.equal(items.length, 8);
+
+ const aLinks = [
+ { url: `${site}/a`, lang: 'en-US' },
+ { url: `${site}/es/a`, lang: 'es-ES' },
+ { url: `${site}/fr/a`, lang: 'fr-CA' },
+ ];
+ const bLinks = [
+ { url: `${site}/b`, lang: 'en-US' },
+ { url: `${site}/es/b`, lang: 'es-ES' },
+ { url: `${site}/fr/b`, lang: 'fr-CA' },
+ ];
+ const cLinks = [
+ { url: `${site}/c`, lang: 'en-US' },
+ { url: `${site}/es/c`, lang: 'es-ES' },
+ ];
+
+ assert.equal(items[0].url, `${site}/a`);
+ assert.deepEqual(items[0].links, aLinks);
+
+ assert.equal(items[1].url, `${site}/b`);
+ assert.deepEqual(items[1].links, bLinks);
+
+ assert.equal(items[2].url, `${site}/c`);
+ assert.deepEqual(items[2].links, cLinks);
+
+ assert.equal(items[3].url, `${site}/es/a`);
+ assert.deepEqual(items[3].links, aLinks);
+
+ assert.equal(items[4].url, `${site}/es/b`);
+ assert.deepEqual(items[4].links, bLinks);
+
+ assert.equal(items[5].url, `${site}/es/c`);
+ assert.deepEqual(items[5].links, cLinks);
+
+ assert.equal(items[6].url, `${site}/fr/a`);
+ assert.deepEqual(items[6].links, aLinks);
+
+ assert.equal(items[7].url, `${site}/fr/b`);
+ assert.deepEqual(items[7].links, bLinks);
+ });
+ });
+});
diff --git a/packages/integrations/sitemap/tsconfig.json b/packages/integrations/sitemap/tsconfig.json
new file mode 100644
index 000000000..1504b4b6d
--- /dev/null
+++ b/packages/integrations/sitemap/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "include": ["src"],
+ "compilerOptions": {
+ "outDir": "./dist"
+ }
+}