├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── bun.lockb ├── package.json ├── src ├── app.d.ts ├── app.html ├── lib │ ├── data │ │ └── blog.ts │ ├── fixtures │ │ ├── expected-sitemap-index-subpage1.xml │ │ ├── expected-sitemap-index-subpage2.xml │ │ ├── expected-sitemap-index-subpage3.xml │ │ ├── expected-sitemap-index.xml │ │ ├── expected-sitemap.xml │ │ └── mocks.js │ ├── index.ts │ ├── sampled.test.ts │ ├── sampled.ts │ ├── sitemap.test.ts │ ├── sitemap.ts │ └── test.js ├── params │ └── integer.ts └── routes │ ├── (authenticated) │ └── dashboard │ │ ├── +page.svelte │ │ ├── +page.ts │ │ └── profile │ │ ├── +page.svelte │ │ └── +page.ts │ ├── (public) │ ├── [[lang]] │ │ ├── +page.svelte │ │ ├── +page.ts │ │ ├── [foo] │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ ├── about │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ ├── blog │ │ │ ├── +page.svelte │ │ │ ├── +page.ts │ │ │ ├── [page=integer] │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── [slug] │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ └── tag │ │ │ │ └── [tag] │ │ │ │ ├── +page.svelte │ │ │ │ ├── +page.ts │ │ │ │ └── [page=integer] │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ ├── campsites │ │ │ └── [country] │ │ │ │ └── [state] │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ ├── login │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ ├── og │ │ │ └── blog │ │ │ │ └── [title].png │ │ │ │ └── +server.ts │ │ ├── optionals │ │ │ ├── [[optional]] │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ │ ├── many │ │ │ │ └── [[paramA]] │ │ │ │ │ ├── +page.svelte │ │ │ │ │ └── [[paramB]] │ │ │ │ │ └── foo │ │ │ │ │ ├── +page.svelte │ │ │ │ │ └── +page.ts │ │ │ └── to-exclude │ │ │ │ └── [[optional]] │ │ │ │ ├── +page.svelte │ │ │ │ └── +page.ts │ │ ├── pricing │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ ├── privacy │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ ├── signup │ │ │ ├── +page.svelte │ │ │ └── +page.ts │ │ ├── sitemap[[page]].xml │ │ │ └── +server.ts │ │ └── terms │ │ │ ├── +page.ts │ │ │ └── +page@.svelte │ ├── markdown-md │ │ └── +page.md │ └── markdown-svx │ │ └── +page.svx │ ├── (secret-group) │ └── secret-page │ │ ├── +page.svelte │ │ └── +page.ts │ └── dashboard │ └── settings │ ├── +page.svelte │ └── +page.ts ├── static └── favicon.png ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.eslintignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2017: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:svelte/recommended', 11 | 'prettier', 12 | 'plugin:perfectionist/recommended-natural', 13 | ], 14 | overrides: [ 15 | { 16 | files: ['*.svelte'], 17 | parser: 'svelte-eslint-parser', 18 | parserOptions: { 19 | parser: '@typescript-eslint/parser', 20 | }, 21 | }, 22 | ], 23 | parser: '@typescript-eslint/parser', 24 | parserOptions: { 25 | ecmaVersion: 2020, 26 | extraFileExtensions: ['.svelte'], 27 | sourceType: 'module', 28 | }, 29 | plugins: ['@typescript-eslint'], 30 | root: true, 31 | rules: { 32 | '@typescript-eslint/no-explicit-any': 'off', 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci.yml 2 | env: 3 | CI: true 4 | NODE_VERSION: 18 5 | 6 | on: 7 | push: 8 | pull_request: 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: write # for dependabot updates 13 | 14 | jobs: 15 | unit-tests: 16 | runs-on: ubuntu-latest 17 | timeout-minutes: 5 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: actions/setup-node@v3 21 | - uses: oven-sh/setup-bun@v1 22 | with: 23 | bun-version: latest 24 | - name: Install dependencies 25 | run: npm install 26 | # run: bun install --frozen-lockfile 27 | - run: ls -la && ls src/lib -la 28 | - name: Run unit tests 29 | run: bun run test 30 | 31 | publish-to-npm-public: 32 | runs-on: ubuntu-latest 33 | timeout-minutes: 5 34 | needs: unit-tests 35 | # Avoid running for non-main branches and non-merge events like new pull requests. 36 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 37 | steps: 38 | - uses: actions/checkout@v4 39 | - uses: oven-sh/setup-bun@v1 40 | - uses: actions/setup-node@v3 41 | with: 42 | node-version: ${{ env.NODE_VERSION }} 43 | - run: npm install 44 | # - run: bun install --frozen-lockfile 45 | - run: ls -la && ls src/lib -la 46 | - name: Publish to NPM, if version was incremented 47 | uses: JS-DevTools/npm-publish@v2 48 | with: 49 | token: ${{ secrets.NPM_TOKEN }} 50 | ignore-scripts: false # Allows the project's `prepublishOnly` script. 51 | strategy: upgrade # Publish only if the version was incremented. 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /dist 5 | /.svelte-kit 6 | /package 7 | .env 8 | .env.* 9 | !.env.example 10 | vite.config.js.timestamp-* 11 | vite.config.ts.timestamp-* 12 | misc 13 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | resolution-mode=highest 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte"], 7 | "pluginSearchDirs": ["."], 8 | "overrides": [ 9 | { 10 | "files": "*.svelte", 11 | "options": { 12 | "parser": "svelte" 13 | } 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "explicit" 4 | }, 5 | "search.exclude": { 6 | "**/.git": true, 7 | "**/node_modules": true, 8 | "**/.svelte-kit": true 9 | }, 10 | "cSpell.words": [ 11 | "changefreq", 12 | "datetime", 13 | "devs", 14 | "lastmod", 15 | "loc", 16 | "prerender", 17 | "prerendering", 18 | "publint", 19 | "signup", 20 | "sitemapindex", 21 | "subpage", 22 | "subpages" 23 | ], 24 | "cSpell.ignoreWords": [] 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | Svelte Super Sitemap 3 | 4 |

SvelteKit sitemap focused on ease of use and
making it impossible to forget to add your paths.

5 | 6 | 7 | license badge 8 | 9 | 10 | npm badge 11 | 12 | 13 | unit tests badge 14 | 15 |
16 | 17 | **

v1.0 is released! 🎉🚀

