@stacksjs/ui 0.70.45 → 0.70.53

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/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright (c) 2023 Open Web Foundation
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // @bun
2
- export{r as ui,i as CssEngine};
2
+ export{H as urlForPage,h as ui,B as renderFontPreloads,A as renderFontHead,z as renderFontFaceCss,G as paginatorVariant,F as buildPageSequence,q as CssEngine};
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Render `<link rel="preload" as="font" crossorigin>` tags for every
3
+ * font with `preload !== false`. Cross-origin attribute is always
4
+ * emitted because most CDN-hosted fonts require it and same-origin
5
+ * fonts ignore it harmlessly.
6
+ *
7
+ * Output is a single string with one tag per line, ready to drop into
8
+ * a stx layout's `<head>` via `{{ renderFontPreloads(fonts) }}`.
9
+ */
10
+ export declare function renderFontPreloads(fonts?: FontEntry[]): string;
11
+ /**
12
+ * Render an `@font-face { ... }` block for every entry. `font-display:
13
+ * swap` is the default; override per-entry with `display`.
14
+ *
15
+ * Wrap the result in `<style>` tags when inlining into a layout's
16
+ * `<head>`, or write to a separate CSS file and `<link>` it.
17
+ */
18
+ export declare function renderFontFaceCss(fonts?: FontEntry[]): string;
19
+ /**
20
+ * Convenience helper: render BOTH the preload tags and the wrapped
21
+ * `<style>...@font-face...</style>` block in one go, separated by a
22
+ * newline. Drop this into the very top of your layout's `<head>` for
23
+ * the maximal CLS win.
24
+ *
25
+ * @example
26
+ * ```stx
27
+ * <head>
28
+ * {{ renderFontHead(config.ui.fonts) }}
29
+ * <title>...</title>
30
+ * </head>
31
+ * ```
32
+ */
33
+ export declare function renderFontHead(fonts?: FontEntry[]): string;
34
+ /**
35
+ * Web-font preload + `@font-face` rendering helpers
36
+ * (stacksjs/stacks#283 — prevent layout shifts).
37
+ *
38
+ * Layout shifts (the LS in CLS) happen when a page renders with the
39
+ * fallback font, then re-flows once the web font loads. The two
40
+ * mitigations this module ships against that:
41
+ *
42
+ * 1. **Preload** — emit `<link rel="preload" as="font" crossorigin>`
43
+ * for each font listed in the UI config, so the browser starts the
44
+ * font download in parallel with HTML parsing rather than waiting
45
+ * to discover the URL via CSS.
46
+ *
47
+ * 2. **`font-display: swap`** — the default `@font-face` block uses
48
+ * `font-display: swap`, which shows the fallback font immediately
49
+ * and swaps in the web font once available. Layout still shifts
50
+ * slightly at swap time, but the page is readable from t=0 instead
51
+ * of being blocked by the font fetch.
52
+ *
53
+ * Apps drop a list of fonts into `config/ui.ts` and call these helpers
54
+ * from their layout's `<head>` — see {@link renderFontHead}. Both
55
+ * outputs are safe to inline into HTML (no user-controlled values
56
+ * interpolate; the FontEntry shape is config-only).
57
+ */
58
+ /** One configured web font. */
59
+ export declare interface FontEntry {
60
+ family: string
61
+ src: string
62
+ format?: 'woff2' | 'woff' | 'truetype' | 'opentype'
63
+ weight?: number | string
64
+ style?: 'normal' | 'italic' | 'oblique'
65
+ display?: 'auto' | 'swap' | 'block' | 'fallback' | 'optional'
66
+ preload?: boolean
67
+ unicodeRange?: string
68
+ }
@@ -1,3 +1,22 @@
1
1
  import { ui } from '@stacksjs/config';
2
2
  import * as CssEngine from '@cwcss/crosswind';
3
+ export type { FontEntry } from './fonts';
4
+ export type { PaginatorView } from './pagination';
3
5
  export { CssEngine, ui };
