├── .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 |
3 |
4 |
SvelteKit sitemap focused on ease of use and making it impossible to forget to add your paths.
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
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 |
--------------------------------------------------------------------------------