The only breaking changes from v0.15.0 are to 1.) rename `priority` to `defaultPriority` and 2.) rename `changefreq` to `defaultChangefreq` in your sitemap config. See [changelog](#changelog).** 18 | 19 | ## Table of Contents 20 | 21 | - [Features](#features) 22 | - [Installation](#installation) 23 | - [Usage](#usage) 24 | - [Basic example](#basic-example) 25 | - [The "everything" example](#the-everything-example) 26 | - [Sitemap Index](#sitemap-index) 27 | - [Param Values](#param-values) 28 | - [Optional Params](#optional-params) 29 | - [`processPaths()` callback](#processpaths-callback) 30 | - [i18n](#i18n) 31 | - [Sampled URLs](#sampled-urls) 32 | - [Sampled Paths](#sampled-paths) 33 | - [Robots.txt](#robotstxt) 34 | - [Playwright test](#playwright-test) 35 | - [Tip: Querying your database for param values using SQL](#tip-querying-your-database-for-param-values-using-sql) 36 | - [Example sitemap output](#example-sitemap-output) 37 | - [Changelog](#changelog) 38 | 39 | ## Features 40 | 41 | - 🤓 Supports any rendering method. 42 | - 🪄 Automatically collects routes from `/src/routes` using Vite + data for route 43 | parameters provided by you. 44 | - 🧠 Easy maintenance–accidental omission of data for parameterized routes 45 | throws an error and requires the developer to either explicitly exclude the 46 | route pattern or provide an array of data for that param value. 47 | - 👻 Exclude specific routes or patterns using regex patterns (e.g. 48 | `^/dashboard.*`, paginated URLs, etc). 49 | - 🚀 Defaults to 1h CDN cache, no browser cache. 50 | - 💆 Set custom headers to override [default headers](https://github.com/jasongitmail/super-sitemap/blob/main/src/lib/sitemap.ts#L142): 51 | `sitemap.response({ headers: {'cache-control: 'max-age=0, s-maxage=60'} })`. 52 | - 💡 Google, and other modern search engines, [ignore `priority` and 53 | `changefreq`](https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap#xml) 54 | and use their own heuristics to determine when to crawl pages on your site. As 55 | such, these properties are not included by default to minimize KB size and 56 | enable faster crawling. Optionally, you can enable them like so: 57 | `sitemap.response({ defaultChangefreq: 'daily', defaultPriority: 0.7 })`. 58 | - 🗺️ [Sitemap indexes](#sitemap-index) 59 | - 🌎 [i18n](#i18n) 60 | - 🧪 Well tested. 61 | - 🫶 Built with TypeScript. 62 | 63 | ## Installation 64 | 65 | `npm i -D super-sitemap` 66 | 67 | or 68 | 69 | `bun add -d super-sitemap` 70 | 71 | Then see the [Usage](#usage), [Robots.txt](#robotstxt), & [Playwright Test](#playwright-test) sections. 72 | 73 | ## Usage 74 | 75 | ## Basic example 76 | 77 | JavaScript: 78 | 79 | ```js 80 | // /src/routes/sitemap.xml/+server.js 81 | import * as sitemap from 'super-sitemap'; 82 | 83 | export const GET = async () => { 84 | return await sitemap.response({ 85 | origin: 'https://example.com', 86 | }); 87 | }; 88 | ``` 89 | 90 | TypeScript: 91 | 92 | ```ts 93 | // /src/routes/sitemap.xml/+server.ts 94 | import * as sitemap from 'super-sitemap'; 95 | import type { RequestHandler } from '@sveltejs/kit'; 96 | 97 | export const GET: RequestHandler = async () => { 98 | return await sitemap.response({ 99 | origin: 'https://example.com', 100 | }); 101 | }; 102 | ``` 103 | 104 | Always include the `.xml` extension on your sitemap route name–e.g. `sitemap.xml`. This ensures your web server always sends the correct `application/xml` content type even if you decide to prerender your sitemap to static files. 105 | 106 | ## The "everything" example 107 | 108 | _**All aspects of the below example are optional, except for `origin` and 109 | `paramValues` to provide data for parameterized routes.**_ 110 | 111 | JavaScript: 112 | 113 | ```js 114 | // /src/routes/sitemap.xml/+server.js 115 | import * as sitemap from 'super-sitemap'; 116 | import * as blog from '$lib/data/blog'; 117 | 118 | export const prerender = true; // optional 119 | 120 | export const GET = async () => { 121 | // Get data for parameterized routes however you need to; this is only an example. 122 | let blogSlugs, blogTags; 123 | try { 124 | [blogSlugs, blogTags] = await Promise.all([blog.getSlugs(), blog.getTags()]); 125 | } catch (err) { 126 | throw error(500, 'Could not load data for param values.'); 127 | } 128 | 129 | return await sitemap.response({ 130 | origin: 'https://example.com', 131 | excludeRoutePatterns: [ 132 | '^/dashboard.*', // i.e. routes starting with `/dashboard` 133 | '.*\\[page=integer\\].*', // i.e. routes containing `[page=integer]`–e.g. `/blog/2` 134 | '.*\\(authenticated\\).*', // i.e. routes within a group 135 | ], 136 | paramValues: { 137 | // paramValues can be a 1D array of strings 138 | '/blog/[slug]': blogSlugs, // e.g. ['hello-world', 'another-post'] 139 | '/blog/tag/[tag]': blogTags, // e.g. ['red', 'green', 'blue'] 140 | 141 | // Or a 2D array of strings 142 | '/campsites/[country]/[state]': [ 143 | ['usa', 'new-york'], 144 | ['usa', 'california'], 145 | ['canada', 'toronto'], 146 | ], 147 | 148 | // Or an array of ParamValue objects 149 | '/athlete-rankings/[country]/[state]': [ 150 | { 151 | values: ['usa', 'new-york'], // required 152 | lastmod: '2025-01-01T00:00:00Z', // optional 153 | changefreq: 'daily', // optional 154 | priority: 0.5, // optional 155 | }, 156 | { 157 | values: ['usa', 'california'], 158 | lastmod: '2025-01-01T00:00:00Z', 159 | changefreq: 'daily', 160 | priority: 0.5, 161 | }, 162 | ], 163 | }, 164 | headers: { 165 | 'custom-header': 'foo', // case insensitive; xml content type & 1h CDN cache by default 166 | }, 167 | additionalPaths: [ 168 | '/foo.pdf', // for example, to a file in your static dir 169 | ], 170 | defaultChangefreq: 'daily', 171 | defaultPriority: 0.7, 172 | sort: 'alpha', // default is false; 'alpha' sorts all paths alphabetically. 173 | processPaths: (paths) => { 174 | // Optional callback to allow arbitrary processing of your path objects. See the 175 | // processPaths() section of the README. 176 | return paths; 177 | }, 178 | }); 179 | }; 180 | ``` 181 | 182 | TypeScript: 183 | 184 | ```ts 185 | // /src/routes/sitemap.xml/+server.ts 186 | import type { RequestHandler } from '@sveltejs/kit'; 187 | import * as sitemap from 'super-sitemap'; 188 | import * as blog from '$lib/data/blog'; 189 | 190 | export const prerender = true; // optional 191 | 192 | export const GET: RequestHandler = async () => { 193 | // Get data for parameterized routes however you need to; this is only an example. 194 | let blogSlugs, blogTags; 195 | try { 196 | [blogSlugs, blogTags] = await Promise.all([blog.getSlugs(), blog.getTags()]); 197 | } catch (err) { 198 | throw error(500, 'Could not load data for param values.'); 199 | } 200 | 201 | return await sitemap.response({ 202 | origin: 'https://example.com', 203 | excludeRoutePatterns: [ 204 | '^/dashboard.*', // i.e. routes starting with `/dashboard` 205 | '.*\\[page=integer\\].*', // i.e. routes containing `[page=integer]`–e.g. `/blog/2` 206 | '.*\\(authenticated\\).*', // i.e. routes within a group 207 | ], 208 | paramValues: { 209 | // paramValues can be a 1D array of strings 210 | '/blog/[slug]': blogSlugs, // e.g. ['hello-world', 'another-post'] 211 | '/blog/tag/[tag]': blogTags, // e.g. ['red', 'green', 'blue'] 212 | 213 | // Or a 2D array of strings 214 | '/campsites/[country]/[state]': [ 215 | ['usa', 'new-york'], 216 | ['usa', 'california'], 217 | ['canada', 'toronto'], 218 | ], 219 | 220 | // Or an array of ParamValue objects 221 | '/athlete-rankings/[country]/[state]': [ 222 | { 223 | values: ['usa', 'new-york'], // required 224 | lastmod: '2025-01-01T00:00:00Z', // optional 225 | changefreq: 'daily', // optional 226 | priority: 0.5, // optional 227 | }, 228 | { 229 | values: ['usa', 'california'], 230 | lastmod: '2025-01-01T00:00:00Z', 231 | changefreq: 'daily', 232 | priority: 0.5, 233 | }, 234 | ], 235 | }, 236 | headers: { 237 | 'custom-header': 'foo', // case insensitive; xml content type & 1h CDN cache by default 238 | }, 239 | additionalPaths: [ 240 | '/foo.pdf', // for example, to a file in your static dir 241 | ], 242 | defaultChangefreq: 'daily', 243 | defaultPriority: 0.7, 244 | sort: 'alpha', // default is false; 'alpha' sorts all paths alphabetically. 245 | processPaths: (paths: sitemap.PathObj[]) => { 246 | // Optional callback to allow arbitrary processing of your path objects. See the 247 | // processPaths() section of the README. 248 | return paths; 249 | }, 250 | }); 251 | }; 252 | ``` 253 | 254 | ## Sitemap Index 255 | 256 | _**You only need to enable or read this if you will have >=50,000 URLs in your sitemap, which is the number 257 | recommended by Google.**_ 258 | 259 | You can enable sitemap index support with just two changes: 260 | 261 | 1. Rename your route to `sitemap[[page]].xml` 262 | 2. Pass the page param via your sitemap config 263 | 264 | JavaScript: 265 | 266 | ```js 267 | // /src/routes/sitemap[[page]].xml/+server.js 268 | import * as sitemap from 'super-sitemap'; 269 | 270 | export const GET = async ({ params }) => { 271 | return await sitemap.response({ 272 | origin: 'https://example.com', 273 | page: params.page, 274 | // maxPerPage: 45_000 // optional; defaults to 50_000 275 | }); 276 | }; 277 | ``` 278 | 279 | TypeScript: 280 | 281 | ```ts 282 | // /src/routes/sitemap[[page]].xml/+server.ts 283 | import * as sitemap from 'super-sitemap'; 284 | import type { RequestHandler } from '@sveltejs/kit'; 285 | 286 | export const GET: RequestHandler = async ({ params }) => { 287 | return await sitemap.response({ 288 | origin: 'https://example.com', 289 | page: params.page, 290 | // maxPerPage: 45_000 // optional; defaults to 50_000 291 | }); 292 | }; 293 | ``` 294 | 295 | **Feel free to always set up your sitemap as a sitemap index, given it will work optimally whether you 296 | have few or many URLs.** 297 | 298 | Your `sitemap.xml` route will now return a regular sitemap when your sitemap's total URLs is less than or equal 299 | to `maxPerPage` (defaults to 50,000 per the [sitemap 300 | protocol](https://www.sitemaps.org/protocol.html)) or it will contain a sitemap index when exceeding 301 | `maxPerPage`. 302 | 303 | The sitemap index will contain links to `sitemap1.xml`, `sitemap2.xml`, etc, which contain your 304 | paginated URLs automatically. 305 | 306 | ```xml 307 | 308 | 309 | https://example.com/sitemap1.xml 310 | 311 | 312 | https://example.com/sitemap2.xml 313 | 314 | 315 | https://example.com/sitemap3.xml 316 | 317 | 318 | ``` 319 | 320 | ## Param Values 321 | 322 | When specifying values for the params of your parameterized routes, 323 | you can use any of the following types: 324 | `string[]`, `string[][]`, or `ParamValue[]`. 325 | 326 | Example: 327 | 328 | ```ts 329 | paramValues: { 330 | '/blog/[slug]': ['hello-world', 'another-post'] 331 | '/campsites/[country]/[state]': [ 332 | ['usa', 'colorado'], 333 | ['canada', 'toronto'] 334 | ], 335 | '/athlete-rankings/[country]/[state]': [ 336 | { 337 | values: ['usa', 'new-york'], // required 338 | lastmod: '2025-01-01T00:00:00Z', // optional 339 | changefreq: 'daily', // optional 340 | priority: 0.5, // optional 341 | }, 342 | { 343 | values: ['usa', 'california'], 344 | lastmod: '2025-01-01T01:16:52Z', 345 | changefreq: 'daily', 346 | priority: 0.5, 347 | }, 348 | ], 349 | }, 350 | ``` 351 | 352 | If any of the optional properties of `ParamValue` are not provided, the sitemap will use the default 353 | value. If a default value is not defined, the property will be excluded from that sitemap entry. 354 | 355 | ## Optional Params 356 | 357 | _**You only need to read this if you want to understand how super sitemap handles optional params and why.**_ 358 | 359 | SvelteKit allows you to create a route with one or more optional parameters like this: 360 | 361 | ```text 362 | src/ 363 | routes/ 364 | something/ 365 | [[paramA]]/ 366 | [[paramB]]/ 367 | +page.svelte 368 | +page.ts 369 | ``` 370 | 371 | Your app would then respond to HTTP requests for all of the following: 372 | 373 | - `/something` 374 | - `/something/foo` 375 | - `/something/foo/bar` 376 | 377 | Consequently, Super Sitemap will include all such path variations in your 378 | sitemap and will require you to either exclude these using `excludeRoutePatterns` or 379 | provide param values for them using `paramValues`, within your sitemap 380 | config object. 381 | 382 | ### For example: 383 | 384 | - `/something` will exist in your sitemap unless excluded with a pattern of 385 | `/something$`. 386 | - `/something/[[paramA]]` must be either excluded using an `excludeRoutePattern` of 387 | `.*/something/\\[\\[paramA\\]\\]$` _or_ appear within your config's 388 | `paramValues` like this: `'/something/[[paramA]]': ['foo', 'foo2', 'foo3']`. 389 | - And `/something/[[paramA]]/[[paramB]]` must be either excluded using an 390 | `excludeRoutePattern` of `.*/something/\\[\\[paramA\\]\\]/\\[\\[paramB\\]\\]$` _or_ 391 | appear within your config's `paramValues` like this: `'/something/[[paramA]]/[[paramB]]': 392 | [['foo','bar'], ['foo2','bar2'], ['foo3','bar3']]`. 393 | 394 | Alternatively, you can exclude ALL versions of this route by providing a single 395 | regex pattern within `excludeRoutePatterns` that matches all of them, such as 396 | `/something`; notice this do NOT end with a `$`, thereby allowing this pattern 397 | to match all 3 versions of this route. 398 | 399 | If you plan to mix and match use of `excludeRoutePatterns` and `paramValues` for a 400 | given route that contains optional params, terminate all of your 401 | `excludeRoutePatterns` for that route with `$`, to target only the specific desired 402 | versions of that route. 403 | 404 | ## processPaths() callback 405 | 406 | _**The `processPaths()` callback is powerful, but rarely needed.**_ 407 | 408 | It allows you to arbitrarily process the path objects for your site before they become XML, with the 409 | only requirement that your callback function must return the expected type of 410 | [`PathObj[]`](https://github.com/jasongitmail/super-sitemap/blob/main/src/lib/sitemap.ts#L34). 411 | 412 | This can be useful to do something bespoke that would not otherwise be possible. For example: 413 | 414 | 1. Excluding a specific path, when `excludeRoutePatterns` based on the _route 415 | pattern_ would be too broad. (For example, you might want to exclude a path 416 | when you have not yet translated its content into one or more of your site’s 417 | supported languages; e.g. to exclude only `/zh/about`, but retain all others 418 | like `/about`, `/es/about`, etc.) 419 | 2. Adding a trailing slash to URLs (not a recommended style, but possible). 420 | 3. Appending paths from an external sitemap, like from a hosted headless blog 421 | backend. However, you can also accomplish this by providing these within the 422 | `additionalPaths` array in your super sitemap config, which is a more concise approach. 423 | 424 | `processPaths()` runs after all paths have been generated for your site, but prior to de-duplication 425 | of paths based on unique path names, sorting (if enabled by your config), and creation of XML. 426 | 427 | Note that `processPaths()` is intentionally NOT async. This design decision is 428 | to encourage a consistent pattern within the sitemap request handler where all HTTP 429 | requests, including any to fetch param values from a database, [occur 430 | together using `Promise.all()`](), for best performance and consistent code pattern 431 | among super sitemap users for best DX. 432 | 433 | ### Example code - to remove specific paths 434 | 435 | ```ts 436 | return await sitemap.response({ 437 | // ... 438 | processPaths: (paths: sitemap.PathObj[]) => { 439 | const pathsToExclude = ['/zh/about', '/de/team']; 440 | return paths.filter(({ path }) => !pathsToExclude.includes(path)); 441 | }, 442 | }); 443 | ``` 444 | 445 | Note: If using `excludeRoutePatterns`–which matches again the _route_ pattern–would 446 | be sufficient for your needs, you should prefer it for performance reasons. This 447 | is because a site will have fewer routes than paths, consequently route-based 448 | exclusions are more performant than path-based exclusions. Although, the 449 | difference will be inconsequential in virtually all cases, unless you have a 450 | very large number of excluded paths and many millions of generated paths to 451 | search within. 452 | 453 | ### Example code - to add trailing slashes 454 | 455 | ```ts 456 | return await sitemap.response({ 457 | // ... 458 | processPaths: (paths: sitemap.PathObj[]) => { 459 | // Add trailing slashes to all paths. (This is just an example and not 460 | // actually recommended. Using SvelteKit's default of no trailing slash is 461 | // preferable because it provides consistency among all possible paths, 462 | // even files like `/foo.pdf`.) 463 | return paths.map(({ path, alternates, ...rest }) => { 464 | const rtrn = { path: path === '/' ? path : `${path}/`, ...rest }; 465 | 466 | if (alternates) { 467 | rtrn.alternates = alternates.map((alternate: sitemap.Alternate) => ({ 468 | ...alternate, 469 | path: alternate.path === '/' ? alternate.path : `${alternate.path}/`, 470 | })); 471 | } 472 | 473 | return rtrn; 474 | }); 475 | }, 476 | }); 477 | ``` 478 | 479 | ## i18n 480 | 481 | Super Sitemap supports [multilingual site 482 | annotations](https://developers.google.com/search/blog/2012/05/multilingual-and-multinational-site) 483 | within your sitemap. This allows search engines to be aware of alternate 484 | language versions of your pages. 485 | 486 | ### Set up 487 | 488 | 1. Create a directory named `[[lang]]` at `src/routes/[[lang]]`. Place any 489 | routes that you intend to translate inside here. 490 | 491 | - **This parameter must be named `lang`.** 492 | - This parameter can specify a [param 493 | matcher](https://kit.svelte.dev/docs/advanced-routing#matching), if 494 | desired. For example: `src/routes/(public)/[[lang=lang]]`, when you defined 495 | a param matcher at `src/params/lang.js`. The param matcher can have any 496 | name as long as it uses only lowercase letters. 497 | - This directory can be located within a route group, if desired, e.g. 498 | `src/routes/(public)/[[lang]]`. 499 | - Advanced: If you want to _require_ a language parameter as part of _all_ 500 | your urls, use single square brackets like `src/routes/[lang]` or 501 | `src/routes/[lang=lang]`. Importantly, **if you take this approach, you 502 | should redirect your index route (`/`) to one of your language-specific 503 | index paths (e.g. `/en`, `/es`, etc)**, because a root url of `/` will not be 504 | included in the sitemap when you have _required_ the language param to 505 | exist. (The remainder of these docs will assume you are using an 506 | _optional_ lang parameter.) 507 | 508 | 2. Within your `sitemap.xml` route, update your Super Sitemap config object to 509 | add a `lang` property specifying your desired languages. 510 | 511 | ```js 512 | lang: { 513 | default: 'en', // e.g. /about 514 | alternates: ['zh', 'de'] // e.g. /zh/about, /de/about 515 | } 516 | ``` 517 | 518 | The default language will not appear in your URLs (e.g. `/about`). Alternate 519 | languages will appear as part of the URLs within your sitemap (e.g. 520 | `/zh/about`, `/de/about`). 521 | 522 | These language properties accept any string value, but choose a valid 523 | language code. They will appear in two places: 1.) as a slug within your 524 | paths (e.g. `/zh/about`), and 2.) as `hreflang` attributes within the sitemap 525 | output. 526 | 527 | Note: If you used a _required_ lang param (e.g. `[lang]`), you can set 528 | _any_ of your desired languages as the `default` and the rest as the `alternates`; they will _all_ be 529 | processed in the same way though. 530 | 531 | 3. Within your `sitemap.xml` route again, update your Super Sitemap config 532 | object's `paramValues` to prepend `/[[lang]]` (or `/[[lang=lang]]`, `[lang]`, etc–whatever you used earlier) onto the property names of all routes you moved 533 | into your `/src/routes/[[lang]]` directory, e.g.: 534 | 535 | ```js 536 | paramValues: { 537 | '/[[lang]]/blog/[slug]': ['hello-world', 'post-2'], // was '/blog/[slug]' 538 | '/[[lang]]/campsites/[country]/[state]': [ // was '/campsites/[country]/[state]' 539 | ['usa', 'new-york'], 540 | ['canada', 'toronto'], 541 | ], 542 | }, 543 | ``` 544 | 545 | ### Example 546 | 547 | 1. Create `/src/routes/[[lang]]/about/+page.svelte` with any content. 548 | 2. Assuming you have a [basic sitemap](#basic-example) set up at 549 | `/src/routes/sitemap.xml/+server.ts`, add a `lang` property to your sitemap's 550 | config object, as described in Step 2 in the previous section. 551 | 3. Your `sitemap.xml` will then include the following: 552 | 553 | ```xml 554 | ... 555 | 556 | https://example.com/about 557 | 558 | 559 | 560 | 561 | 562 | https://example.com/de/about 563 | 564 | 565 | 566 | 567 | 568 | https://example.com/zh/about 569 | 570 | 571 | 572 | 573 | ... 574 | ``` 575 | 576 | ### Note on i18n 577 | 578 | Super Sitemap handles creation of URLs within your sitemap, but it is 579 | _not_ an i18n library. 580 | 581 | You need a separate i18n library to translate strings within your app. Just 582 | ensure the library you choose allows a similar URL pattern as described here, 583 | with a default language (e.g. `/about`) and lang slugs for alternate languages 584 | (e.g. `/zh/about`, `/de/about`). 585 | 586 | ### Q&A on i18n 587 | 588 | - **What about translated paths like `/about` (English), `/acerca` (Spanish), `/uber` (German)?** 589 | 590 | Realistically, this would break the route patterns and assumptions that Super 591 | Sitemap relies on to identify your routes, to know what language to use, and 592 | to build the sitemap. "Never say never", but there are no plans to support this. 593 | 594 | ## Sampled URLs 595 | 596 | _**`sampledUrls()` is an optional utility to be used in your Playwright tests. You do not need to read this if just getting started.**_ 597 | 598 | Sampled URLs provides a utility to obtain a sample URL for each unique route on your site–i.e.: 599 | 600 | 1. the URL for every static route (e.g. `/`, `/about`, `/pricing`, etc.), and 601 | 2. one URL for each parameterized route (e.g. `/blog/[slug]`) 602 | 603 | This can be helpful for writing functional tests, performing SEO analyses of your public pages, & 604 | similar. 605 | 606 | This data is generated by analyzing your site's `sitemap.xml`, so keep in mind that it will not 607 | contain any URLs excluded by `excludeRoutePatterns` in your sitemap config. 608 | 609 | ```js 610 | import { sampledUrls } from 'super-sitemap'; 611 | 612 | const urls = await sampledUrls('http://localhost:5173/sitemap.xml'); 613 | // [ 614 | // 'http://localhost:5173/', 615 | // 'http://localhost:5173/about', 616 | // 'http://localhost:5173/pricing', 617 | // 'http://localhost:5173/features', 618 | // 'http://localhost:5173/login', 619 | // 'http://localhost:5173/signup', 620 | // 'http://localhost:5173/blog', 621 | // 'http://localhost:5173/blog/hello-world', 622 | // 'http://localhost:5173/blog/tag/red', 623 | // ] 624 | ``` 625 | 626 | ### Limitations 627 | 628 | 1. Result URLs will not include any `additionalPaths` from your sitemap config because it's 629 | impossible to identify those by a pattern given only your routes and `sitemap.xml` as inputs. 630 | 2. `sampledUrls()` does not distinguish between routes that differ only due to a pattern matcher. 631 | For example, `/foo/[foo]` and `/foo/[foo=integer]` will evaluated as `/foo/[foo]` and one sample 632 | URL will be returned. 633 | 634 | ### Designed as a testing utility 635 | 636 | Both `sampledUrls()` and `sampledPaths()` are intended as utilities for use 637 | within your Playwright tests. Their design aims for developer convenience (i.e. 638 | no need to set up a 2nd sitemap config), not for performance, and they require a 639 | runtime with access to the file system like Node, to read your `/src/routes`. In 640 | other words, use for testing, not as a data source for production. 641 | 642 | You can use it in a Playwright test like below, then you'll have `sampledPublicPaths` available to use within your tests in this file. 643 | 644 | ```js 645 | // foo.test.js 646 | import { expect, test } from '@playwright/test'; 647 | import { sampledPaths } from 'super-sitemap'; 648 | 649 | let sampledPublicPaths = []; 650 | try { 651 | sampledPublicPaths = await sampledPaths('http://localhost:4173/sitemap.xml'); 652 | } catch (err) { 653 | console.error('Error:', err); 654 | } 655 | 656 | // ... 657 | ``` 658 | 659 | ## Sampled Paths 660 | 661 | Same as [Sampled URLs](#sampled-urls), except it returns paths. 662 | 663 | ```js 664 | import { sampledPaths } from 'super-sitemap'; 665 | 666 | const urls = await sampledPaths('http://localhost:5173/sitemap.xml'); 667 | // [ 668 | // '/about', 669 | // '/pricing', 670 | // '/features', 671 | // '/login', 672 | // '/signup', 673 | // '/blog', 674 | // '/blog/hello-world', 675 | // '/blog/tag/red', 676 | // ] 677 | ``` 678 | 679 | ## Robots.txt 680 | 681 | It's important to create a `robots.txt` so search engines know where to find your sitemap. 682 | 683 | You can create it at `/static/robots.txt`: 684 | 685 | ```text 686 | User-agent: * 687 | Allow: / 688 | 689 | Sitemap: https://example.com/sitemap.xml 690 | ``` 691 | 692 | Or, at `/src/routes/robots.txt/+server.ts`, if you have defined `PUBLIC_ORIGIN` within your 693 | project's `.env` and want to access it: 694 | 695 | ```ts 696 | import * as env from '$env/static/public'; 697 | 698 | export const prerender = true; 699 | 700 | export async function GET(): Promise { 701 | // prettier-ignore 702 | const body = [ 703 | 'User-agent: *', 704 | 'Allow: /', 705 | '', 706 | `Sitemap: ${env.PUBLIC_ORIGIN}/sitemap.xml` 707 | ].join('\n').trim(); 708 | 709 | const headers = { 710 | 'Content-Type': 'text/plain', 711 | }; 712 | 713 | return new Response(body, { headers }); 714 | } 715 | ``` 716 | 717 | ## Playwright Test 718 | 719 | It's recommended to add a Playwright test that calls your sitemap. 720 | 721 | For pre-rendered sitemaps, you'll receive an error _at build time_ if your data param values are 722 | misconfigured. But for non-prerendered sitemaps, your data is loaded when the sitemap is loaded, and 723 | consequently a functional test is more important to confirm you have not misconfigured data for your 724 | param values. 725 | 726 | Feel free to use or adapt this example test: 727 | 728 | ```js 729 | // /src/tests/sitemap.test.js 730 | 731 | import { expect, test } from '@playwright/test'; 732 | 733 | test('/sitemap.xml is valid', async ({ page }) => { 734 | const response = await page.goto('/sitemap.xml'); 735 | expect(response.status()).toBe(200); 736 | 737 | // Ensure XML is valid. Playwright parses the XML here and will error if it 738 | // cannot be parsed. 739 | const urls = await page.$$eval('url', (urls) => 740 | urls.map((url) => ({ 741 | loc: url.querySelector('loc').textContent, 742 | // changefreq: url.querySelector('changefreq').textContent, // if you enabled in your sitemap 743 | // priority: url.querySelector('priority').textContent, 744 | })) 745 | ); 746 | 747 | // Sanity check 748 | expect(urls.length).toBeGreaterThan(5); 749 | 750 | // Ensure entries are in a valid format. 751 | for (const url of urls) { 752 | expect(url.loc).toBeTruthy(); 753 | expect(() => new URL(url.loc)).not.toThrow(); 754 | // expect(url.changefreq).toBe('daily'); 755 | // expect(url.priority).toBe('0.7'); 756 | } 757 | }); 758 | ``` 759 | 760 | ## Tip: Querying your database for param values using SQL 761 | 762 | As a helpful tip, below are a few examples demonstrating how to query an SQL 763 | database to obtain data to provide as `paramValues` for your routes: 764 | 765 | ```SQL 766 | -- Route: /blog/[slug] 767 | SELECT slug FROM blog_posts WHERE status = 'published'; 768 | 769 | -- Route: /blog/category/[category] 770 | SELECT DISTINCT LOWER(category) FROM blog_posts WHERE status = 'published'; 771 | 772 | -- Route: /campsites/[country]/[state] 773 | SELECT DISTINCT LOWER(country), LOWER(state) FROM campsites; 774 | ``` 775 | 776 | Using `DISTINCT` will prevent duplicates in your result set. Use this when your 777 | table could contain multiple rows with the same params, like in the 2nd and 3rd 778 | examples. This will be the case for routes that show a list of items. 779 | 780 | Then if your result is an array of objects, convert into an array of arrays of 781 | string values: 782 | 783 | ```js 784 | const arrayOfArrays = resultFromDB.map((row) => Object.values(row)); 785 | // [['usa','new-york'],['usa', 'california']] 786 | ``` 787 | 788 | That's it. 789 | 790 | Going in the other direction, i.e. when loading data for a component for your 791 | UI, your database query should typically lowercase both the URL param and value 792 | in the database during comparison–e.g.: 793 | 794 | ```sql 795 | -- Obviously, remember to escape your `params.slug` values to prevent SQL injection. 796 | SELECT * FROM campsites WHERE LOWER(country) = LOWER(params.country) AND LOWER(state) = LOWER(params.state) LIMIT 10; 797 | ``` 798 | 799 |
800 |

Example sitemap output

801 | 802 | ```xml 803 | 807 | 808 | https://example/ 809 | daily 810 | 0.7 811 | 812 | 813 | https://example/about 814 | daily 815 | 0.7 816 | 817 | 818 | https://example/blog 819 | daily 820 | 0.7 821 | 822 | 823 | https://example/login 824 | daily 825 | 0.7 826 | 827 | 828 | https://example/pricing 829 | daily 830 | 0.7 831 | 832 | 833 | https://example/privacy 834 | daily 835 | 0.7 836 | 837 | 838 | https://example/signup 839 | daily 840 | 0.7 841 | 842 | 843 | https://example/support 844 | daily 845 | 0.7 846 | 847 | 848 | https://example/terms 849 | daily 850 | 0.7 851 | 852 | 853 | https://example/blog/hello-world 854 | daily 855 | 0.7 856 | 857 | 858 | https://example/blog/another-post 859 | daily 860 | 0.7 861 | 862 | 863 | https://example/blog/tag/red 864 | daily 865 | 0.7 866 | 867 | 868 | https://example/blog/tag/green 869 | daily 870 | 0.7 871 | 872 | 873 | https://example/blog/tag/blue 874 | daily 875 | 0.7 876 | 877 | 878 | https://example/campsites/usa/new-york 879 | daily 880 | 0.7 881 | 882 | 883 | https://example/campsites/usa/california 884 | daily 885 | 0.7 886 | 887 | 888 | https://example/campsites/canada/toronto 889 | daily 890 | 0.7 891 | 892 | 893 | https://example/foo.pdf 894 | daily 895 | 0.7 896 | 897 | 898 | ``` 899 | 900 |
901 | 902 | ## Changelog 903 | 904 | - `1.0.0` - BREAKING: `priority` renamed to `defaultPriority`, and `changefreq` renamed to `defaultChangefreq`. NON-BREAKING: Support for `paramValues` to contain either `string[]`, `string[][]`, or `ParamValueObj[]` values to allow per-path specification of `lastmod`, `changefreq`, and `priority`. 905 | - `0.15.0` - BREAKING: Rename `excludePatterns` to `excludeRoutePatterns`. 906 | - `0.14.20` - Adds [processPaths() callback](#processpaths-callback). 907 | - `0.14.19` - Support `.md` and `.svx` route extensions for msdvex users. 908 | - `0.14.17` - Support for param matchers (e.g. `[[lang=lang]]`) & 909 | required lang params (e.g. `[lang]`). Thanks @JadedBlueEyes & @epoxide! 910 | - `0.14.13` - Support route files named to allow [breaking out of a layout](https://kit.svelte.dev/docs/advanced-routing#advanced-layouts-breaking-out-of-layouts). 911 | - `0.14.12` - Adds [`i18n`](#i18n) support. 912 | - `0.14.11` - Adds [`optional params`](#optional-params) support. 913 | - `0.14.0` - Adds [`sitemap index`](#sitemap-index) support. 914 | - `0.13.0` - Adds [`sampledUrls()`](#sampled-urls) and [`sampledPaths()`](#sampled-paths). 915 | - `0.12.0` - Adds config option to sort `'alpha'` or `false` (default). 916 | - `0.11.0` - BREAKING: Rename to `super-sitemap` on npm! 🚀 917 | - `0.10.0` - Adds ability to use unlimited dynamic params per route! 🎉 918 | - `0.9.0` - BREAKING: Adds configurable `changefreq` and `priority` and 919 | _excludes these by default_. See the README's features list for why. 920 | - `0.8.0` - Adds ability to specify `additionalPaths` that live outside 921 | `/src/routes`, such as `/foo.pdf` located at `/static/foo.pdf`. 922 | 923 | ## Contributing 924 | 925 | ```bash 926 | git clone https://github.com/jasongitmail/super-sitemap.git 927 | bun install 928 | # Then edit files in `/src/lib` 929 | ``` 930 | 931 | ## Publishing 932 | 933 | A new version of this npm package is automatically published when the semver 934 | version within `package.json` is incremented. 935 | 936 | ## Credits 937 | 938 | - Built by [x.com/@zkjason\_](https://twitter.com/zkjason_) 939 | - Made possible by [SvelteKit](https://kit.svelte.dev/) & [Svelte](https://svelte.dev/). 940 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasongitmail/super-sitemap/ca6a9363a4af62e467afb70d5fb1e0b3b4fa70f5/bun.lockb -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "super-sitemap", 3 | "version": "1.0.3", 4 | "description": "SvelteKit sitemap focused on ease of use and making it impossible to forget to add your paths.", 5 | "sideEffects": false, 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/jasongitmail/super-sitemap.git" 9 | }, 10 | "license": "MIT", 11 | "keywords": [ 12 | "sitemap", 13 | "svelte kit", 14 | "sveltekit", 15 | "svelte", 16 | "seo", 17 | "sitemap.xml", 18 | "sitemap generator", 19 | "robots.txt", 20 | "supersitemap" 21 | ], 22 | "scripts": { 23 | "dev": "vite dev", 24 | "build": "vite build && npm run package", 25 | "preview": "vite preview", 26 | "package": "svelte-kit sync && svelte-package && publint", 27 | "prepublishOnly": "npm run package", 28 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 29 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 30 | "test": "vitest", 31 | "test:unit": "vitest", 32 | "lint": "prettier --plugin-search-dir . --check . && eslint .", 33 | "format": "prettier --plugin-search-dir . --write . && eslint . --fix" 34 | }, 35 | "exports": { 36 | ".": { 37 | "types": "./dist/index.d.ts", 38 | "svelte": "./dist/index.js", 39 | "default": "./dist/index.js" 40 | } 41 | }, 42 | "files": [ 43 | "dist", 44 | "!dist/**/*.test.*", 45 | "!dist/**/*.spec.*" 46 | ], 47 | "peerDependencies": { 48 | "svelte": ">=4.0.0 <6.0.0" 49 | }, 50 | "devDependencies": { 51 | "@sveltejs/adapter-auto": "^2.1.0", 52 | "@sveltejs/kit": "^1.27.2", 53 | "@sveltejs/package": "^2.2.2", 54 | "@typescript-eslint/eslint-plugin": "^6.9.1", 55 | "@typescript-eslint/parser": "^6.9.1", 56 | "eslint": "^8.52.0", 57 | "eslint-config-prettier": "^8.10.0", 58 | "eslint-plugin-perfectionist": "^2.2.0", 59 | "eslint-plugin-svelte": "^2.34.0", 60 | "eslint-plugin-tsdoc": "^0.2.17", 61 | "mdsvex": "^0.11.2", 62 | "msw": "^2.0.2", 63 | "prettier": "^2.8.8", 64 | "prettier-plugin-svelte": "^2.10.1", 65 | "publint": "^0.2.5", 66 | "svelte": "^4.2.2", 67 | "svelte-check": "^3.5.2", 68 | "tslib": "^2.6.2", 69 | "typescript": "^5.2.2", 70 | "vite": "^4.5.0", 71 | "vitest": "^0.34.6" 72 | }, 73 | "dependencies": { 74 | "directory-tree": "^3.5.1", 75 | "fast-xml-parser": "^4.3.2" 76 | }, 77 | "svelte": "./dist/index.js", 78 | "types": "./dist/index.d.ts", 79 | "type": "module" 80 | } -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface Platform {} 9 | } 10 | } 11 | 12 | export {}; 13 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
%sveltekit.body%
11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/data/blog.ts: -------------------------------------------------------------------------------- 1 | export async function getSlugs() { 2 | return ['hello-world', 'another-post', 'awesome-post']; 3 | } 4 | 5 | export async function getTags() { 6 | return ['red', 'blue']; 7 | } 8 | -------------------------------------------------------------------------------- /src/lib/fixtures/expected-sitemap-index-subpage1.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | https://example.com/ 9 | daily 10 | 0.7 11 | 12 | 13 | 14 | 15 | https://example.com/about 16 | daily 17 | 0.7 18 | 19 | 20 | 21 | 22 | https://example.com/blog 23 | daily 24 | 0.7 25 | 26 | 27 | 28 | 29 | https://example.com/blog/another-post 30 | daily 31 | 0.7 32 | 33 | 34 | 35 | 36 | https://example.com/blog/awesome-post 37 | daily 38 | 0.7 39 | 40 | 41 | 42 | 43 | https://example.com/blog/hello-world 44 | daily 45 | 0.7 46 | 47 | 48 | 49 | 50 | https://example.com/blog/tag/blue 51 | daily 52 | 0.7 53 | 54 | 55 | 56 | 57 | https://example.com/blog/tag/red 58 | daily 59 | 0.7 60 | 61 | 62 | 63 | 64 | https://example.com/campsites/canada/toronto 65 | daily 66 | 0.7 67 | 68 | 69 | 70 | 71 | https://example.com/campsites/usa/california 72 | daily 73 | 0.7 74 | 75 | 76 | 77 | 78 | https://example.com/campsites/usa/new-york 79 | daily 80 | 0.7 81 | 82 | 83 | 84 | 85 | https://example.com/foo-path-1 86 | daily 87 | 0.7 88 | 89 | 90 | 91 | 92 | https://example.com/foo.pdf 93 | daily 94 | 0.7 95 | 96 | 97 | https://example.com/login 98 | daily 99 | 0.7 100 | 101 | 102 | 103 | 104 | https://example.com/markdown-md 105 | daily 106 | 0.7 107 | 108 | 109 | https://example.com/markdown-svx 110 | daily 111 | 0.7 112 | 113 | 114 | https://example.com/optionals 115 | daily 116 | 0.7 117 | 118 | 119 | 120 | 121 | https://example.com/optionals/many 122 | daily 123 | 0.7 124 | 125 | 126 | 127 | 128 | https://example.com/optionals/many/data-a1 129 | daily 130 | 0.7 131 | 132 | 133 | 134 | 135 | https://example.com/optionals/many/data-a1/data-b1 136 | daily 137 | 0.7 138 | 139 | 140 | 141 | 142 | -------------------------------------------------------------------------------- /src/lib/fixtures/expected-sitemap-index-subpage2.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | https://example.com/optionals/many/data-a1/data-b1/foo 9 | daily 10 | 0.7 11 | 12 | 13 | 14 | 15 | https://example.com/optionals/many/data-a2 16 | daily 17 | 0.7 18 | 19 | 20 | 21 | 22 | https://example.com/optionals/many/data-a2/data-b2 23 | daily 24 | 0.7 25 | 26 | 27 | 28 | 29 | https://example.com/optionals/many/data-a2/data-b2/foo 30 | daily 31 | 0.7 32 | 33 | 34 | 35 | 36 | https://example.com/optionals/optional-1 37 | daily 38 | 0.7 39 | 40 | 41 | 42 | 43 | https://example.com/optionals/optional-2 44 | daily 45 | 0.7 46 | 47 | 48 | 49 | 50 | https://example.com/pricing 51 | daily 52 | 0.7 53 | 54 | 55 | 56 | 57 | https://example.com/privacy 58 | daily 59 | 0.7 60 | 61 | 62 | 63 | 64 | https://example.com/signup 65 | daily 66 | 0.7 67 | 68 | 69 | 70 | 71 | https://example.com/terms 72 | daily 73 | 0.7 74 | 75 | 76 | 77 | 78 | https://example.com/zh 79 | daily 80 | 0.7 81 | 82 | 83 | 84 | 85 | https://example.com/zh/about 86 | daily 87 | 0.7 88 | 89 | 90 | 91 | 92 | https://example.com/zh/blog 93 | daily 94 | 0.7 95 | 96 | 97 | 98 | 99 | https://example.com/zh/blog/another-post 100 | daily 101 | 0.7 102 | 103 | 104 | 105 | 106 | https://example.com/zh/blog/awesome-post 107 | daily 108 | 0.7 109 | 110 | 111 | 112 | 113 | https://example.com/zh/blog/hello-world 114 | daily 115 | 0.7 116 | 117 | 118 | 119 | 120 | https://example.com/zh/blog/tag/blue 121 | daily 122 | 0.7 123 | 124 | 125 | 126 | 127 | https://example.com/zh/blog/tag/red 128 | daily 129 | 0.7 130 | 131 | 132 | 133 | 134 | https://example.com/zh/campsites/canada/toronto 135 | daily 136 | 0.7 137 | 138 | 139 | 140 | 141 | https://example.com/zh/campsites/usa/california 142 | daily 143 | 0.7 144 | 145 | 146 | 147 | 148 | -------------------------------------------------------------------------------- /src/lib/fixtures/expected-sitemap-index-subpage3.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | https://example.com/zh/campsites/usa/new-york 9 | daily 10 | 0.7 11 | 12 | 13 | 14 | 15 | https://example.com/zh/foo-path-1 16 | daily 17 | 0.7 18 | 19 | 20 | 21 | 22 | https://example.com/zh/login 23 | daily 24 | 0.7 25 | 26 | 27 | 28 | 29 | https://example.com/zh/optionals 30 | daily 31 | 0.7 32 | 33 | 34 | 35 | 36 | https://example.com/zh/optionals/many 37 | daily 38 | 0.7 39 | 40 | 41 | 42 | 43 | https://example.com/zh/optionals/many/data-a1 44 | daily 45 | 0.7 46 | 47 | 48 | 49 | 50 | https://example.com/zh/optionals/many/data-a1/data-b1 51 | daily 52 | 0.7 53 | 54 | 55 | 56 | 57 | https://example.com/zh/optionals/many/data-a1/data-b1/foo 58 | daily 59 | 0.7 60 | 61 | 62 | 63 | 64 | https://example.com/zh/optionals/many/data-a2 65 | daily 66 | 0.7 67 | 68 | 69 | 70 | 71 | https://example.com/zh/optionals/many/data-a2/data-b2 72 | daily 73 | 0.7 74 | 75 | 76 | 77 | 78 | https://example.com/zh/optionals/many/data-a2/data-b2/foo 79 | daily 80 | 0.7 81 | 82 | 83 | 84 | 85 | https://example.com/zh/optionals/optional-1 86 | daily 87 | 0.7 88 | 89 | 90 | 91 | 92 | https://example.com/zh/optionals/optional-2 93 | daily 94 | 0.7 95 | 96 | 97 | 98 | 99 | https://example.com/zh/pricing 100 | daily 101 | 0.7 102 | 103 | 104 | 105 | 106 | https://example.com/zh/privacy 107 | daily 108 | 0.7 109 | 110 | 111 | 112 | 113 | https://example.com/zh/signup 114 | daily 115 | 0.7 116 | 117 | 118 | 119 | 120 | https://example.com/zh/terms 121 | daily 122 | 0.7 123 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /src/lib/fixtures/expected-sitemap-index.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | https://example.com/sitemap1.xml 5 | 6 | 7 | https://example.com/sitemap2.xml 8 | 9 | 10 | https://example.com/sitemap3.xml 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/lib/fixtures/expected-sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | https://example.com/ 9 | daily 10 | 0.7 11 | 12 | 13 | 14 | 15 | https://example.com/about 16 | daily 17 | 0.7 18 | 19 | 20 | 21 | 22 | https://example.com/blog 23 | daily 24 | 0.7 25 | 26 | 27 | 28 | 29 | https://example.com/blog/another-post 30 | daily 31 | 0.7 32 | 33 | 34 | 35 | 36 | https://example.com/blog/awesome-post 37 | daily 38 | 0.7 39 | 40 | 41 | 42 | 43 | https://example.com/blog/hello-world 44 | daily 45 | 0.7 46 | 47 | 48 | 49 | 50 | https://example.com/blog/tag/blue 51 | daily 52 | 0.7 53 | 54 | 55 | 56 | 57 | https://example.com/blog/tag/red 58 | daily 59 | 0.7 60 | 61 | 62 | 63 | 64 | https://example.com/campsites/canada/toronto 65 | daily 66 | 0.7 67 | 68 | 69 | 70 | 71 | https://example.com/campsites/usa/california 72 | daily 73 | 0.7 74 | 75 | 76 | 77 | 78 | https://example.com/campsites/usa/new-york 79 | daily 80 | 0.7 81 | 82 | 83 | 84 | 85 | https://example.com/foo-path-1 86 | daily 87 | 0.7 88 | 89 | 90 | 91 | 92 | https://example.com/foo.pdf 93 | daily 94 | 0.7 95 | 96 | 97 | https://example.com/login 98 | daily 99 | 0.7 100 | 101 | 102 | 103 | 104 | https://example.com/markdown-md 105 | daily 106 | 0.7 107 | 108 | 109 | https://example.com/markdown-svx 110 | daily 111 | 0.7 112 | 113 | 114 | https://example.com/optionals 115 | daily 116 | 0.7 117 | 118 | 119 | 120 | 121 | https://example.com/optionals/many 122 | daily 123 | 0.7 124 | 125 | 126 | 127 | 128 | https://example.com/optionals/many/data-a1 129 | daily 130 | 0.7 131 | 132 | 133 | 134 | 135 | https://example.com/optionals/many/data-a1/data-b1 136 | daily 137 | 0.7 138 | 139 | 140 | 141 | 142 | https://example.com/optionals/many/data-a1/data-b1/foo 143 | daily 144 | 0.7 145 | 146 | 147 | 148 | 149 | https://example.com/optionals/many/data-a2 150 | daily 151 | 0.7 152 | 153 | 154 | 155 | 156 | https://example.com/optionals/many/data-a2/data-b2 157 | daily 158 | 0.7 159 | 160 | 161 | 162 | 163 | https://example.com/optionals/many/data-a2/data-b2/foo 164 | daily 165 | 0.7 166 | 167 | 168 | 169 | 170 | https://example.com/optionals/optional-1 171 | daily 172 | 0.7 173 | 174 | 175 | 176 | 177 | https://example.com/optionals/optional-2 178 | daily 179 | 0.7 180 | 181 | 182 | 183 | 184 | https://example.com/pricing 185 | daily 186 | 0.7 187 | 188 | 189 | 190 | 191 | https://example.com/privacy 192 | daily 193 | 0.7 194 | 195 | 196 | 197 | 198 | https://example.com/signup 199 | daily 200 | 0.7 201 | 202 | 203 | 204 | 205 | https://example.com/terms 206 | daily 207 | 0.7 208 | 209 | 210 | 211 | 212 | https://example.com/zh 213 | daily 214 | 0.7 215 | 216 | 217 | 218 | 219 | https://example.com/zh/about 220 | daily 221 | 0.7 222 | 223 | 224 | 225 | 226 | https://example.com/zh/blog 227 | daily 228 | 0.7 229 | 230 | 231 | 232 | 233 | https://example.com/zh/blog/another-post 234 | daily 235 | 0.7 236 | 237 | 238 | 239 | 240 | https://example.com/zh/blog/awesome-post 241 | daily 242 | 0.7 243 | 244 | 245 | 246 | 247 | https://example.com/zh/blog/hello-world 248 | daily 249 | 0.7 250 | 251 | 252 | 253 | 254 | https://example.com/zh/blog/tag/blue 255 | daily 256 | 0.7 257 | 258 | 259 | 260 | 261 | https://example.com/zh/blog/tag/red 262 | daily 263 | 0.7 264 | 265 | 266 | 267 | 268 | https://example.com/zh/campsites/canada/toronto 269 | daily 270 | 0.7 271 | 272 | 273 | 274 | 275 | https://example.com/zh/campsites/usa/california 276 | daily 277 | 0.7 278 | 279 | 280 | 281 | 282 | https://example.com/zh/campsites/usa/new-york 283 | daily 284 | 0.7 285 | 286 | 287 | 288 | 289 | https://example.com/zh/foo-path-1 290 | daily 291 | 0.7 292 | 293 | 294 | 295 | 296 | https://example.com/zh/login 297 | daily 298 | 0.7 299 | 300 | 301 | 302 | 303 | https://example.com/zh/optionals 304 | daily 305 | 0.7 306 | 307 | 308 | 309 | 310 | https://example.com/zh/optionals/many 311 | daily 312 | 0.7 313 | 314 | 315 | 316 | 317 | https://example.com/zh/optionals/many/data-a1 318 | daily 319 | 0.7 320 | 321 | 322 | 323 | 324 | https://example.com/zh/optionals/many/data-a1/data-b1 325 | daily 326 | 0.7 327 | 328 | 329 | 330 | 331 | https://example.com/zh/optionals/many/data-a1/data-b1/foo 332 | daily 333 | 0.7 334 | 335 | 336 | 337 | 338 | https://example.com/zh/optionals/many/data-a2 339 | daily 340 | 0.7 341 | 342 | 343 | 344 | 345 | https://example.com/zh/optionals/many/data-a2/data-b2 346 | daily 347 | 0.7 348 | 349 | 350 | 351 | 352 | https://example.com/zh/optionals/many/data-a2/data-b2/foo 353 | daily 354 | 0.7 355 | 356 | 357 | 358 | 359 | https://example.com/zh/optionals/optional-1 360 | daily 361 | 0.7 362 | 363 | 364 | 365 | 366 | https://example.com/zh/optionals/optional-2 367 | daily 368 | 0.7 369 | 370 | 371 | 372 | 373 | https://example.com/zh/pricing 374 | daily 375 | 0.7 376 | 377 | 378 | 379 | 380 | https://example.com/zh/privacy 381 | daily 382 | 0.7 383 | 384 | 385 | 386 | 387 | https://example.com/zh/signup 388 | daily 389 | 0.7 390 | 391 | 392 | 393 | 394 | https://example.com/zh/terms 395 | daily 396 | 0.7 397 | 398 | 399 | 400 | 401 | -------------------------------------------------------------------------------- /src/lib/fixtures/mocks.js: -------------------------------------------------------------------------------- 1 | // Mock Service Worker, to mock HTTP requests for tests. 2 | // https://mswjs.io/docs/basics/mocking-responses 3 | import fs from 'fs'; 4 | import { http } from 'msw'; 5 | import { setupServer } from 'msw/node'; 6 | 7 | const sitemap1 = fs.readFileSync('./src/lib/fixtures/expected-sitemap-index-subpage1.xml', 'utf8'); 8 | const sitemap2 = fs.readFileSync('./src/lib/fixtures/expected-sitemap-index-subpage2.xml', 'utf8'); 9 | const sitemap3 = fs.readFileSync('./src/lib/fixtures/expected-sitemap-index-subpage3.xml', 'utf8'); 10 | 11 | export const handlers = [ 12 | http.get('http://localhost:4173/sitemap1.xml', () => new Response(sitemap1)), 13 | http.get('http://localhost:4173/sitemap2.xml', () => new Response(sitemap2)), 14 | http.get('http://localhost:4173/sitemap3.xml', () => new Response(sitemap3)), 15 | ]; 16 | 17 | export const server = setupServer(...handlers); 18 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export { sampledPaths, sampledUrls } from './sampled.js'; 2 | 3 | export type { ParamValues, SitemapConfig } from './sitemap.js'; 4 | export { response } from './sitemap.js'; 5 | -------------------------------------------------------------------------------- /src/lib/sampled.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; 3 | 4 | import { server } from './fixtures/mocks.js'; 5 | import * as sitemap from './sampled.js'; 6 | 7 | beforeAll(() => server.listen()); 8 | afterEach(() => server.resetHandlers()); 9 | afterAll(() => server.close()); 10 | 11 | describe('sample.ts', () => { 12 | describe('_sampledUrls()', () => { 13 | const expectedSampledUrls = [ 14 | // static 15 | 'https://example.com/', 16 | 'https://example.com/about', 17 | 'https://example.com/blog', 18 | 'https://example.com/login', 19 | 'https://example.com/pricing', 20 | 'https://example.com/privacy', 21 | 'https://example.com/signup', 22 | 'https://example.com/terms', 23 | // dynamic 24 | 'https://example.com/blog/another-post', 25 | 'https://example.com/blog/tag/blue', 26 | 'https://example.com/campsites/canada/toronto', 27 | 'https://example.com/foo-path-1', 28 | ]; 29 | 30 | describe('sitemap', () => { 31 | it('should return expected urls', async () => { 32 | const xml = await fs.promises.readFile('./src/lib/fixtures/expected-sitemap.xml', 'utf-8'); 33 | const result = await sitemap._sampledUrls(xml); 34 | expect(result).toEqual(expectedSampledUrls); 35 | }); 36 | }); 37 | 38 | describe('sitemap index', () => { 39 | it('should return expected urls from subpages', async () => { 40 | const xml = await fs.promises.readFile( 41 | './src/lib/fixtures/expected-sitemap-index.xml', 42 | 'utf-8' 43 | ); 44 | const result = await sitemap._sampledUrls(xml); 45 | expect(result).toEqual(expectedSampledUrls); 46 | }); 47 | }); 48 | }); 49 | 50 | describe('_sampledPaths()', () => { 51 | const expectedSampledPaths = [ 52 | '/', 53 | '/about', 54 | '/blog', 55 | '/login', 56 | '/pricing', 57 | '/privacy', 58 | '/signup', 59 | '/terms', 60 | '/blog/another-post', 61 | '/blog/tag/blue', 62 | '/campsites/canada/toronto', 63 | '/foo-path-1', 64 | ]; 65 | 66 | describe('sitemap', () => { 67 | it('should return expected paths', async () => { 68 | const xml = await fs.promises.readFile('./src/lib/fixtures/expected-sitemap.xml', 'utf-8'); 69 | const result = await sitemap._sampledPaths(xml); 70 | expect(result).toEqual(expectedSampledPaths); 71 | expect(result).not.toEqual(['/dashboard', '/dashboard/settings']); 72 | }); 73 | }); 74 | 75 | describe('sitemap index', () => { 76 | it('should return expected paths', async () => { 77 | const xml = await fs.promises.readFile( 78 | './src/lib/fixtures/expected-sitemap-index.xml', 79 | 'utf-8' 80 | ); 81 | const result = await sitemap._sampledPaths(xml); 82 | expect(result).toEqual(expectedSampledPaths); 83 | expect(result).not.toEqual(['/dashboard', '/dashboard/settings']); 84 | }); 85 | }); 86 | }); 87 | 88 | describe('findFirstMatches()', () => { 89 | it('should a max of one match for each regex', () => { 90 | const patterns = new Set(['/blog/([^/]+)', '/blog/([^/]+)/([^/]+)']); 91 | const haystack = [ 92 | // static routes 93 | 'https://example.com/', 94 | 'https://example.com/blog', 95 | 96 | // /blog/[slug] 97 | 'https://example.com/blog/hello-world', 98 | 'https://example.com/blog/another-post', 99 | 100 | // /blog/tag/[tag] 101 | 'https://example.com/blog/tag/red', 102 | 'https://example.com/blog/tag/green', 103 | 'https://example.com/blog/tag/blue', 104 | 105 | // /campsites/[country]/[state] 106 | 'https://example.com/campsites/usa/new-york', 107 | 'https://example.com/campsites/usa/california', 108 | 'https://example.com/campsites/canada/ontario', 109 | ]; 110 | const result = sitemap.findFirstMatches(patterns, haystack); 111 | expect(result).toEqual( 112 | new Set(['https://example.com/blog/hello-world', 'https://example.com/blog/tag/red']) 113 | ); 114 | }); 115 | }); 116 | }); 117 | -------------------------------------------------------------------------------- /src/lib/sampled.ts: -------------------------------------------------------------------------------- 1 | import dirTree from 'directory-tree'; 2 | import { XMLParser } from 'fast-xml-parser'; 3 | 4 | import { filterRoutes } from './sitemap.js'; 5 | 6 | /** 7 | * Given the URL to this project's sitemap, _which must have been generated by 8 | * Super Sitemap for this to work as designed_, returns an array containing: 9 | * 1. the URL of every static route, and 10 | * 2. one URL for every parameterized route. 11 | * 12 | * ```js 13 | * // Example result: 14 | * [ 'http://localhost:5173/', 'http://localhost:5173/about', 'http://localhost:5173/blog', 'http://localhost:5173/blog/hello-world', 'http://localhost:5173/blog/tag/red' ] 15 | * ``` 16 | * 17 | * @public 18 | * @param sitemapUrl - E.g. http://localhost:5173/sitemap.xml 19 | * @returns Array of paths, one for each route; grouped by static, then dynamic; sub-sorted alphabetically. 20 | * 21 | * @remarks 22 | * - This is intended as a utility to gather unique URLs for SEO analysis, 23 | * functional tests for public routes, etc. 24 | * - As a utility, the design favors ease of use for the developer over runtime 25 | * performance, and consequently consumes `/sitemap.xml` directly, to avoid 26 | * the developer needing to recreate and maintain a duplicate sitemap config, 27 | * param values, exclusion rules, etc. 28 | * - LIMITATIONS: 29 | * 1. The result does not include `additionalPaths` from the sitemap config 30 | * b/c it's impossible to identify those by pattern using only the result. 31 | * 2. This does not distinguish between routes that differ only due to a 32 | * pattern matcher–e.g.`/foo/[foo]` and `/foo/[foo=integer]` will evaluated 33 | * as `/foo/[foo]` and one sample URL will be returned. 34 | */ 35 | export async function sampledUrls(sitemapUrl: string): Promise { 36 | const response = await fetch(sitemapUrl); 37 | const sitemapXml = await response.text(); 38 | return await _sampledUrls(sitemapXml); 39 | } 40 | 41 | /** 42 | * Given the URL to this project's sitemap, _which must have been generated by 43 | * Super Sitemap for this to work as designed_, returns an array containing: 44 | * 1. the path of every static route, and 45 | * 2. one path for every parameterized route. 46 | * 47 | * ```js 48 | * // Example result: 49 | * [ '/', '/about', '/blog', '/blog/hello-world', '/blog/tag/red' ] 50 | * ``` 51 | * 52 | * @public 53 | * @param sitemapUrl - E.g. http://localhost:5173/sitemap.xml 54 | * @returns Array of paths, one for each route; grouped by static, then dynamic; sub-sorted alphabetically. 55 | * 56 | * @remarks 57 | * - This is intended as a utility to gather unique paths for SEO analysis, 58 | * functional tests for public routes, etc. 59 | * - As a utility, the design favors ease of use for the developer over runtime 60 | * performance, and consequently consumes `/sitemap.xml` directly, to avoid 61 | * the developer needing to recreate and maintain a duplicate sitemap config, 62 | * param values, exclusion rules, etc. 63 | * - LIMITATIONS: 64 | * 1. The result does not include `additionalPaths` from the sitemap config 65 | * b/c it's impossible to identify those by pattern using only the result. 66 | * 2. This does not distinguish between routes that differ only due to a 67 | * pattern matcher–e.g.`/foo/[foo]` and `/foo/[foo=integer]` will evaluated 68 | * as `/foo/[foo]` and one sample path will be returned. 69 | */ 70 | export async function sampledPaths(sitemapUrl: string): Promise { 71 | const response = await fetch(sitemapUrl); 72 | const sitemapXml = await response.text(); 73 | return await _sampledPaths(sitemapXml); 74 | } 75 | 76 | /** 77 | * Given the body of this site's sitemap.xml, returns an array containing: 78 | * 1. the URL of every static (non-parameterized) route, and 79 | * 2. one URL for every parameterized route. 80 | * 81 | * @private 82 | * @param sitemapXml - The XML string of the sitemap to analyze. This must have 83 | * been created by Super Sitemap to work as designed. 84 | * @returns Array of URLs, sorted alphabetically 85 | */ 86 | export async function _sampledUrls(sitemapXml: string): Promise { 87 | const parser = new XMLParser(); 88 | const sitemap = parser.parse(sitemapXml); 89 | 90 | let urls: string[] = []; 91 | 92 | // If this is a sitemap index, fetch all sub sitemaps and combine their URLs. 93 | // Note: _sampledUrls() is intended to be used by devs within Playwright 94 | // tests. Because of this, we know what host to expect and can replace 95 | // whatever origin the dev set with localhost:4173, which is where Playwright 96 | // serves the app during testing. For unit tests, our mock.js mocks also 97 | // expect this host. 98 | if (sitemap.sitemapindex) { 99 | const subSitemapUrls = sitemap.sitemapindex.sitemap.map((obj: any) => obj.loc); 100 | for (const url of subSitemapUrls) { 101 | const path = new URL(url).pathname; 102 | const res = await fetch('http://localhost:4173' + path); 103 | const xml = await res.text(); 104 | const _sitemap = parser.parse(xml); 105 | const _urls = _sitemap.urlset.url.map((x: any) => x.loc); 106 | urls.push(..._urls); 107 | } 108 | } else { 109 | urls = sitemap.urlset.url.map((x: any) => x.loc); 110 | } 111 | 112 | // Can't use this because Playwright doesn't use Vite. 113 | // let routes = Object.keys(import.meta.glob('/src/routes/**/+page.svelte')); 114 | 115 | // Read /src/routes to build 'routes'. 116 | let routes: string[] = []; 117 | try { 118 | let projDir; 119 | 120 | const filePath = import.meta.url.slice(7); // Strip out "file://" protocol 121 | if (filePath.includes('node_modules')) { 122 | // Currently running as an npm package. 123 | projDir = filePath.split('node_modules')[0]; 124 | } else { 125 | // Currently running unit tests during dev. 126 | projDir = filePath.split('/src/')[0]; 127 | projDir += '/'; 128 | } 129 | 130 | const dirTreeRes = dirTree(projDir + 'src/routes'); 131 | routes = extractPaths(dirTreeRes); 132 | // Match +page.svelte or +page@.svelte (used to break out of a layout). 133 | //https://kit.svelte.dev/docs/advanced-routing#advanced-layouts-breaking-out-of-layouts 134 | routes = routes.filter((route) => route.match(/\+page.*\.svelte$/)); 135 | 136 | // 1. Trim everything to left of '/src/routes/' so it starts with 137 | // `src/routes/` as `filterRoutes()` expects. 138 | // 2. Remove all grouping segments. i.e. those starting with '(' and ending 139 | // with ')' 140 | const i = routes[0].indexOf('/src/routes/'); 141 | const regex = /\/\([^)]+\)/g; 142 | routes = routes.map((route) => route.slice(i).replace(regex, '')); 143 | } catch (err) { 144 | console.error('An error occurred:', err); 145 | } 146 | 147 | // Filter to reformat from file paths into site paths. The 2nd arg for 148 | // excludeRoutePatterns is empty the exclusion pattern was already applied during 149 | // generation of the sitemap. 150 | routes = filterRoutes(routes, []); 151 | 152 | // Remove any optional `/[[lang]]` prefix. We can just use the default language that 153 | // will not have this stem, for the purposes of this sampling. But ensure root 154 | // becomes '/', not an empty string. 155 | routes = routes.map((route) => { 156 | return route.replace(/\/?\[\[lang(=[a-z]+)?\]\]/, '') || '/'; 157 | }); 158 | 159 | // Separate static and dynamic routes. Remember these are _routes_ from disk 160 | // and consequently have not had any exclusion patterns applied against them, 161 | // they could contain `/about`, `/blog/[slug]`, routes that will need to be 162 | // excluded like `/dashboard`. 163 | const nonExcludedStaticRoutes = []; 164 | const nonExcludedDynamicRoutes = []; 165 | for (const route of routes) { 166 | if (/\[.*\]/.test(route)) { 167 | nonExcludedDynamicRoutes.push(route); 168 | } else { 169 | nonExcludedStaticRoutes.push(route); 170 | } 171 | } 172 | 173 | const ORIGIN = new URL(urls[0]).origin; 174 | const nonExcludedStaticRouteUrls = new Set(nonExcludedStaticRoutes.map((path) => ORIGIN + path)); 175 | 176 | // Using URLs as the source, separate into static and dynamic routes. This: 177 | // 1. Gather URLs that are static routes. We cannot use staticRoutes items 178 | // directly because it is generated from reading `/src/routes` and has not 179 | // had the dev's `excludeRoutePatterns` applied so an excluded routes like 180 | // `/dashboard` could exist within in, but _won't_ in the sitemap URLs. 181 | // 2. Removing static routes from the sitemap URLs before sampling for 182 | // dynamic paths is necessary due to SvelteKit's route specificity rules. 183 | // E.g. we remove paths like `/about` so they aren't sampled as a match for 184 | // a dynamic route like `/[foo]`. 185 | const dynamicRouteUrls = []; 186 | const staticRouteUrls = []; 187 | for (const url of urls) { 188 | if (nonExcludedStaticRouteUrls.has(url)) { 189 | staticRouteUrls.push(url); 190 | } else { 191 | dynamicRouteUrls.push(url); 192 | } 193 | } 194 | 195 | // Convert dynamic route patterns into regex patterns. 196 | // - Use Set to make unique. Duplicates may occur given we haven't applied 197 | // excludeRoutePatterns to the dynamic **routes** (e.g. `/blog/[page=integer]` 198 | // and `/blog/[slug]` both become `/blog/[^/]+`). When we sample URLs for 199 | // each of these patterns, however the excluded patterns won't exist in the 200 | // URLs from the sitemap, so it's not a problem. 201 | // - ORIGIN is required, otherwise a false match can be found when one pattern 202 | // is a subset of a another. Merely terminating with "$" is not sufficient 203 | // an overlapping subset may still be found from the end. 204 | const regexPatterns = new Set( 205 | nonExcludedDynamicRoutes.map((path) => { 206 | const regexPattern = path.replace(/\[[^\]]+\]/g, '[^/]+'); 207 | return ORIGIN + regexPattern + '$'; 208 | }) 209 | ); 210 | 211 | // Gather a max of one URL for each dynamic route's regex pattern. 212 | // - Remember, a regex pattern may exist in these routes that was excluded by 213 | // the exclusionPatterns when the sitemap was generated. This is OK because 214 | // no URLs will exist to be matched with them. 215 | const sampledDynamicUrls = findFirstMatches(regexPatterns, dynamicRouteUrls); 216 | 217 | return [...staticRouteUrls.sort(), ...Array.from(sampledDynamicUrls).sort()]; 218 | } 219 | 220 | /** 221 | * Given the body of this site's sitemap.xml, returns an array containing: 222 | * 1. the path of every static (non-parameterized) route, and 223 | * 2. one path for every parameterized route. 224 | * 225 | * @private 226 | * @param sitemapXml - The XML string of the sitemap to analyze. This must have 227 | * been created by Super Sitemap to work as designed. 228 | * @returns Array of paths, sorted alphabetically 229 | */ 230 | export async function _sampledPaths(sitemapXml: string): Promise { 231 | const urls = await _sampledUrls(sitemapXml); 232 | return urls.map((url: string) => new URL(url).pathname); 233 | } 234 | 235 | /** 236 | * Given a set of strings, return the first matching string for every regex 237 | * within a set of regex patterns. It is possible and allowed for no match to be 238 | * found for a given regex. 239 | * 240 | * @private 241 | * @param regexPatterns - Set of regex patterns to search for. 242 | * @param haystack - Array of strings to search within. 243 | * @returns Set of strings where each is the first match found for a pattern. 244 | * 245 | * @example 246 | * ```ts 247 | * const patterns = new Set(["a.*", "b.*"]); 248 | * const haystack = ["apple", "banana", "cherry"]; 249 | * const result = findFirstMatches(patterns, haystack); // Set { 'apple', 'banana' } 250 | * ``` 251 | */ 252 | export function findFirstMatches(regexPatterns: Set, haystack: string[]): Set { 253 | const firstMatches = new Set(); 254 | 255 | for (const pattern of regexPatterns) { 256 | const regex = new RegExp(pattern); 257 | 258 | for (const needle of haystack) { 259 | if (regex.test(needle)) { 260 | firstMatches.add(needle); 261 | break; 262 | } 263 | } 264 | } 265 | 266 | return firstMatches; 267 | } 268 | 269 | /** 270 | * Extracts the paths from a dirTree response and returns an array of strings 271 | * representing full disk paths to each route and directory. 272 | * - This needs to be filtered to remove items that do not end in `+page.svelte` 273 | * in order to represent routes; we do that outside of this function given 274 | * this is recursive. 275 | * 276 | * @param obj - The dirTree response object. https://www.npmjs.com/package/directory-tree 277 | * @param paths - Array of existing paths to append to (leave unspecified; used 278 | * for recursion) 279 | * @returns An array of strings representing disk paths to each route. 280 | */ 281 | export function extractPaths(obj: dirTree.DirectoryTree, paths: string[] = []): string[] { 282 | if (obj.path) { 283 | paths.push(obj.path); 284 | } 285 | 286 | if (Array.isArray(obj.children)) { 287 | for (const child of obj.children) { 288 | extractPaths(child, paths); 289 | } 290 | } 291 | 292 | return paths; 293 | } 294 | -------------------------------------------------------------------------------- /src/lib/sitemap.test.ts: -------------------------------------------------------------------------------- 1 | import { XMLValidator } from 'fast-xml-parser'; 2 | import fs from 'node:fs'; 3 | import { describe, expect, it } from 'vitest'; 4 | 5 | import type { LangConfig, PathObj, SitemapConfig } from './sitemap.js'; 6 | 7 | import * as sitemap from './sitemap.js'; 8 | 9 | describe('sitemap.ts', () => { 10 | describe('response()', async () => { 11 | const config: SitemapConfig = { 12 | additionalPaths: ['/foo.pdf'], 13 | defaultChangefreq: 'daily', 14 | excludeRoutePatterns: [ 15 | '.*/dashboard.*', 16 | '(secret-group)', 17 | 18 | // Exclude a single optional parameter; using 'optionals/to-exclude' as 19 | // the pattern would exclude both of the next 2 patterns, but I want to 20 | // test them separately. 21 | '/optionals/to-exclude/\\[\\[optional\\]\\]', 22 | '/optionals/to-exclude$', 23 | 24 | '/optionals$', 25 | 26 | // Exclude routes containing `[page=integer]`–e.g. `/blog/2` 27 | '.*\\[page=integer\\].*', 28 | ], 29 | headers: { 30 | 'custom-header': 'mars', 31 | }, 32 | origin: 'https://example.com', 33 | 34 | /* eslint-disable perfectionist/sort-objects */ 35 | paramValues: { 36 | '/[[lang]]/[foo]': ['foo-path-1'], 37 | // Optional params 38 | '/[[lang]]/optionals/[[optional]]': ['optional-1', 'optional-2'], 39 | '/[[lang]]/optionals/many/[[paramA]]': ['data-a1', 'data-a2'], 40 | '/[[lang]]/optionals/many/[[paramA]]/[[paramB]]': [ 41 | ['data-a1', 'data-b1'], 42 | ['data-a2', 'data-b2'], 43 | ], 44 | '/[[lang]]/optionals/many/[[paramA]]/[[paramB]]/foo': [ 45 | ['data-a1', 'data-b1'], 46 | ['data-a2', 'data-b2'], 47 | ], 48 | // 1D array 49 | '/[[lang]]/blog/[slug]': ['hello-world', 'another-post', 'awesome-post'], 50 | // 2D with only 1 element each 51 | // '/[[lang]]/blog/tag/[tag]': [['red'], ['blue'], ['green'], ['cyan']], 52 | '/[[lang]]/blog/tag/[tag]': [['red'], ['blue']], 53 | // 2D array 54 | '/[[lang]]/campsites/[country]/[state]': [ 55 | ['usa', 'new-york'], 56 | ['usa', 'california'], 57 | ['canada', 'toronto'], 58 | ], 59 | }, 60 | defaultPriority: 0.7, 61 | sort: 'alpha', // helps predictability of test data 62 | lang: { 63 | default: 'en', 64 | alternates: ['zh'], 65 | }, 66 | }; 67 | 68 | it('when URLs <= maxPerPage (50_000 50_000), should return a sitemap', async () => { 69 | // This test creates a sitemap based off the actual routes found within 70 | // this projects `/src/routes`, for a realistic test of: 71 | // 1. basic static pages (e.g. `/about`) 72 | // 2. multiple exclusion patterns (e.g. dashboard and pagination) 73 | // 3. groups that should be ignored (e.g. `(public)`) 74 | // 4. multiple routes with a single parameter (e.g. `/blog/[slug]` & 75 | // `/blog/tag/[tag]`) 76 | // 5. ignoring of server-side routes (e.g. `/og/blog/[title].png` and 77 | // `sitemap.xml` itself) 78 | const res = await sitemap.response(config); 79 | const resultXml = await res.text(); 80 | const expectedSitemapXml = await fs.promises.readFile( 81 | './src/lib/fixtures/expected-sitemap.xml', 82 | 'utf-8' 83 | ); 84 | expect(resultXml).toEqual(expectedSitemapXml.trim()); 85 | expect(res.headers.get('custom-header')).toEqual('mars'); 86 | }); 87 | 88 | it('when config.origin is not provided, should throw error', async () => { 89 | const newConfig = JSON.parse(JSON.stringify(config)); 90 | delete newConfig.origin; 91 | const fn = () => sitemap.response(newConfig); 92 | expect(fn()).rejects.toThrow('Sitemap: `origin` property is required in sitemap config.'); 93 | }); 94 | 95 | it('when processPaths() is provided, should process all paths through it', async () => { 96 | const newConfig = JSON.parse(JSON.stringify(config)); 97 | newConfig.processPaths = (processPaths: PathObj[]) => { 98 | const processedPaths = [ 99 | { 100 | path: '/process-paths-was-here', 101 | }, 102 | ...processPaths, 103 | ]; 104 | 105 | return processedPaths; 106 | }; 107 | const res = await sitemap.response(newConfig); 108 | const resultXml = await res.text(); 109 | 110 | // Adds a record like below, but I want this test to remain flexible and 111 | // not break if changefreq or priority are changed within the test config. 112 | // 113 | // 114 | // https://example.com/process-paths-was-here 115 | // daily 116 | // 0.7 117 | // ; 118 | expect(resultXml).toContain('https://example.com/process-paths-was-here'); 119 | }); 120 | 121 | it('should deduplicate paths objects based on value of path', async () => { 122 | const newConfig = JSON.parse(JSON.stringify(config)); 123 | newConfig.processPaths = (paths: PathObj[]) => { 124 | return [{ path: '/duplicate-path' }, { path: '/duplicate-path' }, ...paths]; 125 | }; 126 | const res = await sitemap.response(newConfig); 127 | const resultXml = await res.text(); 128 | expect( 129 | resultXml.match(/https:\/\/example\.com\/duplicate-path<\/loc>/g)?.length 130 | ).toBeLessThanOrEqual(1); 131 | }); 132 | 133 | it.todo( 134 | 'when param values are not provided for a parameterized route, should throw error', 135 | async () => { 136 | const newConfig = JSON.parse(JSON.stringify(config)); 137 | delete newConfig.paramValues['/campsites/[country]/[state]']; 138 | const fn = () => sitemap.response(newConfig); 139 | expect(fn()).rejects.toThrow( 140 | "Sitemap: paramValues not provided for: '/campsites/[country]/[state]'" 141 | ); 142 | } 143 | ); 144 | 145 | it('when param values are provided for route that does not exist, should throw error', async () => { 146 | const newConfig = JSON.parse(JSON.stringify(config)); 147 | newConfig.paramValues['/old-route/[foo]'] = ['a', 'b', 'c']; 148 | const fn = () => sitemap.response(newConfig); 149 | await expect(fn()).rejects.toThrow( 150 | "Sitemap: paramValues were provided for a route that does not exist within src/routes/: '/old-route/[foo]'. Remove this property from your paramValues." 151 | ); 152 | }); 153 | 154 | describe('sitemap index', () => { 155 | it('when URLs > maxPerPage, should return a sitemap index', async () => { 156 | config.maxPerPage = 20; 157 | const res = await sitemap.response(config); 158 | const resultXml = await res.text(); 159 | const expectedSitemapXml = await fs.promises.readFile( 160 | './src/lib/fixtures/expected-sitemap-index.xml', 161 | 'utf-8' 162 | ); 163 | expect(resultXml).toEqual(expectedSitemapXml.trim()); 164 | }); 165 | 166 | it.skip.each([ 167 | ['1', './src/lib/fixtures/expected-sitemap-index-subpage1.xml'], 168 | ['2', './src/lib/fixtures/expected-sitemap-index-subpage2.xml'], 169 | ['3', './src/lib/fixtures/expected-sitemap-index-subpage3.xml'], 170 | ])( 171 | 'subpage (e.g. sitemap%s.xml) should return a sitemap with expected URL subset', 172 | async (page, expectedFile) => { 173 | config.maxPerPage = 20; 174 | config.page = page; 175 | const res = await sitemap.response(config); 176 | const resultXml = await res.text(); 177 | const expectedSitemapXml = await fs.promises.readFile(expectedFile, 'utf-8'); 178 | expect(resultXml).toEqual(expectedSitemapXml.trim()); 179 | } 180 | ); 181 | 182 | it.each([['-3'], ['3.3'], ['invalid']])( 183 | `when page param is invalid ('%s'), should respond 400`, 184 | async (page) => { 185 | config.maxPerPage = 20; 186 | config.page = page; 187 | const res = await sitemap.response(config); 188 | expect(res.status).toEqual(400); 189 | } 190 | ); 191 | 192 | it('when page param is greater than subpages that exist, should respond 404', async () => { 193 | config.maxPerPage = 20; 194 | config.page = '999999'; 195 | const res = await sitemap.response(config); 196 | expect(res.status).toEqual(404); 197 | }); 198 | }); 199 | }); 200 | 201 | describe('generateBody()', () => { 202 | it('should generate the expected XML sitemap string with changefreq, priority, and lastmod when exists within pathObj', () => { 203 | const pathObjs: PathObj[] = [ 204 | { path: '/path1', changefreq: 'weekly', priority: 0.5, lastmod: '2024-10-01' }, 205 | { path: '/path2', changefreq: 'daily', priority: 0.6, lastmod: '2024-10-02' }, 206 | { 207 | path: '/about', 208 | changefreq: 'monthly', 209 | priority: 0.4, 210 | lastmod: '2024-10-05', 211 | alternates: [ 212 | { lang: 'en', path: '/about' }, 213 | { lang: 'de', path: '/de/about' }, 214 | { lang: 'es', path: '/es/about' }, 215 | ], 216 | }, 217 | ]; 218 | const resultXml = sitemap.generateBody('https://example.com', pathObjs); 219 | 220 | const expected = ` 221 | 222 | 226 | 227 | https://example.com/path1 228 | 2024-10-01 229 | weekly 230 | 0.5 231 | 232 | 233 | https://example.com/path2 234 | 2024-10-02 235 | daily 236 | 0.6 237 | 238 | 239 | https://example.com/about 240 | 2024-10-05 241 | monthly 242 | 0.4 243 | 244 | 245 | 246 | 247 | `.trim(); 248 | 249 | expect(resultXml).toEqual(expected); 250 | }); 251 | 252 | it('should generate XML sitemap string without changefreq and priority when no defaults are defined', () => { 253 | const pathObjs = [ 254 | { path: '/path1' }, 255 | { path: '/path2' }, 256 | { 257 | path: '/about', 258 | alternates: [ 259 | { lang: 'en', path: '/about' }, 260 | { lang: 'de', path: '/de/about' }, 261 | { lang: 'es', path: '/es/about' }, 262 | ], 263 | }, 264 | ]; 265 | const resultXml = sitemap.generateBody('https://example.com', pathObjs, undefined, undefined); 266 | 267 | const expected = ` 268 | 269 | 273 | 274 | https://example.com/path1 275 | 276 | 277 | https://example.com/path2 278 | 279 | 280 | https://example.com/about 281 | 282 | 283 | 284 | 285 | `.trim(); 286 | 287 | expect(resultXml).toEqual(expected); 288 | }); 289 | 290 | it('should return valid XML', () => { 291 | const paths = [ 292 | { path: '/path1' }, 293 | { path: '/path2' }, 294 | { 295 | path: '/about', 296 | alternates: [ 297 | { lang: 'en', path: '/about' }, 298 | { lang: 'de', path: '/de/about' }, 299 | { lang: 'es', path: '/es/about' }, 300 | ], 301 | }, 302 | ]; 303 | const resultXml = sitemap.generateBody('https://example.com', paths); 304 | const validationResult = XMLValidator.validate(resultXml); 305 | expect(validationResult).toBe(true); 306 | }); 307 | }); 308 | 309 | describe('generatePaths()', () => { 310 | it('should throw error if one or more routes contains [[lang]], but lang config not provided', async () => { 311 | // This test creates a sitemap based off the actual routes found within 312 | // this projects `/src/routes`, given generatePaths() uses 313 | // `import.meta.glob()`. 314 | const excludeRoutePatterns: string[] = []; 315 | const paramValues = {}; 316 | const fn = () => { 317 | sitemap.generatePaths(excludeRoutePatterns, paramValues, undefined, undefined, undefined); 318 | }; 319 | expect(fn).toThrowError(); 320 | }); 321 | 322 | it('should return expected result', async () => { 323 | // This test creates a sitemap based off the actual routes found within 324 | // this projects `/src/routes`, given generatePaths() uses 325 | // `import.meta.glob()`. 326 | 327 | const excludeRoutePatterns = [ 328 | '.*/dashboard.*', 329 | '(secret-group)', 330 | '(authenticated)', 331 | '/optionals/to-exclude', 332 | 333 | // Exclude routes containing `[page=integer]`–e.g. `/blog/2` 334 | '.*\\[page=integer\\].*', 335 | ]; 336 | 337 | // Provide data for parameterized routes 338 | /* eslint-disable perfectionist/sort-objects */ 339 | const paramValues = { 340 | '/[[lang]]/[foo]': ['foo-path-1'], 341 | // Optional params 342 | '/[[lang]]/optionals/[[optional]]': ['optional-1', 'optional-2'], 343 | '/[[lang]]/optionals/many/[[paramA]]': ['param-a1', 'param-a2'], 344 | '/[[lang]]/optionals/many/[[paramA]]/[[paramB]]': [ 345 | ['param-a1', 'param-b1'], 346 | ['param-a2', 'param-b2'], 347 | ], 348 | '/[[lang]]/optionals/many/[[paramA]]/[[paramB]]/foo': [ 349 | ['param-a1', 'param-b1'], 350 | ['param-a2', 'param-b2'], 351 | ], 352 | // 1D array 353 | '/[[lang]]/blog/[slug]': ['hello-world', 'another-post'], 354 | // 2D with only 1 element each 355 | '/[[lang]]/blog/tag/[tag]': [['red'], ['blue']], 356 | // 2D array 357 | '/[[lang]]/campsites/[country]/[state]': [ 358 | ['usa', 'new-york'], 359 | ['usa', 'california'], 360 | ['canada', 'toronto'], 361 | ], 362 | }; 363 | 364 | const langConfig: LangConfig = { 365 | default: 'en', 366 | alternates: ['zh'], 367 | }; 368 | const resultPaths = sitemap.generatePaths({ 369 | excludeRoutePatterns, 370 | paramValues, 371 | lang: langConfig, 372 | defaultChangefreq: undefined, 373 | defaultPriority: undefined, 374 | }); 375 | const expectedPaths = [ 376 | // prettier-ignore 377 | { 378 | path: '/markdown-md', 379 | }, 380 | { 381 | path: '/markdown-svx', 382 | }, 383 | { 384 | alternates: [ 385 | { lang: 'en', path: '/' }, 386 | { lang: 'zh', path: '/zh' }, 387 | ], 388 | path: '/', 389 | }, 390 | { 391 | alternates: [ 392 | { lang: 'en', path: '/' }, 393 | { lang: 'zh', path: '/zh' }, 394 | ], 395 | path: '/zh', 396 | }, 397 | { 398 | alternates: [ 399 | { lang: 'en', path: '/about' }, 400 | { lang: 'zh', path: '/zh/about' }, 401 | ], 402 | path: '/about', 403 | }, 404 | { 405 | alternates: [ 406 | { lang: 'en', path: '/about' }, 407 | { lang: 'zh', path: '/zh/about' }, 408 | ], 409 | path: '/zh/about', 410 | }, 411 | { 412 | alternates: [ 413 | { lang: 'en', path: '/blog' }, 414 | { lang: 'zh', path: '/zh/blog' }, 415 | ], 416 | path: '/blog', 417 | }, 418 | { 419 | alternates: [ 420 | { lang: 'en', path: '/blog' }, 421 | { lang: 'zh', path: '/zh/blog' }, 422 | ], 423 | path: '/zh/blog', 424 | }, 425 | { 426 | alternates: [ 427 | { lang: 'en', path: '/login' }, 428 | { lang: 'zh', path: '/zh/login' }, 429 | ], 430 | path: '/login', 431 | }, 432 | { 433 | alternates: [ 434 | { lang: 'en', path: '/login' }, 435 | { lang: 'zh', path: '/zh/login' }, 436 | ], 437 | path: '/zh/login', 438 | }, 439 | { 440 | alternates: [ 441 | { lang: 'en', path: '/optionals' }, 442 | { lang: 'zh', path: '/zh/optionals' }, 443 | ], 444 | path: '/optionals', 445 | }, 446 | { 447 | alternates: [ 448 | { lang: 'en', path: '/optionals' }, 449 | { lang: 'zh', path: '/zh/optionals' }, 450 | ], 451 | path: '/zh/optionals', 452 | }, 453 | { 454 | alternates: [ 455 | { lang: 'en', path: '/optionals/many' }, 456 | { lang: 'zh', path: '/zh/optionals/many' }, 457 | ], 458 | path: '/optionals/many', 459 | }, 460 | { 461 | alternates: [ 462 | { lang: 'en', path: '/optionals/many' }, 463 | { lang: 'zh', path: '/zh/optionals/many' }, 464 | ], 465 | path: '/zh/optionals/many', 466 | }, 467 | { 468 | alternates: [ 469 | { lang: 'en', path: '/pricing' }, 470 | { lang: 'zh', path: '/zh/pricing' }, 471 | ], 472 | path: '/pricing', 473 | }, 474 | { 475 | alternates: [ 476 | { lang: 'en', path: '/pricing' }, 477 | { lang: 'zh', path: '/zh/pricing' }, 478 | ], 479 | path: '/zh/pricing', 480 | }, 481 | { 482 | alternates: [ 483 | { lang: 'en', path: '/privacy' }, 484 | { lang: 'zh', path: '/zh/privacy' }, 485 | ], 486 | path: '/privacy', 487 | }, 488 | { 489 | alternates: [ 490 | { lang: 'en', path: '/privacy' }, 491 | { lang: 'zh', path: '/zh/privacy' }, 492 | ], 493 | path: '/zh/privacy', 494 | }, 495 | { 496 | alternates: [ 497 | { lang: 'en', path: '/signup' }, 498 | { lang: 'zh', path: '/zh/signup' }, 499 | ], 500 | path: '/signup', 501 | }, 502 | { 503 | alternates: [ 504 | { lang: 'en', path: '/signup' }, 505 | { lang: 'zh', path: '/zh/signup' }, 506 | ], 507 | path: '/zh/signup', 508 | }, 509 | { 510 | alternates: [ 511 | { lang: 'en', path: '/terms' }, 512 | { lang: 'zh', path: '/zh/terms' }, 513 | ], 514 | path: '/terms', 515 | }, 516 | { 517 | alternates: [ 518 | { lang: 'en', path: '/terms' }, 519 | { lang: 'zh', path: '/zh/terms' }, 520 | ], 521 | path: '/zh/terms', 522 | }, 523 | { 524 | alternates: [ 525 | { lang: 'en', path: '/foo-path-1' }, 526 | { lang: 'zh', path: '/zh/foo-path-1' }, 527 | ], 528 | path: '/foo-path-1', 529 | }, 530 | { 531 | alternates: [ 532 | { lang: 'en', path: '/foo-path-1' }, 533 | { lang: 'zh', path: '/zh/foo-path-1' }, 534 | ], 535 | path: '/zh/foo-path-1', 536 | }, 537 | { 538 | alternates: [ 539 | { lang: 'en', path: '/optionals/optional-1' }, 540 | { lang: 'zh', path: '/zh/optionals/optional-1' }, 541 | ], 542 | path: '/optionals/optional-1', 543 | }, 544 | { 545 | alternates: [ 546 | { lang: 'en', path: '/optionals/optional-1' }, 547 | { lang: 'zh', path: '/zh/optionals/optional-1' }, 548 | ], 549 | path: '/zh/optionals/optional-1', 550 | }, 551 | { 552 | alternates: [ 553 | { lang: 'en', path: '/optionals/optional-2' }, 554 | { lang: 'zh', path: '/zh/optionals/optional-2' }, 555 | ], 556 | path: '/optionals/optional-2', 557 | }, 558 | { 559 | alternates: [ 560 | { lang: 'en', path: '/optionals/optional-2' }, 561 | { lang: 'zh', path: '/zh/optionals/optional-2' }, 562 | ], 563 | path: '/zh/optionals/optional-2', 564 | }, 565 | { 566 | alternates: [ 567 | { lang: 'en', path: '/optionals/many/param-a1' }, 568 | { lang: 'zh', path: '/zh/optionals/many/param-a1' }, 569 | ], 570 | path: '/optionals/many/param-a1', 571 | }, 572 | { 573 | alternates: [ 574 | { lang: 'en', path: '/optionals/many/param-a1' }, 575 | { lang: 'zh', path: '/zh/optionals/many/param-a1' }, 576 | ], 577 | path: '/zh/optionals/many/param-a1', 578 | }, 579 | { 580 | alternates: [ 581 | { lang: 'en', path: '/optionals/many/param-a2' }, 582 | { lang: 'zh', path: '/zh/optionals/many/param-a2' }, 583 | ], 584 | path: '/optionals/many/param-a2', 585 | }, 586 | { 587 | alternates: [ 588 | { lang: 'en', path: '/optionals/many/param-a2' }, 589 | { lang: 'zh', path: '/zh/optionals/many/param-a2' }, 590 | ], 591 | path: '/zh/optionals/many/param-a2', 592 | }, 593 | { 594 | alternates: [ 595 | { lang: 'en', path: '/optionals/many/param-a1/param-b1' }, 596 | { lang: 'zh', path: '/zh/optionals/many/param-a1/param-b1' }, 597 | ], 598 | path: '/optionals/many/param-a1/param-b1', 599 | }, 600 | { 601 | alternates: [ 602 | { lang: 'en', path: '/optionals/many/param-a1/param-b1' }, 603 | { lang: 'zh', path: '/zh/optionals/many/param-a1/param-b1' }, 604 | ], 605 | path: '/zh/optionals/many/param-a1/param-b1', 606 | }, 607 | { 608 | alternates: [ 609 | { lang: 'en', path: '/optionals/many/param-a2/param-b2' }, 610 | { lang: 'zh', path: '/zh/optionals/many/param-a2/param-b2' }, 611 | ], 612 | path: '/optionals/many/param-a2/param-b2', 613 | }, 614 | { 615 | alternates: [ 616 | { lang: 'en', path: '/optionals/many/param-a2/param-b2' }, 617 | { lang: 'zh', path: '/zh/optionals/many/param-a2/param-b2' }, 618 | ], 619 | path: '/zh/optionals/many/param-a2/param-b2', 620 | }, 621 | { 622 | alternates: [ 623 | { lang: 'en', path: '/optionals/many/param-a1/param-b1/foo' }, 624 | { lang: 'zh', path: '/zh/optionals/many/param-a1/param-b1/foo' }, 625 | ], 626 | path: '/optionals/many/param-a1/param-b1/foo', 627 | }, 628 | { 629 | alternates: [ 630 | { lang: 'en', path: '/optionals/many/param-a1/param-b1/foo' }, 631 | { lang: 'zh', path: '/zh/optionals/many/param-a1/param-b1/foo' }, 632 | ], 633 | path: '/zh/optionals/many/param-a1/param-b1/foo', 634 | }, 635 | { 636 | alternates: [ 637 | { lang: 'en', path: '/optionals/many/param-a2/param-b2/foo' }, 638 | { lang: 'zh', path: '/zh/optionals/many/param-a2/param-b2/foo' }, 639 | ], 640 | path: '/optionals/many/param-a2/param-b2/foo', 641 | }, 642 | { 643 | alternates: [ 644 | { lang: 'en', path: '/optionals/many/param-a2/param-b2/foo' }, 645 | { lang: 'zh', path: '/zh/optionals/many/param-a2/param-b2/foo' }, 646 | ], 647 | path: '/zh/optionals/many/param-a2/param-b2/foo', 648 | }, 649 | { 650 | alternates: [ 651 | { lang: 'en', path: '/blog/hello-world' }, 652 | { lang: 'zh', path: '/zh/blog/hello-world' }, 653 | ], 654 | path: '/blog/hello-world', 655 | }, 656 | { 657 | alternates: [ 658 | { lang: 'en', path: '/blog/hello-world' }, 659 | { lang: 'zh', path: '/zh/blog/hello-world' }, 660 | ], 661 | path: '/zh/blog/hello-world', 662 | }, 663 | { 664 | alternates: [ 665 | { lang: 'en', path: '/blog/another-post' }, 666 | { lang: 'zh', path: '/zh/blog/another-post' }, 667 | ], 668 | path: '/blog/another-post', 669 | }, 670 | { 671 | alternates: [ 672 | { lang: 'en', path: '/blog/another-post' }, 673 | { lang: 'zh', path: '/zh/blog/another-post' }, 674 | ], 675 | path: '/zh/blog/another-post', 676 | }, 677 | { 678 | alternates: [ 679 | { lang: 'en', path: '/blog/tag/red' }, 680 | { lang: 'zh', path: '/zh/blog/tag/red' }, 681 | ], 682 | path: '/blog/tag/red', 683 | }, 684 | { 685 | alternates: [ 686 | { lang: 'en', path: '/blog/tag/red' }, 687 | { lang: 'zh', path: '/zh/blog/tag/red' }, 688 | ], 689 | path: '/zh/blog/tag/red', 690 | }, 691 | { 692 | alternates: [ 693 | { lang: 'en', path: '/blog/tag/blue' }, 694 | { lang: 'zh', path: '/zh/blog/tag/blue' }, 695 | ], 696 | path: '/blog/tag/blue', 697 | }, 698 | { 699 | alternates: [ 700 | { lang: 'en', path: '/blog/tag/blue' }, 701 | { lang: 'zh', path: '/zh/blog/tag/blue' }, 702 | ], 703 | path: '/zh/blog/tag/blue', 704 | }, 705 | { 706 | alternates: [ 707 | { lang: 'en', path: '/campsites/usa/new-york' }, 708 | { lang: 'zh', path: '/zh/campsites/usa/new-york' }, 709 | ], 710 | path: '/campsites/usa/new-york', 711 | }, 712 | { 713 | alternates: [ 714 | { lang: 'en', path: '/campsites/usa/new-york' }, 715 | { lang: 'zh', path: '/zh/campsites/usa/new-york' }, 716 | ], 717 | path: '/zh/campsites/usa/new-york', 718 | }, 719 | { 720 | alternates: [ 721 | { lang: 'en', path: '/campsites/usa/california' }, 722 | { lang: 'zh', path: '/zh/campsites/usa/california' }, 723 | ], 724 | path: '/campsites/usa/california', 725 | }, 726 | { 727 | alternates: [ 728 | { lang: 'en', path: '/campsites/usa/california' }, 729 | { lang: 'zh', path: '/zh/campsites/usa/california' }, 730 | ], 731 | path: '/zh/campsites/usa/california', 732 | }, 733 | { 734 | alternates: [ 735 | { lang: 'en', path: '/campsites/canada/toronto' }, 736 | { lang: 'zh', path: '/zh/campsites/canada/toronto' }, 737 | ], 738 | path: '/campsites/canada/toronto', 739 | }, 740 | { 741 | alternates: [ 742 | { lang: 'en', path: '/campsites/canada/toronto' }, 743 | { lang: 'zh', path: '/zh/campsites/canada/toronto' }, 744 | ], 745 | path: '/zh/campsites/canada/toronto', 746 | }, 747 | ]; 748 | 749 | expect(resultPaths).toEqual(expectedPaths); 750 | }); 751 | }); 752 | 753 | describe('filterRoutes()', () => { 754 | it('should filter routes correctly', () => { 755 | const routes = [ 756 | '/src/routes/(marketing)/(home)/+page.svelte', 757 | '/src/routes/(marketing)/about/+page.svelte', 758 | '/src/routes/(marketing)/blog/(index)/+page.svelte', 759 | '/src/routes/(marketing)/blog/(index)/[page=integer]/+page.svelte', 760 | '/src/routes/(marketing)/blog/[slug]/+page.svelte', 761 | '/src/routes/(marketing)/blog/tag/[tag]/+page.svelte', 762 | '/src/routes/(marketing)/blog/tag/[tag]/[page=integer]/+page.svelte', 763 | '/src/routes/(marketing)/do-not-remove-this-dashboard-occurrence/+page.svelte', 764 | '/src/routes/(marketing)/login/+page.svelte', 765 | '/src/routes/(marketing)/pricing/+page.svelte', 766 | '/src/routes/(marketing)/privacy/+page.svelte', 767 | '/src/routes/(marketing)/signup/+page.svelte', 768 | '/src/routes/(marketing)/support/+page.svelte', 769 | '/src/routes/(marketing)/terms/+page@.svelte', 770 | '/src/routes/(marketing)/foo/[[paramA]]/+page.svelte', 771 | '/src/routes/dashboard/(index)/+page.svelte', 772 | '/src/routes/dashboard/settings/+page.svelte', 773 | '/src/routes/(authenticated)/hidden/+page.svelte', 774 | '/src/routes/(test-non-aplhanumeric-group-name)/test-group/+page.svelte', 775 | '/src/routes/(public)/markdown-md/+page.md', 776 | '/src/routes/(public)/markdown-svx/+page.svx', 777 | ]; 778 | 779 | const excludeRoutePatterns = [ 780 | '^/dashboard.*', 781 | '(authenticated)', 782 | 783 | // Exclude all routes that contain [page=integer], e.g. `/blog/2` 784 | '.*\\[page\\=integer\\].*', 785 | ]; 786 | 787 | const expectedResult = [ 788 | '/', 789 | '/about', 790 | '/blog', 791 | '/blog/[slug]', 792 | '/blog/tag/[tag]', 793 | '/do-not-remove-this-dashboard-occurrence', 794 | '/foo/[[paramA]]', 795 | '/login', 796 | '/markdown-md', 797 | '/markdown-svx', 798 | '/pricing', 799 | '/privacy', 800 | '/signup', 801 | '/support', 802 | '/terms', 803 | '/test-group', 804 | ]; 805 | 806 | const result = sitemap.filterRoutes(routes, excludeRoutePatterns); 807 | expect(result).toEqual(expectedResult); 808 | }); 809 | }); 810 | 811 | describe('generatePathsWithParamValues()', () => { 812 | const routes = [ 813 | '/', 814 | '/about', 815 | '/pricing', 816 | '/blog', 817 | '/blog/[slug]', 818 | '/blog/tag/[tag]', 819 | '/campsites/[country]/[state]', 820 | '/optionals/[[optional]]', 821 | ]; 822 | const paramValues = { 823 | '/optionals/[[optional]]': ['optional-1', 'optional-2'], 824 | 825 | // 1D array 826 | '/blog/[slug]': ['hello-world', 'another-post'], 827 | // 2D with only 1 element each 828 | '/blog/tag/[tag]': [['red'], ['blue'], ['green']], 829 | // 2D array 830 | '/campsites/[country]/[state]': [ 831 | ['usa', 'new-york'], 832 | ['usa', 'california'], 833 | ['canada', 'toronto'], 834 | ], 835 | }; 836 | 837 | it('should build parameterized paths and remove the original tokenized route(s)', () => { 838 | const expectedPathsWithoutLang = [ 839 | { path: '/' }, 840 | { path: '/about' }, 841 | { path: '/pricing' }, 842 | { path: '/blog' }, 843 | { path: '/optionals/optional-1' }, 844 | { path: '/optionals/optional-2' }, 845 | { path: '/blog/hello-world' }, 846 | { path: '/blog/another-post' }, 847 | { path: '/blog/tag/red' }, 848 | { path: '/blog/tag/blue' }, 849 | { path: '/blog/tag/green' }, 850 | { path: '/campsites/usa/new-york' }, 851 | { path: '/campsites/usa/california' }, 852 | { path: '/campsites/canada/toronto' }, 853 | ]; 854 | 855 | const { pathsWithLang, pathsWithoutLang } = sitemap.generatePathsWithParamValues( 856 | routes, 857 | paramValues, 858 | undefined, 859 | undefined 860 | ); 861 | expect(pathsWithoutLang).toEqual(expectedPathsWithoutLang); 862 | expect(pathsWithLang).toEqual([]); 863 | }); 864 | 865 | it('should return routes unchanged, when no tokenized routes exist & given no paramValues', () => { 866 | const routes = ['/', '/about', '/pricing', '/blog']; 867 | const paramValues = {}; 868 | 869 | const { pathsWithLang, pathsWithoutLang } = sitemap.generatePathsWithParamValues( 870 | routes, 871 | paramValues, 872 | undefined, 873 | undefined 874 | ); 875 | expect(pathsWithLang).toEqual([]); 876 | expect(pathsWithoutLang).toEqual(routes.map((path) => ({ path }))); 877 | }); 878 | 879 | it('should throw error, when paramValues contains data for a route that no longer exists', () => { 880 | const routes = ['/', '/about', '/pricing', '/blog']; 881 | 882 | const result = () => { 883 | sitemap.generatePathsWithParamValues(routes, paramValues, undefined, undefined); 884 | }; 885 | expect(result).toThrow(Error); 886 | }); 887 | 888 | it('should throw error, when tokenized routes exist that are not given data via paramValues', () => { 889 | const routes = ['/', '/about', '/blog', '/products/[product]']; 890 | const paramValues = {}; 891 | 892 | const result = () => { 893 | sitemap.generatePathsWithParamValues(routes, paramValues, undefined, undefined); 894 | }; 895 | expect(result).toThrow(Error); 896 | }); 897 | }); 898 | 899 | describe('generateSitemapIndex()', () => { 900 | it('should generate sitemap index with correct number of pages', () => { 901 | const origin = 'https://example.com'; 902 | const pages = 3; 903 | const expectedSitemapIndex = ` 904 | 905 | 906 | https://example.com/sitemap1.xml 907 | 908 | 909 | https://example.com/sitemap2.xml 910 | 911 | 912 | https://example.com/sitemap3.xml 913 | 914 | `; 915 | 916 | const sitemapIndex = sitemap.generateSitemapIndex(origin, pages); 917 | expect(sitemapIndex).toEqual(expectedSitemapIndex); 918 | }); 919 | }); 920 | 921 | describe('processRoutesForOptionalParams()', () => { 922 | it('should process routes with optional parameters correctly', () => { 923 | const routes = [ 924 | '/foo/[[paramA]]', 925 | '/foo/bar/[paramB]/[[paramC]]/[[paramD]]', 926 | '/product/[id]', 927 | '/other', 928 | ]; 929 | const expected = [ 930 | // route 0 931 | '/foo', 932 | '/foo/[[paramA]]', 933 | // route 1 934 | '/foo/bar/[paramB]', 935 | '/foo/bar/[paramB]/[[paramC]]', 936 | '/foo/bar/[paramB]/[[paramC]]/[[paramD]]', 937 | // route 2 938 | '/product/[id]', 939 | // route 3 940 | '/other', 941 | ]; 942 | 943 | const result = sitemap.processRoutesForOptionalParams(routes); 944 | expect(result).toEqual(expected); 945 | }); 946 | 947 | it('when /[[lang]] exists, should process routes with optional parameters correctly', () => { 948 | const routes = [ 949 | '/[[lang]]', 950 | '/[[lang]]/foo/[[paramA]]', 951 | '/[[lang]]/foo/bar/[paramB]/[[paramC]]/[[paramD]]', 952 | '/[[lang]]/product/[id]', 953 | '/[[lang]]/other', 954 | ]; 955 | const expected = [ 956 | '/[[lang]]', 957 | // route 0 958 | '/[[lang]]/foo', 959 | '/[[lang]]/foo/[[paramA]]', 960 | // route 1 961 | '/[[lang]]/foo/bar/[paramB]', 962 | '/[[lang]]/foo/bar/[paramB]/[[paramC]]', 963 | '/[[lang]]/foo/bar/[paramB]/[[paramC]]/[[paramD]]', 964 | // route 2 965 | '/[[lang]]/product/[id]', 966 | // route 3 967 | '/[[lang]]/other', 968 | ]; 969 | 970 | const result = sitemap.processRoutesForOptionalParams(routes); 971 | expect(result).toEqual(expected); 972 | }); 973 | 974 | it('when /[lang] exists, should process routes with optional parameters correctly', () => { 975 | const routes = [ 976 | '/[lang=lang]', 977 | '/[lang]/foo/[[paramA]]', 978 | '/[lang]/foo/bar/[paramB]/[[paramC]]/[[paramD]]', 979 | '/[lang]/product/[id]', 980 | '/[lang]/other', 981 | ]; 982 | const expected = [ 983 | '/[lang=lang]', 984 | // route 0 985 | '/[lang]/foo', 986 | '/[lang]/foo/[[paramA]]', 987 | // route 1 988 | '/[lang]/foo/bar/[paramB]', 989 | '/[lang]/foo/bar/[paramB]/[[paramC]]', 990 | '/[lang]/foo/bar/[paramB]/[[paramC]]/[[paramD]]', 991 | // route 2 992 | '/[lang]/product/[id]', 993 | // route 3 994 | '/[lang]/other', 995 | ]; 996 | 997 | const result = sitemap.processRoutesForOptionalParams(routes); 998 | expect(result).toEqual(expected); 999 | }); 1000 | }); 1001 | 1002 | describe('processOptionalParams()', () => { 1003 | const testData = [ 1004 | { 1005 | input: '/[[lang]]/products/other/[[optional]]/[[optionalB]]/more', 1006 | expected: [ 1007 | '/[[lang]]/products/other', 1008 | '/[[lang]]/products/other/[[optional]]', 1009 | '/[[lang]]/products/other/[[optional]]/[[optionalB]]', 1010 | '/[[lang]]/products/other/[[optional]]/[[optionalB]]/more', 1011 | ], 1012 | }, 1013 | { 1014 | input: '/foo/[[paramA]]', 1015 | expected: ['/foo', '/foo/[[paramA]]'], 1016 | }, 1017 | { 1018 | input: '/foo/[[paramA]]/[[paramB]]', 1019 | expected: ['/foo', '/foo/[[paramA]]', '/foo/[[paramA]]/[[paramB]]'], 1020 | }, 1021 | { 1022 | input: '/foo/bar/[paramB]/[[paramC]]/[[paramD]]', 1023 | expected: [ 1024 | '/foo/bar/[paramB]', 1025 | '/foo/bar/[paramB]/[[paramC]]', 1026 | '/foo/bar/[paramB]/[[paramC]]/[[paramD]]', 1027 | ], 1028 | }, 1029 | { 1030 | input: '/foo/[[paramA]]/[[paramB]]/[[paramC]]', 1031 | expected: [ 1032 | '/foo', 1033 | '/foo/[[paramA]]', 1034 | '/foo/[[paramA]]/[[paramB]]', 1035 | '/foo/[[paramA]]/[[paramB]]/[[paramC]]', 1036 | ], 1037 | }, 1038 | { 1039 | input: '/[[bar]]', 1040 | expected: ['/', '/[[bar]]'], 1041 | }, 1042 | { 1043 | input: '/[[lang]]', 1044 | expected: ['/[[lang]]'], 1045 | }, 1046 | // Special case b/c first param is [[lang]], followed by an optional param 1047 | { 1048 | input: '/[[lang]]/[[bar]]', 1049 | expected: ['/[[lang]]', '/[[lang]]/[[bar]]'], 1050 | }, 1051 | { 1052 | input: '/[[lang]]/[foo]/[[bar]]', 1053 | expected: ['/[[lang]]/[foo]', '/[[lang]]/[foo]/[[bar]]'], 1054 | }, 1055 | ]; 1056 | 1057 | // Running the tests 1058 | for (const { input, expected } of testData) { 1059 | it(`should create all versions of a route containing >=1 optional param, given: "${input}"`, () => { 1060 | const result = sitemap.processOptionalParams(input); 1061 | expect(result).toEqual(expected); 1062 | }); 1063 | } 1064 | }); 1065 | 1066 | describe('generatePathsWithlang()', () => { 1067 | const paths = [ 1068 | { path: '/[[lang]]' }, 1069 | { path: '/[[lang]]/about' }, 1070 | { path: '/[[lang]]/foo/something' }, 1071 | ]; 1072 | const langConfig: LangConfig = { 1073 | default: 'en', 1074 | alternates: ['de', 'es'], 1075 | }; 1076 | 1077 | it('should return expected objects for all paths', () => { 1078 | const result = sitemap.processPathsWithLang(paths, langConfig); 1079 | const expectedRootAlternates = [ 1080 | { lang: 'en', path: '/' }, 1081 | { lang: 'de', path: '/de' }, 1082 | { lang: 'es', path: '/es' }, 1083 | ]; 1084 | const expectedAboutAlternates = [ 1085 | { lang: 'en', path: '/about' }, 1086 | { lang: 'de', path: '/de/about' }, 1087 | { lang: 'es', path: '/es/about' }, 1088 | ]; 1089 | const expectedFooAlternates = [ 1090 | { lang: 'en', path: '/foo/something' }, 1091 | { lang: 'de', path: '/de/foo/something' }, 1092 | { lang: 'es', path: '/es/foo/something' }, 1093 | ]; 1094 | const expected = [ 1095 | { 1096 | path: '/', 1097 | alternates: expectedRootAlternates, 1098 | }, 1099 | { 1100 | path: '/de', 1101 | alternates: expectedRootAlternates, 1102 | }, 1103 | { 1104 | path: '/es', 1105 | alternates: expectedRootAlternates, 1106 | }, 1107 | { 1108 | path: '/about', 1109 | alternates: expectedAboutAlternates, 1110 | }, 1111 | { 1112 | path: '/de/about', 1113 | alternates: expectedAboutAlternates, 1114 | }, 1115 | { 1116 | path: '/es/about', 1117 | alternates: expectedAboutAlternates, 1118 | }, 1119 | { 1120 | path: '/foo/something', 1121 | alternates: expectedFooAlternates, 1122 | }, 1123 | { 1124 | path: '/de/foo/something', 1125 | alternates: expectedFooAlternates, 1126 | }, 1127 | { 1128 | path: '/es/foo/something', 1129 | alternates: expectedFooAlternates, 1130 | }, 1131 | ]; 1132 | expect(result).toEqual(expected); 1133 | }); 1134 | }); 1135 | 1136 | describe('generatePathsWithLang()', () => { 1137 | const pathObjs: PathObj[] = [ 1138 | { path: '/[lang]' }, 1139 | { path: '/[lang]/about' }, 1140 | { path: '/[lang]/foo/something' }, 1141 | ]; 1142 | const langConfig: LangConfig = { 1143 | default: 'en', 1144 | alternates: ['de', 'es'], 1145 | }; 1146 | 1147 | it('should return expected objects for all paths', () => { 1148 | const result = sitemap.processPathsWithLang(pathObjs, langConfig); 1149 | const expectedRootAlternates = [ 1150 | { lang: 'en', path: '/en' }, 1151 | { lang: 'de', path: '/de' }, 1152 | { lang: 'es', path: '/es' }, 1153 | ]; 1154 | const expectedAboutAlternates = [ 1155 | { lang: 'en', path: '/en/about' }, 1156 | { lang: 'de', path: '/de/about' }, 1157 | { lang: 'es', path: '/es/about' }, 1158 | ]; 1159 | const expectedFooAlternates = [ 1160 | { lang: 'en', path: '/en/foo/something' }, 1161 | { lang: 'de', path: '/de/foo/something' }, 1162 | { lang: 'es', path: '/es/foo/something' }, 1163 | ]; 1164 | const expected = [ 1165 | { 1166 | path: '/en', 1167 | alternates: expectedRootAlternates, 1168 | }, 1169 | { 1170 | path: '/de', 1171 | alternates: expectedRootAlternates, 1172 | }, 1173 | { 1174 | path: '/es', 1175 | alternates: expectedRootAlternates, 1176 | }, 1177 | { 1178 | path: '/en/about', 1179 | alternates: expectedAboutAlternates, 1180 | }, 1181 | { 1182 | path: '/de/about', 1183 | alternates: expectedAboutAlternates, 1184 | }, 1185 | { 1186 | path: '/es/about', 1187 | alternates: expectedAboutAlternates, 1188 | }, 1189 | { 1190 | path: '/en/foo/something', 1191 | alternates: expectedFooAlternates, 1192 | }, 1193 | { 1194 | path: '/de/foo/something', 1195 | alternates: expectedFooAlternates, 1196 | }, 1197 | { 1198 | path: '/es/foo/something', 1199 | alternates: expectedFooAlternates, 1200 | }, 1201 | ]; 1202 | expect(result).toEqual(expected); 1203 | }); 1204 | }); 1205 | 1206 | describe('deduplicatePaths()', () => { 1207 | it('should remove duplicate paths', () => { 1208 | const paths = [ 1209 | { path: '/path1' }, 1210 | { path: '/path2' }, 1211 | { path: '/path1' }, 1212 | { path: '/path3' }, 1213 | ]; 1214 | const expected = [{ path: '/path1' }, { path: '/path2' }, { path: '/path3' }]; 1215 | expect(sitemap.deduplicatePaths(paths)).toEqual(expected); 1216 | }); 1217 | }); 1218 | 1219 | describe('generateAdditionalPaths()', () => { 1220 | it('should normalize additionalPaths to ensure each starts with a forward slash', () => { 1221 | const additionalPaths = ['/foo', 'bar', '/baz']; 1222 | const expected = [ 1223 | { path: '/foo', lastmod: undefined, changefreq: 'monthly', priority: 0.6 }, 1224 | { path: '/bar', lastmod: undefined, changefreq: 'monthly', priority: 0.6 }, 1225 | { path: '/baz', lastmod: undefined, changefreq: 'monthly', priority: 0.6 }, 1226 | ]; 1227 | expect( 1228 | sitemap.generateAdditionalPaths({ 1229 | additionalPaths, 1230 | defaultChangefreq: 'monthly', 1231 | defaultPriority: 0.6, 1232 | }) 1233 | ).toEqual(expected); 1234 | }); 1235 | }); 1236 | }); 1237 | -------------------------------------------------------------------------------- /src/lib/sitemap.ts: -------------------------------------------------------------------------------- 1 | export type Changefreq = 'always' | 'daily' | 'hourly' | 'monthly' | 'never' | 'weekly' | 'yearly'; 2 | 3 | /* eslint-disable perfectionist/sort-object-types */ 4 | export type ParamValue = { 5 | values: string[]; 6 | lastmod?: string; 7 | priority?: Priority; 8 | changefreq?: Changefreq; 9 | }; 10 | 11 | /* eslint-disable perfectionist/sort-object-types */ 12 | export type ParamValues = Record; 13 | 14 | export type Priority = 0.0 | 0.1 | 0.2 | 0.3 | 0.4 | 0.5 | 0.6 | 0.7 | 0.8 | 0.9 | 1.0; 15 | 16 | /* eslint-disable perfectionist/sort-object-types */ 17 | export type SitemapConfig = { 18 | additionalPaths?: [] | string[]; 19 | excludeRoutePatterns?: [] | string[]; 20 | headers?: Record; 21 | lang?: { 22 | default: string; 23 | alternates: string[]; 24 | }; 25 | maxPerPage?: number; 26 | origin: string; 27 | page?: string; 28 | 29 | /** 30 | * Parameter values for dynamic routes, where the values can be: 31 | * - `string[]` 32 | * - `string[][]` 33 | * - `ParamValueObj[]` 34 | */ 35 | paramValues?: ParamValues; 36 | 37 | /** 38 | * Optional. Default changefreq, when not specified within a route's `paramValues` objects. 39 | * Omitting from sitemap config will omit changefreq from all sitemap entries except 40 | * those where you set `changefreq` property with a route's `paramValues` objects. 41 | */ 42 | defaultChangefreq?: Changefreq; 43 | 44 | /** 45 | * Optional. Default priority, when not specified within a route's `paramValues` objects. 46 | * Omitting from sitemap config will omit priority from all sitemap entries except 47 | * those where you set `priority` property with a route's `paramValues` objects. 48 | */ 49 | defaultPriority?: Priority; 50 | 51 | processPaths?: (paths: PathObj[]) => PathObj[]; 52 | sort?: 'alpha' | false; 53 | }; 54 | 55 | export type LangConfig = { 56 | default: string; 57 | alternates: string[]; 58 | }; 59 | 60 | export type Alternate = { 61 | lang: string; 62 | path: string; 63 | }; 64 | 65 | export type PathObj = { 66 | path: string; 67 | lastmod?: string; // ISO 8601 datetime 68 | changefreq?: Changefreq; 69 | priority?: Priority; 70 | alternates?: Alternate[]; 71 | }; 72 | 73 | const langRegex = /\/?\[(\[lang(=[a-z]+)?\]|lang(=[a-z]+)?)\]/; 74 | const langRegexNoPath = /\[(\[lang(=[a-z]+)?\]|lang(=[a-z]+)?)\]/; 75 | 76 | /** 77 | * Generates an HTTP response containing an XML sitemap. 78 | * 79 | * @public 80 | * @remarks Default headers set 1h CDN cache & no browser cache. 81 | * 82 | * @param config - Config object. 83 | * @param config.origin - Required. Origin URL. E.g. `https://example.com`. No trailing slash 84 | * @param config.excludeRoutePatterns - Optional. Array of regex patterns for routes to exclude. 85 | * @param config.paramValues - Optional. Object of parameter values. See format in example below. 86 | * @param config.additionalPaths - Optional. Array of paths to include manually. E.g. `/foo.pdf` in your `static` directory. 87 | * @param config.headers - Optional. Custom headers. Case insensitive. 88 | * @param config.defaultChangefreq - Optional. Default `changefreq` value to use for all paths. Omit this property to not use a default value. 89 | * @param config.defaultPriority - Optional. Default `priority` value to use for all paths. Omit this property to not use a default value. 90 | * @param config.processPaths - Optional. Callback function to arbitrarily process path objects. 91 | * @param config.sort - Optional. Default is `false` and groups paths as static paths (sorted), dynamic paths (unsorted), and then additional paths (unsorted). `alpha` sorts all paths alphabetically. 92 | * @param config.maxPerPage - Optional. Default is `50_000`, as specified in https://www.sitemaps.org/protocol.html If you have more than this, a sitemap index will be created automatically. 93 | * @param config.page - Optional, but when using a route like `sitemap[[page]].xml to support automatic sitemap indexes. The `page` URL param. 94 | * @returns An HTTP response containing the generated XML sitemap. 95 | * 96 | * @example 97 | * 98 | * ```js 99 | * return await sitemap.response({ 100 | * origin: 'https://example.com', 101 | * excludeRoutePatterns: [ 102 | * '^/dashboard.*', 103 | * `.*\\[page=integer\\].*` 104 | * ], 105 | * paramValues: { 106 | * '/blog/[slug]': ['hello-world', 'another-post'] 107 | * '/campsites/[country]/[state]': [ 108 | * ['usa', 'new-york'], 109 | * ['usa', 'california'], 110 | * ['canada', 'toronto'] 111 | * ], 112 | * '/athlete-rankings/[country]/[state]': [ 113 | * { 114 | * values: ['usa', 'new-york'], 115 | * lastmod: '2025-01-01', 116 | * changefreq: 'daily', 117 | * priority: 0.5, 118 | * }, 119 | * { 120 | * values: ['usa', 'california'], 121 | * lastmod: '2025-01-01', 122 | * changefreq: 'daily', 123 | * priority: 0.5, 124 | * }, 125 | * ], 126 | * }, 127 | * additionalPaths: ['/foo.pdf'], 128 | * headers: { 129 | * 'Custom-Header': 'blazing-fast' 130 | * }, 131 | * changefreq: 'daily', 132 | * priority: 0.7, 133 | * sort: 'alpha' 134 | * }); 135 | * ``` 136 | */ 137 | export async function response({ 138 | additionalPaths = [], 139 | defaultChangefreq, 140 | defaultPriority, 141 | excludeRoutePatterns, 142 | headers = {}, 143 | lang, 144 | maxPerPage = 50_000, 145 | origin, 146 | page, 147 | paramValues, 148 | processPaths, 149 | sort = false, 150 | }: SitemapConfig): Promise { 151 | // Cause a 500 error for visibility 152 | if (!origin) { 153 | throw new Error('Sitemap: `origin` property is required in sitemap config.'); 154 | } 155 | 156 | let paths = [ 157 | ...generatePaths({ 158 | defaultChangefreq, 159 | defaultPriority, 160 | excludeRoutePatterns, 161 | lang, 162 | paramValues, 163 | }), 164 | ...generateAdditionalPaths({ 165 | additionalPaths, 166 | defaultChangefreq, 167 | defaultPriority, 168 | }), 169 | ]; 170 | 171 | if (processPaths) { 172 | paths = processPaths(paths); 173 | } 174 | 175 | paths = deduplicatePaths(paths); 176 | 177 | if (sort === 'alpha') { 178 | paths.sort((a, b) => a.path.localeCompare(b.path)); 179 | } 180 | 181 | const totalPages = Math.ceil(paths.length / maxPerPage); 182 | 183 | let body: string; 184 | if (!page) { 185 | // User is visiting `/sitemap.xml` or `/sitemap[[page]].xml` without page. 186 | if (paths.length <= maxPerPage) { 187 | body = generateBody(origin, paths); 188 | } else { 189 | body = generateSitemapIndex(origin, totalPages); 190 | } 191 | } else { 192 | // User is visiting a sitemap index's subpage–e.g. `sitemap[[page]].xml`. 193 | 194 | // Ensure `page` param is numeric. We do it this way to avoid needing to 195 | // instruct devs to create a route matcher, to ease set up for best DX. 196 | if (!/^[1-9]\d*$/.test(page)) { 197 | return new Response('Invalid page param', { status: 400 }); 198 | } 199 | 200 | const pageInt = Number(page); 201 | if (pageInt > totalPages) { 202 | return new Response('Page does not exist', { status: 404 }); 203 | } 204 | 205 | const pathsOnThisPage = paths.slice((pageInt - 1) * maxPerPage, pageInt * maxPerPage); 206 | body = generateBody(origin, pathsOnThisPage); 207 | } 208 | 209 | // Merge keys case-insensitive; custom headers take precedence over defaults. 210 | const newHeaders = { 211 | 'cache-control': 'max-age=0, s-maxage=3600', // 1h CDN cache 212 | 'content-type': 'application/xml', 213 | ...Object.fromEntries( 214 | Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]) 215 | ), 216 | }; 217 | 218 | return new Response(body, { headers: newHeaders }); 219 | } 220 | 221 | /** 222 | * Generates an XML response body based on the provided paths, using sitemap 223 | * structure from https://kit.svelte.dev/docs/seo#manual-setup-sitemaps. 224 | * 225 | * @private 226 | * @remarks 227 | * - Based on https://kit.svelte.dev/docs/seo#manual-setup-sitemaps 228 | * - Google ignores changefreq and priority, but we support these optionally. 229 | * - TODO We could consider adding `` with an ISO 8601 datetime, but 230 | * not worrying about this for now. 231 | * https://developers.google.com/search/blog/2014/10/best-practices-for-xml-sitemaps-rssatom 232 | * 233 | * @param origin - The origin URL. E.g. `https://example.com`. No trailing slash 234 | * because "/" is the index page. 235 | * @param pathObjs - Array of path objects to include in the sitemap. Each path within it should 236 | * start with a '/'; but if not, it will be added. 237 | * @returns The generated XML sitemap. 238 | */ 239 | export function generateBody(origin: string, pathObjs: PathObj[]): string { 240 | const urlElements = pathObjs 241 | .map((pathObj) => { 242 | const { alternates, changefreq, lastmod, path, priority } = pathObj; 243 | 244 | let url = '\n \n'; 245 | url += ` ${origin}${path}\n`; 246 | url += lastmod ? ` ${lastmod}\n` : ''; 247 | url += changefreq ? ` ${changefreq}\n` : ''; 248 | url += priority ? ` ${priority}\n` : ''; 249 | 250 | if (alternates) { 251 | url += alternates 252 | .map( 253 | ({ lang, path }) => 254 | ` \n` 255 | ) 256 | .join(''); 257 | } 258 | 259 | url += ' '; 260 | 261 | return url; 262 | }) 263 | .join(''); 264 | 265 | return ` 266 | ${urlElements} 270 | `; 271 | } 272 | 273 | /** 274 | * Generates a sitemap index XML string. 275 | * 276 | * @private 277 | * @param origin - The origin URL. E.g. `https://example.com`. No trailing slash. 278 | * @param pages - The number of sitemap pages to include in the index. 279 | * @returns The generated XML sitemap index. 280 | */ 281 | export function generateSitemapIndex(origin: string, pages: number): string { 282 | let str = ` 283 | `; 284 | 285 | for (let i = 1; i <= pages; i++) { 286 | str += ` 287 | 288 | ${origin}/sitemap${i}.xml 289 | `; 290 | } 291 | str += ` 292 | `; 293 | 294 | return str; 295 | } 296 | 297 | /** 298 | * Generates an array of paths, based on `src/routes`, to be included in a 299 | * sitemap. 300 | * 301 | * @public 302 | * 303 | * @param excludeRoutePatterns - Optional. An array of patterns for routes to be excluded. 304 | * @param paramValues - Optional. An object mapping each parameterized route to 305 | * an array of param values for that route. 306 | * @param lang - Optional. The language configuration. 307 | * @returns An array of strings, each representing a path for the sitemap. 308 | */ 309 | export function generatePaths({ 310 | defaultChangefreq, 311 | defaultPriority, 312 | excludeRoutePatterns = [], 313 | lang, 314 | paramValues = {}, 315 | }: { 316 | excludeRoutePatterns?: string[]; 317 | paramValues?: ParamValues; 318 | lang?: LangConfig; 319 | defaultChangefreq: SitemapConfig['defaultChangefreq']; 320 | defaultPriority: SitemapConfig['defaultPriority']; 321 | }): PathObj[] { 322 | // Match +page.svelte, +page@.svelte, +page@foo.svelte, +page@[id].svelte, and +page@(id).svelte 323 | // - See: https://kit.svelte.dev/docs/advanced-routing#advanced-layouts-breaking-out-of-layouts 324 | // - The `.md` and `.svx` extensions are to support MDSveX, which is a common 325 | // markdown preprocessor for SvelteKit. 326 | const svelteRoutes = Object.keys(import.meta.glob('/src/routes/**/+page*.svelte')); 327 | const mdRoutes = Object.keys(import.meta.glob('/src/routes/**/+page*.md')); 328 | const svxRoutes = Object.keys(import.meta.glob('/src/routes/**/+page*.svx')); 329 | const allRoutes = [...svelteRoutes, ...mdRoutes, ...svxRoutes]; 330 | 331 | // Validation: if dev has one or more routes that contain a lang parameter, 332 | // optional or required, require that they have defined the `lang.default` and 333 | // `lang.alternates` in their config or throw an error to cause a 500 error 334 | // for visibility. 335 | let routesContainLangParam = false; 336 | for (const route of allRoutes) { 337 | if (route.match(langRegex)?.length) { 338 | routesContainLangParam = true; 339 | break; 340 | } 341 | } 342 | if (routesContainLangParam && (!lang?.default || !lang?.alternates.length)) { 343 | throw Error( 344 | 'Must specify `lang` property within the sitemap config because one or more routes contain [[lang]].' 345 | ); 346 | } 347 | 348 | // Notice this means devs MUST include `[[lang]]/` within any route strings 349 | // used within `excludeRoutePatterns` if that's part of their route. 350 | const filteredRoutes = filterRoutes(allRoutes, excludeRoutePatterns); 351 | const processedRoutes = processRoutesForOptionalParams(filteredRoutes); 352 | 353 | const { pathsWithLang, pathsWithoutLang } = generatePathsWithParamValues( 354 | processedRoutes, 355 | paramValues, 356 | defaultChangefreq, 357 | defaultPriority 358 | ); 359 | 360 | const pathsWithLangAlternates = processPathsWithLang(pathsWithLang, lang); 361 | 362 | return [...pathsWithoutLang, ...pathsWithLangAlternates]; 363 | } 364 | 365 | /** 366 | * Filters and normalizes an array of route paths. 367 | * 368 | * @public 369 | * 370 | * @param routes - An array of route strings from Vite's `import.meta.blog`. 371 | * E.g. ['src/routes/blog/[slug]/+page.svelte', ...] 372 | * @param excludeRoutePatterns - An array of regular expression patterns to match 373 | * routes to exclude. 374 | * @returns A sorted array of cleaned-up route strings. 375 | * E.g. ['/blog/[slug]', ...] 376 | * 377 | * @remarks 378 | * - Removes trailing slashes from routes, except for the homepage route. If 379 | * SvelteKit specified this option in a config, rather than layouts, we could 380 | * read the user's preference, but it doesn't, we use SvelteKit's default no 381 | * trailing slash https://kit.svelte.dev/docs/page-options#trailingslash 382 | */ 383 | export function filterRoutes(routes: string[], excludeRoutePatterns: string[]): string[] { 384 | return ( 385 | routes 386 | // Remove `/src/routes` prefix, `+page.svelte suffix` or any variation 387 | // like `+page@.svelte`, and trailing slash except on homepage. Trailing 388 | // slash must be removed before excludeRoutePatterns so `$` termination of a 389 | // regex pattern will work as expected. 390 | .map((x) => { 391 | // Don't trim initial '/' yet, b/c a developer's excludeRoutePatterns may start with it. 392 | x = x.substring(11); 393 | x = x.replace(/\/\+page.*\.(svelte|md|svx)$/, ''); 394 | return !x ? '/' : x; 395 | }) 396 | 397 | // Remove any routes that match an exclude pattern 398 | .filter((x) => !excludeRoutePatterns.some((pattern) => new RegExp(pattern).test(x))) 399 | 400 | // Remove initial `/` now and any `/(groups)`, because decorative only. 401 | // Must follow excludeRoutePatterns. Ensure index page is '/' in case it was 402 | // part of a group. The pattern to match the group is from 403 | // https://github.com/sveltejs/kit/blob/99cddbfdb2332111d348043476462f5356a23660/packages/kit/src/utils/routing.js#L119 404 | .map((x) => { 405 | x = x.replaceAll(/\/\([^)]+\)/g, ''); 406 | return !x ? '/' : x; 407 | }) 408 | 409 | .sort() 410 | ); 411 | } 412 | 413 | /** 414 | * Builds parameterized paths using paramValues provided (e.g. 415 | * `/blog/hello-world`) and then removes the respective tokenized route (e.g. 416 | * `/blog/[slug]`) from the routes array. 417 | * 418 | * @public 419 | * 420 | * @param routes - An array of route strings, including parameterized routes 421 | * E.g. ['/', '/about', '/blog/[slug]', /blog/tags/[tag]'] 422 | * @param paramValues - An object mapping parameterized routes to a 1D or 2D 423 | * array of their parameter's values. E.g. 424 | * { 425 | * '/blog/[slug]': ['hello-world', 'another-post'] 426 | * '/campsites/[country]/[state]': [ 427 | * ['usa','miami'], 428 | * ['usa','new-york'], 429 | * ['canada','toronto'] 430 | * ], 431 | * '/athlete-rankings/[country]/[state]':[ 432 | * { 433 | * params: ['usa', 'new-york'], 434 | * lastmod: '2024-01-01', 435 | * changefreq: 'daily', 436 | * priority: 0.5, 437 | * }, 438 | * { 439 | * params: ['usa', 'california'], 440 | * lastmod: '2024-01-01', 441 | * changefreq: 'daily', 442 | * priority: 0.5, 443 | * }, 444 | * ] 445 | * } 446 | * 447 | * 448 | * @returns A tuple where the first element is an array of routes and the second 449 | * element is an array of generated parameterized paths. 450 | * 451 | * @throws Will throw an error if a `paramValues` key doesn't correspond to an 452 | * existing route, for visibility to the developer. 453 | * @throws Will throw an error if a parameterized route does not have data 454 | * within paramValues, for visibility to the developer. 455 | */ 456 | export function generatePathsWithParamValues( 457 | routes: string[], 458 | paramValues: ParamValues, 459 | defaultChangefreq: SitemapConfig['defaultChangefreq'], 460 | defaultPriority: SitemapConfig['defaultPriority'] 461 | ): { pathsWithLang: PathObj[]; pathsWithoutLang: PathObj[] } { 462 | // Throw if paramValues contains keys that don't exist within src/routes/. 463 | for (const paramValueKey in paramValues) { 464 | if (!routes.includes(paramValueKey)) { 465 | throw new Error( 466 | `Sitemap: paramValues were provided for a route that does not exist within src/routes/: '${paramValueKey}'. Remove this property from your paramValues.` 467 | ); 468 | } 469 | } 470 | 471 | // `changefreq`, `lastmod`, & `priority` are intentionally left with undefined values (for 472 | // consistency of property name within the `processPaths() callback, if used) when the dev does 473 | // not specify them either in pathObj or as defaults in the sitemap config. 474 | const defaults = { 475 | changefreq: defaultChangefreq, 476 | lastmod: undefined, 477 | priority: defaultPriority, 478 | }; 479 | 480 | let pathsWithLang: PathObj[] = []; 481 | let pathsWithoutLang: PathObj[] = []; 482 | 483 | for (const paramValuesKey in paramValues) { 484 | const hasLang = langRegex.exec(paramValuesKey); 485 | const routeSansLang = paramValuesKey.replace(langRegex, ''); 486 | const paramValue = paramValues[paramValuesKey]; 487 | 488 | let pathObjs: PathObj[] = []; 489 | 490 | // Handle when paramValue contains ParamValueObj[] 491 | if (typeof paramValue[0] === 'object' && !Array.isArray(paramValue[0])) { 492 | const objArray = paramValue as ParamValue[]; 493 | 494 | pathObjs.push( 495 | ...objArray.map((item) => { 496 | let i = 0; 497 | 498 | return { 499 | changefreq: item.changefreq ?? defaults.changefreq, 500 | lastmod: item.lastmod, 501 | path: routeSansLang.replace(/(\[\[.+?\]\]|\[.+?\])/g, () => item.values[i++] || ''), 502 | priority: item.priority ?? defaults.priority, 503 | }; 504 | }) 505 | ); 506 | } else if (Array.isArray(paramValue[0])) { 507 | // Handle when paramValue contains a 2D array of strings (e.g. [['usa', 'new-york'], ['usa', 508 | // 'california']]) 509 | // - `replace()` replaces every [[foo]] or [foo] with a value from the array. 510 | const array2D = paramValue as string[][]; 511 | pathObjs = array2D.map((data) => { 512 | let i = 0; 513 | return { 514 | ...defaults, 515 | path: routeSansLang.replace(/(\[\[.+?\]\]|\[.+?\])/g, () => data[i++] || ''), 516 | }; 517 | }); 518 | } else { 519 | // Handle 1D array of strings (e.g. ['hello-world', 'another-post', 'foo-post']) to generate 520 | // paths using these param values. 521 | const array1D = paramValue as string[]; 522 | pathObjs = array1D.map((paramValue) => ({ 523 | ...defaults, 524 | path: routeSansLang.replace(/\[.*\]/, paramValue), 525 | })); 526 | } 527 | 528 | // Process path objects to add lang onto each path, when applicable. 529 | if (hasLang) { 530 | const lang = hasLang?.[0]; 531 | pathsWithLang.push( 532 | ...pathObjs.map((pathObj) => ({ 533 | ...pathObj, 534 | path: pathObj.path.slice(0, hasLang?.index) + lang + pathObj.path.slice(hasLang?.index), 535 | })) 536 | ); 537 | } else { 538 | pathsWithoutLang.push(...pathObjs); 539 | } 540 | 541 | // Remove this from routes 542 | routes.splice(routes.indexOf(paramValuesKey), 1); 543 | } 544 | 545 | // Handle "static" routes (i.e. /foo, /[[lang]]/bar, etc). These will not have any parameters 546 | // other than exactly `[[lang]]`. 547 | const staticWithLang: PathObj[] = []; 548 | const staticWithoutLang: PathObj[] = []; 549 | for (const route of routes) { 550 | const hasLang = route.match(langRegex); 551 | if (hasLang) { 552 | staticWithLang.push({ ...defaults, path: route }); 553 | } else { 554 | staticWithoutLang.push({ ...defaults, path: route }); 555 | } 556 | } 557 | 558 | // This just keeps static paths first, which I prefer. 559 | pathsWithLang = [...staticWithLang, ...pathsWithLang]; 560 | pathsWithoutLang = [...staticWithoutLang, ...pathsWithoutLang]; 561 | 562 | // Check for missing paramValues. 563 | // Throw error if app contains any parameterized routes NOT handled in the 564 | // sitemap, to alert the developer. Prevents accidental omission of any paths. 565 | for (const route of routes) { 566 | // Check whether any instance of [foo] or [[foo]] exists 567 | const regex = /.*(\[\[.+\]\]|\[.+\]).*/; 568 | const routeSansLang = route.replace(langRegex, '') || '/'; 569 | if (regex.test(routeSansLang)) { 570 | throw new Error( 571 | `Sitemap: paramValues not provided for: '${route}'\nUpdate your sitemap's excludedRoutePatterns to exclude this route OR add data for this route's param(s) to the paramValues object of your sitemap config.` 572 | ); 573 | } 574 | } 575 | 576 | return { pathsWithLang, pathsWithoutLang }; 577 | } 578 | 579 | /** 580 | * Given an array of all routes, return a new array of routes that includes all versions of each 581 | * route that contains one or more optional params _other than_ `[[lang]]`. 582 | * 583 | * @private 584 | */ 585 | export function processRoutesForOptionalParams(routes: string[]): string[] { 586 | const processedRoutes = routes.flatMap((route) => { 587 | const routeWithoutLangIfAny = route.replace(langRegex, ''); 588 | return /\[\[.*\]\]/.test(routeWithoutLangIfAny) ? processOptionalParams(route) : route; 589 | }); 590 | 591 | // Ensure no duplicates exist after processing 592 | return Array.from(new Set(processedRoutes)); 593 | } 594 | 595 | /** 596 | * Processes a route containing >=1 optional parameters (i.e. those with double square brackets) to 597 | * generate all possible versions of this route that SvelteKit considers valid. 598 | * 599 | * @private 600 | * @param route - Route to process. E.g. `/foo/[[paramA]]` 601 | * @returns An array of routes. E.g. [`/foo`, `/foo/[[paramA]]`] 602 | */ 603 | export function processOptionalParams(originalRoute: string): string[] { 604 | // Remove lang to simplify 605 | const hasLang = langRegex.exec(originalRoute); 606 | const route = hasLang ? originalRoute.replace(langRegex, '') : originalRoute; 607 | 608 | let results: string[] = []; 609 | 610 | // Get path up to _before_ the first optional param; use `i-1` to exclude 611 | // trailing slash after this. This is our first result. 612 | results.push(route.slice(0, route.indexOf('[[') - 1)); 613 | 614 | // Extract the portion of the route starting at the first optional parameter 615 | const remaining = route.slice(route.indexOf('[[')); 616 | 617 | // Split, then filter to remove empty items. 618 | const segments = remaining.split('/').filter(Boolean); 619 | 620 | let j = 1; 621 | for (const segment of segments) { 622 | // Start a new potential result 623 | if (!results[j]) results[j] = results[j - 1]; 624 | 625 | results[j] = `${results[j]}/${segment}`; 626 | 627 | if (segment.startsWith('[[')) { 628 | j++; 629 | } 630 | } 631 | 632 | // Re-add lang to all results. 633 | if (hasLang) { 634 | const lang = hasLang?.[0]; 635 | results = results.map( 636 | (result) => `${result.slice(0, hasLang?.index)}${lang}${result.slice(hasLang?.index)}` 637 | ); 638 | } 639 | 640 | // When the first path segment is an optional parameter (except for [[lang]]), the first result 641 | // will be an empty string. We set this to '/' b/c the root path is one of the valid paths 642 | // combinations in such a scenario. 643 | if (!results[0].length) results[0] = '/'; 644 | 645 | return results; 646 | } 647 | 648 | /** 649 | * Processes path objects that contain `[[lang]]` or `[lang]` to 1.) generate one PathObj for each 650 | * language in the lang config, and 2.) to add an `alternates` property to each such PathObj. 651 | * 652 | * @private 653 | */ 654 | export function processPathsWithLang(pathObjs: PathObj[], langConfig: LangConfig): PathObj[] { 655 | if (!pathObjs.length) return []; 656 | 657 | const processedPathObjs = []; 658 | 659 | for (const pathObj of pathObjs) { 660 | const path = pathObj.path; 661 | // The Sitemap standard specifies for hreflang elements to include 1.) the 662 | // current path itself, and 2.) all of its alternates. So all versions of 663 | // this path will be given the same "variations" array that will be used to 664 | // build hreflang items for the path. 665 | // https://developers.google.com/search/blog/2012/05/multilingual-and-multinational-site 666 | 667 | // - If the lang param is required (i.e. `[lang]`), all variations of this 668 | // path must include the lang param within the path. 669 | // - If the lang param is optional (i.e. `[[lang]]`), the default lang will 670 | // not contain the language in the path but all other variations will. 671 | const hasLangRequired = /\/?\[lang(=[a-z]+)?\](?!\])/.exec(path); 672 | const _path = hasLangRequired 673 | ? path.replace(langRegex, `/${langConfig.default}`) 674 | : path.replace(langRegex, '') || '/'; 675 | 676 | // Add the default path (e.g. '/about', or `/es/about` when lang is required). 677 | const variations = [ 678 | { 679 | lang: langConfig.default, 680 | path: _path, 681 | }, 682 | ]; 683 | 684 | // Add alternate paths (e.g. '/de/about', etc.) 685 | for (const lang of langConfig.alternates) { 686 | variations.push({ 687 | lang, 688 | path: path.replace(langRegexNoPath, lang), 689 | }); 690 | } 691 | 692 | // Generate a PathObj for each variation. 693 | const pathObjs = []; 694 | for (const x of variations) { 695 | pathObjs.push({ 696 | ...pathObj, // keep original pathObj properties 697 | alternates: variations, 698 | path: x.path, 699 | }); 700 | } 701 | 702 | processedPathObjs.push(...pathObjs); 703 | } 704 | 705 | return processedPathObjs; 706 | } 707 | 708 | /** 709 | * Removes duplicate paths from an array of PathObj, keeping the last occurrence of any duplicates. 710 | * 711 | * - Duplicate pathObjs could occur due to a developer using additionalPaths or processPaths() and 712 | * not properly excluding a pre-existing path. 713 | * 714 | * @private 715 | */ 716 | export function deduplicatePaths(pathObjs: PathObj[]): PathObj[] { 717 | const uniquePaths = new Map(); 718 | 719 | for (const pathObj of pathObjs) { 720 | uniquePaths.set(pathObj.path, pathObj); 721 | } 722 | 723 | return Array.from(uniquePaths.values()); 724 | } 725 | 726 | /** 727 | * Converts the user-provided `additionalPaths` into `PathObj[]` type, ensuring each path starts 728 | * with a forward slash and each PathObj contains default changefreq and priority. 729 | * 730 | * - `additionalPaths` are never translated based on the lang config because they could be something 731 | * like a PDF within the user's static dir. 732 | * 733 | * @private 734 | */ 735 | export function generateAdditionalPaths({ 736 | additionalPaths, 737 | defaultChangefreq, 738 | defaultPriority, 739 | }: { 740 | additionalPaths: string[]; 741 | defaultChangefreq: SitemapConfig['defaultChangefreq']; 742 | defaultPriority: SitemapConfig['defaultPriority']; 743 | }): PathObj[] { 744 | const defaults = { 745 | changefreq: defaultChangefreq, 746 | lastmod: undefined, 747 | priority: defaultPriority, 748 | }; 749 | 750 | return additionalPaths.map((path) => ({ 751 | ...defaults, 752 | path: path.startsWith('/') ? path : `/${path}`, 753 | })); 754 | } 755 | -------------------------------------------------------------------------------- /src/lib/test.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasongitmail/super-sitemap/ca6a9363a4af62e467afb70d5fb1e0b3b4fa70f5/src/lib/test.js -------------------------------------------------------------------------------- /src/params/integer.ts: -------------------------------------------------------------------------------- 1 | import type { ParamMatcher } from '@sveltejs/kit'; 2 | 3 | // Returns true if 0 or greater. 4 | export const match: ParamMatcher = (param) => { 5 | return /^\d+$/.test(param); 6 | }; 7 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/dashboard/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Dashboard

6 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/dashboard/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | title: `Dashboard`, 4 | }; 5 | 6 | return { meta }; 7 | } 8 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/dashboard/profile/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Dashboard Profile

