@rettangoli/sites 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -0
- package/package.json +1 -1
- package/src/cli/build.js +3 -0
- package/src/createSiteBuilder.js +23 -0
- package/src/sitemap.js +466 -0
- package/src/utils/loadSiteConfig.js +7 -2
package/README.md
CHANGED
|
@@ -37,6 +37,7 @@ my-site/
|
|
|
37
37
|
- Collections built from page tags
|
|
38
38
|
- `$if`, `$for`, `$partial`, template functions
|
|
39
39
|
- Static file copying from `static/` to `_site/`
|
|
40
|
+
- Default sitemap generation when `data.site.baseUrl` is configured
|
|
40
41
|
- Watch mode with local dev server + websocket reload
|
|
41
42
|
|
|
42
43
|
## Site Config
|
|
@@ -76,8 +77,20 @@ imports:
|
|
|
76
77
|
partials:
|
|
77
78
|
docs/nav: https://example.com/partials/docs-nav.yaml
|
|
78
79
|
data:
|
|
80
|
+
site:
|
|
81
|
+
baseUrl: https://example.com
|
|
79
82
|
themeCssHref: /public/theme.css
|
|
80
83
|
themeBodyClass: dark
|
|
84
|
+
sitemap:
|
|
85
|
+
outputPath: sitemap.xml
|
|
86
|
+
defaults:
|
|
87
|
+
changefreq: weekly
|
|
88
|
+
priority: 0.5
|
|
89
|
+
exclude:
|
|
90
|
+
- /drafts/*
|
|
91
|
+
pages:
|
|
92
|
+
/:
|
|
93
|
+
priority: 1
|
|
81
94
|
```
|
|
82
95
|
|
|
83
96
|
In the default starter template, CDN runtime scripts are controlled via `data/site.yaml`:
|
|
@@ -117,6 +130,45 @@ url: /company/
|
|
|
117
130
|
External URLs, query strings, fragments, whitespace, and `.` / `..` path segments are rejected.
|
|
118
131
|
Duplicate page URLs are rejected after normalization.
|
|
119
132
|
|
|
133
|
+
## Sitemap
|
|
134
|
+
|
|
135
|
+
Sites writes `_site/sitemap.xml` by default when `data.site.baseUrl` is configured. Use `sitemap` in `sites.config.yaml` to customize output, or set `sitemap: false` to disable it.
|
|
136
|
+
|
|
137
|
+
```yaml
|
|
138
|
+
data:
|
|
139
|
+
site:
|
|
140
|
+
baseUrl: https://example.com
|
|
141
|
+
sitemap:
|
|
142
|
+
outputPath: sitemap.xml
|
|
143
|
+
defaults:
|
|
144
|
+
changefreq: weekly
|
|
145
|
+
priority: 0.5
|
|
146
|
+
exclude:
|
|
147
|
+
- /drafts/*
|
|
148
|
+
pages:
|
|
149
|
+
/:
|
|
150
|
+
priority: 1
|
|
151
|
+
changefreq: daily
|
|
152
|
+
lastmod: "2026-05-25"
|
|
153
|
+
/private/: false
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
If you do not use `data.site.baseUrl`, set `sitemap.siteUrl` instead.
|
|
157
|
+
Generated entries use normalized page URLs, including page frontmatter `url` overrides.
|
|
158
|
+
Use page frontmatter for per-page control:
|
|
159
|
+
|
|
160
|
+
```md
|
|
161
|
+
---
|
|
162
|
+
sitemap:
|
|
163
|
+
changefreq: monthly
|
|
164
|
+
priority: 0.8
|
|
165
|
+
lastmod: "2026-05-25"
|
|
166
|
+
---
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Set `sitemap: false` in page frontmatter to exclude one page.
|
|
170
|
+
`sitemap.exclude` accepts exact page URLs and prefix patterns ending in `*`, such as `/drafts/*`.
|
|
171
|
+
|
|
120
172
|
`imports` lets you map aliases to remote YAML files (HTTP/HTTPS only). Use aliases in pages/templates:
|
|
121
173
|
- page frontmatter: `template: base` or `template: docs`
|
|
122
174
|
- template/page content: `$partial: docs/nav`
|
package/package.json
CHANGED
package/src/cli/build.js
CHANGED
|
@@ -8,6 +8,7 @@ import { loadSiteConfig } from '../utils/loadSiteConfig.js';
|
|
|
8
8
|
* @param {string} options.rootDir - Root directory of the site (defaults to cwd)
|
|
9
9
|
* @param {string} options.outputPath - Output directory path (relative to rootDir by default)
|
|
10
10
|
* @param {Object} options.md - Optional markdown renderer
|
|
11
|
+
* @param {Object|boolean} options.sitemap - Optional sitemap generation config
|
|
11
12
|
* @param {boolean} options.quiet - Suppress build output logs
|
|
12
13
|
* @param {boolean} options.isScreenshotMode - Optional build flag exposed to templates via build.isScreenshotMode
|
|
13
14
|
*/
|
|
@@ -17,6 +18,7 @@ export const buildSite = async (options = {}) => {
|
|
|
17
18
|
outputPath = '_site',
|
|
18
19
|
md,
|
|
19
20
|
functions,
|
|
21
|
+
sitemap,
|
|
20
22
|
quiet = false,
|
|
21
23
|
isScreenshotMode = false
|
|
22
24
|
} = options;
|
|
@@ -32,6 +34,7 @@ export const buildSite = async (options = {}) => {
|
|
|
32
34
|
keepMarkdownFiles: config.build?.keepMarkdownFiles === true,
|
|
33
35
|
imports: config.imports || {},
|
|
34
36
|
data: config.data || {},
|
|
37
|
+
sitemap: sitemap === undefined ? config.sitemap : sitemap,
|
|
35
38
|
functions: functions || {},
|
|
36
39
|
quiet,
|
|
37
40
|
isScreenshotMode
|
package/src/createSiteBuilder.js
CHANGED
|
@@ -8,6 +8,7 @@ import matter from 'gray-matter';
|
|
|
8
8
|
import MarkdownIt from 'markdown-it';
|
|
9
9
|
import rtglMarkdown from './rtglMarkdown.js';
|
|
10
10
|
import builtinTemplateFunctions from './builtinTemplateFunctions.js';
|
|
11
|
+
import { buildSitemapXml, resolveSitemapOutputPath } from './sitemap.js';
|
|
11
12
|
|
|
12
13
|
const MATTER_OPTIONS = {
|
|
13
14
|
engines: {
|
|
@@ -435,6 +436,7 @@ export function createSiteBuilder({
|
|
|
435
436
|
keepMarkdownFiles = false,
|
|
436
437
|
imports = {},
|
|
437
438
|
data = {},
|
|
439
|
+
sitemap,
|
|
438
440
|
fetchImpl,
|
|
439
441
|
functions = {},
|
|
440
442
|
quiet = false,
|
|
@@ -836,6 +838,24 @@ export function createSiteBuilder({
|
|
|
836
838
|
}
|
|
837
839
|
}
|
|
838
840
|
|
|
841
|
+
function writeSitemap() {
|
|
842
|
+
const sitemapXml = buildSitemapXml({ pageEntries, sitemap, globalData });
|
|
843
|
+
if (sitemapXml === null) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const sitemapOutputRelativePath = resolveSitemapOutputPath(sitemap);
|
|
848
|
+
const sitemapOutputPath = path.join(outputRootDir, ...sitemapOutputRelativePath.split('/'));
|
|
849
|
+
const sitemapOutputDir = path.dirname(sitemapOutputPath);
|
|
850
|
+
|
|
851
|
+
if (!fs.existsSync(sitemapOutputDir)) {
|
|
852
|
+
fs.mkdirSync(sitemapOutputDir, { recursive: true });
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
fs.writeFileSync(sitemapOutputPath, sitemapXml);
|
|
856
|
+
if (!quiet) console.log(` -> Written sitemap to ${sitemapOutputPath}`);
|
|
857
|
+
}
|
|
858
|
+
|
|
839
859
|
// Function to copy static files recursively
|
|
840
860
|
function copyStaticFiles() {
|
|
841
861
|
const staticDir = path.join(rootDir, 'static');
|
|
@@ -891,6 +911,9 @@ export function createSiteBuilder({
|
|
|
891
911
|
// Process all pages (can overwrite static files)
|
|
892
912
|
await processAllPages();
|
|
893
913
|
|
|
914
|
+
// Generate sitemap after pages so it can overwrite static files if configured.
|
|
915
|
+
writeSitemap();
|
|
916
|
+
|
|
894
917
|
if (!quiet) console.log('Build complete!');
|
|
895
918
|
};
|
|
896
919
|
}
|
package/src/sitemap.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
const ALLOWED_CHANGEFREQS = new Set(['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']);
|
|
2
|
+
const ALLOWED_TOP_LEVEL_KEYS = new Set(['enabled', 'siteUrl', 'outputPath', 'defaults', 'exclude', 'pages']);
|
|
3
|
+
const ALLOWED_DEFAULT_KEYS = new Set(['changefreq', 'priority', 'lastmod']);
|
|
4
|
+
const ALLOWED_ENTRY_KEYS = new Set(['changefreq', 'priority', 'lastmod', 'exclude']);
|
|
5
|
+
const SITEMAP_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))?$/u;
|
|
6
|
+
|
|
7
|
+
function isPlainObject(value) {
|
|
8
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function hasOwn(object, key) {
|
|
12
|
+
return Object.prototype.hasOwnProperty.call(object, key);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function rejectInvalidUrlString(rawUrl, contextLabel) {
|
|
16
|
+
if (typeof rawUrl !== 'string') {
|
|
17
|
+
throw new Error(`${contextLabel}: expected a string.`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (rawUrl === '') {
|
|
21
|
+
throw new Error(`${contextLabel}: expected a non-empty string.`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (/[\u0000-\u001F\u007F]/u.test(rawUrl)) {
|
|
25
|
+
throw new Error(`${contextLabel}: must not contain control characters.`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (/\s/u.test(rawUrl)) {
|
|
29
|
+
throw new Error(`${contextLabel}: must not contain whitespace.`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(rawUrl) || rawUrl.startsWith('//')) {
|
|
33
|
+
throw new Error(`${contextLabel}: expected a site-relative URL path.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (rawUrl.includes('\\')) {
|
|
37
|
+
throw new Error(`${contextLabel}: must use forward slashes.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (rawUrl.includes('?') || rawUrl.includes('#')) {
|
|
41
|
+
throw new Error(`${contextLabel}: must not include query strings or fragments.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function normalizeSitemapUrlPath(rawUrl, contextLabel) {
|
|
46
|
+
rejectInvalidUrlString(rawUrl, contextLabel);
|
|
47
|
+
|
|
48
|
+
const withLeadingSlash = rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`;
|
|
49
|
+
const collapsedUrl = withLeadingSlash.replace(/\/+/g, '/');
|
|
50
|
+
const pathWithoutSlashes = collapsedUrl.replace(/^\/+|\/+$/g, '');
|
|
51
|
+
|
|
52
|
+
if (pathWithoutSlashes === '') {
|
|
53
|
+
return '/';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const segments = pathWithoutSlashes.split('/');
|
|
57
|
+
for (const segment of segments) {
|
|
58
|
+
let decodedSegment;
|
|
59
|
+
try {
|
|
60
|
+
decodedSegment = decodeURIComponent(segment);
|
|
61
|
+
} catch {
|
|
62
|
+
throw new Error(`${contextLabel}: contains invalid URL encoding.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (decodedSegment === '.' || decodedSegment === '..') {
|
|
66
|
+
throw new Error(`${contextLabel}: must not contain "." or ".." segments.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (decodedSegment.includes('/') || decodedSegment.includes('\\')) {
|
|
70
|
+
throw new Error(`${contextLabel}: must not include encoded slashes or backslashes.`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (/[\u0000-\u001F\u007F]/u.test(decodedSegment)) {
|
|
74
|
+
throw new Error(`${contextLabel}: must not contain control characters.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (/\s/u.test(decodedSegment)) {
|
|
78
|
+
throw new Error(`${contextLabel}: must not contain whitespace.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `/${segments.join('/')}/`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function normalizeSitemapUrlPattern(rawPattern, contextLabel) {
|
|
86
|
+
if (typeof rawPattern !== 'string') {
|
|
87
|
+
throw new Error(`${contextLabel}: expected a string.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (rawPattern.endsWith('*')) {
|
|
91
|
+
const rawPrefix = rawPattern.slice(0, -1);
|
|
92
|
+
return `${normalizeSitemapUrlPath(rawPrefix, contextLabel)}*`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return normalizeSitemapUrlPath(rawPattern, contextLabel);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function validateSiteUrl(siteUrl, contextLabel) {
|
|
99
|
+
if (typeof siteUrl !== 'string' || siteUrl.trim() === '') {
|
|
100
|
+
throw new Error(`${contextLabel}: expected a non-empty URL string.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let parsed;
|
|
104
|
+
try {
|
|
105
|
+
parsed = new URL(siteUrl);
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(`${contextLabel}: "${siteUrl}" is not a valid URL.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
111
|
+
throw new Error(`${contextLabel}: protocol "${parsed.protocol}" is not supported. Allowed protocols: http:, https:.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (parsed.search || parsed.hash) {
|
|
115
|
+
throw new Error(`${contextLabel}: must not include query strings or fragments.`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/u, '');
|
|
119
|
+
return parsed.toString().replace(/\/$/u, '');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateOutputPath(outputPath, contextLabel) {
|
|
123
|
+
if (typeof outputPath !== 'string' || outputPath.trim() === '') {
|
|
124
|
+
throw new Error(`${contextLabel}: expected a non-empty string.`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (/[\u0000-\u001F\u007F]/u.test(outputPath)) {
|
|
128
|
+
throw new Error(`${contextLabel}: must not contain control characters.`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (/\s/u.test(outputPath)) {
|
|
132
|
+
throw new Error(`${contextLabel}: must not contain whitespace.`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (outputPath.startsWith('/') || /^[A-Za-z][A-Za-z0-9+.-]*:/u.test(outputPath)) {
|
|
136
|
+
throw new Error(`${contextLabel}: expected a relative output path.`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (outputPath.includes('\\') || outputPath.includes('?') || outputPath.includes('#')) {
|
|
140
|
+
throw new Error(`${contextLabel}: must be a clean relative file path.`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const segments = outputPath.split('/').filter(Boolean);
|
|
144
|
+
if (segments.length === 0) {
|
|
145
|
+
throw new Error(`${contextLabel}: expected a relative file path.`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const segment of segments) {
|
|
149
|
+
if (segment === '.' || segment === '..') {
|
|
150
|
+
throw new Error(`${contextLabel}: must not contain "." or ".." segments.`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return segments.join('/');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeLastmod(lastmod, contextLabel) {
|
|
158
|
+
if (lastmod instanceof Date) {
|
|
159
|
+
if (Number.isNaN(lastmod.getTime())) {
|
|
160
|
+
throw new Error(`${contextLabel}: expected a valid date.`);
|
|
161
|
+
}
|
|
162
|
+
return lastmod.toISOString();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (typeof lastmod !== 'string' || lastmod.trim() === '') {
|
|
166
|
+
throw new Error(`${contextLabel}: expected an ISO date string.`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const trimmed = lastmod.trim();
|
|
170
|
+
if (!SITEMAP_DATE_RE.test(trimmed)) {
|
|
171
|
+
throw new Error(`${contextLabel}: expected an ISO date string like "2026-05-25" or "2026-05-25T12:00:00Z".`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return trimmed;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function normalizePriority(priority, contextLabel) {
|
|
178
|
+
if (typeof priority !== 'number' || !Number.isFinite(priority)) {
|
|
179
|
+
throw new Error(`${contextLabel}: expected a number from 0 to 1.`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (priority < 0 || priority > 1) {
|
|
183
|
+
throw new Error(`${contextLabel}: expected a number from 0 to 1.`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return priority;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeChangefreq(changefreq, contextLabel) {
|
|
190
|
+
if (typeof changefreq !== 'string' || changefreq.trim() === '') {
|
|
191
|
+
throw new Error(`${contextLabel}: expected a non-empty string.`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const normalized = changefreq.trim();
|
|
195
|
+
if (!ALLOWED_CHANGEFREQS.has(normalized)) {
|
|
196
|
+
throw new Error(`${contextLabel}: expected one of ${Array.from(ALLOWED_CHANGEFREQS).join(', ')}.`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return normalized;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeEntryOptions(value, contextLabel, { allowExclude }) {
|
|
203
|
+
if (!isPlainObject(value)) {
|
|
204
|
+
throw new Error(`${contextLabel}: expected an object.`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const allowedKeys = allowExclude ? ALLOWED_ENTRY_KEYS : ALLOWED_DEFAULT_KEYS;
|
|
208
|
+
const normalized = {};
|
|
209
|
+
|
|
210
|
+
for (const key of Object.keys(value)) {
|
|
211
|
+
if (!allowedKeys.has(key)) {
|
|
212
|
+
throw new Error(`${contextLabel}: unsupported option "${key}". Supported options: ${Array.from(allowedKeys).join(', ')}.`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (value.changefreq !== undefined) {
|
|
217
|
+
normalized.changefreq = normalizeChangefreq(value.changefreq, `${contextLabel}.changefreq`);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (value.priority !== undefined) {
|
|
221
|
+
normalized.priority = normalizePriority(value.priority, `${contextLabel}.priority`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (value.lastmod !== undefined) {
|
|
225
|
+
normalized.lastmod = normalizeLastmod(value.lastmod, `${contextLabel}.lastmod`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (allowExclude && value.exclude !== undefined) {
|
|
229
|
+
if (typeof value.exclude !== 'boolean') {
|
|
230
|
+
throw new Error(`${contextLabel}.exclude: expected a boolean.`);
|
|
231
|
+
}
|
|
232
|
+
normalized.exclude = value.exclude;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return normalized;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizePagesConfig(value, configPath) {
|
|
239
|
+
if (!isPlainObject(value)) {
|
|
240
|
+
throw new Error(`Invalid sitemap.pages in "${configPath}": expected an object.`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const pages = {};
|
|
244
|
+
for (const [rawUrl, rawOptions] of Object.entries(value)) {
|
|
245
|
+
const url = normalizeSitemapUrlPath(rawUrl, `Invalid sitemap.pages URL "${rawUrl}" in "${configPath}"`);
|
|
246
|
+
if (rawOptions === false) {
|
|
247
|
+
pages[url] = { exclude: true };
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
pages[url] = normalizeEntryOptions(rawOptions, `Invalid sitemap.pages.${rawUrl} in "${configPath}"`, { allowExclude: true });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return pages;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function normalizeSitemapConfig(value, configPath = 'sitemap config') {
|
|
258
|
+
if (value === undefined || value === null) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (typeof value === 'boolean') {
|
|
263
|
+
return { enabled: value };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!isPlainObject(value)) {
|
|
267
|
+
throw new Error(`Invalid sitemap config in "${configPath}": expected a boolean or object.`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const normalized = { enabled: true };
|
|
271
|
+
|
|
272
|
+
for (const key of Object.keys(value)) {
|
|
273
|
+
if (!ALLOWED_TOP_LEVEL_KEYS.has(key)) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`Unsupported sitemap option "${key}" in "${configPath}". Supported options: ${Array.from(ALLOWED_TOP_LEVEL_KEYS).join(', ')}.`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (value.enabled !== undefined) {
|
|
281
|
+
if (typeof value.enabled !== 'boolean') {
|
|
282
|
+
throw new Error(`Invalid sitemap.enabled in "${configPath}": expected a boolean.`);
|
|
283
|
+
}
|
|
284
|
+
normalized.enabled = value.enabled;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (value.siteUrl !== undefined) {
|
|
288
|
+
normalized.siteUrl = validateSiteUrl(value.siteUrl, `Invalid sitemap.siteUrl in "${configPath}"`);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (value.outputPath !== undefined) {
|
|
292
|
+
normalized.outputPath = validateOutputPath(value.outputPath, `Invalid sitemap.outputPath in "${configPath}"`);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (value.defaults !== undefined) {
|
|
296
|
+
normalized.defaults = normalizeEntryOptions(value.defaults, `Invalid sitemap.defaults in "${configPath}"`, { allowExclude: false });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (value.exclude !== undefined) {
|
|
300
|
+
if (!Array.isArray(value.exclude)) {
|
|
301
|
+
throw new Error(`Invalid sitemap.exclude in "${configPath}": expected an array.`);
|
|
302
|
+
}
|
|
303
|
+
normalized.exclude = value.exclude.map((pattern, index) => (
|
|
304
|
+
normalizeSitemapUrlPattern(pattern, `Invalid sitemap.exclude[${index}] in "${configPath}"`)
|
|
305
|
+
));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (value.pages !== undefined) {
|
|
309
|
+
normalized.pages = normalizePagesConfig(value.pages, configPath);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return normalized;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function resolveSitemapSiteUrl(sitemap, globalData, { required = true } = {}) {
|
|
316
|
+
if (sitemap.siteUrl) {
|
|
317
|
+
return sitemap.siteUrl;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const baseUrl = globalData?.site?.baseUrl;
|
|
321
|
+
if (baseUrl === undefined) {
|
|
322
|
+
if (!required) {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
throw new Error('Sitemap generation requires sitemap.siteUrl or data.site.baseUrl.');
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return validateSiteUrl(baseUrl, 'Invalid data.site.baseUrl');
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function matchesExclude(url, pattern) {
|
|
332
|
+
if (pattern.endsWith('*')) {
|
|
333
|
+
return url.startsWith(pattern.slice(0, -1));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return url === pattern;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function normalizePageSitemapOptions(rawSitemap, pagePath) {
|
|
340
|
+
if (rawSitemap === undefined || rawSitemap === null) {
|
|
341
|
+
return {};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (rawSitemap === false) {
|
|
345
|
+
return { exclude: true };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (rawSitemap === true) {
|
|
349
|
+
return {};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return normalizeEntryOptions(rawSitemap, `Invalid sitemap frontmatter in ${pagePath}`, { allowExclude: true });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function escapeXml(value) {
|
|
356
|
+
return String(value)
|
|
357
|
+
.replace(/&/g, '&')
|
|
358
|
+
.replace(/</g, '<')
|
|
359
|
+
.replace(/>/g, '>')
|
|
360
|
+
.replace(/"/g, '"')
|
|
361
|
+
.replace(/'/g, ''');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function formatPriority(priority) {
|
|
365
|
+
return String(Number(priority.toFixed(3))).replace(/\.0+$/u, '');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function joinSiteUrl(siteUrl, pageUrl) {
|
|
369
|
+
const parsed = new URL(siteUrl);
|
|
370
|
+
const basePath = parsed.pathname.replace(/\/+$/u, '');
|
|
371
|
+
parsed.pathname = `${basePath}${pageUrl}`.replace(/\/+/g, '/');
|
|
372
|
+
return parsed.toString();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function buildUrlEntryXml(entry) {
|
|
376
|
+
const lines = [
|
|
377
|
+
' <url>',
|
|
378
|
+
` <loc>${escapeXml(entry.loc)}</loc>`
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
if (entry.lastmod !== undefined) {
|
|
382
|
+
lines.push(` <lastmod>${escapeXml(entry.lastmod)}</lastmod>`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (entry.changefreq !== undefined) {
|
|
386
|
+
lines.push(` <changefreq>${escapeXml(entry.changefreq)}</changefreq>`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (entry.priority !== undefined) {
|
|
390
|
+
lines.push(` <priority>${formatPriority(entry.priority)}</priority>`);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
lines.push(' </url>');
|
|
394
|
+
return lines.join('\n');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function buildSitemapXml({ pageEntries, sitemap, globalData }) {
|
|
398
|
+
if (sitemap === false || sitemap?.enabled === false) {
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const hasExplicitSitemapConfig = sitemap !== undefined && sitemap !== null;
|
|
403
|
+
const normalizedSitemap = normalizeSitemapConfig(hasExplicitSitemapConfig ? sitemap : true);
|
|
404
|
+
if (normalizedSitemap.enabled === false) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const siteUrl = resolveSitemapSiteUrl(normalizedSitemap, globalData, {
|
|
409
|
+
required: hasExplicitSitemapConfig
|
|
410
|
+
});
|
|
411
|
+
if (siteUrl === null) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
const excludePatterns = normalizedSitemap.exclude || [];
|
|
415
|
+
const defaultOptions = normalizedSitemap.defaults || {};
|
|
416
|
+
const pageOptions = normalizedSitemap.pages || {};
|
|
417
|
+
|
|
418
|
+
const entries = [];
|
|
419
|
+
for (const pageEntry of pageEntries) {
|
|
420
|
+
const url = pageEntry.url;
|
|
421
|
+
const configuredOptions = pageOptions[url] || {};
|
|
422
|
+
const frontmatterOptions = normalizePageSitemapOptions(pageEntry.frontmatter?.sitemap, pageEntry.pagePath);
|
|
423
|
+
const options = {
|
|
424
|
+
...defaultOptions,
|
|
425
|
+
...configuredOptions,
|
|
426
|
+
...frontmatterOptions
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
if (excludePatterns.some((pattern) => matchesExclude(url, pattern)) || options.exclude === true) {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const entry = {
|
|
434
|
+
loc: joinSiteUrl(siteUrl, url)
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
if (hasOwn(options, 'lastmod')) {
|
|
438
|
+
entry.lastmod = options.lastmod;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
if (hasOwn(options, 'changefreq')) {
|
|
442
|
+
entry.changefreq = options.changefreq;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (hasOwn(options, 'priority')) {
|
|
446
|
+
entry.priority = options.priority;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
entries.push(entry);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
entries.sort((left, right) => left.loc.localeCompare(right.loc));
|
|
453
|
+
|
|
454
|
+
return [
|
|
455
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
456
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
457
|
+
...entries.map(buildUrlEntryXml),
|
|
458
|
+
'</urlset>',
|
|
459
|
+
''
|
|
460
|
+
].join('\n');
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function resolveSitemapOutputPath(sitemap) {
|
|
464
|
+
const normalizedSitemap = normalizeSitemapConfig(sitemap);
|
|
465
|
+
return normalizedSitemap?.outputPath || 'sitemap.xml';
|
|
466
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import yaml from 'js-yaml';
|
|
4
|
+
import { normalizeSitemapConfig } from '../sitemap.js';
|
|
4
5
|
|
|
5
|
-
const ALLOWED_TOP_LEVEL_KEYS = new Set(['markdown', 'markdownit', 'build', 'imports', 'data']);
|
|
6
|
+
const ALLOWED_TOP_LEVEL_KEYS = new Set(['markdown', 'markdownit', 'build', 'imports', 'data', 'sitemap']);
|
|
6
7
|
const MARKDOWN_BOOLEAN_KEYS = new Set(['html', 'linkify', 'typographer', 'breaks', 'xhtmlOut']);
|
|
7
8
|
const MARKDOWN_STRING_KEYS = new Set(['langPrefix', 'quotes', 'preset']);
|
|
8
9
|
const MARKDOWN_NUMBER_KEYS = new Set(['maxNesting']);
|
|
@@ -219,7 +220,7 @@ function validateConfig(rawConfig, configPath) {
|
|
|
219
220
|
for (const key of Object.keys(config)) {
|
|
220
221
|
if (!ALLOWED_TOP_LEVEL_KEYS.has(key)) {
|
|
221
222
|
throw new Error(
|
|
222
|
-
`Unsupported key "${key}" in "${configPath}". Supported keys: markdownit (recommended), markdown (legacy alias), build, imports, data.`
|
|
223
|
+
`Unsupported key "${key}" in "${configPath}". Supported keys: markdownit (recommended), markdown (legacy alias), build, imports, data, sitemap.`
|
|
223
224
|
);
|
|
224
225
|
}
|
|
225
226
|
}
|
|
@@ -315,6 +316,10 @@ function validateConfig(rawConfig, configPath) {
|
|
|
315
316
|
normalizedConfig.data = validateDataConfig(config.data, configPath);
|
|
316
317
|
}
|
|
317
318
|
|
|
319
|
+
if (config.sitemap !== undefined) {
|
|
320
|
+
normalizedConfig.sitemap = normalizeSitemapConfig(config.sitemap, configPath);
|
|
321
|
+
}
|
|
322
|
+
|
|
318
323
|
return normalizedConfig;
|
|
319
324
|
}
|
|
320
325
|
|