@invisibleloop/pulse 0.17.3 → 0.18.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/CLAUDE.md CHANGED
@@ -68,6 +68,13 @@ export const mySpec = {
68
68
  // query param, list it here so distinct values get distinct cache entries.
69
69
  cacheVary: ['q', 'page'],
70
70
 
71
+ // Sitemap inclusion (when createServer has sitemap: true).
72
+ // Static routes are included automatically — only set this to:
73
+ // false → exclude this page
74
+ // true → include a guarded page (excluded by default)
75
+ // async () => [...] → enumerate a dynamic :param route's URLs
76
+ sitemap: async () => (await db.posts.list()).map(p => `/blog/${p.slug}`),
77
+
71
78
  // Global store keys this page subscribes to — appears in view's server arg
72
79
  // Store mutations update all subscribed pages without a server round-trip
73
80
  store: ['user', 'cart'],
@@ -282,6 +289,11 @@ await createServer(
282
289
  healthCheck: '/healthz', // built-in health endpoint path, or false to disable
283
290
  secret: process.env.PULSE_SECRET, // HMAC secret for CSRF tokens — set a stable value
284
291
  // when running multiple instances (default: random per boot)
292
+ sitemap: true, // auto-serve /sitemap.xml + robots.txt from registered routes
293
+ // also accepts { origin: 'https://mysite.com' } to pin the URL origin;
294
+ // dynamic :param routes contribute via spec.sitemap enumerators;
295
+ // a physical sitemap.xml/robots.txt in staticDir always wins
296
+ robots: null, // robots.txt when sitemap on: null = auto, false = off, string = verbatim
285
297
  csp: { // extra sources merged into the framework's default CSP
286
298
  'style-src': ['https://fonts.googleapis.com'],
287
299
  'font-src': ['https://fonts.gstatic.com'],
@@ -115,6 +115,33 @@ meta: {
115
115
  }`, 'js'))}
116
116
  ${callout('note', '<strong>Streaming caveat:</strong> when <code>stream: true</code> (the default), the <code>&lt;head&gt;</code> is written before server fetchers resolve, so <code>serverState</code> will be <code>null</code> for streaming responses. If your canonical depends on server data, set <code>stream: false</code> on that spec, or derive it from <code>ctx.params</code> instead.')}
117
117
 
118
+ ${section('sitemap', 'Sitemap & robots.txt')}
119
+ <p>Pulse generates <code>/sitemap.xml</code> and <code>/robots.txt</code> from the registered routes — enable it with one option:</p>
120
+ ${codeBlock(highlight(`await createServer(pages, {
121
+ sitemap: true, // or { origin: 'https://mysite.com' } to pin the origin
122
+ })`, 'js'))}
123
+ <p>Static page routes are included automatically. Excluded by default: the <code>route: '*'</code> not-found page, raw content specs, guarded pages (set <code>sitemap: true</code> on the spec to opt one in), and any page with <code>sitemap: false</code>.</p>
124
+ <p>Dynamic <code>:param</code> routes can't be enumerated automatically — give them a <code>sitemap</code> enumerator or they are skipped (the server logs a startup hint listing them):</p>
125
+ ${codeBlock(highlight(`export default {
126
+ route: '/blog/:slug',
127
+ sitemap: async () => {
128
+ const posts = await db.posts.allPublished()
129
+ return posts.map(p => ({ path: \`/blog/\${p.slug}\`, lastmod: p.updatedAt }))
130
+ },
131
+ // entries are '/path' strings or { path, lastmod } objects
132
+ }`, 'js'))}
133
+ ${table(
134
+ ['Where', 'Value', 'Effect'],
135
+ [
136
+ ['<code>createServer</code>', '<code>sitemap: true | { origin }</code>', 'Enables generation. Origin defaults to the request host; pin it for multi-domain setups.'],
137
+ ['<code>createServer</code>', '<code>robots: false | string</code>', 'Disable robots.txt, or serve a custom string verbatim. Default auto-generates allow-all + the Sitemap line.'],
138
+ ['spec', '<code>sitemap: false</code>', 'Exclude this page.'],
139
+ ['spec', '<code>sitemap: true</code>', 'Include a guarded page that would be excluded by default.'],
140
+ ['spec', '<code>sitemap: array | function</code>', 'Enumerate URLs — required for dynamic routes.'],
141
+ ]
142
+ )}
143
+ ${callout('note', 'A physical <code>sitemap.xml</code> or <code>robots.txt</code> in <code>staticDir</code> always wins over the generated one — drop a file in <code>public/</code> to take full manual control. For fully hand-built XML, see <a href="/raw-responses#sitemap">Raw Responses</a>.')}
144
+
118
145
  ${section('styles', 'Stylesheets')}
119
146
  <p>The <code>styles</code> array accepts any number of stylesheet URLs. They are emitted as <code>&lt;link rel="stylesheet"&gt;</code> tags in the <code>&lt;head&gt;</code> in the order declared:</p>
120
147
  ${codeBlock(highlight(`meta: {
@@ -66,6 +66,7 @@ export default {
66
66
  }`, 'js'))}
67
67
 
68
68
  ${section('sitemap', 'XML sitemap')}
69
+ ${callout('tip', 'For most sites you don\'t need a raw spec for this — set <code>sitemap: true</code> in <code>createServer</code> and Pulse generates <code>/sitemap.xml</code> and <code>robots.txt</code> from the registered routes automatically. See <a href="/meta#sitemap">Metadata &amp; SEO</a>. Write a raw sitemap spec only when you need full control over the XML.')}
69
70
  ${codeBlock(highlight(`export default {
70
71
  route: '/sitemap.xml',
71
72
  contentType: 'application/xml',
@@ -49,6 +49,8 @@ await createServer(entries, options)`, 'js'))}
49
49
  ['<code>healthCheck</code>', '<code>string | false</code>', '<code>"/healthz"</code>', 'Path for the built-in health check endpoint. Returns <code>{ status: "ok", uptime }</code>. Set to <code>false</code> to disable. The endpoint bypasses <code>onRequest</code> so load balancers always get a response.'],
50
50
  ['<code>resolveBrand</code>', '<code>async (host) => any</code>', '<code>undefined</code>', 'Multi-brand support. Called once per host (cached 60s). Result is attached to <code>ctx.brand</code> and available in <code>guard</code>, <code>server</code>, and <code>meta</code> functions.'],
51
51
  ['<code>secret</code>', '<code>string</code>', 'random per boot', 'HMAC secret for CSRF tokens (<a href="/forms">Forms</a>). Set a stable value when running multiple instances so tokens issued by one validate on another.'],
52
+ ['<code>sitemap</code>', '<code>boolean | { origin }</code>', '<code>false</code>', 'Auto-serve <code>/sitemap.xml</code> + <code>robots.txt</code> from the registered routes. Dynamic <code>:param</code> routes contribute via <code>spec.sitemap</code> enumerators. See <a href="/meta#sitemap">Metadata &amp; SEO</a>.'],
53
+ ['<code>robots</code>', '<code>string | false</code>', 'auto', 'robots.txt behaviour when sitemap is enabled — <code>false</code> disables, a string is served verbatim.'],
52
54
  ['<code>onRequest</code>', '<code>function</code>', '<code>undefined</code>', 'Called on every request before routing. Return <code>false</code> to short-circuit Pulse handling.'],
53
55
  ['<code>onError</code>', '<code>function</code>', '<code>undefined</code>', 'Called on unhandled errors. Receives <code>(err, req, res)</code>. See <a href="/error-pages">Error Pages</a>.'],
54
56
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.17.3",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "description": "AI-first frontend framework. The spec is the source of truth.",
6
6
  "license": "MIT",
@@ -1 +1 @@
1
- 0.17.3
1
+ 0.18.0
@@ -147,6 +147,31 @@ export default {
147
147
  - **Every site should have one** — without it, visitors to a bad URL get the framework's unbranded default 404.
148
148
  - 500 errors are customised via `createServer`'s `onError` option, not a spec.
149
149
 
150
+ ## Sitemap & robots.txt
151
+
152
+ Enable in `pulse.config.js` / `createServer` — the framework serves `/sitemap.xml` and `/robots.txt` generated from the registered routes:
153
+
154
+ ```js
155
+ // pulse.config.js
156
+ export default {
157
+ sitemap: true, // or { origin: 'https://mysite.com' } to pin the origin
158
+ }
159
+ ```
160
+
161
+ - **Static routes are included automatically.** Excluded by default: `route: '*'`, raw content specs, guarded pages (set `sitemap: true` on the spec to opt a guarded page in), and `sitemap: false` pages.
162
+ - **Dynamic `:param` routes need an enumerator** or they are skipped (a startup hint lists them):
163
+
164
+ ```js
165
+ export default {
166
+ route: '/blog/:slug',
167
+ sitemap: async () => (await db.posts.list()).map(p => ({ path: `/blog/${p.slug}`, lastmod: p.updatedAt })),
168
+ // entries can be '/path' strings or { path, lastmod } objects
169
+ }
170
+ ```
171
+
172
+ - `robots: false` disables robots.txt; a string serves verbatim. A physical `sitemap.xml`/`robots.txt` in `staticDir` always wins over the generated one.
173
+ - **Always enable this on production sites** — it is one config line and crawlers need it.
174
+
150
175
  ## Canonical URLs
151
176
 
152
177
  Pulse auto-derives a canonical URL from every request and emits `<link rel="canonical">` in the `<head>`. No config needed in most cases.
@@ -546,6 +546,11 @@ export async function createServer(entries, options = {}) {
546
546
  csp = {}, // extra CSP sources: { 'style-src': ['https://fonts.googleapis.com'] }
547
547
  secret = null, // HMAC secret for CSRF tokens — set a stable value when running
548
548
  // multiple instances; defaults to a random per-boot secret
549
+ sitemap = false, // true | { origin } — auto-serve /sitemap.xml + robots.txt from
550
+ // the registered routes. Dynamic :param routes contribute via
551
+ // spec.sitemap = async () => ['/blog/a', ...]
552
+ robots = null, // robots.txt content when sitemap is enabled:
553
+ // null = auto-generate, false = disable, string = serve verbatim
549
554
  onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
550
555
  onRequest,
551
556
  agentMode = false, // show agent-active indicator in banner
@@ -614,6 +619,20 @@ export async function createServer(entries, options = {}) {
614
619
  // tied to a cookie, so a restart only means in-flight forms re-render once.
615
620
  const csrfSecret = secret || crypto.randomBytes(32).toString('hex')
616
621
 
622
+ // Sitemap — track the live spec list (updated on hot reload) and surface a
623
+ // startup hint for dynamic routes that can't be enumerated automatically.
624
+ const sitemapEnabled = !!sitemap
625
+ const sitemapOrigin = (typeof sitemap === 'object' && sitemap?.origin) ? String(sitemap.origin).replace(/\/$/, '') : null
626
+ let allSpecs = specs
627
+ if (sitemapEnabled && !quiet) {
628
+ const unenumerated = specs.filter(s =>
629
+ !s.contentType && s.route !== '*' && s.route.includes(':') && s.sitemap === undefined
630
+ )
631
+ if (unenumerated.length > 0) {
632
+ console.log(` ℹ sitemap: ${unenumerated.length} dynamic route${unenumerated.length === 1 ? '' : 's'} not in the sitemap — add \`sitemap: async () => ['/path', …]\` to enumerate: ${unenumerated.map(s => s.route).join(', ')}`)
633
+ }
634
+ }
635
+
617
636
  // Resolve the logger module once at startup rather than dynamically importing
618
637
  // it on every request's `finish` event. `logRequest` stays null until loaded.
619
638
  let logRequest = null
@@ -658,6 +677,39 @@ export async function createServer(entries, options = {}) {
658
677
  if (served) return
659
678
  }
660
679
 
680
+ // Auto-generated sitemap.xml + robots.txt — after static serving so a
681
+ // physical file in staticDir always wins over the generated one.
682
+ if (sitemapEnabled && (req.method === 'GET' || req.method === 'HEAD') &&
683
+ (pathname === '/sitemap.xml' || (pathname === '/robots.txt' && robots !== false))) {
684
+ const origin = sitemapOrigin ||
685
+ `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers['x-forwarded-host'] || req.headers.host || `localhost:${port}`}`
686
+
687
+ let body, contentType
688
+ if (pathname === '/robots.txt') {
689
+ contentType = 'text/plain; charset=utf-8'
690
+ body = typeof robots === 'string'
691
+ ? robots
692
+ : `User-agent: *\nAllow: /\n\nSitemap: ${origin}/sitemap.xml\n`
693
+ } else {
694
+ contentType = 'application/xml; charset=utf-8'
695
+ const entries = await collectSitemapEntries(allSpecs, trailingSlash)
696
+ body = buildSitemapXml(entries, origin)
697
+ }
698
+
699
+ const encoding = negotiateEncoding(req)
700
+ const buf = await compressBuffer(Buffer.from(body, 'utf8'), encoding)
701
+ const headers = {
702
+ 'Content-Type': contentType,
703
+ 'Cache-Control': dev ? 'no-store' : 'public, max-age=3600',
704
+ 'Vary': 'Accept-Encoding',
705
+ ...SECURITY_HEADERS, ...httpsHeaders(req),
706
+ }
707
+ if (encoding) headers['Content-Encoding'] = encoding
708
+ res.writeHead(200, headers)
709
+ res.end(req.method === 'HEAD' ? undefined : buf)
710
+ return
711
+ }
712
+
661
713
  // Trailing slash normalisation — GET/HEAD only (redirects don't apply to POST etc.)
662
714
  if ((req.method === 'GET' || req.method === 'HEAD') && pathname !== '/') {
663
715
  if (trailingSlash === 'remove' && pathname.endsWith('/')) {
@@ -948,7 +1000,8 @@ export async function createServer(entries, options = {}) {
948
1000
  shutdown,
949
1001
  updateSpecs(newSpecs) {
950
1002
  notFoundSpec = newSpecs.find(s => s.route === '*') || null
951
- router = buildRouter(newSpecs.filter(s => s.route !== '*'))
1003
+ router = buildRouter(newSpecs.filter(s => s.route !== '*'))
1004
+ allSpecs = newSpecs
952
1005
  }
953
1006
  }
954
1007
  }
@@ -1671,6 +1724,94 @@ function escHtml(str) {
1671
1724
  .replace(/'/g, '&#39;')
1672
1725
  }
1673
1726
 
1727
+ /**
1728
+ * Collect sitemap entries from the registered specs.
1729
+ *
1730
+ * Inclusion rules:
1731
+ * - page specs with static routes are included automatically
1732
+ * - route '*' (not-found), raw content specs, and spec.sitemap === false are excluded
1733
+ * - guarded pages are excluded by default (likely auth-gated) — set spec.sitemap
1734
+ * to true/array/function to include them explicitly
1735
+ * - dynamic :param routes contribute only via spec.sitemap — an array of paths
1736
+ * or an async function returning one. Entries can be strings or
1737
+ * { path, lastmod } objects.
1738
+ *
1739
+ * @param {Array} specs
1740
+ * @param {'remove'|'add'|'allow'} trailingSlash
1741
+ * @returns {Promise<Array<{ path: string, lastmod?: string }>>}
1742
+ */
1743
+ async function collectSitemapEntries(specs, trailingSlash) {
1744
+ const entries = []
1745
+
1746
+ const normalise = (item) => {
1747
+ const e = typeof item === 'string' ? { path: item } : { path: item?.path, lastmod: item?.lastmod }
1748
+ if (!e.path || typeof e.path !== 'string' || !e.path.startsWith('/')) return null
1749
+ // Apply the trailing-slash mode so sitemap URLs match the canonical form
1750
+ if (e.path !== '/') {
1751
+ if (trailingSlash === 'add' && !e.path.endsWith('/')) e.path = e.path + '/'
1752
+ if (trailingSlash !== 'add' && e.path.endsWith('/')) e.path = e.path.slice(0, -1)
1753
+ }
1754
+ return e
1755
+ }
1756
+
1757
+ for (const spec of specs) {
1758
+ if (spec.contentType || spec.route === '*' || spec.sitemap === false) continue
1759
+
1760
+ const isDynamic = spec.route.includes(':')
1761
+
1762
+ if (typeof spec.sitemap === 'function') {
1763
+ try {
1764
+ const list = await spec.sitemap()
1765
+ for (const item of (Array.isArray(list) ? list : [])) {
1766
+ const e = normalise(item)
1767
+ if (e) entries.push(e)
1768
+ }
1769
+ } catch (err) {
1770
+ // An enumerator failure must not take down the whole sitemap
1771
+ console.error(`[Pulse] sitemap enumerator for "${spec.route}" failed:`, err.message)
1772
+ }
1773
+ continue
1774
+ }
1775
+
1776
+ if (Array.isArray(spec.sitemap)) {
1777
+ for (const item of spec.sitemap) {
1778
+ const e = normalise(item)
1779
+ if (e) entries.push(e)
1780
+ }
1781
+ continue
1782
+ }
1783
+
1784
+ if (isDynamic) continue // no enumerator → skip
1785
+ if (typeof spec.guard === 'function' && spec.sitemap !== true) continue // auth-gated by default
1786
+
1787
+ const e = normalise(spec.route)
1788
+ if (e) entries.push(e)
1789
+ }
1790
+
1791
+ // Dedupe by path, preserving first occurrence (which may carry lastmod)
1792
+ const seen = new Set()
1793
+ return entries.filter(e => seen.has(e.path) ? false : (seen.add(e.path), true))
1794
+ }
1795
+
1796
+ /** Escape a value for XML text content. */
1797
+ function escXml(str) {
1798
+ return String(str)
1799
+ .replace(/&/g, '&amp;')
1800
+ .replace(/</g, '&lt;')
1801
+ .replace(/>/g, '&gt;')
1802
+ .replace(/"/g, '&quot;')
1803
+ .replace(/'/g, '&apos;')
1804
+ }
1805
+
1806
+ /** Build the sitemap XML document from collected entries. */
1807
+ function buildSitemapXml(entries, origin) {
1808
+ const urls = entries.map(e => {
1809
+ const lastmod = e.lastmod ? `\n <lastmod>${escXml(e.lastmod)}</lastmod>` : ''
1810
+ return ` <url>\n <loc>${escXml(origin + e.path)}</loc>${lastmod}\n </url>`
1811
+ }).join('\n')
1812
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n`
1813
+ }
1814
+
1674
1815
  /**
1675
1816
  * Derive the CSRF token for a cookie id: HMAC-SHA256(secret, id), base64url.
1676
1817
  * The token goes in the form's hidden _csrf field; the id stays in an HttpOnly
@@ -1446,6 +1446,120 @@ await test('submit pages are excluded from the in-process page cache', async ()
1446
1446
 
1447
1447
  // ---------------------------------------------------------------------------
1448
1448
 
1449
+ console.log('\nSitemap + robots.txt\n')
1450
+
1451
+ const aboutSpec = { route: '/about', view: () => '<main id="main-content">About</main>' }
1452
+ const blogSpec = {
1453
+ route: '/blog/:slug',
1454
+ sitemap: async () => ['/blog/first-post', { path: '/blog/second-post', lastmod: '2026-01-15' }],
1455
+ server: { post: async (ctx) => ({ slug: ctx.params.slug }) },
1456
+ view: (_s, server) => `<main id="main-content">${server.post.slug}</main>`,
1457
+ }
1458
+ const secretSpec = { route: '/admin', guard: async () => {}, view: () => '<main id="main-content">admin</main>' }
1459
+ const hiddenSpec = { route: '/internal', sitemap: false, view: () => '<main id="main-content">internal</main>' }
1460
+
1461
+ await test('sitemap: true serves /sitemap.xml with static routes', async () => {
1462
+ await withServer([helloSpec, aboutSpec], { stream: false, sitemap: true }, async (port) => {
1463
+ const { status, headers, body } = await get(port, '/sitemap.xml')
1464
+ assert(status === 200, `Expected 200, got ${status}`)
1465
+ assert(headers['content-type'].includes('application/xml'), `Expected XML, got ${headers['content-type']}`)
1466
+ assert(body.includes(`<loc>http://localhost:${port}/hello</loc>`), `Missing /hello: ${body}`)
1467
+ assert(body.includes(`<loc>http://localhost:${port}/about</loc>`), `Missing /about: ${body}`)
1468
+ })
1469
+ })
1470
+
1471
+ await test('dynamic routes contribute via spec.sitemap enumerator (with lastmod)', async () => {
1472
+ await withServer([blogSpec], { stream: false, sitemap: true, quiet: true }, async (port) => {
1473
+ const { body } = await get(port, '/sitemap.xml')
1474
+ assert(body.includes('/blog/first-post</loc>'), `Missing enumerated path: ${body}`)
1475
+ assert(body.includes('/blog/second-post</loc>'), 'Missing second enumerated path')
1476
+ assert(body.includes('<lastmod>2026-01-15</lastmod>'), 'Missing lastmod')
1477
+ })
1478
+ })
1479
+
1480
+ await test('dynamic routes without an enumerator are skipped, not broken', async () => {
1481
+ const bare = { route: '/items/:id', server: { i: async (ctx) => ctx.params.id }, view: (_s, s) => `<main id="main-content">${s.i}</main>` }
1482
+ await withServer([helloSpec, bare], { stream: false, sitemap: true, quiet: true }, async (port) => {
1483
+ const { status, body } = await get(port, '/sitemap.xml')
1484
+ assert(status === 200)
1485
+ assert(!body.includes(':id'), `Raw :param leaked into sitemap: ${body}`)
1486
+ assert(body.includes('/hello</loc>'), 'Static route should still be present')
1487
+ })
1488
+ })
1489
+
1490
+ await test('guarded pages, route "*", and sitemap:false are excluded', async () => {
1491
+ const nf = { route: '*', view: () => '<main id="main-content">404</main>' }
1492
+ await withServer([helloSpec, secretSpec, hiddenSpec, nf], { stream: false, sitemap: true }, async (port) => {
1493
+ const { body } = await get(port, '/sitemap.xml')
1494
+ assert(!body.includes('/admin'), 'Guarded page must be excluded by default')
1495
+ assert(!body.includes('/internal'), 'sitemap:false page must be excluded')
1496
+ assert(!body.includes('<loc>http://localhost:' + port + '/*'), 'route * must be excluded')
1497
+ assert(body.includes('/hello</loc>'))
1498
+ })
1499
+ })
1500
+
1501
+ await test('a guarded page opts in with sitemap: true', async () => {
1502
+ const publicGuarded = { route: '/landing', guard: async () => {}, sitemap: true, view: () => '<main id="main-content">x</main>' }
1503
+ await withServer([publicGuarded], { stream: false, sitemap: true }, async (port) => {
1504
+ const { body } = await get(port, '/sitemap.xml')
1505
+ assert(body.includes('/landing</loc>'), `Expected opted-in guarded page: ${body}`)
1506
+ })
1507
+ })
1508
+
1509
+ await test('robots.txt is generated and points at the sitemap', async () => {
1510
+ await withServer([helloSpec], { stream: false, sitemap: true }, async (port) => {
1511
+ const { status, body } = await get(port, '/robots.txt')
1512
+ assert(status === 200)
1513
+ assert(body.includes('User-agent: *'), `Missing user-agent: ${body}`)
1514
+ assert(body.includes(`Sitemap: http://localhost:${port}/sitemap.xml`), `Missing sitemap line: ${body}`)
1515
+ })
1516
+ })
1517
+
1518
+ await test('robots: false disables robots.txt; custom string is served verbatim', async () => {
1519
+ await withServer([helloSpec], { stream: false, sitemap: true, robots: false }, async (port) => {
1520
+ const { status } = await get(port, '/robots.txt')
1521
+ assert(status === 404, `Expected 404 with robots:false, got ${status}`)
1522
+ })
1523
+ await withServer([helloSpec], { stream: false, sitemap: true, robots: 'User-agent: *\nDisallow: /private\n' }, async (port) => {
1524
+ const { body } = await get(port, '/robots.txt')
1525
+ assert(body.includes('Disallow: /private'), 'Expected custom robots content')
1526
+ })
1527
+ })
1528
+
1529
+ await test('sitemap origin can be pinned via { origin }', async () => {
1530
+ await withServer([helloSpec], { stream: false, sitemap: { origin: 'https://mysite.com' } }, async (port) => {
1531
+ const { body } = await get(port, '/sitemap.xml')
1532
+ assert(body.includes('<loc>https://mysite.com/hello</loc>'), `Expected pinned origin: ${body}`)
1533
+ const robotsRes = await get(port, '/robots.txt')
1534
+ assert(robotsRes.body.includes('Sitemap: https://mysite.com/sitemap.xml'), 'robots should use pinned origin')
1535
+ })
1536
+ })
1537
+
1538
+ await test('trailingSlash "add" is reflected in sitemap URLs', async () => {
1539
+ await withServer([helloSpec, aboutSpec], { stream: false, sitemap: true, trailingSlash: 'add' }, async (port) => {
1540
+ const { body } = await get(port, '/sitemap.xml')
1541
+ assert(body.includes('/about/</loc>'), `Expected trailing slash on URLs: ${body}`)
1542
+ })
1543
+ })
1544
+
1545
+ await test('sitemap disabled by default — /sitemap.xml is 404', async () => {
1546
+ await withServer([helloSpec], { stream: false }, async (port) => {
1547
+ const { status } = await get(port, '/sitemap.xml')
1548
+ assert(status === 404, `Expected 404 when sitemap not enabled, got ${status}`)
1549
+ })
1550
+ })
1551
+
1552
+ await test('a failing enumerator does not take down the sitemap', async () => {
1553
+ const broken = { route: '/x/:id', sitemap: async () => { throw new Error('db down') }, server: { i: async () => 1 }, view: (_s, s) => `<main id="main-content">${s.i}</main>` }
1554
+ await withServer([helloSpec, broken], { stream: false, sitemap: true, quiet: true }, async (port) => {
1555
+ const { status, body } = await get(port, '/sitemap.xml')
1556
+ assert(status === 200, `Expected 200 despite enumerator failure, got ${status}`)
1557
+ assert(body.includes('/hello</loc>'), 'Other routes should still be listed')
1558
+ })
1559
+ })
1560
+
1561
+ // ---------------------------------------------------------------------------
1562
+
1449
1563
  console.log('\nMalformed request handling\n')
1450
1564
 
1451
1565
  await test('malformed percent-encoding in path returns 404, not 500', async () => {
@@ -153,6 +153,25 @@ export function validateSpec(spec) {
153
153
  }
154
154
  }
155
155
 
156
+ // sitemap — inclusion control for the auto-generated sitemap (createServer { sitemap: true })
157
+ // false → exclude this page
158
+ // true → include a guarded static page that would be excluded by default
159
+ // array/function → enumerate paths (required for dynamic :param routes)
160
+ if (spec.sitemap !== undefined) {
161
+ const t = spec.sitemap
162
+ const ok = typeof t === 'boolean' || typeof t === 'function' || Array.isArray(t)
163
+ if (!ok) {
164
+ errors.push('spec.sitemap must be a boolean, an array of paths, or a function — e.g. sitemap: async () => [\'/blog/a\', { path: \'/blog/b\', lastmod: \'2026-01-01\' }]')
165
+ }
166
+ if (Array.isArray(t)) {
167
+ const bad = t.find(item => !(typeof item === 'string' && item.startsWith('/')) &&
168
+ !(item && typeof item === 'object' && typeof item.path === 'string' && item.path.startsWith('/')))
169
+ if (bad !== undefined) {
170
+ errors.push('spec.sitemap array entries must be "/path" strings or { path: \'/path\', lastmod? } objects')
171
+ }
172
+ }
173
+ }
174
+
156
175
  // csrf — opt out of CSRF protection on the submit handler (default: enforced)
157
176
  if (spec.csrf !== undefined) {
158
177
  if (typeof spec.csrf !== 'boolean') {
@@ -445,6 +445,22 @@ test('csrf: false with submit warns about unprotected POSTs', () => {
445
445
  assert(warnings.some(w => w.includes('CSRF')), 'Expected a warning about disabled CSRF protection')
446
446
  })
447
447
 
448
+ // --- sitemap ------------------------------------------------------------------
449
+
450
+ test('sitemap accepts boolean, array, and function', () => {
451
+ for (const v of [true, false, ['/a', { path: '/b', lastmod: '2026-01-01' }], async () => ['/a']]) {
452
+ const { valid, errors } = validateSpec({ route: '/x', view: () => '', sitemap: v })
453
+ assert(valid, `Expected sitemap ${typeof v} to be valid: ${errors.join('; ')}`)
454
+ }
455
+ })
456
+
457
+ test('sitemap rejects invalid types and malformed array entries', () => {
458
+ const { valid: v1 } = validateSpec({ route: '/x', view: () => '', sitemap: 'yes' })
459
+ assert(!v1, 'Expected string sitemap to be invalid')
460
+ const { valid: v2, errors } = validateSpec({ route: '/x', view: () => '', sitemap: ['no-slash'] })
461
+ assert(!v2 && errors.some(e => e.includes('sitemap')), 'Expected malformed array entry to be invalid')
462
+ })
463
+
448
464
  // ---------------------------------------------------------------------------
449
465
 
450
466
  console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`)
package/types/schema.d.ts CHANGED
@@ -75,6 +75,9 @@ export type SubmitResult =
75
75
  | { redirect: string }
76
76
  | { status?: number; [key: string]: unknown }
77
77
 
78
+ /** A sitemap entry: a '/path' string, or { path, lastmod } for a per-URL lastmod date. */
79
+ export type SitemapEntry = string | { path: string; lastmod?: string }
80
+
78
81
  export type GuardResult =
79
82
  | { redirect: string }
80
83
  | { status: number; json?: unknown; body?: string; headers?: Record<string, string> }
@@ -226,6 +229,14 @@ export interface PulseSpec<S extends object = Record<string, unknown>> {
226
229
  */
227
230
  csrf?: boolean
228
231
 
232
+ /**
233
+ * Inclusion control for the auto-generated sitemap (createServer { sitemap: true }).
234
+ * false → exclude this page. true → include a guarded static page that would be
235
+ * excluded by default. Array or function → enumerate paths — required for dynamic
236
+ * :param routes. Entries are '/path' strings or { path, lastmod } objects.
237
+ */
238
+ sitemap?: boolean | SitemapEntry[] | (() => Promise<SitemapEntry[]> | SitemapEntry[])
239
+
229
240
  /**
230
241
  * Called before server data fetchers on every request.
231
242
  * Return { redirect } to redirect, { status, json } for a custom response,
package/types/server.d.ts CHANGED
@@ -79,6 +79,21 @@ export interface ServerOptions {
79
79
  */
80
80
  secret?: string | null
81
81
 
82
+ /**
83
+ * Auto-serve /sitemap.xml (and robots.txt) from the registered routes.
84
+ * true derives the origin from request headers; { origin } pins it.
85
+ * Static page routes are included automatically; dynamic :param routes
86
+ * contribute via spec.sitemap; guarded pages are excluded unless the spec
87
+ * opts in. A physical sitemap.xml in staticDir always wins.
88
+ */
89
+ sitemap?: boolean | { origin?: string }
90
+
91
+ /**
92
+ * robots.txt behaviour when sitemap is enabled. null/undefined auto-generates
93
+ * (allow all + Sitemap line); false disables; a string is served verbatim.
94
+ */
95
+ robots?: string | false | null
96
+
82
97
  /**
83
98
  * Extra CSP sources to merge into the framework's default Content-Security-Policy.
84
99
  * Use this to allow external stylesheets, fonts, or API origins.