6 | -------------------------------------------------------------------------------- /src/routes/(authenticated)/dashboard/profile/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Profile`, 4 | title: `Profile`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/+page.svelte: -------------------------------------------------------------------------------- 1 |

Home

2 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Foo meta description...`, 4 | title: `Foo`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/[foo]/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Foo parameterized route

6 | 7 |

8 | Appears as a general fallback. Exists to test route specificity handling by 9 | `sampled.findFirstMatches()` (an internal function) 10 |

11 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/[foo]/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Foo meta description...`, 4 | title: `Foo`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/about/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

About

6 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/about/+page.ts: -------------------------------------------------------------------------------- 1 | import { sampledPaths, sampledUrls } from '$lib/sampled'; // Import from 'super-sitemap' in your app 2 | 3 | export async function load() { 4 | const meta = { 5 | description: `About this site`, 6 | title: `About`, 7 | }; 8 | 9 | console.log('sampledUrls', await sampledUrls('http://localhost:5173/sitemap.xml')); 10 | console.log('sampledPaths', await sampledPaths('http://localhost:5173/sitemap.xml')); 11 | 12 | return { meta }; 13 | } 14 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/blog/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Blog

6 | 7 |

Show first page of blog posts.

8 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/blog/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Blog meta description...`, 4 | title: `Blog`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/blog/[page=integer]/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |

