├── .eslintrc
├── .eslintignore
├── .gitignore
├── test
├── package.json
└── test.js
├── package.json
├── .github
└── workflows
│ └── main.yml
├── LICENSE.md
├── CHANGELOG.md
├── README.md
└── index.js
/.eslintrc:
--------------------------------------------------------------------------------
1 | { "extends": "apostrophe" }
2 |
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | /test/apos-build
2 | /test/public
3 | /test/locales
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Ignore MacOS X metadata forks (fusefs)
2 | ._*
3 | package-lock.json
4 | *.DS_Store
5 | node_modules
6 |
7 | # vim swp files
8 | .*.sw*
9 |
10 | # Test files
11 | /test/apos-build
12 | /test/public
13 | /test/locales
14 |
--------------------------------------------------------------------------------
/test/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "//": "This package.json file is not actually installed.",
3 | "//": "Apostrophe requires that all npm modules to be loaded by moog",
4 | "//": "exist in package.json at project level, which for a test is here",
5 | "dependencies": {
6 | "apostrophe": "^3.8.1",
7 | "@apostrophecms/sitemap": "git://github.com/apostrophecms/sitemap.git"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@apostrophecms/sitemap",
3 | "version": "1.2.0",
4 | "description": "Sitemap generator for ApostropheCMS.",
5 | "main": "index.js",
6 | "scripts": {
7 | "lint": "npm run eslint",
8 | "eslint": "eslint .",
9 | "test": "npm run lint && mocha"
10 | },
11 | "repository": {
12 | "type": "git",
13 | "url": "git+https://github.com/apostrophecms/sitemap.git"
14 | },
15 | "homepage": "https://github.com/apostrophecms/sitemap#readme",
16 | "author": "Apostrophe Technologies",
17 | "license": "MIT",
18 | "devDependencies": {
19 | "apostrophe": "^4.0.0",
20 | "eslint-config-apostrophe": "^5.0.0",
21 | "mocha": "^7.1.2"
22 | },
23 | "dependencies": {
24 | "common-tags": "^1.8.0"
25 | }
26 | }
--------------------------------------------------------------------------------
/.github/workflows/main.yml:
--------------------------------------------------------------------------------
1 | name: Tests
2 |
3 | on:
4 | push:
5 | branches: ["main"]
6 | pull_request:
7 | branches: ["*"]
8 |
9 | workflow_dispatch:
10 |
11 | jobs:
12 | build:
13 | runs-on: ubuntu-latest
14 | strategy:
15 | matrix:
16 | node-version: [20, 22, 24]
17 | mongodb-version: [6.0, 7.0, 8.0]
18 |
19 | steps:
20 | - name: Git checkout
21 | uses: actions/checkout@v4
22 |
23 | - name: Use Node.js ${{ matrix.node-version }}
24 | uses: actions/setup-node@v4
25 | with:
26 | node-version: ${{ matrix.node-version }}
27 |
28 | - name: Start MongoDB
29 | uses: supercharge/mongodb-github-action@1.11.0
30 | with:
31 | mongodb-version: ${{ matrix.mongodb-version }}
32 |
33 | - run: npm install
34 |
35 | - run: npm test
36 | env:
37 | CI: true
38 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | Copyright (c) 2021 Apostrophe Technologies
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4 |
5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6 |
7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | ## 1.2.0 (2025-08-05)
4 |
5 | ### Adds
6 |
7 | - Factors out a `getReq(locale)` method to allow extension of that logic, no behavior change
8 | - Adds tests for the `perLocale` option
9 |
10 | ### Changes
11 |
12 | - Updates the README to include the `perLocale` option
13 |
14 | ### Fixes
15 |
16 | ## 1.1.1 (2025-07-09)
17 |
18 | ### Fixes
19 |
20 | * Sitemaps per locale are generated correctly. Thanks to [Eduardo Correal](https://github.com/ecb34) for this change.
21 |
22 | ### Changes
23 |
24 | * Bumps `eslint-config-apostrophe` to `5`, fixes errors, removes unused dependencies.
25 |
26 | ## 1.1.0 (2024-12-18)
27 |
28 | * Adds support for multiple locales (localization).
29 |
30 | ## 1.0.3 (2024-08-08)
31 |
32 | * Edits README and package description. No code changes.
33 | * Fix module configuration example in README.
34 |
35 | ## 1.0.2 - 2022-12-21
36 |
37 | * Fixes the `package.json` file to point to the correct URLs for the homepage and repository. No functional changes.
38 |
39 | ## 1.0.1 - 2021-12-08
40 |
41 | * Replaces the symlink in the tests with the `testModule` option.
42 |
43 | ## 1.0.0
44 |
45 | * Initial release for Apostrophe 3.x.
46 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
17 |
18 | The Apostrophe Sitemap module generates XML sitemaps for websites powered by [ApostropheCMS](https://apostrophecms.com). The sitemap includes all of the pages on your site that are visible to the public, including "piece" content, such as events and blog posts.
19 |
20 | A frequently updated and accurate XML sitemap allows search engines to index your content more quickly and spot new pages. The Sitemap module will maintain a cached sitemap to load quickly, but then automatically refresh after one hour (by default). This also prevents the sitemap from getting out-of-date, which would be *very bad* for SEO.
21 |
22 | ## Roadmap
23 |
24 | | Feature | Status |
25 | | --- | --- |
26 | | Sitemap generation for single-locale websites | ✅ Implemented |
27 | | Module configuration to exclude certain doc types | ✅ Implemented |
28 | | Tasks to manually generate sitemap | ✅ Implemented |
29 | | Text-style sitemap generation (for content strategy work) | 🚧 Planned |
30 | | Support for multiple locales (localization) | ✅ Implemented |
31 | | Output customization function | 🚧 Planned |
32 |
33 | ## Installation
34 |
35 | ```bash
36 | npm install @apostrophecms/sitemap
37 | ```
38 |
39 | ## Use
40 |
41 | ### Initialization
42 |
43 | Configure `@apostrophecms/sitemap` in `app.js` as a project module.
44 |
45 | ```javascript
46 | // app.js
47 | require('apostrophe')({
48 | shortName: 'my-project',
49 | baseUrl: 'https://example.com',
50 | modules: {
51 | '@apostrophecms/sitemap': {}
52 | }
53 | });
54 | ```
55 |
56 | **Start the site** (with `node app` or your preferred command) and visit `http://localhost:3000/sitemap.xml` (in local development). You should now see any pages displayed in a sitemap as well as any pieces that have an associated piece page.
57 |
58 | ### Setting the `baseUrl`
59 |
60 | It is important to configure a `baseUrl` for the project to properly display URLs. That can be done in the application configuration object as shown above. To support different domains in production and development environments, it can also be configured in a `data/local.js` file which should be ignored by version control. `data/local.js` will take precedence over `app.js`, so both can be used to support multiple environments as well.
61 |
62 | ```javascript
63 | // data/local.js
64 | module.exports = {
65 | baseUrl: 'http://localhost:3000'
66 | };
67 | ```
68 |
69 | ### Options
70 |
71 | All sitemap module options are configured in an `options` object.
72 |
73 | ```javascript
74 | // modules/@apostrophecms/sitemap/index.js
75 | module.exports = {
76 | // 👇 Module options
77 | options: {
78 | cacheLifetime: 1800,
79 | excludeTypes: [ 'exclusive-page', 'category' ]
80 | piecesPerBatch: 500
81 | }
82 | };
83 | ```
84 |
85 | These can be added in the `app.js` configuration object for the module, but it is better practice to use a dedicated file for module configuration.
86 |
87 | #### `cacheLifetime`
88 |
89 | By default sitemaps are cached for one hour. You can change this by specifying the `cacheLifetime` option to this module, in seconds. It must be a number greater than zero.
90 |
91 | **Tip:** To make entering the cache lifetime easier it can help to write it as a math expression, multiplying the desired number of minutes by sixty:
92 |
93 | ```javascript
94 | cacheLifetime: 30 * 60 // or 1800 seconds
95 | ```
96 |
97 | Keep in mind: Google and other search engines more than weekly, if that.Refreshing once every hour is usually more than often enough.
98 |
99 | #### `excludeTypes`
100 |
101 | If there are particular page types or piece content types that should *not* be in the sitemap, list them in an array as the `excludeType` option.
102 |
103 | ```javascript
104 | excludeTypes: [ 'exclusive-page', 'category' ]
105 | ```
106 |
107 | #### `piecesPerBatch`
108 |
109 | If you have thousands of URLs to index, building the sitemap may take a long time. By default, this module processes 100 pieces at a time, to avoid using too much memory. You can adjust this by setting the `piecesPerBatch` option to a larger number. Be aware that if you have many fields and content relationships **it is possible this can use a great deal of memory**.
110 |
111 | ```javascript
112 | piecesPerBatch: 500
113 | ```
114 |
115 | ### `perLocale`
116 |
117 | If your project uses multiple locales and you want **each locale to have its own sitemap**, enable the `perLocale` option:
118 |
119 | ```js
120 | // modules/@apostrophecms/sitemap/index.js
121 | module.exports = {
122 | options: {
123 | perLocale: true
124 | }
125 | };
126 | ```
127 |
128 | This will:
129 |
130 | * Generate separate sitemap files for each locale (e.g., `/sitemaps/en.xml`, `/sitemaps/es.xml`, etc.)
131 | * Serve a sitemap index file at `/sitemaps/index.xml`
132 | * Disable the default `/sitemap.xml` route (returns a 404)
133 |
134 | > 💡 **Tip**
135 | > If you're using multiple locales, enable `perLocale` to generate a separate sitemap for each one.
136 |
137 | > ```
138 | > Sitemap: https://example.com/sitemaps/index.xml
139 | > ```
140 |
141 | ### Tasks
142 |
143 | #### `print`
144 |
145 | The `print` command will generate an up-to-date sitemap on demand and **print the sitemap into the console**. You can also pipe the output it as needed, to help generate a static file version. On the command line, run:
146 |
147 | ```bash
148 | node app @apostrophecms/sitemap:print
149 | ```
150 |
151 | #### `update-cache`
152 |
153 | Use the `update-cache` task to force a cache update at any time. If the website is very large (multiple hundreds of URLs), running this task option with a cron job on the production server more often than the standard cache refresh can help ensure the cache is available when a search engine begins crawling the site.
154 |
155 | ```bash
156 | node app @apostrophecms/sitemap:update-cache
157 | ```
158 |
159 | #### `clear`
160 |
161 | You can manually clear the cached sitemap at any time with the `clear` task. This will force a new sitemap to be generated on the next request to `/sitemap.xml`. On the command line, run:
162 |
163 | ```bash
164 | node app @apostrophecms/sitemap:clear
165 | ```
166 |
167 | ### Telling search engines about the sitemap
168 |
169 | Create a `public/robots.txt` file if you do not already have one and add a sitemap line. Here is a valid example for a site that doesn't have any other `robots.txt` rules:
170 |
171 | ```
172 | Sitemap: https://example.com/sitemap.xml
173 | ```
174 |
175 | ### Troubleshooting
176 |
177 | - If you already have a static `public/sitemap.xml` file, **that file will be shown at the `/sitemap.xml` URL path instead.** Remove it to let the module take over.
178 | - Sitemaps are cached for one hour by default, so you won't see content changes instantly. See above about the `cacheLifetime` option, `clear` task, and `update-cache` task for ways to refresh the sitemap more frequently.
179 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const { stripIndent } = require('common-tags');
3 |
4 | const sitemapCacheName = 'apos-sitemap';
5 |
6 | const noBaseUrlWarning = stripIndent`
7 | ⚠️ You must specify the site-level baseUrl option for the application when
8 | configuring Apostrophe to use sitemap indexes.
9 |
10 | Example: \`baseUrl: "https://mycompany.com"\` (no trailing slash)
11 |
12 | Usually you will only do this in \`data/local.js\` in production.
13 | `;
14 |
15 | module.exports = {
16 | options: {
17 | alias: 'sitemap',
18 | // The number of pieces to index in each loop.
19 | piecesPerBatch: 100
20 | },
21 | init(self, options) {
22 | self.updatingCache = true;
23 | // Cache sitemaps for 1 hour by default. Depending on page rank Google may
24 | // look at your sitemap somewhere between daily and monthly, so don't get
25 | // your hopes up too far about changing this
26 | self.cacheLifetime = 60 * 60;
27 |
28 | if (typeof options.cacheLifetime === 'number' && options.cacheLifetime > 0) {
29 | self.cacheLifetime = options.cacheLifetime;
30 | } else if (options.cacheLifetime || options.cacheLifetime === 0) {
31 | self.apos.util.warn('⚠️ The sitemap cacheLifetime option must be a number greater than zero.');
32 | }
33 |
34 | self.piecesPerBatch = options.piecesPerBatch;
35 |
36 | self.baseUrl = self.apos.baseUrl;
37 |
38 | if (!self.baseUrl) {
39 | throw new Error(noBaseUrlWarning);
40 | }
41 |
42 | self.defaultLocale = self.apos.i18n.defaultLocale;
43 | },
44 | tasks (self) {
45 | return {
46 | print: {
47 | usage: 'Print a sitemap',
48 | async task (argv) {
49 | return self.mapTask(false);
50 | }
51 | },
52 | 'update-cache': {
53 | usage: 'Update the sitemap cache',
54 | async task (argv) {
55 | return self.mapTask(true);
56 | }
57 | },
58 | clear: {
59 | usage: 'Clear the existing sitemap',
60 | async task (argv) {
61 | // Just forget the current sitemaps to make room
62 | // for regeneration on the next request
63 | return self.apos.cache.clear(sitemapCacheName);
64 | }
65 | }
66 | };
67 | },
68 | routes (self) {
69 | return {
70 | get: {
71 | '/sitemap.xml': async function(req, res) {
72 | return self.sendCache(res, 'sitemap.xml');
73 | },
74 | '/sitemaps/*': async function(req, res) {
75 | return self.sendCache(res, 'sitemaps/' + req.params[0]);
76 | }
77 | }
78 | };
79 | },
80 | methods (self, options) {
81 | return {
82 | mapTask: async function (caching) {
83 | self.updatingCache = caching;
84 |
85 | if (!self.baseUrl) {
86 | const error = noBaseUrlWarning;
87 |
88 | return self.apos.util.error(error);
89 | }
90 |
91 | return self.map();
92 | },
93 | map: async function () {
94 | const argv = self.apos.argv;
95 |
96 | if (self.updatingCache) {
97 | self.cacheOutput = [];
98 | }
99 |
100 | await lock();
101 | initConfig();
102 | await map();
103 | await hreflang();
104 | await write();
105 | await unlock();
106 |
107 | async function lock() {
108 | await self.apos.lock.lock('apos-sitemap');
109 | }
110 |
111 | function initConfig() {
112 | // TODO: Bring this back when supporting multiple formats.
113 | // self.format = argv.format || options.format || 'xml';
114 | self.format = 'xml';
115 |
116 | // TODO: Bring back when supporting text format.
117 | // self.indent = (typeof argv.indent !== 'undefined')
118 | // ? argv.indent
119 | // : options.indent;
120 | self.indent = false;
121 |
122 | self.excludeTypes = options.excludeTypes || [];
123 |
124 | if (argv['exclude-types']) {
125 | self.excludeTypes = self.excludeTypes.concat(argv['exclude-types']
126 | .split(','));
127 | }
128 |
129 | self.perLocale = options.perLocale || argv['per-locale'];
130 |
131 | // Exception: plaintext sitemaps and sitemap indexes don't go
132 | // together, so we can presume that if they explicitly ask
133 | // for plaintext they are just doing content strategy and we
134 | // should produce a single report
135 | // TODO: Revisit when supporting text format
136 | if (self.format === 'text') {
137 | self.perLocale = false;
138 | }
139 | }
140 |
141 | async function map () {
142 | self.maps = {};
143 |
144 | const locales = Object.keys(self.apos.i18n.getLocales());
145 |
146 | for (const locale of locales) {
147 | const req = self.getReq(locale);
148 |
149 | await self.getPages(req);
150 | await self.getPieces(req);
151 | }
152 | }
153 |
154 | async function hreflang() {
155 | const alternativesByAposId = {};
156 |
157 | for (const [ locale, entries ] of Object.entries(self.maps)) {
158 | entries.forEach(entry => {
159 | entry.url['xhtml:link'] = [ {
160 | _attributes: {
161 | rel: 'alternate',
162 | hreflang: locale,
163 | href: entry.url.loc
164 | }
165 | } ];
166 |
167 | alternativesByAposId[entry.url.id] ??= [];
168 | alternativesByAposId[entry.url.id].push(entry);
169 | });
170 | }
171 |
172 | for (const entries of Object.values(self.maps)) {
173 | entries.forEach(entry => {
174 | const links = alternativesByAposId[entry.url.id]
175 | .filter(alternative => alternative !== entry)
176 | .map(alternative => ({
177 | _attributes: {
178 | rel: 'alternate',
179 | hreflang: alternative.url.locale,
180 | href: alternative.url.loc
181 | }
182 | }));
183 | entry.url['xhtml:link'].push(...links);
184 | });
185 | }
186 |
187 | for (const entries of Object.values(self.maps)) {
188 | entries.forEach(entry => {
189 | delete entry.url.id;
190 | delete entry.url.locale;
191 | });
192 | }
193 | }
194 |
195 | function write() {
196 | return self.writeSitemap();
197 | }
198 |
199 | async function unlock() {
200 | await self.apos.lock.unlock('apos-sitemap');
201 | }
202 | },
203 | // Reqturn a req suitable for fetching content in the given locale
204 | // that belongs in the sitemap. A useful extension point for projects
205 | // that do unusual things with proxied URLs, etc.
206 | getReq(locale) {
207 | return self.apos.task.getAnonReq({
208 | locale,
209 | mode: 'published'
210 | });
211 | },
212 | writeSitemap: function() {
213 | if (!self.perLocale) {
214 | // Simple single-file sitemap
215 | self.file = self.updatingCache
216 | ? 'sitemap.xml'
217 | : (self.apos.argv.file || '/dev/stdout');
218 |
219 | const map = Object.keys(self.maps).map(locale => {
220 | return self.maps[locale].map(self.stringify).join('\n');
221 | }).join('\n');
222 |
223 | self.writeMap(self.file, map);
224 | } else {
225 | // They should be broken down by host, in which case we automatically
226 | // place them in public/sitemaps in a certain naming pattern
227 | self.ensureDir('sitemaps');
228 |
229 | for (const key in self.maps) {
230 | let map = self.maps[key];
231 | // TODO: Revisit when supporting text format
232 | const extension = (self.format === 'xml') ? 'xml' : 'txt';
233 |
234 | map = map.map(self.stringify).join('\n');
235 |
236 | self.writeMap('sitemaps/' + key + '.' + extension, map);
237 |
238 | }
239 |
240 | self.writeIndex();
241 | }
242 | if (self.updatingCache) {
243 | return self.writeToCache();
244 | }
245 | return null;
246 | },
247 | writeToCache: async function(callback) {
248 | await self.apos.cache.clear(sitemapCacheName);
249 | await insert();
250 |
251 | async function insert() {
252 | for (const doc of self.cacheOutput) {
253 | await self.apos.cache.set(
254 | sitemapCacheName,
255 | doc.filename,
256 | doc,
257 | self.cacheLifetime
258 | );
259 | }
260 | }
261 |
262 | return null;
263 | },
264 | writeIndex: function() {
265 | const now = new Date();
266 | if (!self.baseUrl) {
267 | throw new Error(noBaseUrlWarning);
268 | }
269 |
270 | self.writeFile('sitemaps/index.xml',
271 |
272 | '\n' +
273 | '\n' +
275 |
276 | Object.keys(self.maps).map(function(key) {
277 | const sitemap = ' \n' +
278 | ' ' + self.baseUrl + self.apos.prefix + '/sitemaps/' + key + '.xml' +
279 | '\n' +
280 | ' ' + now.toISOString() + '\n' +
281 | ' \n';
282 | return sitemap;
283 | }).join('') +
284 | '\n'
285 | );
286 |
287 | },
288 | writeMap: function(file, map) {
289 | // TODO: Revisit when supporting text format
290 | if (self.format === 'xml') {
291 | self.writeXmlMap(file, map);
292 | } else {
293 | self.writeFile(file, map);
294 | }
295 | },
296 | writeXmlMap: function(file, map) {
297 | self.writeFile(file,
298 | '\n' +
299 | '\n' +
301 | map +
302 | '\n'
303 | );
304 | },
305 | writeFile: function(filename, str) {
306 | if (!self.updatingCache) {
307 | filename = require('path').resolve(self.apos.rootDir + '/public', filename);
308 | if (filename === '/dev/stdout') {
309 | // Strange bug on MacOS when using writeFileSync with /dev/stdout
310 | fs.writeSync(1, str);
311 | } else {
312 | fs.writeFileSync(filename, str);
313 | }
314 | } else {
315 | self.cacheOutput.push({
316 | filename,
317 | data: str,
318 | createdAt: new Date()
319 | });
320 | }
321 | },
322 | async getPages (req) {
323 | const pages = await self.apos.page.find(req, {}).areas(false)
324 | .relationships(false).sort({
325 | level: 1,
326 | rank: 1
327 | }).toArray();
328 |
329 | pages.forEach(self.output);
330 | },
331 | async getPieces(req) {
332 | const modules = Object.values(self.apos.modules).filter(function(mod) {
333 | return mod.__meta.chain.find(entry => {
334 | return entry.name === '@apostrophecms/piece-type';
335 | });
336 | });
337 |
338 | let skip = 0;
339 |
340 | for (const appModule of modules) {
341 | if (self.excludeTypes.includes(appModule.__meta.name)) {
342 | continue;
343 | }
344 | await stashPieces(appModule);
345 | skip = 0;
346 | }
347 |
348 | async function stashPieces(appModule) {
349 | // Paginate through 100 (by default) at a time to avoid slamming
350 | // memory
351 | const pieceSet = await appModule.find(req, {})
352 | .relationships(false).areas(false).skip(skip)
353 | .limit(self.piecesPerBatch).toArray();
354 |
355 | pieceSet.forEach(function(piece) {
356 | if (!piece._url) {
357 | // This one has no page to be viewed on
358 | return;
359 | }
360 | // Results in a reasonable priority relative
361 | // to regular pages
362 | piece.level = 3;
363 |
364 | self.output(piece);
365 | });
366 |
367 | if (pieceSet.length) {
368 | skip += pieceSet.length;
369 |
370 | await stashPieces(appModule);
371 | }
372 | }
373 | },
374 | // Output the sitemap entry for the given doc, including its children if
375 | // any. The entry is buffered for output as part of the map for the
376 | // appropriate locale.
377 | output: async function(page) {
378 | const locale = (page.aposLocale || self.defaultLocale).split(':')[0];
379 |
380 | if (!self.excludeTypes.includes(page.type)) {
381 | let url;
382 |
383 | // TODO: Revisit when supporting text format
384 | if (self.format === 'text') {
385 | if (self.indent) {
386 | let i;
387 |
388 | for (i = 0; (i < page.level); i++) {
389 | self.write(locale, ' ');
390 | }
391 |
392 | self.write(locale, page._url + '\n');
393 | }
394 | } else {
395 | url = page._url;
396 | let priority = (page.level < 10) ? (1.0 - page.level / 10) : 0.1;
397 |
398 | if (typeof (page.siteMapPriority) === 'number') {
399 | priority = page.siteMapPriority;
400 | }
401 |
402 | self.write(locale, {
403 | url: {
404 | id: page.aposDocId,
405 | locale,
406 | priority,
407 | changefreq: 'daily',
408 | loc: url
409 | }
410 | });
411 | }
412 | }
413 |
414 | },
415 | // Append `str` to an array set aside for the map entries
416 | // for the host `locale`.
417 | write: function(locale, str) {
418 | self.maps[locale] = self.maps[locale] || [];
419 | self.maps[locale].push(str);
420 | },
421 | sendCache: async function(res, path) {
422 | try {
423 | const file = await self.apos.cache.get(sitemapCacheName, path);
424 |
425 | if (!file) {
426 | // If anything else exists in our little filesystem, this should be
427 | // a 404 (think of a URL like /sitemap/madeupstuff). Otherwise it
428 | // just means the cache has expired or has never been populated.
429 | //
430 | // Check for the sitemap index or, if we're not running in that
431 | // mode, check for sitemap.xml.
432 | //
433 | // Without this check every 404 would cause a lot of work to be
434 | // done.
435 | const sitemapFile = self.perLocale ? 'sitemaps/index.xml' : 'sitemap.xml';
436 | const exists = await self.apos.cache.get(sitemapCacheName, sitemapFile);
437 |
438 | if (exists) {
439 | return notFound();
440 | }
441 | return self.cacheAndRetry(res, path);
442 | }
443 | return res.contentType('text/xml').send(file.data);
444 | } catch (error) {
445 | return fail(error);
446 | }
447 |
448 | function notFound() {
449 | return res.status(404).send('not found');
450 | }
451 |
452 | function fail(err) {
453 | self.apos.util.error(err);
454 | return res.status(500).send('error');
455 | }
456 | },
457 | cacheAndRetry: async function(res, path) {
458 | try {
459 | await self.map();
460 | return self.sendCache(res, path);
461 | } catch (error) {
462 | return fail(error);
463 | }
464 |
465 | function fail(err) {
466 | self.apos.util.error('cacheAndRetry error:', err);
467 | return res.status(500).send('error');
468 | }
469 | },
470 | stringify(value) {
471 | // TODO: Revisit when supporting text format
472 | if (Array.isArray(value) && (self.format !== 'xml')) {
473 | return value.join('');
474 | }
475 | if (typeof (value) !== 'object') {
476 | // TODO: Revisit when supporting text format
477 | if (self.format === 'xml') {
478 | return self.apos.util.escapeHtml(value);
479 | }
480 | return value;
481 | }
482 | let xml = '';
483 | for (const k in value) {
484 | const v = value[k];
485 | if (k === '_attributes') {
486 | return;
487 | }
488 | if (Array.isArray(v)) {
489 | v.forEach(function(el) {
490 | element(k, el);
491 | });
492 | } else {
493 | element(k, v);
494 | }
495 | }
496 |
497 | function element(k, v) {
498 | xml += '<' + k;
499 | if (v && v._attributes) {
500 | for (const a in v._attributes) {
501 | const av = v._attributes[a];
502 |
503 | xml += ' ' + a + '="' + self.apos.util.escapeHtml(av) + '"';
504 | }
505 | }
506 | // Ensure that empty tags are self-closing
507 | const value = self.stringify(v || '');
508 | if (typeof value === 'undefined' || value === '') {
509 | xml += ' />\n';
510 | return;
511 | }
512 | xml += '>';
513 | xml += value;
514 | xml += '' + k + '>\n';
515 | }
516 |
517 | return xml;
518 | },
519 | ensureDir (dir) {
520 | if (!self.updatingCache) {
521 | dir = self.apos.rootDir + '/public/' + dir;
522 | try {
523 | fs.mkdirSync(dir);
524 | } catch (e) {
525 | // The directory already exists.
526 | }
527 | }
528 | }
529 | // End of methods obj
530 | };
531 | }
532 | };
533 |
--------------------------------------------------------------------------------
/test/test.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | const assert = require('assert');
3 | const t = require('apostrophe/test-lib/util');
4 |
5 | describe('Apostrophe Sitemap', function() {
6 | let apos;
7 | let testDraftProduct;
8 |
9 | this.timeout(t.timeout);
10 |
11 | after(async function() {
12 | return t.destroy(apos);
13 | });
14 |
15 | it('should be a property of the apos object', async function() {
16 | const appConfig = getAppConfig();
17 |
18 | await t.create({
19 | root: module,
20 | baseUrl: 'http://localhost:7780',
21 | testModule: true,
22 | modules: {
23 | ...appConfig,
24 | testRunner: {
25 | handlers(self) {
26 | return {
27 | 'apostrophe:afterInit': {
28 | checkSitemap () {
29 | apos = self.apos;
30 | assert(self.apos.modules['@apostrophecms/sitemap']);
31 | }
32 | }
33 | };
34 | }
35 | }
36 | }
37 | });
38 | });
39 |
40 | it('insert a product for test purposes', async function() {
41 | testDraftProduct = apos.product.newInstance();
42 | testDraftProduct.title = 'Cheese';
43 | testDraftProduct.slug = 'cheese';
44 |
45 | const inserted = await apos.product.insert(apos.task.getReq(), testDraftProduct);
46 |
47 | assert(inserted._id);
48 | assert(inserted.slug === 'cheese');
49 | });
50 |
51 | it('insert an unpublished product for test purposes', async function() {
52 | const rockProduct = apos.product.newInstance();
53 | rockProduct.title = 'Rocks';
54 | rockProduct.slug = 'rocks';
55 | rockProduct.published = false;
56 |
57 | const inserted = await apos.product.insert(apos.task.getReq({
58 | mode: 'draft'
59 | }), rockProduct);
60 |
61 | assert(inserted.aposMode === 'draft');
62 | assert(inserted.published === false);
63 | assert(inserted.slug === 'rocks');
64 | });
65 |
66 | it('should generate a suitable sitemap', async function() {
67 | try {
68 | const xml = await apos.http.get('/sitemap.xml');
69 |
70 | assert(xml);
71 | assert(xml.indexOf('http://localhost:7780/') !== -1);
72 | assert(xml.indexOf('') !== -1);
73 | assert(xml.indexOf('http://localhost:7780/tab-one') !== -1);
74 | assert(xml.indexOf('') !== -1);
75 | assert(xml.indexOf('http://localhost:7780/tab-two') !== -1);
76 | assert(xml.indexOf('') !== -1);
77 | assert(xml.indexOf('http://localhost:7780/tab-one/child-one') !== -1);
78 | assert(xml.indexOf('') !== -1);
79 | assert(xml.indexOf('http://localhost:7780/products') !== -1);
80 | assert(xml.indexOf('') !== -1);
81 | assert(xml.indexOf('http://localhost:7780/products/cheese') !== -1);
82 | assert(xml.indexOf('') !== -1);
83 | assert(xml.indexOf('http://localhost:7780/products/rocks') === -1);
84 | assert(xml.indexOf('') === -1);
85 | } catch (error) {
86 | assert(!error);
87 | }
88 | });
89 |
90 | it('should destroy the app', async function () {
91 | return t.destroy(apos);
92 | });
93 |
94 | it('should be a property of the 🆕 apos object that excludes products', async function() {
95 | const appConfig = getAppConfig({
96 | excludeTypes: [ 'product-page', 'product' ]
97 | });
98 |
99 | apos = await t.create({
100 | root: module,
101 | baseUrl: 'http://localhost:7780',
102 | testModule: true,
103 | modules: {
104 | ...appConfig,
105 | testRunner: {
106 | handlers(self) {
107 | return {
108 | 'apostrophe:afterInit': {
109 | checkSitemap () {
110 | apos = self.apos;
111 | assert(self.apos.modules['@apostrophecms/sitemap']);
112 | }
113 | }
114 | };
115 | }
116 | }
117 | }
118 | });
119 | });
120 |
121 | it('insert 🧀 again', async function() {
122 | testDraftProduct = apos.product.newInstance();
123 | testDraftProduct.title = 'Cheese';
124 | testDraftProduct.slug = 'cheese';
125 |
126 | const inserted = await apos.product.insert(apos.task.getReq(), testDraftProduct);
127 |
128 | assert(inserted._id);
129 | assert(inserted.slug === 'cheese');
130 | });
131 |
132 | it('should generate a sitemap without products or product pages', async function() {
133 | try {
134 | const xml = await apos.http.get('/sitemap.xml');
135 |
136 | assert(xml);
137 | assert(xml.indexOf('http://localhost:7780/') !== -1);
138 | assert(xml.indexOf('http://localhost:7780/tab-one') !== -1);
139 | assert(xml.indexOf('http://localhost:7780/tab-two') !== -1);
140 | assert(xml.indexOf('http://localhost:7780/tab-one/child-one') !== -1);
141 |
142 | assert(xml.indexOf('http://localhost:7780/products') === -1);
143 | assert(xml.indexOf('http://localhost:7780/products/cheese') === -1);
144 | assert(xml.indexOf('http://localhost:7780/products/rocks') === -1);
145 | } catch (error) {
146 | assert(!error);
147 | }
148 | });
149 |
150 | it('should create new multi-language app', async function () {
151 | await t.destroy(apos);
152 |
153 | const appConfig = getAppConfig({
154 | multilanguage: true
155 | });
156 | apos = await t.create({
157 | root: module,
158 | baseUrl: 'http://localhost:7780',
159 | testModule: true,
160 | modules: appConfig
161 | });
162 |
163 | assert.deepEqual(Object.keys(apos.i18n.getLocales()), [ 'en', 'es', 'fr' ]);
164 |
165 | {
166 | const rockProduct = apos.product.newInstance();
167 | rockProduct.title = 'Rocks';
168 | rockProduct.slug = 'rocks';
169 | rockProduct.published = false;
170 |
171 | const inserted = await apos.product.insert(apos.task.getReq({
172 | mode: 'draft'
173 | }), rockProduct);
174 |
175 | assert(inserted.aposMode === 'draft');
176 | assert(inserted.published === false);
177 | assert(inserted.slug === 'rocks');
178 | }
179 |
180 | {
181 | const cheeseProduct = apos.product.newInstance();
182 | cheeseProduct.title = 'Cheese';
183 | cheeseProduct.slug = 'cheese';
184 |
185 | const inserted = await apos.product.insert(apos.task.getReq(), cheeseProduct);
186 | await apos.product.publish(apos.task.getReq(), inserted);
187 | inserted.slug = 'cheese-es';
188 | const localized = await apos.product.localize(apos.task.getReq(), inserted, 'es');
189 | await apos.product.publish(apos.task.getReq(), localized);
190 |
191 | assert(inserted._id);
192 | assert(inserted._id !== localized._id);
193 | assert(localized.slug === 'cheese-es');
194 | }
195 | });
196 |
197 | it('should generate a multi-language sitemap', async function () {
198 | const xml = await apos.http.get('/sitemap.xml');
199 |
200 | assert(xml);
201 | // Home
202 | assert(
203 | xml.indexOf(
204 | 'http://localhost:7780/\n' +
205 | '\n' +
206 | '\n' +
207 | '\n'
208 | ) !== -1
209 | );
210 | assert(
211 | xml.indexOf(
212 | 'http://localhost:7780/es/\n' +
213 | '\n' +
214 | '\n' +
215 | '\n'
216 | ) !== -1
217 | );
218 | assert(
219 | xml.indexOf(
220 | 'http://fr.example.com/\n' +
221 | '\n' +
222 | '\n' +
223 | '\n'
224 | ) !== -1
225 | );
226 | // Child One
227 | assert(
228 | xml.indexOf(
229 | 'http://localhost:7780/tab-one/child-one\n' +
230 | '\n' +
231 | '\n' +
232 | '\n'
233 | ) !== -1
234 | );
235 | assert(
236 | xml.indexOf(
237 | 'http://localhost:7780/es/tab-one/child-one\n' +
238 | '\n' +
239 | '\n' +
240 | '\n'
241 | ) !== -1
242 | );
243 | assert(
244 | xml.indexOf(
245 | 'http://fr.example.com/tab-one/child-one\n' +
246 | '\n' +
247 | '\n' +
248 | '\n'
249 | ) !== -1
250 | );
251 | // Product Cheese
252 | assert(
253 | xml.indexOf(
254 | 'http://localhost:7780/products/cheese\n' +
255 | '\n' +
256 | ''
257 | ) !== -1
258 | );
259 | assert(
260 | xml.indexOf(
261 | 'http://localhost:7780/es/products/cheese-es\n' +
262 | '\n' +
263 | '\n'
264 | ) !== -1
265 | );
266 | assert(
267 | xml.indexOf(
268 | 'http://fr.example.com/products/cheese'
269 | ) === -1
270 | );
271 | assert(
272 | xml.indexOf(
273 | ''
274 | ) === -1
275 | );
276 | });
277 |
278 | it('should create new multi-language app', async function () {
279 | await t.destroy(apos);
280 |
281 | const appConfig = getAppConfig({
282 | multilanguage: true
283 | });
284 | apos = await t.create({
285 | root: module,
286 | baseUrl: 'http://localhost:7780',
287 | testModule: true,
288 | modules: appConfig
289 | });
290 |
291 | assert.deepEqual(Object.keys(apos.i18n.getLocales()), [ 'en', 'es', 'fr' ]);
292 |
293 | {
294 | const rockProduct = apos.product.newInstance();
295 | rockProduct.title = 'Rocks';
296 | rockProduct.slug = 'rocks';
297 | rockProduct.published = false;
298 |
299 | const inserted = await apos.product.insert(apos.task.getReq({
300 | mode: 'draft'
301 | }), rockProduct);
302 |
303 | assert(inserted.aposMode === 'draft');
304 | assert(inserted.published === false);
305 | assert(inserted.slug === 'rocks');
306 | }
307 |
308 | {
309 | const cheeseProduct = apos.product.newInstance();
310 | cheeseProduct.title = 'Cheese';
311 | cheeseProduct.slug = 'cheese';
312 |
313 | const inserted = await apos.product.insert(apos.task.getReq(), cheeseProduct);
314 | await apos.product.publish(apos.task.getReq(), inserted);
315 | inserted.slug = 'cheese-es';
316 | const localized = await apos.product.localize(apos.task.getReq(), inserted, 'es');
317 | await apos.product.publish(apos.task.getReq(), localized);
318 |
319 | assert(inserted._id);
320 | assert(inserted._id !== localized._id);
321 | assert(localized.slug === 'cheese-es');
322 | }
323 | });
324 |
325 | it('should generate a multi-language sitemap', async function () {
326 | const xml = await apos.http.get('/sitemap.xml');
327 |
328 | assert(xml);
329 | // Home
330 | assert(
331 | xml.indexOf(
332 | 'http://localhost:7780/\n' +
333 | '\n' +
334 | '\n' +
335 | '\n'
336 | ) !== -1
337 | );
338 | assert(
339 | xml.indexOf(
340 | 'http://localhost:7780/es/\n' +
341 | '\n' +
342 | '\n' +
343 | '\n'
344 | ) !== -1
345 | );
346 | assert(
347 | xml.indexOf(
348 | 'http://fr.example.com/\n' +
349 | '\n' +
350 | '\n' +
351 | '\n'
352 | ) !== -1
353 | );
354 | // Child One
355 | assert(
356 | xml.indexOf(
357 | 'http://localhost:7780/tab-one/child-one\n' +
358 | '\n' +
359 | '\n' +
360 | '\n'
361 | ) !== -1
362 | );
363 | assert(
364 | xml.indexOf(
365 | 'http://localhost:7780/es/tab-one/child-one\n' +
366 | '\n' +
367 | '\n' +
368 | '\n'
369 | ) !== -1
370 | );
371 | assert(
372 | xml.indexOf(
373 | 'http://fr.example.com/tab-one/child-one\n' +
374 | '\n' +
375 | '\n' +
376 | '\n'
377 | ) !== -1
378 | );
379 | // Product Cheese
380 | assert(
381 | xml.indexOf(
382 | 'http://localhost:7780/products/cheese\n' +
383 | '\n' +
384 | ''
385 | ) !== -1
386 | );
387 | assert(
388 | xml.indexOf(
389 | 'http://localhost:7780/es/products/cheese-es\n' +
390 | '\n' +
391 | '\n'
392 | ) !== -1
393 | );
394 | assert(
395 | xml.indexOf(
396 | 'http://fr.example.com/products/cheese'
397 | ) === -1
398 | );
399 | assert(
400 | xml.indexOf(
401 | ''
402 | ) === -1
403 | );
404 | });
405 |
406 | it('should create app with perLocale option enabled', async function () {
407 | await t.destroy(apos);
408 |
409 | const appConfig = getAppConfig({
410 | multilanguage: true,
411 | perLocale: true
412 | });
413 |
414 | apos = await t.create({
415 | root: module,
416 | baseUrl: 'http://localhost:7780',
417 | testModule: true,
418 | modules: appConfig
419 | });
420 |
421 | assert.deepEqual(Object.keys(apos.i18n.getLocales()), [ 'en', 'es', 'fr' ]);
422 |
423 | // Add test content
424 | {
425 | const rockProduct = apos.product.newInstance();
426 | rockProduct.title = 'Rocks';
427 | rockProduct.slug = 'rocks';
428 | rockProduct.published = false;
429 |
430 | const inserted = await apos.product.insert(apos.task.getReq({
431 | mode: 'draft'
432 | }), rockProduct);
433 |
434 | assert(inserted.aposMode === 'draft');
435 | assert(inserted.published === false);
436 | assert(inserted.slug === 'rocks');
437 | }
438 |
439 | {
440 | const cheeseProduct = apos.product.newInstance();
441 | cheeseProduct.title = 'Cheese';
442 | cheeseProduct.slug = 'cheese';
443 |
444 | const inserted = await apos.product.insert(apos.task.getReq(), cheeseProduct);
445 | await apos.product.publish(apos.task.getReq(), inserted);
446 | inserted.slug = 'cheese-es';
447 | const localized = await apos.product.localize(apos.task.getReq(), inserted, 'es');
448 | await apos.product.publish(apos.task.getReq(), localized);
449 |
450 | assert(inserted._id);
451 | assert(inserted._id !== localized._id);
452 | assert(localized.slug === 'cheese-es');
453 | }
454 | });
455 |
456 | it('should generate sitemap index when perLocale is true without crashing', async function () {
457 | try {
458 | // Test that sitemap index is accessible
459 | const indexXml = await apos.http.get('/sitemaps/index.xml');
460 |
461 | assert(indexXml);
462 | assert(indexXml.indexOf('') !== -1);
464 | assert(indexXml.indexOf('http://localhost:7780/sitemaps/en.xml') !== -1);
465 | assert(indexXml.indexOf('http://localhost:7780/sitemaps/es.xml') !== -1);
466 | assert(indexXml.indexOf('http://localhost:7780/sitemaps/fr.xml') !== -1);
467 | assert(indexXml.indexOf('') !== -1);
468 |
469 | // Test that individual locale sitemaps are accessible
470 | const enXml = await apos.http.get('/sitemaps/en.xml');
471 | assert(enXml);
472 | assert(enXml.indexOf('http://localhost:7780/') !== -1);
474 | assert(enXml.indexOf('http://localhost:7780/products/cheese') !== -1);
475 |
476 | const esXml = await apos.http.get('/sitemaps/es.xml');
477 | assert(esXml);
478 | assert(esXml.indexOf('http://localhost:7780/es/') !== -1);
480 |
481 | const frXml = await apos.http.get('/sitemaps/fr.xml');
482 | assert(frXml);
483 | assert(frXml.indexOf('http://fr.example.com/') !== -1);
485 |
486 | } catch (error) {
487 | // If this fails, it means the perLocale option is causing crashes
488 | assert(!error, `perLocale sitemap generation failed: ${error.message}`);
489 | }
490 | });
491 |
492 | it('should verify sitemap.xml does not crash site when perLocale is true', async function () {
493 | try {
494 | // When perLocale is true, the main sitemap.xml should not exist
495 | // and should return a 404 or redirect to the index
496 | const response = await apos.http.get('/sitemap.xml');
497 |
498 | // This might be a 404 or might redirect to index - either is acceptable
499 | // The important thing is that it doesn't crash the application
500 | assert(response !== undefined);
501 |
502 | } catch (error) {
503 | // A 404 is expected behavior when perLocale is true
504 | // We just want to make sure the application doesn't crash
505 | assert(error.status === 404 || error.message.includes('404'));
506 | }
507 | });
508 | });
509 |
510 | const parkedPages = [
511 | {
512 | title: 'Tab One',
513 | type: 'default-page',
514 | slug: '/tab-one',
515 | parkedId: 'tabOne',
516 | _children: [
517 | {
518 | title: 'Tab One Child One',
519 | type: 'default-page',
520 | slug: '/tab-one/child-one',
521 | parkedId: 'tabOneChildOne'
522 | },
523 | {
524 | title: 'Tab One Child Two',
525 | type: 'default-page',
526 | slug: '/tab-one/child-two',
527 | parkedId: 'tabOneChildTwo'
528 | }
529 | ]
530 | },
531 | {
532 | title: 'Tab Two',
533 | type: 'default-page',
534 | slug: '/tab-two',
535 | parkedId: 'tabTwo',
536 | _children: [
537 | {
538 | title: 'Tab Two Child One',
539 | type: 'default-page',
540 | slug: '/tab-two/child-one',
541 | parkedId: 'tabTwoChildOne'
542 | },
543 | {
544 | title: 'Tab Two Child Two',
545 | type: 'default-page',
546 | slug: '/tab-two/child-two',
547 | parkedId: 'tabTwoChildTwo'
548 | }
549 | ]
550 | },
551 | {
552 | title: 'Products',
553 | type: 'product-page',
554 | slug: '/products',
555 | parkedId: 'products'
556 | }
557 | ];
558 |
559 | const pageTypes = [
560 | {
561 | name: '@apostrophecms/home-page',
562 | label: 'Home'
563 | },
564 | {
565 | name: 'default-page',
566 | label: 'Default'
567 | },
568 | {
569 | name: 'product-page',
570 | label: 'Products'
571 | }
572 | ];
573 |
574 | function getAppConfig (options = {}) {
575 | return {
576 | '@apostrophecms/express': {
577 | options: {
578 | port: 7780,
579 | session: { secret: 'supersecret' }
580 | }
581 | },
582 | ...(options.multilanguage
583 | ? {
584 | '@apostrophecms/i18n': {
585 | options: {
586 | defaultLocale: 'en',
587 | locales: {
588 | en: {
589 | label: 'English'
590 | },
591 | es: {
592 | label: 'Español',
593 | prefix: '/es'
594 | },
595 | fr: {
596 | label: 'Français',
597 | hostname: 'fr.example.com'
598 | }
599 | }
600 | }
601 | }
602 | }
603 | : {}
604 | ),
605 | '@apostrophecms/sitemap': {
606 | options: {
607 | excludeTypes: options.excludeTypes,
608 | perLocale: options.perLocale || false
609 | }
610 | },
611 | '@apostrophecms/page': {
612 | options: {
613 | park: parkedPages,
614 | types: pageTypes
615 | }
616 | },
617 | 'default-page': {
618 | extend: '@apostrophecms/page-type'
619 | },
620 | product: {
621 | extend: '@apostrophecms/piece-type',
622 | options: {
623 | alias: 'product'
624 | }
625 | },
626 | 'product-page': {
627 | extend: '@apostrophecms/piece-page-type'
628 | }
629 | };
630 | }
631 |
--------------------------------------------------------------------------------