@invisibleloop/pulse 0.17.3 → 0.18.1
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 +12 -0
- package/docs/src/pages/meta.js +27 -0
- package/docs/src/pages/raw-responses.js +1 -0
- package/docs/src/pages/server-api.js +2 -0
- package/package.json +1 -1
- package/public/.pulse-ui-version +1 -1
- package/scripts/css-purge.js +15 -0
- package/scripts/css-purge.test.js +37 -0
- package/src/agent/guide-routing.md +25 -0
- package/src/server/index.js +142 -1
- package/src/server/server.test.js +114 -0
- package/src/spec/schema.js +19 -0
- package/src/spec/schema.test.js +16 -0
- package/types/schema.d.ts +11 -0
- package/types/server.d.ts +15 -0
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'],
|
package/docs/src/pages/meta.js
CHANGED
|
@@ -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><head></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><link rel="stylesheet"></code> tags in the <code><head></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 & 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 & 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
package/public/.pulse-ui-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.18.1
|
package/scripts/css-purge.js
CHANGED
|
@@ -35,6 +35,21 @@ export function extractUsedClasses(htmlContents, jsContents, safelist = []) {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// classList method arguments are class names BY DEFINITION — collect them even
|
|
39
|
+
// without a hyphen. The generic string scan above requires a hyphen to avoid
|
|
40
|
+
// swallowing every English word, which silently missed runtime state classes
|
|
41
|
+
// (classList.add('open'), .toggle('active')) and purged their compound rules
|
|
42
|
+
// (`.docs-sidebar.open`) from production CSS while dev looked fine.
|
|
43
|
+
for (const js of jsContents) {
|
|
44
|
+
const re = /classList\s*\.\s*(?:add|remove|toggle|replace)\s*\(\s*((?:['"][^'"]+['"]\s*,?\s*)+)/g
|
|
45
|
+
let m
|
|
46
|
+
while ((m = re.exec(js)) !== null) {
|
|
47
|
+
for (const [, cls] of m[1].matchAll(/['"]([^'"]+)['"]/g)) {
|
|
48
|
+
if (/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(cls)) used.add(cls)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
// Apply safelist from pulse.config.js — css.safelist accepts strings or RegExp.
|
|
39
54
|
// String entries are added directly; RegExp entries are matched against all
|
|
40
55
|
// class names defined in the CSS (resolved later in purgeCss via selectorUsed
|
|
@@ -92,3 +92,40 @@ test('minifyCss: calc() expressions keep their operator spacing', () => {
|
|
|
92
92
|
const out = minifyCss(`.x { width: calc(100% - 2rem); }`)
|
|
93
93
|
assert.ok(out.includes('calc(100% - 2rem)'), `calc broken: ${out}`)
|
|
94
94
|
})
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Runtime state classes — classList arguments
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Regression: the JS string scan only keeps hyphenated names (to avoid keeping
|
|
100
|
+
// every English word), so runtime state classes like classList.add('open') were
|
|
101
|
+
// never collected — and selectorUsed checks the rightmost class token, so
|
|
102
|
+
// `.docs-sidebar.open { … }` was purged from production CSS. The mobile docs
|
|
103
|
+
// hamburger toggled the class, but the rule that slides the sidebar in was gone.
|
|
104
|
+
|
|
105
|
+
test('classList method arguments are collected regardless of hyphens', () => {
|
|
106
|
+
const used = extractUsedClasses([], [
|
|
107
|
+
`sidebar.classList.add('open')`,
|
|
108
|
+
`overlay.classList.remove("visible")`,
|
|
109
|
+
`el.classList.toggle('active', isOn)`,
|
|
110
|
+
`el.classList.replace('open', 'closed')`,
|
|
111
|
+
])
|
|
112
|
+
for (const cls of ['open', 'visible', 'active', 'closed']) {
|
|
113
|
+
assert.ok(used.has(cls), `Expected classList arg "${cls}" to be collected`)
|
|
114
|
+
}
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('multiple classList arguments in one call are all collected', () => {
|
|
118
|
+
const used = extractUsedClasses([], [`el.classList.add('one', 'two-x', 'three')`])
|
|
119
|
+
for (const cls of ['one', 'two-x', 'three']) assert.ok(used.has(cls), cls)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('runtime-toggled compound rule survives the purge', () => {
|
|
123
|
+
const css = `.docs-sidebar { transform: translateX(-100%); }\n.docs-sidebar.open { transform: translateX(0); }\n.unused { color: red; }`
|
|
124
|
+
const used = extractUsedClasses(
|
|
125
|
+
[`<aside class="docs-sidebar">`],
|
|
126
|
+
[`document.querySelector('.docs-sidebar').classList.add('open')`]
|
|
127
|
+
)
|
|
128
|
+
const out = purgeCss(css, used)
|
|
129
|
+
assert.ok(out.includes('.docs-sidebar.open'), `Compound state rule was purged: ${out}`)
|
|
130
|
+
assert.ok(!out.includes('.unused'), 'Unused rule should still be purged')
|
|
131
|
+
})
|
|
@@ -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.
|
package/src/server/index.js
CHANGED
|
@@ -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
|
|
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, ''')
|
|
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, '&')
|
|
1800
|
+
.replace(/</g, '<')
|
|
1801
|
+
.replace(/>/g, '>')
|
|
1802
|
+
.replace(/"/g, '"')
|
|
1803
|
+
.replace(/'/g, ''')
|
|
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 () => {
|
package/src/spec/schema.js
CHANGED
|
@@ -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') {
|
package/src/spec/schema.test.js
CHANGED
|
@@ -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.
|