@invisibleloop/pulse 0.18.1 → 0.19.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
@@ -294,6 +294,10 @@ await createServer(
294
294
  // dynamic :param routes contribute via spec.sitemap enumerators;
295
295
  // a physical sitemap.xml/robots.txt in staticDir always wins
296
296
  robots: null, // robots.txt when sitemap on: null = auto, false = off, string = verbatim
297
+ redirects: { // declarative redirects for legacy URLs — 301 by default,
298
+ '/old-blog/:slug': '/blog/:slug', // :params carry over, query string preserved
299
+ '/promo': { to: '/pricing', status: 302 }, // custom status: 301 | 302 | 307 | 308
300
+ }, // checked before routing (GET/HEAD only); validated at startup
297
301
  csp: { // extra sources merged into the framework's default CSP
298
302
  'style-src': ['https://fonts.googleapis.com'],
299
303
  'font-src': ['https://fonts.gstatic.com'],
@@ -1436,6 +1436,15 @@ a:hover {
1436
1436
  line-height: 1.4;
1437
1437
  }
1438
1438
 
1439
+ /* iOS Safari auto-zooms when a focused input's font-size is under 16px —
1440
+ touch devices get 16px so tapping the search field never zooms the page. */
1441
+ @media (pointer: coarse) {
1442
+ .sidebar-search,
1443
+ .search-overlay-input {
1444
+ font-size: max(1rem, 16px);
1445
+ }
1446
+ }
1447
+
1439
1448
  .sidebar-search::placeholder {
1440
1449
  color: var(--muted-2);
1441
1450
  }
@@ -367,6 +367,18 @@ html, body {
367
367
  -webkit-appearance: none;
368
368
  }
369
369
 
370
+ /* iOS Safari auto-zooms the page when a focused input's font-size is under
371
+ 16px, forcing the user to pinch back out afterwards. On touch devices form
372
+ controls must never go below 16px — desktop keeps the compact .9rem. */
373
+ @media (pointer: coarse) {
374
+ .ui-input,
375
+ .ui-textarea,
376
+ .ui-select,
377
+ .ui-search-input {
378
+ font-size: max(1rem, 16px);
379
+ }
380
+ }
381
+
370
382
  .ui-input::placeholder { color: var(--ui-muted); }
371
383
 
372
384
  .ui-input:hover:not(:disabled) { border-color: var(--ui-muted); }
@@ -79,6 +79,19 @@ server: {
79
79
  },
80
80
  }`, 'js'))}
81
81
 
82
+ ${section('redirects', 'Redirects')}
83
+ <p>Migrating an existing site? Preserve the old URLs with the <code>redirects</code> map — legacy links and search rankings survive the move:</p>
84
+ ${codeBlock(highlight(`await createServer(pages, {
85
+ redirects: {
86
+ '/old-blog/:slug': '/blog/:slug', // 301 — :params carry over
87
+ '/pricing-2024': '/pricing', // 301 (default)
88
+ '/promo': { to: '/sale', status: 302 }, // temporary redirect
89
+ '/moved/:slug': 'https://other.com/:slug', // absolute targets for domain moves
90
+ },
91
+ })`, 'js'))}
92
+ <p>Redirects respond <strong>301</strong> by default (permanent — search engines transfer ranking to the target). Use <code>{ to, status }</code> for <code>302</code>, <code>307</code>, or <code>308</code>. The query string is preserved, redirects apply to GET/HEAD only, and the map is validated at startup — a bad entry fails the boot rather than silently misrouting traffic.</p>
93
+ ${callout('note', 'Redirects are checked before route matching, so a redirect source that equals a registered route shadows the page — the server logs a startup warning when that happens. Occasionally intentional (retiring a page), usually a mistake.')}
94
+
82
95
  ${section('not-found', '404 handling')}
83
96
  <p>If no spec matches the incoming request path, Pulse returns a 404. To customise it, create a spec with <code>route: '*'</code> — it renders through the normal pipeline (layout, styles, components) with status 404:</p>
84
97
  ${codeBlock(highlight(`export default {
@@ -51,6 +51,7 @@ await createServer(entries, options)`, 'js'))}
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
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
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.'],
54
+ ['<code>redirects</code>', '<code>object</code>', '<code>null</code>', 'Declarative redirect map for legacy URLs — <code>{ \'/old/:slug\': \'/new/:slug\' }</code>, 301 by default. See <a href="/routing#redirects">Routing</a>.'],
54
55
  ['<code>onRequest</code>', '<code>function</code>', '<code>undefined</code>', 'Called on every request before routing. Return <code>false</code> to short-circuit Pulse handling.'],
55
56
  ['<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>.'],
56
57
  ]
@@ -367,6 +367,18 @@ html, body {
367
367
  -webkit-appearance: none;
368
368
  }
369
369
 
370
+ /* iOS Safari auto-zooms the page when a focused input's font-size is under
371
+ 16px, forcing the user to pinch back out afterwards. On touch devices form
372
+ controls must never go below 16px — desktop keeps the compact .9rem. */
373
+ @media (pointer: coarse) {
374
+ .ui-input,
375
+ .ui-textarea,
376
+ .ui-select,
377
+ .ui-search-input {
378
+ font-size: max(1rem, 16px);
379
+ }
380
+ }
381
+
370
382
  .ui-input::placeholder { color: var(--ui-muted); }
371
383
 
372
384
  .ui-input:hover:not(:disabled) { border-color: var(--ui-muted); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@invisibleloop/pulse",
3
- "version": "0.18.1",
3
+ "version": "0.19.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.18.1
1
+ 0.19.0
@@ -367,6 +367,18 @@ html, body {
367
367
  -webkit-appearance: none;
368
368
  }
369
369
 
370
+ /* iOS Safari auto-zooms the page when a focused input's font-size is under
371
+ 16px, forcing the user to pinch back out afterwards. On touch devices form
372
+ controls must never go below 16px — desktop keeps the compact .9rem. */
373
+ @media (pointer: coarse) {
374
+ .ui-input,
375
+ .ui-textarea,
376
+ .ui-select,
377
+ .ui-search-input {
378
+ font-size: max(1rem, 16px);
379
+ }
380
+ }
381
+
370
382
  .ui-input::placeholder { color: var(--ui-muted); }
371
383
 
372
384
  .ui-input:hover:not(:disabled) { border-color: var(--ui-muted); }
@@ -147,6 +147,25 @@ 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
+ ## Declarative redirects (legacy URLs)
151
+
152
+ When migrating an existing site to Pulse, preserve the old URLs with the `redirects` map in `pulse.config.js` / `createServer` — never let legacy URLs 404:
153
+
154
+ ```js
155
+ export default {
156
+ redirects: {
157
+ '/old-blog/:slug': '/blog/:slug', // 301 — :params carry over
158
+ '/pricing-2024': '/pricing', // 301
159
+ '/promo': { to: '/sale', status: 302 }, // temporary
160
+ '/moved/:slug': 'https://other.com/:slug', // absolute targets for domain moves
161
+ },
162
+ }
163
+ ```
164
+
165
+ - **301 by default** (permanent — search engines transfer ranking). Use `{ to, status }` for 302/307/308.
166
+ - Query strings are preserved; checked before route matching (GET/HEAD only); validated at startup — bad entries fail the boot.
167
+ - A redirect source that equals a registered route shadows the page (startup warning) — that is sometimes intentional (retiring a page), usually a mistake.
168
+
150
169
  ## Sitemap & robots.txt
151
170
 
152
171
  Enable in `pulse.config.js` / `createServer` — the framework serves `/sitemap.xml` and `/robots.txt` generated from the registered routes:
@@ -551,6 +551,9 @@ export async function createServer(entries, options = {}) {
551
551
  // spec.sitemap = async () => ['/blog/a', ...]
552
552
  robots = null, // robots.txt content when sitemap is enabled:
553
553
  // null = auto-generate, false = disable, string = serve verbatim
554
+ redirects = null, // declarative redirect map for legacy URLs (SEO-safe migrations):
555
+ // { '/old-blog/:slug': '/blog/:slug', — 301 (default)
556
+ // '/promo': { to: '/pricing', status: 302 } } — custom status
554
557
  onError = (err, req, res) => defaultErrorHandler(err, req, res, dev),
555
558
  onRequest,
556
559
  agentMode = false, // show agent-active indicator in banner
@@ -619,6 +622,16 @@ export async function createServer(entries, options = {}) {
619
622
  // tied to a cookie, so a restart only means in-flight forms re-render once.
620
623
  const csrfSecret = secret || crypto.randomBytes(32).toString('hex')
621
624
 
625
+ // Redirects — compile the declarative map once at startup (throws on bad
626
+ // entries) and warn when a redirect source shadows a registered page route.
627
+ const redirectTable = compileRedirects(redirects)
628
+ if (redirectTable.length > 0 && !quiet) {
629
+ const shadowed = redirectTable.filter(r => specs.some(s => s.route === r.source))
630
+ if (shadowed.length > 0) {
631
+ console.log(` ⚠ redirects: ${shadowed.map(r => r.source).join(', ')} also match registered page routes — the redirect wins and the page is unreachable`)
632
+ }
633
+ }
634
+
622
635
  // Sitemap — track the live spec list (updated on hot reload) and surface a
623
636
  // startup hint for dynamic routes that can't be enumerated automatically.
624
637
  const sitemapEnabled = !!sitemap
@@ -727,6 +740,27 @@ export async function createServer(entries, options = {}) {
727
740
  // 'allow' — no redirect
728
741
  }
729
742
 
743
+ // Declarative redirects — checked before route matching so legacy URLs
744
+ // redirect even when a spec also matches (a startup warning flags that).
745
+ // GET/HEAD only: redirecting a POST silently drops the body.
746
+ if (redirectTable.length > 0 && (req.method === 'GET' || req.method === 'HEAD')) {
747
+ for (const r of redirectTable) {
748
+ const m = pathname.match(r.pattern)
749
+ if (!m) continue
750
+ // Substitute captured :params into the target, keeping their original
751
+ // percent-encoding intact (no decode/re-encode round trip). Longest
752
+ // names first so :s never clobbers part of :slug.
753
+ let target = r.target
754
+ ;[...r.params.entries()]
755
+ .sort((a, b) => b[1].length - a[1].length)
756
+ .forEach(([i, name]) => { target = target.split(`:${name}`).join(m[i + 1]) })
757
+ if (target === pathname) break // self-redirect — fall through to normal routing
758
+ res.writeHead(r.status, { Location: target + (url.search || ''), ...SECURITY_HEADERS, ...httpsHeaders(req) })
759
+ res.end()
760
+ return
761
+ }
762
+ }
763
+
730
764
  // Match route
731
765
  let match = matchRoute(router, pathname)
732
766
  let responseStatus = 200
@@ -1724,6 +1758,47 @@ function escHtml(str) {
1724
1758
  .replace(/'/g, '&#39;')
1725
1759
  }
1726
1760
 
1761
+ /**
1762
+ * Compile the declarative redirects map into matchable entries.
1763
+ * Validates at startup — a bad entry throws before the server accepts connections.
1764
+ *
1765
+ * Sources are route patterns ('/old/:slug'); targets are paths or absolute URLs
1766
+ * and may reference the same :params. Values are a target string (301) or
1767
+ * { to, status } for 302/307/308.
1768
+ *
1769
+ * @param {Record<string, string | { to: string, status?: number }> | null} redirects
1770
+ * @returns {Array<{ source: string, pattern: RegExp, params: string[], target: string, status: number }>}
1771
+ */
1772
+ function compileRedirects(redirects) {
1773
+ if (!redirects) return []
1774
+ const VALID_STATUS = new Set([301, 302, 307, 308])
1775
+ const table = []
1776
+
1777
+ for (const [source, value] of Object.entries(redirects)) {
1778
+ if (!source.startsWith('/')) {
1779
+ throw new Error(`Invalid redirect source "${source}" — sources must start with "/" (e.g. '/old-blog/:slug')`)
1780
+ }
1781
+ const target = typeof value === 'string' ? value : value?.to
1782
+ const status = typeof value === 'string' ? 301 : (value?.status ?? 301)
1783
+ if (typeof target !== 'string' || !(target.startsWith('/') || /^https?:\/\//.test(target))) {
1784
+ throw new Error(`Invalid redirect target for "${source}" — targets must start with "/" or be an absolute http(s) URL`)
1785
+ }
1786
+ if (!VALID_STATUS.has(status)) {
1787
+ throw new Error(`Invalid redirect status ${status} for "${source}" — use 301, 302, 307, or 308`)
1788
+ }
1789
+ // Targets may only reference params that the source captures
1790
+ const { pattern, params } = routeToRegex(source)
1791
+ const targetParams = [...target.matchAll(/:([a-zA-Z][\w-]*)/g)].map(m => m[1])
1792
+ const unknown = targetParams.filter(p => !params.includes(p))
1793
+ if (unknown.length > 0) {
1794
+ throw new Error(`Redirect target for "${source}" references :${unknown[0]} which the source does not capture`)
1795
+ }
1796
+ table.push({ source, pattern, params, target, status })
1797
+ }
1798
+
1799
+ return table
1800
+ }
1801
+
1727
1802
  /**
1728
1803
  * Collect sitemap entries from the registered specs.
1729
1804
  *
@@ -1446,6 +1446,92 @@ await test('submit pages are excluded from the in-process page cache', async ()
1446
1446
 
1447
1447
  // ---------------------------------------------------------------------------
1448
1448
 
1449
+ console.log('\nDeclarative redirects\n')
1450
+
1451
+ await test('plain redirect responds 301 with Location', async () => {
1452
+ await withServer([helloSpec], { stream: false, redirects: { '/old-hello': '/hello' } }, async (port) => {
1453
+ const { status, headers } = await get(port, '/old-hello')
1454
+ assert(status === 301, `Expected 301, got ${status}`)
1455
+ assert(headers.location === '/hello', `Expected /hello, got ${headers.location}`)
1456
+ })
1457
+ })
1458
+
1459
+ await test(':params are substituted into the target', async () => {
1460
+ await withServer([helloSpec], { stream: false, redirects: { '/old-blog/:slug': '/blog/:slug' } }, async (port) => {
1461
+ const { status, headers } = await get(port, '/old-blog/my-post')
1462
+ assert(status === 301)
1463
+ assert(headers.location === '/blog/my-post', `Expected /blog/my-post, got ${headers.location}`)
1464
+ })
1465
+ })
1466
+
1467
+ await test('percent-encoded params keep their encoding through substitution', async () => {
1468
+ await withServer([helloSpec], { stream: false, redirects: { '/old/:slug': '/new/:slug' } }, async (port) => {
1469
+ const { headers } = await get(port, '/old/caf%C3%A9')
1470
+ assert(headers.location === '/new/caf%C3%A9', `Encoding mangled: ${headers.location}`)
1471
+ })
1472
+ })
1473
+
1474
+ await test('query string is preserved on redirect', async () => {
1475
+ await withServer([helloSpec], { stream: false, redirects: { '/old-hello': '/hello' } }, async (port) => {
1476
+ const { headers } = await get(port, '/old-hello?utm=x&page=2')
1477
+ assert(headers.location === '/hello?utm=x&page=2', `Query lost: ${headers.location}`)
1478
+ })
1479
+ })
1480
+
1481
+ await test('custom status via { to, status }', async () => {
1482
+ await withServer([helloSpec], { stream: false, redirects: { '/promo': { to: '/hello', status: 302 } } }, async (port) => {
1483
+ const { status, headers } = await get(port, '/promo')
1484
+ assert(status === 302, `Expected 302, got ${status}`)
1485
+ assert(headers.location === '/hello')
1486
+ })
1487
+ })
1488
+
1489
+ await test('absolute URL targets work (domain moves)', async () => {
1490
+ await withServer([helloSpec], { stream: false, redirects: { '/moved/:slug': 'https://newsite.com/blog/:slug' } }, async (port) => {
1491
+ const { headers } = await get(port, '/moved/post-1')
1492
+ assert(headers.location === 'https://newsite.com/blog/post-1', `Got ${headers.location}`)
1493
+ })
1494
+ })
1495
+
1496
+ await test('redirect wins over a registered route at the same path', async () => {
1497
+ await withServer([helloSpec], { stream: false, quiet: true, redirects: { '/hello': '/elsewhere' } }, async (port) => {
1498
+ const { status, headers } = await get(port, '/hello')
1499
+ assert(status === 301 && headers.location === '/elsewhere', `Expected redirect to win, got ${status}`)
1500
+ })
1501
+ })
1502
+
1503
+ await test('POST is not redirected', async () => {
1504
+ await withServer([helloSpec], { stream: false, redirects: { '/old-api': '/hello' } }, async (port) => {
1505
+ const { status } = await requestFull(port, 'POST', '/old-api', {})
1506
+ assert(status === 404, `Expected POST to skip redirects (404), got ${status}`)
1507
+ })
1508
+ })
1509
+
1510
+ await test('self-redirects fall through to normal routing instead of looping', async () => {
1511
+ await withServer([helloSpec], { stream: false, redirects: { '/hello/:x': '/hello/:x' } }, async (port) => {
1512
+ const { status } = await get(port, '/hello/abc')
1513
+ assert(status === 404, `Expected fall-through to routing (404 here), got ${status}`)
1514
+ const ok = await get(port, '/hello')
1515
+ assert(ok.status === 200, 'Unrelated routes unaffected')
1516
+ })
1517
+ })
1518
+
1519
+ await test('invalid redirect config throws at startup', async () => {
1520
+ const cases = [
1521
+ { redirects: { 'no-slash': '/x' } },
1522
+ { redirects: { '/x': 'no-slash-target' } },
1523
+ { redirects: { '/x': { to: '/y', status: 418 } } },
1524
+ { redirects: { '/x': '/y/:missing' } },
1525
+ ]
1526
+ for (const opts of cases) {
1527
+ let threw = false
1528
+ try { await createServer([helloSpec], { ...opts, port: nextPort++, quiet: true }) } catch { threw = true }
1529
+ assert(threw, `Expected startup throw for ${JSON.stringify(opts.redirects)}`)
1530
+ }
1531
+ })
1532
+
1533
+ // ---------------------------------------------------------------------------
1534
+
1449
1535
  console.log('\nSitemap + robots.txt\n')
1450
1536
 
1451
1537
  const aboutSpec = { route: '/about', view: () => '<main id="main-content">About</main>' }
package/src/ui/ui.test.js CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { test } from 'node:test'
9
9
  import assert from 'node:assert/strict'
10
+ import { readFileSync } from 'node:fs'
10
11
  import { button } from './button.js'
11
12
  import { badge } from './badge.js'
12
13
  import { card } from './card.js'
@@ -1353,4 +1354,16 @@ test('dropCap: escapes letter and content', () => {
1353
1354
  assert.doesNotMatch(html, /<script>/)
1354
1355
  })
1355
1356
 
1357
+ test('pulse-ui.css: form controls are at least 16px on touch devices (iOS focus-zoom)', () => {
1358
+ // iOS Safari zooms the page when a focused input's font-size is under 16px,
1359
+ // forcing the user to pinch back out. A (pointer: coarse) block must keep
1360
+ // every text-entry control at >= 16px on touch devices.
1361
+ const css = readFileSync(new URL('../../public/pulse-ui.css', import.meta.url), 'utf8')
1362
+ const block = css.match(/@media \(pointer: coarse\) \{[\s\S]*?\n\}/)?.[0] ?? ''
1363
+ for (const sel of ['.ui-input', '.ui-textarea', '.ui-select', '.ui-search-input']) {
1364
+ assert.ok(block.includes(sel), `${sel} missing from the (pointer: coarse) 16px block`)
1365
+ }
1366
+ assert.match(block, /font-size:\s*max\(1rem, 16px\)/)
1367
+ })
1368
+
1356
1369
  console.log('✓ All UI component tests passed')
package/types/server.d.ts CHANGED
@@ -94,6 +94,20 @@ export interface ServerOptions {
94
94
  */
95
95
  robots?: string | false | null
96
96
 
97
+ /**
98
+ * Declarative redirect map for legacy URLs (SEO-safe migrations).
99
+ * Sources are route patterns; targets are paths or absolute URLs and may
100
+ * reference the same :params. A string target redirects with 301; use
101
+ * { to, status } for 302/307/308. Checked before route matching (GET/HEAD
102
+ * only); the query string is preserved. Validated at startup.
103
+ * @example
104
+ * redirects: {
105
+ * '/old-blog/:slug': '/blog/:slug',
106
+ * '/promo': { to: '/pricing', status: 302 },
107
+ * }
108
+ */
109
+ redirects?: Record<string, string | { to: string; status?: 301 | 302 | 307 | 308 }> | null
110
+
97
111
  /**
98
112
  * Extra CSP sources to merge into the framework's default Content-Security-Policy.
99
113
  * Use this to allow external stylesheets, fonts, or API origins.