Blog - Page {params.page}

7 | 8 |

Show a blog post for {params.slug} or 404.

9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/blog/[page=integer]/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Login meta description...`, 4 | title: `Login`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/blog/[slug]/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |

A blog post

7 | 8 |

Show a blog post for {params.slug} or 404.

9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/blog/[slug]/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Login meta description...`, 4 | title: `Login`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/blog/tag/[tag]/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |

Posts tagged {params.tag}

7 | 8 |

Show posts tagged {params.tag} or 404.

9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/blog/tag/[tag]/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Login meta description...`, 4 | title: `Login`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/blog/tag/[tag]/[page=integer]/+page.svelte: -------------------------------------------------------------------------------- 1 | 5 | 6 |

Posts tagged {params.tag}

7 | 8 |

Show page {params.page} of posts tagged {params.tag} or 404.

9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/blog/tag/[tag]/[page=integer]/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Login meta description...`, 4 | title: `Login`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/campsites/[country]/[state]/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Campsites

6 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/campsites/[country]/[state]/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Campsites`, 4 | title: `Campsites`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Login

6 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/login/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Login meta description...`, 4 | title: `Login`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/og/blog/[title].png/+server.ts: -------------------------------------------------------------------------------- 1 | import type { RequestHandler } from '@sveltejs/kit'; 2 | 3 | export const GET: RequestHandler = async () => { 4 | // Pretend this is a SvelteKit OG image generation lib; for testing SK 5 | // Sitemap, we only need the route to exist. 6 | return new Response('OG route', { headers: { 'content-type': 'text/html' } }); 7 | }; 8 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/optionals/[[optional]]/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Route with an optional param

6 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/optionals/[[optional]]/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Foo meta description...`, 4 | title: `Foo`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/optionals/many/[[paramA]]/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Route with an optional param B

6 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/optionals/many/[[paramA]]/[[paramB]]/foo/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Route with an optional param foo

6 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/optionals/many/[[paramA]]/[[paramB]]/foo/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Foo meta description...`, 4 | title: `Foo`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/optionals/to-exclude/[[optional]]/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Route with an optional param

6 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/optionals/to-exclude/[[optional]]/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Foo meta description...`, 4 | title: `Foo`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/pricing/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Pricing

6 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/pricing/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Privacy meta description...`, 4 | title: `Privacy`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/privacy/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Pricing

6 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/privacy/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Pricing meta description...`, 4 | title: `Pricing`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/signup/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Sign up

6 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/signup/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Sign up meta description...`, 4 | title: `Sign up`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/sitemap[[page]].xml/+server.ts: -------------------------------------------------------------------------------- 1 | import * as sitemap from '$lib/sitemap'; // Import from 'super-sitemap' in your app 2 | import type { RequestHandler } from '@sveltejs/kit'; 3 | 4 | import * as blog from '$lib/data/blog'; 5 | import { error } from '@sveltejs/kit'; 6 | 7 | // - Use prerender if you only have static routes or the data for your 8 | // parameterized routes does not change between your builds builds. Otherwise, 9 | // disabling prerendering will allow your database that generate param values 10 | // to be executed when a user request to the sitemap does not hit cache. 11 | // export const prerender = true; 12 | 13 | export const GET: RequestHandler = async ({ params }) => { 14 | // Get data for parameterized routes 15 | let slugs, tags; 16 | try { 17 | [slugs, tags] = await Promise.all([blog.getSlugs(), blog.getTags()]); 18 | } catch (err) { 19 | throw error(500, 'Could not load paths'); 20 | } 21 | 22 | return await sitemap.response({ 23 | additionalPaths: ['/foo.pdf'], // e.g. a file in the `static` dir 24 | excludeRoutePatterns: [ 25 | '/dashboard.*', 26 | '/to-exclude', 27 | '(secret-group)', 28 | 29 | // Exclude routes containing `[page=integer]`–e.g. `/blog/2` 30 | `.*\\[page=integer\\].*`, 31 | ], 32 | // maxPerPage: 20, 33 | origin: 'https://example.com', 34 | page: params.page, 35 | 36 | /* eslint-disable perfectionist/sort-objects */ 37 | paramValues: { 38 | '/[[lang]]/[foo]': ['foo-path-1'], 39 | '/[[lang]]/optionals/[[optional]]': ['optional-1', 'optional-2'], 40 | '/[[lang]]/optionals/many/[[paramA]]': ['data-a1', 'data-a2'], 41 | '/[[lang]]/optionals/many/[[paramA]]/[[paramB]]': [ 42 | ['data-a1', 'data-b1'], 43 | ['data-a2', 'data-b2'], 44 | ], 45 | '/[[lang]]/optionals/many/[[paramA]]/[[paramB]]/foo': [ 46 | ['data-a1', 'data-b1'], 47 | ['data-a2', 'data-b2'], 48 | ], 49 | '/[[lang]]/blog/[slug]': slugs, 50 | '/[[lang]]/blog/tag/[tag]': tags, 51 | '/[[lang]]/campsites/[country]/[state]': [ 52 | { 53 | values: ['usa', 'new-york'], 54 | lastmod: '2025-01-01T00:00:00Z', 55 | changefreq: 'daily', 56 | priority: 0.5, 57 | }, 58 | { 59 | values: ['usa', 'california'], 60 | lastmod: '2025-01-05', 61 | changefreq: 'daily', 62 | priority: 0.4, 63 | }, 64 | // { 65 | // values: ['canada', 'toronto'] 66 | // }, 67 | ], 68 | }, 69 | 70 | defaultPriority: 0.7, 71 | defaultChangefreq: 'daily', 72 | sort: 'alpha', // helps predictability of test data 73 | lang: { 74 | default: 'en', 75 | alternates: ['zh'], 76 | }, 77 | processPaths: (paths: sitemap.PathObj[]) => { 78 | // Add trailing slashes. (In reality, using no trailing slash is 79 | // preferable b/c it provides consistency among all possible paths, even 80 | // items like `/foo.pdf`; this is merely intended to test the 81 | // `processPaths()` callback.) 82 | return paths.map(({ path, alternates, ...rest }) => { 83 | const rtrn = { path: path === '/' ? path : `${path}/`, ...rest }; 84 | 85 | if (alternates) { 86 | rtrn.alternates = alternates.map((alternate: sitemap.Alternate) => ({ 87 | ...alternate, 88 | path: alternate.path === '/' ? alternate.path : `${alternate.path}/`, 89 | })); 90 | } 91 | 92 | return rtrn; 93 | }); 94 | }, 95 | }); 96 | }; 97 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/terms/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `Terms meta description...`, 4 | title: `Terms`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/(public)/[[lang]]/terms/+page@.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |

Terms

8 | -------------------------------------------------------------------------------- /src/routes/(public)/markdown-md/+page.md: -------------------------------------------------------------------------------- 1 | # Markdown example (`.md`) 2 | 3 | This page is to test support markdown files for SvelteKit sites that use MDSveX. 4 | https://github.com/jasongitmail/super-sitemap/issues/34 5 | -------------------------------------------------------------------------------- /src/routes/(public)/markdown-svx/+page.svx: -------------------------------------------------------------------------------- 1 | # Markdown example (`.svx`) 2 | 3 | This page is to test support markdown files for SvelteKit sites that use MDSveX. 4 | https://github.com/jasongitmail/super-sitemap/issues/34 5 | -------------------------------------------------------------------------------- /src/routes/(secret-group)/secret-page/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Secret Page

6 | -------------------------------------------------------------------------------- /src/routes/(secret-group)/secret-page/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | description: `A secret page`, 4 | title: `A secret page`, 5 | }; 6 | 7 | return { meta }; 8 | } 9 | -------------------------------------------------------------------------------- /src/routes/dashboard/settings/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |

