@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koehler8/cms",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Vertex CMS — lightweight, config-driven Vue 3 framework with theming, extensions, and SSG support",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- return ` <url><loc>${escapeXml(entry.loc)}</loc></url>`;
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>\n${altLines}\n </url>`;
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');