├── .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 |
2 | ApostropheCMS logo 3 | 4 |

Sitemap generator for ApostropheCMS

5 |

6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |

16 |
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 += '\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 | --------------------------------------------------------------------------------