@koehler8/cms 1.2.0 → 1.3.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/CHANGELOG.md +31 -0
- package/package.json +1 -1
- package/src/utils/sitemapGenerator.js +54 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`meta.lastmod` — a page can date its own sitemap entry.** `buildSitemap`
|
|
8
|
+
now emits `<lastmod>` for any page supplying `meta.lastmod`, as either a
|
|
9
|
+
complete date (`2026-09-06`) or a full ISO 8601 timestamp
|
|
10
|
+
(`2026-09-06T14:30:00Z`). All locale variants of a page inherit it — a
|
|
11
|
+
translation of a page is the same page.
|
|
12
|
+
|
|
13
|
+
This matters most for sites that publish on a cadence. Without `lastmod` a
|
|
14
|
+
crawler has no priority signal distinguishing the piece published last night
|
|
15
|
+
from one published in May, and has to rediscover that by fetching. On the
|
|
16
|
+
publication this was built for, Search Console reported **490 URLs
|
|
17
|
+
"Discovered - currently not indexed" with `Last crawled: N/A`** — found in the
|
|
18
|
+
sitemap and never fetched at all.
|
|
19
|
+
|
|
20
|
+
**The framework never derives a date.** Build time is not modification time,
|
|
21
|
+
and a sitemap whose `lastmod` moves on every deploy is one search engines
|
|
22
|
+
learn to ignore — Google's stated rule is that it must be consistently
|
|
23
|
+
accurate or it is disregarded. Only the site knows when its content actually
|
|
24
|
+
changed, so only the site can say.
|
|
25
|
+
|
|
26
|
+
A malformed value (`2026`, `09/06/2026`, `2026-02-30`, a `Date`, a number) is
|
|
27
|
+
dropped rather than emitted: an invalid `lastmod` invalidates the whole
|
|
28
|
+
`<url>` entry for some parsers, which is worse than having none.
|
|
29
|
+
`normalizeLastmod` is exported for sites that want to validate before writing.
|
|
30
|
+
|
|
31
|
+
Output for pages that omit `meta.lastmod` is unchanged, so existing sites see
|
|
32
|
+
no diff.
|
|
33
|
+
|
|
3
34
|
## 1.2.0
|
|
4
35
|
|
|
5
36
|
### Added
|
package/package.json
CHANGED
|
@@ -14,6 +14,16 @@
|
|
|
14
14
|
* <xhtml:link rel="alternate" hreflang="..." /> cluster plus an `x-default`
|
|
15
15
|
* pointing at the base-locale URL. The single-locale output stays
|
|
16
16
|
* byte-identical to pre-hreflang.
|
|
17
|
+
*
|
|
18
|
+
* <lastmod> is emitted for a page only when it supplies `meta.lastmod`, as
|
|
19
|
+
* either a date (YYYY-MM-DD) or a full ISO 8601 timestamp. The framework never
|
|
20
|
+
* derives one: build time is not modification time, and a sitemap whose
|
|
21
|
+
* lastmod moves on every deploy is one search engines learn to ignore
|
|
22
|
+
* (Google's stated rule is that it must be consistently accurate). A page that
|
|
23
|
+
* omits it is emitted exactly as before, so existing sites are unchanged.
|
|
24
|
+
*
|
|
25
|
+
* All locale variants of a page share its lastmod — a translation of a page is
|
|
26
|
+
* the same page, and the site is the only thing that knows otherwise.
|
|
17
27
|
*/
|
|
18
28
|
|
|
19
29
|
import { isPathDraft, normalizeDraftPath } from './draftMode.js';
|
|
@@ -30,6 +40,33 @@ function escapeXml(value) {
|
|
|
30
40
|
}[c]));
|
|
31
41
|
}
|
|
32
42
|
|
|
43
|
+
// W3C Datetime, the subset the sitemap protocol allows: a complete date, or a
|
|
44
|
+
// complete date plus hours/minutes/seconds and a timezone. Anything else — a
|
|
45
|
+
// bare year, a US-format date, a Date object stringified, a number — is
|
|
46
|
+
// dropped rather than emitted, because a malformed lastmod invalidates the
|
|
47
|
+
// <url> entry for some parsers and is worse than none at all.
|
|
48
|
+
const LASTMOD_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
49
|
+
const LASTMOD_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
50
|
+
|
|
51
|
+
export function normalizeLastmod(value) {
|
|
52
|
+
if (typeof value !== 'string') return '';
|
|
53
|
+
const raw = value.trim();
|
|
54
|
+
if (!raw) return '';
|
|
55
|
+
if (!LASTMOD_DATE.test(raw) && !LASTMOD_DATETIME.test(raw)) return '';
|
|
56
|
+
// Shape-valid but not a real day (2026-02-30, month 13) — Date rolls those
|
|
57
|
+
// over silently, so compare the round-trip rather than trusting the parse.
|
|
58
|
+
const [y, m, d] = raw.slice(0, 10).split('-').map(Number);
|
|
59
|
+
const probe = new Date(Date.UTC(y, m - 1, d));
|
|
60
|
+
if (
|
|
61
|
+
probe.getUTCFullYear() !== y ||
|
|
62
|
+
probe.getUTCMonth() !== m - 1 ||
|
|
63
|
+
probe.getUTCDate() !== d
|
|
64
|
+
) {
|
|
65
|
+
return '';
|
|
66
|
+
}
|
|
67
|
+
return raw;
|
|
68
|
+
}
|
|
69
|
+
|
|
33
70
|
export function buildSitemap(siteConfig, options = {}) {
|
|
34
71
|
const siteUrl = (siteConfig?.site?.url || '').trim().replace(/\/+$/, '');
|
|
35
72
|
if (!siteUrl) return '';
|
|
@@ -65,8 +102,10 @@ export function buildSitemap(siteConfig, options = {}) {
|
|
|
65
102
|
const baseUrl = buildCanonicalUrl({ siteUrl, baseLocale, locale: baseLocale, path: pagePath, trailingSlash });
|
|
66
103
|
if (!baseUrl) continue;
|
|
67
104
|
|
|
105
|
+
const lastmod = normalizeLastmod(pageData?.meta?.lastmod);
|
|
106
|
+
|
|
68
107
|
if (!isMultiLocale) {
|
|
69
|
-
entries.push({ loc: baseUrl, alternates: [] });
|
|
108
|
+
entries.push({ loc: baseUrl, alternates: [], lastmod });
|
|
70
109
|
continue;
|
|
71
110
|
}
|
|
72
111
|
|
|
@@ -81,7 +120,7 @@ export function buildSitemap(siteConfig, options = {}) {
|
|
|
81
120
|
|
|
82
121
|
for (const loc of availableLocales) {
|
|
83
122
|
const localizedUrl = buildCanonicalUrl({ siteUrl, baseLocale, locale: loc, path: pagePath, trailingSlash });
|
|
84
|
-
if (localizedUrl) entries.push({ loc: localizedUrl, alternates });
|
|
123
|
+
if (localizedUrl) entries.push({ loc: localizedUrl, alternates, lastmod });
|
|
85
124
|
}
|
|
86
125
|
}
|
|
87
126
|
|
|
@@ -94,13 +133,24 @@ export function buildSitemap(siteConfig, options = {}) {
|
|
|
94
133
|
: 'xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"';
|
|
95
134
|
|
|
96
135
|
const renderUrlEntry = (entry) => {
|
|
136
|
+
// <lastmod> follows <loc> and precedes the alternates: the sitemap XSD
|
|
137
|
+
// sequences loc, lastmod, changefreq, priority, and xhtml:link extensions
|
|
138
|
+
// are appended after those.
|
|
139
|
+
const lastmodLine = entry.lastmod
|
|
140
|
+
? `\n <lastmod>${escapeXml(entry.lastmod)}</lastmod>`
|
|
141
|
+
: '';
|
|
142
|
+
|
|
97
143
|
if (entry.alternates.length === 0) {
|
|
98
|
-
|
|
144
|
+
// Kept on one line when there is nothing else to emit, so single-locale
|
|
145
|
+
// sites with no lastmod stay byte-identical to previous versions.
|
|
146
|
+
return entry.lastmod
|
|
147
|
+
? ` <url>\n <loc>${escapeXml(entry.loc)}</loc>${lastmodLine}\n </url>`
|
|
148
|
+
: ` <url><loc>${escapeXml(entry.loc)}</loc></url>`;
|
|
99
149
|
}
|
|
100
150
|
const altLines = entry.alternates
|
|
101
151
|
.map((a) => ` <xhtml:link rel="alternate" hreflang="${escapeXml(a.hreflang)}" href="${escapeXml(a.href)}"/>`)
|
|
102
152
|
.join('\n');
|
|
103
|
-
return ` <url>\n <loc>${escapeXml(entry.loc)}</loc
|
|
153
|
+
return ` <url>\n <loc>${escapeXml(entry.loc)}</loc>${lastmodLine}\n${altLines}\n </url>`;
|
|
104
154
|
};
|
|
105
155
|
|
|
106
156
|
const urlEntries = entries.map(renderUrlEntry).join('\n');
|