@stacksjs/ui 0.70.45 → 0.70.54
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 +21 -0
- package/dist/index.js +1 -1
- package/dist/src/fonts.d.ts +68 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/pagination.d.ts +71 -0
- package/package.json +5 -5
- package/src/fonts.ts +205 -0
- package/src/index.ts +21 -0
- package/src/pagination.ts +115 -0
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{
|
|
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
|
+
}
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "0.70.54",
|
|
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": "
|
|
58
|
-
"@stacksjs/config": "
|
|
57
|
+
"@stacksjs/build": "workspace:*",
|
|
58
|
+
"@stacksjs/config": "0.70.54",
|
|
59
59
|
"better-dx": "^0.2.12",
|
|
60
|
-
"@stacksjs/router": "
|
|
61
|
-
"@stacksjs/stx": "^0.2.
|
|
60
|
+
"@stacksjs/router": "0.70.54",
|
|
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 ({ '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' } 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
|
+
}
|