feat: improved sitemap (#3579)
* feat: extended sitemap functionality
* docs: del samples
* docs: readme
* feat: new sitemap
* feat: createLinkInHead removed
* docs: updated changeset text
* refactor: 'zod' function() instead of self made refine()
* Revert "refactor: 'zod' function() instead of self made refine()"
This reverts commit 036bac730d
.
undo function()
This commit is contained in:
parent
44ba4e1ed9
commit
1031c06f9c
15 changed files with 607 additions and 76 deletions
18
.changeset/popular-cherries-float.md
Normal file
18
.changeset/popular-cherries-float.md
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
---
|
||||||
|
'@astrojs/sitemap': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
# Key features
|
||||||
|
|
||||||
|
- Split up your large sitemap into multiple sitemaps by custom limit.
|
||||||
|
- Ability to add sitemap specific attributes such as `lastmod` etc.
|
||||||
|
- Final output customization via JS function.
|
||||||
|
- Localization support.
|
||||||
|
- Reliability: all config options are validated.
|
||||||
|
|
||||||
|
## Important changes
|
||||||
|
|
||||||
|
The integration always generates at least two files instead of one:
|
||||||
|
|
||||||
|
- `sitemap-index.xml` - index file;
|
||||||
|
- `sitemap-{i}.xml` - actual sitemap.
|
|
@ -9,5 +9,6 @@ import solid from '@astrojs/solid-js';
|
||||||
|
|
||||||
// https://astro.build/config
|
// https://astro.build/config
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
site: 'https://example.com',
|
||||||
integrations: [lit(), react(), tailwind(), turbolinks(), partytown(), sitemap(), solid()],
|
integrations: [lit(), react(), tailwind(), turbolinks(), partytown(), sitemap(), solid()],
|
||||||
});
|
});
|
||||||
|
|
|
@ -64,7 +64,35 @@ export default {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Now, [build your site for production](https://docs.astro.build/en/reference/cli-reference/#astro-build) via the `astro build` command. You should find your sitemap under `dist/sitemap.xml`!
|
Now, [build your site for production](https://docs.astro.build/en/reference/cli-reference/#astro-build) via the `astro build` command. You should find your _sitemap_ under `dist/sitemap-index.xml` and `dist/sitemap-0.xml`!
|
||||||
|
|
||||||
|
Generated sitemap content for two pages website:
|
||||||
|
|
||||||
|
**sitemap-index.xml**
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<sitemap>
|
||||||
|
<loc>https://stargazers.club/sitemap-0.xml</loc>
|
||||||
|
</sitemap>
|
||||||
|
</sitemapindex>
|
||||||
|
```
|
||||||
|
|
||||||
|
**sitemap-0.xml**
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1">
|
||||||
|
<url>
|
||||||
|
<loc>https://stargazers.club/</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://stargazers.club/second-page/</loc>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
|
```
|
||||||
|
|
||||||
You can also check our [Astro Integration Documentation][astro-integration] for more on integrations.
|
You can also check our [Astro Integration Documentation][astro-integration] for more on integrations.
|
||||||
|
|
||||||
|
@ -111,5 +139,158 @@ export default {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### entryLimit
|
||||||
|
|
||||||
|
Non-negative `Number` of entries per sitemap file. Default value is 45000. A sitemap index and multiple sitemaps are created if you have more entries. See explanation on [Google](https://developers.google.com/search/docs/advanced/sitemaps/large-sitemaps).
|
||||||
|
|
||||||
|
__astro.config.mjs__
|
||||||
|
|
||||||
|
```js
|
||||||
|
import sitemap from '@astrojs/sitemap';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
site: 'https://stargazers.club',
|
||||||
|
integrations: [
|
||||||
|
sitemap({
|
||||||
|
entryLimit: 10000,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### changefreq, lastmod, priority
|
||||||
|
|
||||||
|
`changefreq` - How frequently the page is likely to change. Available values: `always` \| `hourly` \| `daily` \| `weekly` \| `monthly` \| `yearly` \| `never`.
|
||||||
|
|
||||||
|
`priority` - The priority of this URL relative to other URLs on your site. Valid values range from 0.0 to 1.0.
|
||||||
|
|
||||||
|
`lastmod` - The date of page last modification.
|
||||||
|
|
||||||
|
`changefreq` and `priority` are ignored by Google.
|
||||||
|
|
||||||
|
See detailed explanation of sitemap specific options on [sitemap.org](https://www.sitemaps.org/protocol.html).
|
||||||
|
|
||||||
|
|
||||||
|
:exclamation: This integration uses 'astro:build:done' hook. The hook exposes generated page paths only. So with present version of Astro the integration has no abilities to analyze a page source, frontmatter etc. The integration can add `changefreq`, `lastmod` and `priority` attributes only in a batch or nothing.
|
||||||
|
|
||||||
|
__astro.config.mjs__
|
||||||
|
|
||||||
|
```js
|
||||||
|
import sitemap from '@astrojs/sitemap';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
site: 'https://stargazers.club',
|
||||||
|
integrations: [
|
||||||
|
sitemap({
|
||||||
|
changefreq: 'weekly',
|
||||||
|
priority: 0.7,
|
||||||
|
lastmod: new Date('2022-02-24'),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### serialize
|
||||||
|
|
||||||
|
Async or sync function called for each sitemap entry just before writing to a disk.
|
||||||
|
|
||||||
|
It receives as parameter `SitemapItem` object which consists of `url` (required, absolute page URL) and optional `changefreq`, `lastmod`, `priority` and `links` properties.
|
||||||
|
|
||||||
|
Optional `links` property contains a `LinkItem` list of alternate pages including a parent page.
|
||||||
|
`LinkItem` type has two required fields: `url` (the fully-qualified URL for the version of this page for the specified language) and `hreflang` (a supported language code targeted by this version of the page).
|
||||||
|
|
||||||
|
`serialize` function should return `SitemapItem`, touched or not.
|
||||||
|
|
||||||
|
The example below shows the ability to add the sitemap specific properties individually.
|
||||||
|
|
||||||
|
__astro.config.mjs__
|
||||||
|
|
||||||
|
```js
|
||||||
|
import sitemap from '@astrojs/sitemap';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
site: 'https://stargazers.club',
|
||||||
|
integrations: [
|
||||||
|
sitemap({
|
||||||
|
serialize(item) {
|
||||||
|
if (/your-special-page/.test(item.url)) {
|
||||||
|
item.changefreq = 'daily';
|
||||||
|
item.lastmod = new Date();
|
||||||
|
item.priority = 0.9;
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
To localize a sitemap you should supply the integration config with the `i18n` option. The integration will check generated page paths on presence of locale keys in paths.
|
||||||
|
|
||||||
|
`i18n` object has two required properties:
|
||||||
|
|
||||||
|
- `defaultLocale`: `String`. Its value must exist as one of `locales` keys.
|
||||||
|
- `locales`: `Record<String, String>`, key/value - pairs. The key is used to look for a locale part in a page path. The value is a language attribute, only English alphabet and hyphen allowed. See more about language attribute on [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/lang).
|
||||||
|
|
||||||
|
|
||||||
|
Read more about localization on Google in [Advanced SEO](https://developers.google.com/search/docs/advanced/crawling/localized-versions#all-method-guidelines).
|
||||||
|
|
||||||
|
__astro.config.mjs__
|
||||||
|
|
||||||
|
```js
|
||||||
|
import sitemap from '@astrojs/sitemap';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
site: 'https://stargazers.club',
|
||||||
|
integrations: [
|
||||||
|
sitemap({
|
||||||
|
i18n: {
|
||||||
|
defaultLocale: 'en', // All urls that don't contain `es` or `fr` after `https://stargazers.club/` will be treated as default locale, i.e. `en`
|
||||||
|
locales: {
|
||||||
|
en: 'en-US', // The `defaultLocale` value must present in `locales` keys
|
||||||
|
es: 'es-ES',
|
||||||
|
fr: 'fr-CA',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
...
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The sitemap content will be:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
...
|
||||||
|
<url>
|
||||||
|
<loc>https://stargazers.club/</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-US" href="https://stargazers.club/"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="es-ES" href="https://stargazers.club/es/"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CA" href="https://stargazers.club/fr/"/>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://stargazers.club/es/</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-US" href="https://stargazers.club/"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="es-ES" href="https://stargazers.club/es/"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CA" href="https://stargazers.club/fr/"/>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://stargazers.club/fr/</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-US" href="https://stargazers.club/"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="es-ES" href="https://stargazers.club/es/"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CA" href="https://stargazers.club/fr/"/>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://stargazers.club/es/second-page/</loc>
|
||||||
|
<xhtml:link rel="alternate" hreflang="es-ES" href="https://stargazers.club/es/second-page/"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="fr-CA" href="https://stargazers.club/fr/second-page/"/>
|
||||||
|
<xhtml:link rel="alternate" hreflang="en-US" href="https://stargazers.club/second-page/"/>
|
||||||
|
</url>
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
[astro-integration]: https://docs.astro.build/en/guides/integrations-guide/
|
[astro-integration]: https://docs.astro.build/en/guides/integrations-guide/
|
||||||
[astro-ui-frameworks]: https://docs.astro.build/en/core-concepts/framework-components/#using-framework-components
|
[astro-ui-frameworks]: https://docs.astro.build/en/core-concepts/framework-components/#using-framework-components
|
||||||
|
|
|
@ -13,7 +13,8 @@
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"astro-component",
|
"astro-component",
|
||||||
"seo"
|
"seo",
|
||||||
|
"sitemap"
|
||||||
],
|
],
|
||||||
"bugs": "https://github.com/withastro/astro/issues",
|
"bugs": "https://github.com/withastro/astro/issues",
|
||||||
"homepage": "https://astro.build",
|
"homepage": "https://astro.build",
|
||||||
|
@ -21,12 +22,18 @@
|
||||||
".": "./dist/index.js",
|
".": "./dist/index.js",
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
|
"build": "astro-scripts build \"src/**/*.ts\" && tsc",
|
||||||
"build:ci": "astro-scripts build \"src/**/*.ts\"",
|
"build:ci": "astro-scripts build \"src/**/*.ts\"",
|
||||||
"dev": "astro-scripts dev \"src/**/*.ts\""
|
"dev": "astro-scripts dev \"src/**/*.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
|
"sitemap": "^7.1.1",
|
||||||
|
"zod": "^3.17.3"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"astro": "workspace:*",
|
"astro": "workspace:*",
|
||||||
"astro-scripts": "workspace:*"
|
"astro-scripts": "workspace:*"
|
||||||
|
|
5
packages/integrations/sitemap/src/config-defaults.ts
Normal file
5
packages/integrations/sitemap/src/config-defaults.ts
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
import type { SitemapOptions } from './index';
|
||||||
|
|
||||||
|
export const SITEMAP_CONFIG_DEFAULTS: SitemapOptions & any = {
|
||||||
|
entryLimit: 45000,
|
||||||
|
};
|
9
packages/integrations/sitemap/src/constants.ts
Normal file
9
packages/integrations/sitemap/src/constants.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export const changefreqValues = [
|
||||||
|
'always',
|
||||||
|
'hourly',
|
||||||
|
'daily',
|
||||||
|
'weekly',
|
||||||
|
'monthly',
|
||||||
|
'yearly',
|
||||||
|
'never',
|
||||||
|
] as const;
|
55
packages/integrations/sitemap/src/generate-sitemap.ts
Normal file
55
packages/integrations/sitemap/src/generate-sitemap.ts
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
import { SitemapItemLoose } from 'sitemap';
|
||||||
|
|
||||||
|
import type { SitemapOptions } from './index';
|
||||||
|
import { parseUrl } from './utils/parse-url';
|
||||||
|
|
||||||
|
const STATUS_CODE_PAGE_REGEXP = /\/[0-9]{3}\/?$/;
|
||||||
|
|
||||||
|
/** Construct sitemap.xml given a set of URLs */
|
||||||
|
export function generateSitemap(pages: string[], finalSiteUrl: string, opts: SitemapOptions) {
|
||||||
|
const { changefreq, priority: prioritySrc, lastmod: lastmodSrc, i18n } = opts || {};
|
||||||
|
// TODO: find way to respect <link rel="canonical"> URLs here
|
||||||
|
const urls = [...pages].filter((url) => !STATUS_CODE_PAGE_REGEXP.test(url));
|
||||||
|
urls.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); // sort alphabetically so sitemap is same each time
|
||||||
|
|
||||||
|
const lastmod = lastmodSrc?.toISOString();
|
||||||
|
const priority = typeof prioritySrc === 'number' ? prioritySrc : undefined;
|
||||||
|
|
||||||
|
const { locales, defaultLocale } = i18n || {};
|
||||||
|
const localeCodes = Object.keys(locales || {});
|
||||||
|
|
||||||
|
const getPath = (url: string) => {
|
||||||
|
const result = parseUrl(url, i18n?.defaultLocale || '', localeCodes, finalSiteUrl);
|
||||||
|
return result?.path;
|
||||||
|
};
|
||||||
|
const getLocale = (url: string) => {
|
||||||
|
const result = parseUrl(url, i18n?.defaultLocale || '', localeCodes, finalSiteUrl);
|
||||||
|
return result?.locale;
|
||||||
|
};
|
||||||
|
|
||||||
|
const urlData = urls.map((url) => {
|
||||||
|
let links;
|
||||||
|
if (defaultLocale && locales) {
|
||||||
|
const currentPath = getPath(url);
|
||||||
|
if (currentPath) {
|
||||||
|
const filtered = urls.filter((subUrl) => getPath(subUrl) === currentPath);
|
||||||
|
if (filtered.length > 1) {
|
||||||
|
links = filtered.map((subUrl) => ({
|
||||||
|
url: subUrl,
|
||||||
|
lang: locales[getLocale(subUrl)!],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
links,
|
||||||
|
lastmod,
|
||||||
|
priority,
|
||||||
|
changefreq, // : changefreq as EnumChangefreq,
|
||||||
|
} as SitemapItemLoose;
|
||||||
|
});
|
||||||
|
|
||||||
|
return urlData;
|
||||||
|
}
|
|
@ -1,91 +1,138 @@
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
import type { AstroConfig, AstroIntegration } from 'astro';
|
import type { AstroConfig, AstroIntegration } from 'astro';
|
||||||
import fs from 'node:fs';
|
import { ZodError } from 'zod';
|
||||||
const STATUS_CODE_PAGE_REGEXP = /\/[0-9]{3}\/?$/;
|
import { LinkItem as LinkItemBase, SitemapItemLoose, simpleSitemapAndIndex } from 'sitemap';
|
||||||
|
|
||||||
type SitemapOptions =
|
import { Logger } from './utils/logger';
|
||||||
|
import { changefreqValues } from './constants';
|
||||||
|
import { validateOptions } from './validate-options';
|
||||||
|
import { generateSitemap } from './generate-sitemap';
|
||||||
|
|
||||||
|
export type ChangeFreq = typeof changefreqValues[number];
|
||||||
|
export type SitemapItem = Pick<
|
||||||
|
SitemapItemLoose,
|
||||||
|
'url' | 'lastmod' | 'changefreq' | 'priority' | 'links'
|
||||||
|
>;
|
||||||
|
export type LinkItem = LinkItemBase;
|
||||||
|
|
||||||
|
export type SitemapOptions =
|
||||||
| {
|
| {
|
||||||
/**
|
|
||||||
* All pages are included in your sitemap by default.
|
|
||||||
* With this config option, you can filter included pages by URL.
|
|
||||||
*
|
|
||||||
* The `page` function parameter is the full URL of your rendered page, including your `site` domain.
|
|
||||||
* Return `true` to include a page in your sitemap, and `false` to remove it.
|
|
||||||
*
|
|
||||||
* ```js
|
|
||||||
* filter: (page) => page !== 'http://example.com/secret-page'
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
filter?(page: string): boolean;
|
filter?(page: string): boolean;
|
||||||
|
customPages?: string[];
|
||||||
/**
|
|
||||||
* If you have any URL, not rendered by Astro, that you want to include in your sitemap,
|
|
||||||
* this config option will help you to include your array of custom pages in your sitemap.
|
|
||||||
*
|
|
||||||
* ```js
|
|
||||||
* customPages: ['http://example.com/custom-page', 'http://example.com/custom-page2']
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
customPages?: Array<string>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* If present, we use the `site` config option as the base for all sitemap URLs
|
|
||||||
* Use `canonicalURL` to override this
|
|
||||||
*/
|
|
||||||
canonicalURL?: string;
|
canonicalURL?: string;
|
||||||
|
|
||||||
|
i18n?: {
|
||||||
|
defaultLocale: string;
|
||||||
|
locales: Record<string, string>;
|
||||||
|
};
|
||||||
|
// number of entries per sitemap file
|
||||||
|
entryLimit?: number;
|
||||||
|
|
||||||
|
// sitemap specific
|
||||||
|
changefreq?: ChangeFreq;
|
||||||
|
lastmod?: Date;
|
||||||
|
priority?: number;
|
||||||
|
|
||||||
|
// called for each sitemap item just before to save them on disk, sync or async
|
||||||
|
serialize?(item: SitemapItemLoose): SitemapItemLoose;
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
/** Construct sitemap.xml given a set of URLs */
|
function formatConfigErrorMessage(err: ZodError) {
|
||||||
function generateSitemap(pages: string[]) {
|
const errorList = err.issues.map((issue) => ` ${issue.path.join('.')} ${issue.message + '.'}`);
|
||||||
// TODO: find way to respect <link rel="canonical"> URLs here
|
return errorList.join('\n');
|
||||||
const urls = [...pages].filter((url) => !STATUS_CODE_PAGE_REGEXP.test(url));
|
|
||||||
urls.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })); // sort alphabetically so sitemap is same each time
|
|
||||||
let sitemap = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">`;
|
|
||||||
for (const url of urls) {
|
|
||||||
sitemap += `<url><loc>${url}</loc></url>`;
|
|
||||||
}
|
|
||||||
sitemap += `</urlset>\n`;
|
|
||||||
return sitemap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function createPlugin({
|
const PKG_NAME = '@astrojs/sitemap';
|
||||||
filter,
|
const OUTFILE = 'sitemap-index.xml';
|
||||||
customPages,
|
|
||||||
canonicalURL,
|
const createPlugin = (options?: SitemapOptions): AstroIntegration => {
|
||||||
}: SitemapOptions = {}): AstroIntegration {
|
|
||||||
let config: AstroConfig;
|
let config: AstroConfig;
|
||||||
return {
|
return {
|
||||||
name: '@astrojs/sitemap',
|
name: PKG_NAME,
|
||||||
|
|
||||||
hooks: {
|
hooks: {
|
||||||
'astro:config:done': async ({ config: _config }) => {
|
'astro:config:done': async ({ config: cfg }) => {
|
||||||
config = _config;
|
config = cfg;
|
||||||
},
|
},
|
||||||
'astro:build:done': async ({ pages, dir }) => {
|
|
||||||
let finalSiteUrl: URL;
|
'astro:build:done': async ({ dir, pages }) => {
|
||||||
if (canonicalURL) {
|
const logger = new Logger(PKG_NAME);
|
||||||
finalSiteUrl = new URL(canonicalURL);
|
|
||||||
finalSiteUrl.pathname += finalSiteUrl.pathname.endsWith('/') ? '' : '/'; // normalizes the final url since it's provided by user
|
try {
|
||||||
} else if (config.site) {
|
const opts = validateOptions(config.site, options);
|
||||||
finalSiteUrl = new URL(config.base, config.site);
|
|
||||||
} else {
|
const { filter, customPages, canonicalURL, serialize, entryLimit } = opts;
|
||||||
console.warn(
|
|
||||||
'The Sitemap integration requires either the `site` astro.config option or `canonicalURL` integration option. Skipping.'
|
let finalSiteUrl: URL;
|
||||||
);
|
if (canonicalURL) {
|
||||||
return;
|
finalSiteUrl = new URL(canonicalURL);
|
||||||
|
if (!finalSiteUrl.pathname.endsWith('/')) {
|
||||||
|
finalSiteUrl.pathname += '/'; // normalizes the final url since it's provided by user
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// `validateOptions` forces to provide `canonicalURL` or `config.site` at least.
|
||||||
|
// So step to check on empty values of `canonicalURL` and `config.site` is dropped.
|
||||||
|
finalSiteUrl = new URL(config.base, config.site);
|
||||||
|
}
|
||||||
|
|
||||||
|
let pageUrls = pages.map((p) => {
|
||||||
|
const path = finalSiteUrl.pathname + p.pathname;
|
||||||
|
return new URL(path, finalSiteUrl).href;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (filter) {
|
||||||
|
pageUrls = pageUrls.filter(filter);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Error filtering pages\n${(err as any).toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (customPages) {
|
||||||
|
pageUrls = [...pageUrls, ...customPages];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageUrls.length === 0) {
|
||||||
|
logger.warn(`No data for sitemap.\n\`${OUTFILE}\` is not created.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let urlData = generateSitemap(pageUrls, finalSiteUrl.href, opts);
|
||||||
|
|
||||||
|
if (serialize) {
|
||||||
|
try {
|
||||||
|
const serializedUrls: SitemapItemLoose[] = [];
|
||||||
|
for (const item of urlData) {
|
||||||
|
const serialized = await Promise.resolve(serialize(item));
|
||||||
|
serializedUrls.push(serialized);
|
||||||
|
}
|
||||||
|
urlData = serializedUrls;
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(`Error serializing pages\n${(err as any).toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await simpleSitemapAndIndex({
|
||||||
|
hostname: finalSiteUrl.href,
|
||||||
|
destinationDir: fileURLToPath(dir),
|
||||||
|
sourceData: urlData,
|
||||||
|
limit: entryLimit,
|
||||||
|
gzip: false,
|
||||||
|
});
|
||||||
|
logger.success(`\`${OUTFILE}\` is created.`);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
logger.warn(formatConfigErrorMessage(err));
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let pageUrls = pages.map((p) => {
|
|
||||||
const path = finalSiteUrl.pathname + p.pathname;
|
|
||||||
return new URL(path, finalSiteUrl).href;
|
|
||||||
});
|
|
||||||
if (filter) {
|
|
||||||
pageUrls = pageUrls.filter((page: string) => filter(page));
|
|
||||||
}
|
|
||||||
if (customPages) {
|
|
||||||
pageUrls = [...pageUrls, ...customPages];
|
|
||||||
}
|
|
||||||
const sitemapContent = generateSitemap(pageUrls);
|
|
||||||
fs.writeFileSync(new URL('sitemap.xml', dir), sitemapContent);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default createPlugin;
|
||||||
|
|
47
packages/integrations/sitemap/src/schema.ts
Normal file
47
packages/integrations/sitemap/src/schema.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { changefreqValues } from './constants';
|
||||||
|
import { SITEMAP_CONFIG_DEFAULTS } from './config-defaults';
|
||||||
|
|
||||||
|
const localeKeySchema = () => z.string().min(1);
|
||||||
|
|
||||||
|
const isFunction = (fn: any) => fn instanceof Function;
|
||||||
|
|
||||||
|
const fnSchema = () =>
|
||||||
|
z
|
||||||
|
.any()
|
||||||
|
.refine((val) => !val || isFunction(val), { message: 'Not a function' })
|
||||||
|
.optional();
|
||||||
|
|
||||||
|
export const SitemapOptionsSchema = z
|
||||||
|
.object({
|
||||||
|
filter: fnSchema(),
|
||||||
|
customPages: z.string().url().array().optional(),
|
||||||
|
canonicalURL: z.string().url().optional(),
|
||||||
|
|
||||||
|
i18n: z
|
||||||
|
.object({
|
||||||
|
defaultLocale: localeKeySchema(),
|
||||||
|
locales: z.record(
|
||||||
|
localeKeySchema(),
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.min(2)
|
||||||
|
.regex(/^[a-zA-Z\-]+$/gm, {
|
||||||
|
message: 'Only English alphabet symbols and hyphen allowed',
|
||||||
|
})
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.refine((val) => !val || val.locales[val.defaultLocale], {
|
||||||
|
message: '`defaultLocale` must exists in `locales` keys',
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
|
||||||
|
entryLimit: z.number().nonnegative().default(SITEMAP_CONFIG_DEFAULTS.entryLimit),
|
||||||
|
serialize: fnSchema(),
|
||||||
|
|
||||||
|
changefreq: z.enum(changefreqValues).optional(),
|
||||||
|
lastmod: z.date().optional(),
|
||||||
|
priority: z.number().min(0).max(1).optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.default(SITEMAP_CONFIG_DEFAULTS);
|
10
packages/integrations/sitemap/src/utils/is-object-empty.ts
Normal file
10
packages/integrations/sitemap/src/utils/is-object-empty.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
// @internal
|
||||||
|
export const isObjectEmpty = (o: any) => {
|
||||||
|
if (!o) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (Array.isArray(o)) {
|
||||||
|
return o.length === 0;
|
||||||
|
}
|
||||||
|
return Object.keys(o).length === 0 && Object.getPrototypeOf(o) === Object.prototype;
|
||||||
|
};
|
13
packages/integrations/sitemap/src/utils/is-valid-url.ts
Normal file
13
packages/integrations/sitemap/src/utils/is-valid-url.ts
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
// @internal
|
||||||
|
export const isValidUrl = (s: any) => {
|
||||||
|
if (typeof s !== 'string' || !s) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const dummy = new URL(s);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
46
packages/integrations/sitemap/src/utils/logger.ts
Normal file
46
packages/integrations/sitemap/src/utils/logger.ts
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
// @internal
|
||||||
|
export interface ILogger {
|
||||||
|
info(msg: string): void;
|
||||||
|
success(msg: string): void;
|
||||||
|
warn(msg: string): void;
|
||||||
|
error(msg: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// @internal
|
||||||
|
export class Logger implements ILogger {
|
||||||
|
private colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
fg: {
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
private packageName: string;
|
||||||
|
|
||||||
|
constructor(packageName: string) {
|
||||||
|
this.packageName = packageName;
|
||||||
|
}
|
||||||
|
|
||||||
|
private log(msg: string, prefix: string = '') {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(`%s${this.packageName}:%s ${msg}\n`, prefix, prefix ? this.colors.reset : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
info(msg: string) {
|
||||||
|
this.log(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
success(msg: string) {
|
||||||
|
this.log(msg, this.colors.fg.green);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(msg: string) {
|
||||||
|
this.log(`Skipped!\n${msg}`, this.colors.fg.yellow);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(msg: string) {
|
||||||
|
this.log(`Failed!\n${msg}`, this.colors.fg.red);
|
||||||
|
}
|
||||||
|
}
|
39
packages/integrations/sitemap/src/utils/parse-url.ts
Normal file
39
packages/integrations/sitemap/src/utils/parse-url.ts
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
export const parseUrl = (
|
||||||
|
url: string,
|
||||||
|
defaultLocale: string,
|
||||||
|
localeCodes: string[],
|
||||||
|
base: string
|
||||||
|
) => {
|
||||||
|
if (
|
||||||
|
!url ||
|
||||||
|
!defaultLocale ||
|
||||||
|
localeCodes.length === 0 ||
|
||||||
|
localeCodes.some((key) => !key) ||
|
||||||
|
!base
|
||||||
|
) {
|
||||||
|
throw new Error('parseUrl: some parameters are empty');
|
||||||
|
}
|
||||||
|
if (url.indexOf(base) !== 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let s = url.replace(base, '');
|
||||||
|
if (!s || s === '/') {
|
||||||
|
return { locale: defaultLocale, path: '/' };
|
||||||
|
}
|
||||||
|
if (!s.startsWith('/')) {
|
||||||
|
s = '/' + s;
|
||||||
|
}
|
||||||
|
const a = s.split('/');
|
||||||
|
const locale = a[1];
|
||||||
|
if (localeCodes.some((key) => key === locale)) {
|
||||||
|
let path = a.slice(2).join('/');
|
||||||
|
if (path === '//') {
|
||||||
|
path = '/';
|
||||||
|
}
|
||||||
|
if (path !== '/' && !path.startsWith('/')) {
|
||||||
|
path = '/' + path;
|
||||||
|
}
|
||||||
|
return { locale, path };
|
||||||
|
}
|
||||||
|
return { locale: defaultLocale, path: s };
|
||||||
|
};
|
22
packages/integrations/sitemap/src/validate-options.ts
Normal file
22
packages/integrations/sitemap/src/validate-options.ts
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { SitemapOptions } from './index';
|
||||||
|
import { SitemapOptionsSchema } from './schema';
|
||||||
|
|
||||||
|
// @internal
|
||||||
|
export const validateOptions = (site: string | undefined, opts: SitemapOptions) => {
|
||||||
|
const result = SitemapOptionsSchema.parse(opts);
|
||||||
|
|
||||||
|
z.object({
|
||||||
|
site: z.string().optional(), // Astro takes care of `site`: how to validate, transform and refine
|
||||||
|
canonicalURL: z.string().optional(), // `canonicalURL` is already validated in prev step
|
||||||
|
})
|
||||||
|
.refine(({ site, canonicalURL }) => site || canonicalURL, {
|
||||||
|
message: 'Required `site` astro.config option or `canonicalURL` integration option',
|
||||||
|
})
|
||||||
|
.parse({
|
||||||
|
site,
|
||||||
|
canonicalURL: result.canonicalURL,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
|
@ -1824,6 +1824,11 @@ importers:
|
||||||
specifiers:
|
specifiers:
|
||||||
astro: workspace:*
|
astro: workspace:*
|
||||||
astro-scripts: workspace:*
|
astro-scripts: workspace:*
|
||||||
|
sitemap: ^7.1.1
|
||||||
|
zod: ^3.17.3
|
||||||
|
dependencies:
|
||||||
|
sitemap: 7.1.1
|
||||||
|
zod: 3.17.3
|
||||||
devDependencies:
|
devDependencies:
|
||||||
astro: link:../../astro
|
astro: link:../../astro
|
||||||
astro-scripts: link:../../../scripts
|
astro-scripts: link:../../../scripts
|
||||||
|
@ -6852,6 +6857,12 @@ packages:
|
||||||
'@types/node': 17.0.41
|
'@types/node': 17.0.41
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/sax/1.2.4:
|
||||||
|
resolution: {integrity: sha512-pSAff4IAxJjfAXUG6tFkO7dsSbTmf8CtUpfhhZ5VhkRpC4628tJhh3+V6H1E+/Gs9piSzYKT5yzHO5M4GG9jkw==}
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 17.0.41
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@types/scheduler/0.16.2:
|
/@types/scheduler/0.16.2:
|
||||||
resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
|
resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
|
||||||
|
|
||||||
|
@ -8164,6 +8175,11 @@ packages:
|
||||||
|
|
||||||
/debug/3.2.7:
|
/debug/3.2.7:
|
||||||
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -11080,6 +11096,8 @@ packages:
|
||||||
debug: 3.2.7
|
debug: 3.2.7
|
||||||
iconv-lite: 0.4.24
|
iconv-lite: 0.4.24
|
||||||
sax: 1.2.4
|
sax: 1.2.4
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/netmask/2.0.2:
|
/netmask/2.0.2:
|
||||||
|
@ -11163,6 +11181,8 @@ packages:
|
||||||
rimraf: 2.7.1
|
rimraf: 2.7.1
|
||||||
semver: 5.7.1
|
semver: 5.7.1
|
||||||
tar: 4.4.19
|
tar: 4.4.19
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/node-releases/2.0.5:
|
/node-releases/2.0.5:
|
||||||
|
@ -12535,6 +12555,17 @@ packages:
|
||||||
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/sitemap/7.1.1:
|
||||||
|
resolution: {integrity: sha512-mK3aFtjz4VdJN0igpIJrinf3EO8U8mxOPsTBzSsy06UtjZQJ3YY3o3Xa7zSc5nMqcMrRwlChHZ18Kxg0caiPBg==}
|
||||||
|
engines: {node: '>=12.0.0', npm: '>=5.6.0'}
|
||||||
|
hasBin: true
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 17.0.41
|
||||||
|
'@types/sax': 1.2.4
|
||||||
|
arg: 5.0.2
|
||||||
|
sax: 1.2.4
|
||||||
|
dev: false
|
||||||
|
|
||||||
/slash/2.0.0:
|
/slash/2.0.0:
|
||||||
resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==}
|
resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
Loading…
Reference in a new issue