@aria-framework/kit 0.1.0 → 0.3.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/README.md +15 -2
- package/index.js +31 -25
- package/isEmail.js +22 -0
- package/package.json +24 -19
- package/paginate.js +97 -0
package/README.md
CHANGED
|
@@ -70,11 +70,24 @@ splitName('Johan van der Merwe') // { first_name: 'Johan van der', last_name: 'M
|
|
|
70
70
|
|
|
71
71
|
`splitName` splits on the **last** space (last word = surname). Compound
|
|
72
72
|
surnames split imperfectly by design. If an app backfills a split in SQL,
|
|
73
|
-
implement the same last-space rule and keep them in step.
|
|
74
|
-
|
|
73
|
+
implement the same last-space rule and keep them in step.
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
## isEmail — shared email-address validator (since 0.2.0)
|
|
77
|
+
|
|
78
|
+
One definition of "looks like an email" for every form: trim, non-empty,
|
|
79
|
+
≤254 chars (RFC 5321 cap), one `@` with a dotted domain. Pragmatic by design —
|
|
80
|
+
mail servers are the real validators; this guards forms and lookups.
|
|
81
|
+
|
|
82
|
+
```js
|
|
83
|
+
if (!isEmail(req.body.email)) errors.push('A valid email is required.');
|
|
84
|
+
```
|
|
75
85
|
|
|
76
86
|
## Changelog
|
|
77
87
|
|
|
88
|
+
- **0.2.0** — added `isEmail(v)` (replaces per-route regex copies that had
|
|
89
|
+
already diverged on length handling); removed the redundant `personName`
|
|
90
|
+
namespace export — use the flat `fullName`/`splitName`.
|
|
78
91
|
- **0.1.0** — first release. Extracted from Support101 `lib/` (safeReturnTo,
|
|
79
92
|
likeEscape, fileSniff, trackingId, personName). One API change vs the app
|
|
80
93
|
originals: `trackingId.generateUnique` takes an `isTaken(id)` callback
|
package/index.js
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @aria-framework/kit — public API.
|
|
3
|
-
*
|
|
4
|
-
* Single-function modules are exported flat; multi-function modules keep a
|
|
5
|
-
* namespace (matching how call sites naturally read):
|
|
6
|
-
*
|
|
7
|
-
* safeReturnTo(returnTo, {fallback, denyPrefix}) — open-redirect guard
|
|
8
|
-
* escapeLike(s) — SQL LIKE escaping
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @aria-framework/kit — public API.
|
|
3
|
+
*
|
|
4
|
+
* Single-function modules are exported flat; multi-function modules keep a
|
|
5
|
+
* namespace (matching how call sites naturally read):
|
|
6
|
+
*
|
|
7
|
+
* safeReturnTo(returnTo, {fallback, denyPrefix}) — open-redirect guard
|
|
8
|
+
* escapeLike(s) — SQL LIKE escaping
|
|
9
|
+
* isEmail(v) — email-address validator
|
|
10
|
+
* fullName(first, last) / splitName(full) — person-name helpers
|
|
11
|
+
* fileSniff.sniff(path) / fileSniff.matches(path, mime)
|
|
12
|
+
* trackingId.generate() / trackingId.generateUnique(isTaken, maxAttempts)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const { escapeLike } = require('./likeEscape');
|
|
16
|
+
const { isEmail } = require('./isEmail');
|
|
17
|
+
const { fullName, splitName } = require('./personName');
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
safeReturnTo: require('./safeReturnTo'),
|
|
21
|
+
escapeLike,
|
|
22
|
+
isEmail,
|
|
23
|
+
fullName,
|
|
24
|
+
splitName,
|
|
25
|
+
fileSniff: require('./fileSniff'),
|
|
26
|
+
trackingId: require('./trackingId'),
|
|
27
|
+
// Page arithmetic for list views: the clamp, the past-the-end correction and the page-link
|
|
28
|
+
// window, which are the three things every hand-rolled paginator here got wrong or omitted.
|
|
29
|
+
paginate: require('./paginate').paginate,
|
|
30
|
+
PAGE_SIZES: require('./paginate').DEFAULT_SIZES
|
|
31
|
+
};
|
package/isEmail.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared email-address validator — ONE definition of "looks like an email"
|
|
3
|
+
* across every form/entry point, so validation can't drift per route (the
|
|
4
|
+
* exact bug this replaces: four copy-pasted regexes where one form rejected
|
|
5
|
+
* over-long addresses, two accepted them, and one silently truncated).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately pragmatic: the classic no-spaces one-@ shape plus the RFC 5321
|
|
8
|
+
* 254-char total-length cap. Not an RFC 5322 grammar — mail servers are the
|
|
9
|
+
* real validators; this guards forms and lookups.
|
|
10
|
+
*
|
|
11
|
+
* @param {*} v - candidate value (trimmed before testing; non-strings fail)
|
|
12
|
+
* @returns {boolean}
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
16
|
+
|
|
17
|
+
function isEmail(v) {
|
|
18
|
+
const s = typeof v === 'string' ? v.trim() : '';
|
|
19
|
+
return s.length > 0 && s.length <= 254 && EMAIL_RE.test(s);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
module.exports = { isEmail };
|
package/package.json
CHANGED
|
@@ -1,19 +1,24 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@aria-framework/kit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Aria App Framework — kit module. Small dependency-free server utilities: open-redirect guard (safeReturnTo), SQL LIKE escaping, magic-byte upload validation (fileSniff), Crockford base32 tracking IDs, and person-name compose/split helpers.",
|
|
5
|
-
"license": "UNLICENSED",
|
|
6
|
-
"private": false,
|
|
7
|
-
"publishConfig": {
|
|
8
|
-
"access": "public"
|
|
9
|
-
},
|
|
10
|
-
"main": "index.js",
|
|
11
|
-
"files": [
|
|
12
|
-
"index.js",
|
|
13
|
-
"safeReturnTo.js",
|
|
14
|
-
"likeEscape.js",
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@aria-framework/kit",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Aria App Framework — kit module. Small dependency-free server utilities: open-redirect guard (safeReturnTo), SQL LIKE escaping, magic-byte upload validation (fileSniff), Crockford base32 tracking IDs, and person-name compose/split helpers.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"private": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"main": "index.js",
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"safeReturnTo.js",
|
|
14
|
+
"likeEscape.js",
|
|
15
|
+
"isEmail.js",
|
|
16
|
+
"fileSniff.js",
|
|
17
|
+
"trackingId.js",
|
|
18
|
+
"personName.js",
|
|
19
|
+
"paginate.js"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node test/smoke.js && node test/paginate.js"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/paginate.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page arithmetic. Pure — no DB, no request, no opinion about how the rows are fetched.
|
|
3
|
+
*
|
|
4
|
+
* Every list in these apps that pages has re-derived this, and the parts that get quietly
|
|
5
|
+
* wrong are always the same three:
|
|
6
|
+
*
|
|
7
|
+
* the clamp `pageSize` arrives from a query string. Unclamped, `?pageSize=100000` asks
|
|
8
|
+
* the database for everything, which is a denial of service written by the
|
|
9
|
+
* person browsing.
|
|
10
|
+
* the last page deleting rows can leave you on page 9 of 4. Returning an empty list there
|
|
11
|
+
* looks like "no records" rather than "you are past the end".
|
|
12
|
+
* the window a list long enough to need paging is long enough that rendering every page
|
|
13
|
+
* link is the same problem as rendering every row. 200 pages is 200 links.
|
|
14
|
+
*
|
|
15
|
+
* So those three are here, once.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
const DEFAULT_SIZES = [25, 50, 100, 200];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {object} opts
|
|
24
|
+
* @param {number|string} [opts.page=1] 1-based; garbage becomes 1
|
|
25
|
+
* @param {number|string} [opts.pageSize] clamped to `sizes`; defaults to the first
|
|
26
|
+
* @param {number} opts.total total matching rows
|
|
27
|
+
* @param {number[]} [opts.sizes] the allowed page sizes, smallest first
|
|
28
|
+
* @param {number} [opts.window=2] page links either side of the current one
|
|
29
|
+
* @returns {{page, pageSize, pages, total, offset, limit, from, to, isFirst, isLast,
|
|
30
|
+
* sizes, numbers, needed}}
|
|
31
|
+
*/
|
|
32
|
+
function paginate({ page, pageSize, total, sizes = DEFAULT_SIZES, window = 2 } = {}) {
|
|
33
|
+
const allowed = (Array.isArray(sizes) && sizes.length ? sizes : DEFAULT_SIZES)
|
|
34
|
+
.map(Number).filter((n) => Number.isFinite(n) && n > 0).sort((a, b) => a - b);
|
|
35
|
+
|
|
36
|
+
const t = Math.max(0, Math.floor(Number(total) || 0));
|
|
37
|
+
|
|
38
|
+
// CLAMPED TO THE ALLOWED SET, not merely bounded. A max-only check still lets `?pageSize=99`
|
|
39
|
+
// through, and then the size dropdown shows nothing selected — the control and the data
|
|
40
|
+
// disagree, which is the kind of small wrongness nobody reports and everybody notices.
|
|
41
|
+
const askedSize = parseInt(pageSize, 10);
|
|
42
|
+
const size = allowed.indexOf(askedSize) !== -1 ? askedSize : allowed[0];
|
|
43
|
+
|
|
44
|
+
const pages = Math.max(1, Math.ceil(t / size));
|
|
45
|
+
|
|
46
|
+
// PAST THE END COMES BACK TO THE END. Deleting rows while someone is on page 9 of 4 must
|
|
47
|
+
// show them the last page, not an empty one that reads as "no records".
|
|
48
|
+
const asked = parseInt(page, 10);
|
|
49
|
+
const current = Math.min(Math.max(1, Number.isFinite(asked) ? asked : 1), pages);
|
|
50
|
+
|
|
51
|
+
const offset = (current - 1) * size;
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
page: current,
|
|
55
|
+
pageSize: size,
|
|
56
|
+
pages,
|
|
57
|
+
total: t,
|
|
58
|
+
offset,
|
|
59
|
+
limit: size,
|
|
60
|
+
// 1-based inclusive, for "showing 26–50 of 1,204". `from` is 0 on an empty list so the
|
|
61
|
+
// caller can say "no records" rather than "showing 1–0 of 0".
|
|
62
|
+
from: t === 0 ? 0 : offset + 1,
|
|
63
|
+
to: Math.min(offset + size, t),
|
|
64
|
+
isFirst: current === 1,
|
|
65
|
+
isLast: current === pages,
|
|
66
|
+
sizes: allowed,
|
|
67
|
+
numbers: windowed(current, pages, window),
|
|
68
|
+
// Whether the controls are worth showing at all: a list that fits on one page at the
|
|
69
|
+
// smallest size does not need a size picker either.
|
|
70
|
+
needed: t > allowed[0]
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The page links to render: `[1, null, 4, 5, 6, 7, 8, null, 20]`, where null is a gap.
|
|
76
|
+
*
|
|
77
|
+
* First and last are always present so the ends stay reachable in one click — the case that
|
|
78
|
+
* matters is "jump to the oldest", which is otherwise a long walk.
|
|
79
|
+
*/
|
|
80
|
+
function windowed(current, pages, window) {
|
|
81
|
+
const w = Math.max(0, Math.floor(Number(window) || 0));
|
|
82
|
+
const keep = new Set([1, pages]);
|
|
83
|
+
for (let i = current - w; i <= current + w; i++) if (i >= 1 && i <= pages) keep.add(i);
|
|
84
|
+
|
|
85
|
+
const sorted = [...keep].sort((a, b) => a - b);
|
|
86
|
+
const out = [];
|
|
87
|
+
let prev = 0;
|
|
88
|
+
for (const n of sorted) {
|
|
89
|
+
// A gap of exactly one is silly — "1 … 3" is longer than "1 2 3" and harder to click.
|
|
90
|
+
if (prev && n - prev > 1) out.push(n - prev === 2 ? prev + 1 : null);
|
|
91
|
+
if (out[out.length - 1] !== n) out.push(n);
|
|
92
|
+
prev = n;
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { paginate, DEFAULT_SIZES };
|