Dashboard settings

6 | -------------------------------------------------------------------------------- /src/routes/dashboard/settings/+page.ts: -------------------------------------------------------------------------------- 1 | export async function load() { 2 | const meta = { 3 | title: `Dashboard settings`, 4 | }; 5 | 6 | return { meta }; 7 | } 8 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasongitmail/super-sitemap/ca6a9363a4af62e467afb70d5fb1e0b3b4fa70f5/static/favicon.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-auto'; 2 | import { vitePreprocess } from '@sveltejs/kit/vite'; 3 | import { mdsvex } from 'mdsvex'; 4 | 5 | /** @type {import('@sveltejs/kit').Config} */ 6 | const config = { 7 | extensions: ['.svelte', '.svx', '.md'], 8 | 9 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors 10 | kit: { 11 | // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. 12 | // If your environment is not supported or you settled on a specific environment, switch out the adapter. 13 | // See https://kit.svelte.dev/docs/adapters for more information about adapters. 14 | adapter: adapter(), 15 | }, 16 | 17 | preprocess: [ 18 | vitePreprocess(), 19 | mdsvex({ 20 | extensions: ['.svx', '.md'], 21 | }), 22 | ], 23 | }; 24 | 25 | export default config; 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "NodeNext" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vitest/config'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | test: { 7 | include: ['src/**/*.{test,spec}.{js,ts}'], 8 | }, 9 | }); 10 | --------------------------------------------------------------------------------