6
+ // Web-font preload + @font-face helpers — used by stx layouts to
7
+ // eliminate the FOUT/CLS that hits when a page renders with the
8
+ // fallback font and then re-flows once the web font lands
9
+ // (stacksjs/stacks#283).
10
+ export {
11
+ renderFontFaceCss,
12
+ renderFontHead,
13
+ renderFontPreloads,
14
+ } from './fonts';
15
+ // Pagination view helpers — pure functions consumed by the
16
+ // <Pagination> stx component (defaults/resources/components/Pagination.stx).
17
+ // stacksjs/stacks#1909 P5.
18
+ export {
19
+ buildPageSequence,
20
+ paginatorVariant,
21
+ urlForPage,
22
+ } from './pagination';
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Build the page-number sequence for a full paginator, inserting an
3
+ * ellipsis placeholder (`'…'`) for the gap between page 1 / the
4
+ * current-page window / the last page.
5
+ *
6
+ * Examples (window=2):
7
+ *
8
+ * current=5, last=12 → [1, '…', 3, 4, 5, 6, 7, '…', 12]
9
+ * current=1, last=3 → [1, 2, 3] (window covers all)
10
+ * current=1, last=1 → [] (single page → no UI)
11
+ * current=5, last=5 → [1, 2, 3, 4, 5] (last is current, no trailing ellipsis)
12
+ *
13
+ * Always anchors the sequence with `1` and `last_page` (when they
14
+ * exist and differ from the current window) so users always have a
15
+ * "jump to start" / "jump to end" affordance.
16
+ *
17
+ * @param current 1-indexed current page
18
+ * @param last 1-indexed last page (`Paginator.last_page`)
19
+ * @param window Number of neighbors on EACH side of `current` to
20
+ * show before the ellipsis kicks in. Default 2 gives
21
+ * the canonical compact shape.
22
+ */
23
+ export declare function buildPageSequence(current: number, last: number, window?: number): Array<number | '…'>;
24
+ /**
25
+ * Compute the URL for a specific page number, re-templating the
26
+ * `page=N` parameter on whichever existing paginator URL is present.
27
+ * Preserves all other query params (search filters, sort, etc.) — the
28
+ * URLs filled in by `enrichPaginatorUrls()` (P2) already carry them,
29
+ * so the re-template just swaps the page number.
30
+ *
31
+ * Falls back to `?page=N` when no template URL is available — covers
32
+ * the case where the paginator was built outside a request scope (CLI
33
+ * / queue / cron) and rendered via a non-default view; produces a
34
+ * relative link that still works against the active page.
35
+ */
36
+ export declare function urlForPage(view: PaginatorView, page: number): string;
37
+ /**
38
+ * Classify a paginator instance by shape so the view picks the right
39
+ * UI variant. Returns one of `'full'` / `'simple'` / `'cursor'` based
40
+ * on which fields are present. This mirrors `isPaginator` /
41
+ * `isSimplePaginator` / `isCursorPaginator` in `@stacksjs/orm` but
42
+ * lives here so the view layer doesn't need to import the orm.
43
+ */
44
+ export declare function paginatorVariant(p: unknown): 'full' | 'simple' | 'cursor' | 'unknown';
45
+ /**
46
+ * Pagination view helpers (stacksjs/stacks#1909, P5 from #1910).
47
+ *
48
+ * Pure functions consumed by the `<Pagination>` stx component
49
+ * (`defaults/resources/components/Pagination.stx`). Extracted here so
50
+ * the page-sequence + URL-templating logic is unit-testable independent
51
+ * of the stx render pipeline, and so apps that want to roll their own
52
+ * pagination UI can reuse the same algorithms.
53
+ *
54
+ * The functions operate on the canonical paginator shapes from
55
+ * `@stacksjs/orm` (Paginator / SimplePaginator / CursorPaginator), but
56
+ * accept any duck-typed object with the right fields so callers don't
57
+ * need a runtime import dependency on the orm module just to format
58
+ * page numbers.
59
+ */
60
+ /**
61
+ * Subset of {@link Paginator} fields that the view-side helpers actually
62
+ * touch. Keeping this minimal keeps the helper decoupled from the orm.
63
+ */
64
+ export declare interface PaginatorView {
65
+ current_page?: number
66
+ last_page?: number
67
+ prev_page_url?: string | null
68
+ next_page_url?: string | null
69
+ first_page_url?: string
70
+ last_page_url?: string
71
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/ui",
3
3
  "type": "module",
4
- "version": "0.70.45",
4
+ "version": "0.70.53",
5
5
  "description": "The Stacks UI engine.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [
@@ -54,10 +54,10 @@
54
54
  "prepublishOnly": "bun run build"
55
55
  },
56
56
  "devDependencies": {
57
- "@stacksjs/build": "^0.70.45",
58
- "@stacksjs/config": "^0.70.45",
57
+ "@stacksjs/build": "workspace:*",
58
+ "@stacksjs/config": "0.70.53",
59
59
  "better-dx": "^0.2.12",
60
- "@stacksjs/router": "^0.70.45",
61
- "@stacksjs/stx": "^0.2.40"
60
+ "@stacksjs/router": "0.70.53",
61
+ "@stacksjs/stx": "^0.2.82"
62
62
  }
63
63
  }
package/src/fonts.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Web-font preload + `@font-face` rendering helpers
3
+ * (stacksjs/stacks#283 — prevent layout shifts).
4
+ *
5
+ * Layout shifts (the LS in CLS) happen when a page renders with the
6
+ * fallback font, then re-flows once the web font loads. The two
7
+ * mitigations this module ships against that:
8
+ *
9
+ * 1. **Preload** — emit `<link rel="preload" as="font" crossorigin>`
10
+ * for each font listed in the UI config, so the browser starts the
11
+ * font download in parallel with HTML parsing rather than waiting
12
+ * to discover the URL via CSS.
13
+ *
14
+ * 2. **`font-display: swap`** — the default `@font-face` block uses
15
+ * `font-display: swap`, which shows the fallback font immediately
16
+ * and swaps in the web font once available. Layout still shifts
17
+ * slightly at swap time, but the page is readable from t=0 instead
18
+ * of being blocked by the font fetch.
19
+ *
20
+ * Apps drop a list of fonts into `config/ui.ts` and call these helpers
21
+ * from their layout's `<head>` — see {@link renderFontHead}. Both
22
+ * outputs are safe to inline into HTML (no user-controlled values
23
+ * interpolate; the FontEntry shape is config-only).
24
+ */
25
+
26
+ /** One configured web font. */
27
+ export interface FontEntry {
28
+ /**
29
+ * Font family name. Used as the `font-family` value in the emitted
30
+ * `@font-face` block and referenced from your CSS.
31
+ */
32
+ family: string
33
+ /**
34
+ * Absolute or app-relative URL to the font file. Local files under
35
+ * `public/` are preferred for the preload path since they share the
36
+ * connection with the document; CDN-hosted fonts work but lose the
37
+ * preload benefit when crossing origins without `crossorigin`.
38
+ */
39
+ src: string
40
+ /**
41
+ * Font file format passed to the `format(...)` clause. Defaults to
42
+ * `'woff2'` because every shipping browser supports it; only set
43
+ * this when the asset is actually a different format.
44
+ *
45
+ * @default 'woff2'
46
+ */
47
+ format?: 'woff2' | 'woff' | 'truetype' | 'opentype'
48
+ /**
49
+ * `font-weight` to apply on the `@font-face` block. Accepts CSS
50
+ * keywords (`'normal'` / `'bold'`) or numeric weights (100-900).
51
+ *
52
+ * @default 'normal'
53
+ */
54
+ weight?: number | string
55
+ /**
56
+ * `font-style` on the `@font-face` block.
57
+ *
58
+ * @default 'normal'
59
+ */
60
+ style?: 'normal' | 'italic' | 'oblique'
61
+ /**
62
+ * `font-display` strategy. `'swap'` is the right default for the CLS
63
+ * goal — see https://web.dev/font-display. `'optional'` is stricter
64
+ * (skip the swap entirely on slow networks) and useful for highly
65
+ * brand-sensitive surfaces; `'block'` re-introduces FOIT and should
66
+ * be avoided unless you have a very specific reason.
67
+ *
68
+ * @default 'swap'
69
+ */
70
+ display?: 'auto' | 'swap' | 'block' | 'fallback' | 'optional'
71
+ /**
72
+ * Whether to emit a `<link rel="preload" as="font">` tag. Defaults
73
+ * to `true` for the first 2 fonts (the typical "above-the-fold"
74
+ * count before TCP slot exhaustion hurts more than it helps) — set
75
+ * `false` on long lists' tail entries.
76
+ */
77
+ preload?: boolean
78
+ /**
79
+ * Optional `unicode-range`. Useful for subsetted fonts (e.g. Latin
80
+ * vs CJK). Omitted from the `@font-face` block when undefined.
81
+ *
82
+ * @example
83
+ * 'U+0000-00FF, U+0131, U+0152-0153'
84
+ */
85
+ unicodeRange?: string
86
+ }
87
+
88
+ /** Map `format` shorthand to the MIME-style label CSS expects. */
89
+ const FORMAT_LABEL: Record<NonNullable<FontEntry['format']>, string> = {
90
+ woff2: 'woff2',
91
+ woff: 'woff',
92
+ truetype: 'truetype',
93
+ opentype: 'opentype',
94
+ }
95
+
96
+ /** Map `format` shorthand to the `<link>` MIME type. */
97
+ const FORMAT_MIME: Record<NonNullable<FontEntry['format']>, string> = {
98
+ woff2: 'font/woff2',
99
+ woff: 'font/woff',
100
+ truetype: 'font/ttf',
101
+ opentype: 'font/otf',
102
+ }
103
+
104
+ /**
105
+ * Render `<link rel="preload" as="font" crossorigin>` tags for every
106
+ * font with `preload !== false`. Cross-origin attribute is always
107
+ * emitted because most CDN-hosted fonts require it and same-origin
108
+ * fonts ignore it harmlessly.
109
+ *
110
+ * Output is a single string with one tag per line, ready to drop into
111
+ * a stx layout's `<head>` via `{{ renderFontPreloads(fonts) }}`.
112
+ */
113
+ export function renderFontPreloads(fonts: FontEntry[] = []): string {
114
+ return fonts
115
+ .filter(font => font.preload !== false)
116
+ .map((font) => {
117
+ const fmt = font.format ?? 'woff2'
118
+ const type = FORMAT_MIME[fmt]
119
+ return `<link rel="preload" href="${escapeAttr(font.src)}" as="font" type="${type}" crossorigin>`
120
+ })
121
+ .join('\n')
122
+ }
123
+
124
+ /**
125
+ * Render an `@font-face { ... }` block for every entry. `font-display:
126
+ * swap` is the default; override per-entry with `display`.
127
+ *
128
+ * Wrap the result in `<style>` tags when inlining into a layout's
129
+ * `<head>`, or write to a separate CSS file and `<link>` it.
130
+ */
131
+ export function renderFontFaceCss(fonts: FontEntry[] = []): string {
132
+ return fonts
133
+ .map((font) => {
134
+ const fmt = font.format ?? 'woff2'
135
+ const fmtLabel = FORMAT_LABEL[fmt]
136
+ const weight = font.weight ?? 'normal'
137
+ const style = font.style ?? 'normal'
138
+ const display = font.display ?? 'swap'
139
+ const ur = font.unicodeRange
140
+ return `@font-face {
141
+ font-family: '${escapeFamily(font.family)}';
142
+ src: url('${escapeUrl(font.src)}') format('${fmtLabel}');
143
+ font-weight: ${weight};
144
+ font-style: ${style};
145
+ font-display: ${display};${ur ? `\n unicode-range: ${ur};` : ''}
146
+ }`
147
+ })
148
+ .join('\n\n')
149
+ }
150
+
151
+ /**
152
+ * Convenience helper: render BOTH the preload tags and the wrapped
153
+ * `<style>...@font-face...</style>` block in one go, separated by a
154
+ * newline. Drop this into the very top of your layout's `<head>` for
155
+ * the maximal CLS win.
156
+ *
157
+ * @example
158
+ * ```stx
159
+ * <head>
160
+ * {{ renderFontHead(config.ui.fonts) }}
161
+ * <title>...</title>
162
+ * </head>
163
+ * ```
164
+ */
165
+ export function renderFontHead(fonts: FontEntry[] = []): string {
166
+ if (fonts.length === 0) return ''
167
+ const preloads = renderFontPreloads(fonts)
168
+ const faces = renderFontFaceCss(fonts)
169
+ return `${preloads}\n<style>\n${faces}\n</style>`
170
+ }
171
+
172
+ /**
173
+ * Escape an HTML attribute value — `&` `<` `>` `"` `'`. The font config
174
+ * is app-controlled, not user-controlled, so injection isn't a realistic
175
+ * threat here; the escape is defense-in-depth for callers that pull
176
+ * font URLs from a CMS or similar untrusted source.
177
+ */
178
+ function escapeAttr(s: string): string {
179
+ return String(s).replace(/[&<>"']/g, (c) => {
180
+ return ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', '\'': '&#39;' } as Record<string, string>)[c]!
181
+ })
182
+ }
183
+
184
+ /**
185
+ * Strip CSS-meaningful chars from a font URL inside `url('...')`.
186
+ *
187
+ * Removes anything that could break out of the `url('...')` wrapper:
188
+ * quotes, parentheses, semicolons, braces (which would terminate the
189
+ * `src:` declaration or `@font-face` block), comment delimiters
190
+ * (`/*`), and whitespace control chars. `/`, `:`, `?`, `=`, `&`, `.`
191
+ * are retained since they're legitimate URL chars.
192
+ */
193
+ function escapeUrl(s: string): string {
194
+ return String(s).replace(/['"`()<>{};\\\r\n\t*]/g, '')
195
+ }
196
+
197
+ /**
198
+ * Strip CSS-meaningful chars from a font-family identifier. Stricter
199
+ * than {@link escapeUrl} because family names are short identifiers,
200
+ * not URLs — only alphanumerics, spaces, dashes, dots, and underscores
201
+ * survive.
202
+ */
203
+ function escapeFamily(s: string): string {
204
+ return String(s).replace(/[^A-Z0-9 \-_.]/gi, '')
205
+ }
package/src/index.ts CHANGED
@@ -2,3 +2,24 @@ import { ui } from '@stacksjs/config'
2
2
  import * as CssEngine from '@cwcss/crosswind'
3
3
 
4
4
  export { CssEngine, ui }
5
+
6
+ // Web-font preload + @font-face helpers — used by stx layouts to
7
+ // eliminate the FOUT/CLS that hits when a page renders with the
8
+ // fallback font and then re-flows once the web font lands
9
+ // (stacksjs/stacks#283).
10
+ export {
11
+ renderFontFaceCss,
12
+ renderFontHead,
13
+ renderFontPreloads,
14
+ } from './fonts'
15
+ export type { FontEntry } from './fonts'
16
+
17
+ // Pagination view helpers — pure functions consumed by the
18
+ // <Pagination> stx component (defaults/resources/components/Pagination.stx).
19
+ // stacksjs/stacks#1909 P5.
20
+ export {
21
+ buildPageSequence,
22
+ paginatorVariant,
23
+ urlForPage,
24
+ } from './pagination'
25
+ export type { PaginatorView } from './pagination'
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Pagination view helpers (stacksjs/stacks#1909, P5 from #1910).
3
+ *
4
+ * Pure functions consumed by the `<Pagination>` stx component
5
+ * (`defaults/resources/components/Pagination.stx`). Extracted here so
6
+ * the page-sequence + URL-templating logic is unit-testable independent
7
+ * of the stx render pipeline, and so apps that want to roll their own
8
+ * pagination UI can reuse the same algorithms.
9
+ *
10
+ * The functions operate on the canonical paginator shapes from
11
+ * `@stacksjs/orm` (Paginator / SimplePaginator / CursorPaginator), but
12
+ * accept any duck-typed object with the right fields so callers don't
13
+ * need a runtime import dependency on the orm module just to format
14
+ * page numbers.
15
+ */
16
+
17
+ /**
18
+ * Subset of {@link Paginator} fields that the view-side helpers actually
19
+ * touch. Keeping this minimal keeps the helper decoupled from the orm.
20
+ */
21
+ export interface PaginatorView {
22
+ current_page?: number
23
+ last_page?: number
24
+ prev_page_url?: string | null
25
+ next_page_url?: string | null
26
+ first_page_url?: string
27
+ last_page_url?: string
28
+ }
29
+
30
+ /**
31
+ * Build the page-number sequence for a full paginator, inserting an
32
+ * ellipsis placeholder (`'…'`) for the gap between page 1 / the
33
+ * current-page window / the last page.
34
+ *
35
+ * Examples (window=2):
36
+ *
37
+ * current=5, last=12 → [1, '…', 3, 4, 5, 6, 7, '…', 12]
38
+ * current=1, last=3 → [1, 2, 3] (window covers all)
39
+ * current=1, last=1 → [] (single page → no UI)
40
+ * current=5, last=5 → [1, 2, 3, 4, 5] (last is current, no trailing ellipsis)
41
+ *
42
+ * Always anchors the sequence with `1` and `last_page` (when they
43
+ * exist and differ from the current window) so users always have a
44
+ * "jump to start" / "jump to end" affordance.
45
+ *
46
+ * @param current 1-indexed current page
47
+ * @param last 1-indexed last page (`Paginator.last_page`)
48
+ * @param window Number of neighbors on EACH side of `current` to
49
+ * show before the ellipsis kicks in. Default 2 gives
50
+ * the canonical compact shape.
51
+ */
52
+ export function buildPageSequence(
53
+ current: number,
54
+ last: number,
55
+ window: number = 2,
56
+ ): Array<number | '…'> {
57
+ if (last <= 1) return []
58
+ const out: Array<number | '…'> = []
59
+ // Window bounds, clamped to [2, last-1] so we don't double-emit 1 or last.
60
+ const lo = Math.max(2, current - window)
61
+ const hi = Math.min(last - 1, current + window)
62
+ out.push(1)
63
+ // Only emit the leading ellipsis when the gap is >1 page wide; a gap of
64
+ // exactly 1 (e.g. lo=3, hiding only page 2) is just shown as the real
65
+ // page number — the ellipsis would be wider on screen than the digit it
66
+ // replaces, and clicking it does nothing.
67
+ if (lo === 3) out.push(2)
68
+ else if (lo > 3) out.push('…')
69
+ for (let i = lo; i <= hi; i++) out.push(i)
70
+ // Symmetric for the trailing side.
71
+ if (hi === last - 2) out.push(last - 1)
72
+ else if (hi < last - 2) out.push('…')
73
+ if (last > 1) out.push(last)
74
+ return out
75
+ }
76
+
77
+ /**
78
+ * Compute the URL for a specific page number, re-templating the
79
+ * `page=N` parameter on whichever existing paginator URL is present.
80
+ * Preserves all other query params (search filters, sort, etc.) — the
81
+ * URLs filled in by `enrichPaginatorUrls()` (P2) already carry them,
82
+ * so the re-template just swaps the page number.
83
+ *
84
+ * Falls back to `?page=N` when no template URL is available — covers
85
+ * the case where the paginator was built outside a request scope (CLI
86
+ * / queue / cron) and rendered via a non-default view; produces a
87
+ * relative link that still works against the active page.
88
+ */
89
+ export function urlForPage(view: PaginatorView, page: number): string {
90
+ const template = view.next_page_url || view.prev_page_url || view.first_page_url || view.last_page_url
91
+ if (template) {
92
+ if (/[?&]page=\d+/.test(template))
93
+ return template.replace(/([?&])page=\d+/, `$1page=${page}`)
94
+ // Template has no page= param yet (rare — paginator built without P2
95
+ // enrichment but a URL was attached manually); append it.
96
+ return `${template}${template.includes('?') ? '&' : '?'}page=${page}`
97
+ }
98
+ return `?page=${page}`
99
+ }
100
+
101
+ /**
102
+ * Classify a paginator instance by shape so the view picks the right
103
+ * UI variant. Returns one of `'full'` / `'simple'` / `'cursor'` based
104
+ * on which fields are present. This mirrors `isPaginator` /
105
+ * `isSimplePaginator` / `isCursorPaginator` in `@stacksjs/orm` but
106
+ * lives here so the view layer doesn't need to import the orm.
107
+ */
108
+ export function paginatorVariant(p: unknown): 'full' | 'simple' | 'cursor' | 'unknown' {
109
+ if (p === null || typeof p !== 'object') return 'unknown'
110
+ const v = p as Record<string, unknown>
111
+ if ('next_cursor' in v) return 'cursor'
112
+ if ('total' in v && 'last_page' in v) return 'full'
113
+ if ('current_page' in v && 'per_page' in v) return 'simple'
114
+ return 'unknown'
115
+ }