@blamejs/core 0.7.4 → 0.7.18
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/CHANGELOG.md +423 -395
- package/README.md +150 -149
- package/bin/blamejs.js +0 -0
- package/index.js +308 -284
- package/lib/api-key.js +660 -663
- package/lib/api-snapshot.js +338 -338
- package/lib/app-shutdown.js +385 -385
- package/lib/app.js +365 -365
- package/lib/archive.js +250 -250
- package/lib/atomic-file.js +544 -544
- package/lib/audit-chain.js +177 -177
- package/lib/audit-sign.js +344 -344
- package/lib/audit-tools.js +677 -677
- package/lib/audit.js +766 -766
- package/lib/auth/jwt.js +311 -311
- package/lib/auth/lockout.js +436 -436
- package/lib/auth/oauth.js +721 -721
- package/lib/auth/passkey.js +181 -181
- package/lib/auth/password.js +594 -594
- package/lib/backup/bundle.js +217 -217
- package/lib/backup/crypto.js +176 -176
- package/lib/backup/index.js +515 -515
- package/lib/backup/manifest.js +282 -282
- package/lib/break-glass.js +1338 -1338
- package/lib/bundler.js +441 -441
- package/lib/cache-redis.js +256 -256
- package/lib/cache.js +1206 -1206
- package/lib/canonical-json.js +115 -115
- package/lib/chain-writer.js +234 -234
- package/lib/cli-helpers.js +206 -206
- package/lib/cli.js +2334 -2334
- package/lib/cluster-provider-db.js +317 -317
- package/lib/cluster-storage.js +226 -226
- package/lib/cluster.js +703 -703
- package/lib/codepoint-class.js +196 -0
- package/lib/config-drift.js +301 -301
- package/lib/consent.js +222 -222
- package/lib/constants.js +191 -191
- package/lib/cookies.js +315 -315
- package/lib/credential-hash.js +322 -322
- package/lib/crypto.js +266 -266
- package/lib/csv.js +275 -286
- package/lib/db-declare-row-policy.js +267 -267
- package/lib/db-declare-view.js +420 -421
- package/lib/db-query.js +406 -406
- package/lib/db-schema.js +319 -319
- package/lib/db.js +1288 -1288
- package/lib/deprecate.js +222 -222
- package/lib/dev.js +335 -335
- package/lib/dual-control.js +473 -473
- package/lib/error-page.js +420 -420
- package/lib/external-db-migrate.js +441 -441
- package/lib/external-db.js +1061 -1061
- package/lib/file-type.js +273 -273
- package/lib/file-upload.js +213 -10
- package/lib/forms.js +422 -422
- package/lib/framework-error.js +293 -215
- package/lib/framework-schema.js +717 -717
- package/lib/gate-contract.js +971 -0
- package/lib/guard-all.js +405 -0
- package/lib/guard-archive.js +739 -0
- package/lib/guard-csv.js +816 -0
- package/lib/guard-email.js +744 -0
- package/lib/guard-filename.js +724 -0
- package/lib/guard-html.js +976 -0
- package/lib/guard-json.js +729 -0
- package/lib/guard-markdown.js +586 -0
- package/lib/guard-svg.js +976 -0
- package/lib/guard-xml.js +405 -0
- package/lib/guard-yaml.js +529 -0
- package/lib/handlers.js +350 -350
- package/lib/http-client-cookie-jar.js +508 -508
- package/lib/http-client.js +1195 -1195
- package/lib/i18n.js +878 -878
- package/lib/jobs.js +185 -185
- package/lib/log-stream-cloudwatch.js +369 -369
- package/lib/log-stream-local.js +146 -146
- package/lib/log-stream-otlp-grpc.js +410 -410
- package/lib/log-stream-otlp.js +286 -286
- package/lib/log-stream-syslog.js +302 -302
- package/lib/log-stream-webhook.js +199 -199
- package/lib/log-stream.js +330 -330
- package/lib/log.js +500 -500
- package/lib/mail-bounce.js +528 -528
- package/lib/mail-dkim.js +369 -362
- package/lib/mail.js +981 -962
- package/lib/metrics.js +683 -683
- package/lib/middleware/api-encrypt.js +936 -936
- package/lib/middleware/attach-user.js +157 -157
- package/lib/middleware/body-parser.js +1170 -1091
- package/lib/middleware/bot-guard.js +178 -178
- package/lib/middleware/compression.js +452 -452
- package/lib/middleware/cors.js +314 -314
- package/lib/middleware/csp-nonce.js +348 -348
- package/lib/middleware/csrf-protect.js +316 -316
- package/lib/middleware/db-role-for.js +264 -264
- package/lib/middleware/health.js +392 -392
- package/lib/middleware/index.js +79 -79
- package/lib/middleware/rate-limit.js +358 -358
- package/lib/middleware/request-id.js +61 -61
- package/lib/middleware/request-log.js +168 -168
- package/lib/middleware/require-auth.js +104 -104
- package/lib/middleware/security-headers.js +116 -116
- package/lib/middleware/sse.js +166 -166
- package/lib/migrations.js +383 -383
- package/lib/mtls-ca.js +518 -518
- package/lib/mtls-engine-default.js +481 -481
- package/lib/network-dns.js +632 -632
- package/lib/network-heartbeat.js +290 -290
- package/lib/network-nts.js +574 -574
- package/lib/network-proxy.js +265 -265
- package/lib/network-tls.js +328 -328
- package/lib/network.js +233 -233
- package/lib/notify.js +612 -612
- package/lib/ntp-check.js +229 -229
- package/lib/numeric-bounds.js +111 -91
- package/lib/object-store/azure-blob-bucket-ops.js +349 -349
- package/lib/object-store/azure-blob.js +488 -488
- package/lib/object-store/gcs-bucket-ops.js +351 -351
- package/lib/object-store/gcs.js +519 -519
- package/lib/object-store/http-put.js +153 -153
- package/lib/object-store/index.js +197 -197
- package/lib/object-store/sigv4-bucket-ops.js +1092 -1092
- package/lib/object-store/sigv4.js +903 -903
- package/lib/observability.js +151 -151
- package/lib/otel-export.js +269 -269
- package/lib/pagination.js +464 -464
- package/lib/parsers/index.js +80 -80
- package/lib/parsers/safe-env.js +642 -642
- package/lib/parsers/safe-ini.js +292 -292
- package/lib/parsers/safe-toml.js +784 -784
- package/lib/parsers/safe-xml.js +390 -390
- package/lib/parsers/safe-yaml.js +1015 -1015
- package/lib/permissions.js +708 -708
- package/lib/pqc-agent.js +87 -87
- package/lib/pqc-gate.js +279 -279
- package/lib/protobuf-encoder.js +190 -190
- package/lib/protocol-dispatcher.js +161 -161
- package/lib/pubsub-redis.js +167 -167
- package/lib/pubsub.js +429 -429
- package/lib/queue-local.js +476 -476
- package/lib/queue-redis.js +745 -745
- package/lib/queue-sqs.js +319 -319
- package/lib/queue.js +695 -695
- package/lib/redis-client.js +519 -519
- package/lib/request-helpers.js +340 -340
- package/lib/restore-bundle.js +237 -237
- package/lib/restore-rollback.js +259 -259
- package/lib/restore.js +409 -409
- package/lib/retry.js +376 -376
- package/lib/router.js +748 -748
- package/lib/safe-async.js +735 -735
- package/lib/safe-buffer.js +237 -237
- package/lib/safe-json.js +541 -541
- package/lib/safe-schema.js +1266 -1266
- package/lib/safe-url.js +159 -159
- package/lib/scheduler.js +706 -706
- package/lib/security-assert.js +373 -373
- package/lib/seeders.js +618 -618
- package/lib/session.js +478 -478
- package/lib/slug.js +269 -269
- package/lib/ssrf-guard.js +401 -401
- package/lib/static.js +184 -4
- package/lib/storage.js +471 -471
- package/lib/subject.js +281 -281
- package/lib/template.js +791 -791
- package/lib/testing.js +798 -798
- package/lib/time.js +310 -310
- package/lib/totp.js +302 -302
- package/lib/tracing.js +494 -494
- package/lib/uuid.js +132 -132
- package/lib/validate-opts.js +340 -319
- package/lib/vault/index.js +308 -308
- package/lib/vault/rotate.js +784 -784
- package/lib/vault/wrap.js +296 -296
- package/lib/vendor/noble-ciphers.cjs +9 -9
- package/lib/webhook.js +595 -595
- package/lib/websocket.js +1048 -1048
- package/package.json +77 -77
- package/sbom.cyclonedx.json +7 -7
package/lib/slug.js
CHANGED
|
@@ -1,269 +1,269 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* b.slug — URL-safe slug generation.
|
|
4
|
-
*
|
|
5
|
-
* b.slug("Hello, World!") // "hello-world"
|
|
6
|
-
* b.slug("café", { preserveUnicode: false }) // "cafe"
|
|
7
|
-
* b.slug("Привет мир", { preserveUnicode: true }) // "привет-мир"
|
|
8
|
-
*
|
|
9
|
-
* var slug = b.slug.create({ maxLength: 60 });
|
|
10
|
-
* slug("Title");
|
|
11
|
-
*
|
|
12
|
-
* var s = await b.slug.unique("Hello, World!", function (cand) {
|
|
13
|
-
* return db.bundles.exists({ slug: cand });
|
|
14
|
-
* });
|
|
15
|
-
* // → "hello-world", or "hello-world-2", "hello-world-3", ...
|
|
16
|
-
*
|
|
17
|
-
* The default ASCII path uses Unicode NFKD decomposition + combining-mark
|
|
18
|
-
* strip (`café` → `cafe`) and drops anything outside `[a-zA-Z0-9]`. The
|
|
19
|
-
* `preserveUnicode: true` path uses NFC and only drops Unicode punctuation,
|
|
20
|
-
* symbols, and separators — Cyrillic, Greek, CJK, and other scripts pass
|
|
21
|
-
* through. Operators with non-Latin user content opt into preserveUnicode.
|
|
22
|
-
*
|
|
23
|
-
* Validation policy:
|
|
24
|
-
*
|
|
25
|
-
* - Opts at first call (every public fn) → throw at call site
|
|
26
|
-
* - title not a string → throw at call site
|
|
27
|
-
* - title normalizes to empty → return opts.fallback (tolerant)
|
|
28
|
-
* - unique() exhausts maxAttempts → throw SlugError at call site
|
|
29
|
-
*
|
|
30
|
-
* Out of scope (v1):
|
|
31
|
-
* - Word-by-word transliteration tables (Russian → English, Chinese →
|
|
32
|
-
* Pinyin). Use preserveUnicode: true as the v1 escape hatch.
|
|
33
|
-
* - Stemming / lemmatization / stopword removal.
|
|
34
|
-
* - HTML-tag stripping (sanitize textually before slugging).
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
var numericChecks = require("./numeric-checks");
|
|
38
|
-
var { SlugError } = require("./framework-error");
|
|
39
|
-
var _err = SlugError.factory;
|
|
40
|
-
|
|
41
|
-
// ---- Defaults ----
|
|
42
|
-
|
|
43
|
-
// Default slug max length — 0x50 (80 chars). Long enough for nearly any
|
|
44
|
-
// title, short enough to keep URLs / file paths comfortable on every OS.
|
|
45
|
-
var DEFAULT_SLUG_MAX_LENGTH = 0x50;
|
|
46
|
-
|
|
47
|
-
var DEFAULTS = Object.freeze({
|
|
48
|
-
separator: "-",
|
|
49
|
-
lowercase: true,
|
|
50
|
-
maxLength: DEFAULT_SLUG_MAX_LENGTH,
|
|
51
|
-
preserveUnicode: false,
|
|
52
|
-
fallback: "",
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
// Common reserved web slugs. Operators extend (e.g. via Set union with
|
|
56
|
-
// router.getReservedSlugs()) and pass into b.slug.unique's isUsed predicate.
|
|
57
|
-
// Returned as a fresh Set on each access via the getter so callers cannot
|
|
58
|
-
// mutate the shared reference.
|
|
59
|
-
var _RESERVED = Object.freeze([
|
|
60
|
-
"admin", "api", "auth", "login", "logout", "signup", "signin", "signout",
|
|
61
|
-
"register", "settings", "account", "profile", "users", "user", "me",
|
|
62
|
-
"static", "assets", "public", "favicon.ico", "robots.txt", "sitemap.xml",
|
|
63
|
-
"health", "metrics", "ping", "status",
|
|
64
|
-
"docs", "doc", "help", "support", "terms", "privacy", "legal",
|
|
65
|
-
"search", "feed", "rss", "atom",
|
|
66
|
-
"new", "edit", "delete", "create", "update",
|
|
67
|
-
]);
|
|
68
|
-
|
|
69
|
-
// ---- Call-site validation helpers (throw on bad input) ----
|
|
70
|
-
|
|
71
|
-
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
72
|
-
|
|
73
|
-
function _validateOpts(name, opts) {
|
|
74
|
-
if (typeof opts.separator !== "string" || opts.separator.length !== 1) {
|
|
75
|
-
throw _err("BAD_OPT", name + ": separator must be a single-character string, got " +
|
|
76
|
-
typeof opts.separator + " " + JSON.stringify(opts.separator), true);
|
|
77
|
-
}
|
|
78
|
-
if (typeof opts.lowercase !== "boolean") {
|
|
79
|
-
throw _err("BAD_OPT", name + ": lowercase must be a boolean, got " + typeof opts.lowercase, true);
|
|
80
|
-
}
|
|
81
|
-
if (opts.maxLength !== null && !_isPositiveInt(opts.maxLength)) {
|
|
82
|
-
throw _err("BAD_OPT", name + ": maxLength must be a positive integer or null, got " +
|
|
83
|
-
typeof opts.maxLength + " " + JSON.stringify(opts.maxLength), true);
|
|
84
|
-
}
|
|
85
|
-
if (typeof opts.preserveUnicode !== "boolean") {
|
|
86
|
-
throw _err("BAD_OPT", name + ": preserveUnicode must be a boolean, got " +
|
|
87
|
-
typeof opts.preserveUnicode, true);
|
|
88
|
-
}
|
|
89
|
-
if (typeof opts.fallback !== "string") {
|
|
90
|
-
throw _err("BAD_OPT", name + ": fallback must be a string, got " + typeof opts.fallback, true);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// ---- Core slugify ----
|
|
95
|
-
|
|
96
|
-
// Drops Unicode marks (combining accents) after NFKD decomposition.
|
|
97
|
-
// The two regexes are pre-compiled because slug() is on the hot path
|
|
98
|
-
// for any title-driven workflow (uploads, bundle creation, seeders).
|
|
99
|
-
var _COMBINING_MARKS = /\p{M}+/gu;
|
|
100
|
-
// Anything outside ASCII alphanumeric → separator (default ASCII path).
|
|
101
|
-
var _NON_ASCII_ALNUM = /[^a-zA-Z0-9]+/g;
|
|
102
|
-
// Unicode-preserving path: drop punctuation, symbols, separators only.
|
|
103
|
-
// \p{P} = Punctuation, \p{S} = Symbol, \p{Z} = Separator (incl. spaces),
|
|
104
|
-
// \p{C} = Control/format. Letters and Numbers in any script pass through.
|
|
105
|
-
var _UNICODE_NON_ALNUM = /[\p{P}\p{S}\p{Z}\p{C}]+/gu;
|
|
106
|
-
|
|
107
|
-
function _slugify(title, opts) {
|
|
108
|
-
if (typeof title !== "string") {
|
|
109
|
-
throw _err("BAD_TITLE", "slug: title must be a string, got " + typeof title, true);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
var sep = opts.separator;
|
|
113
|
-
|
|
114
|
-
var s = title;
|
|
115
|
-
|
|
116
|
-
if (opts.preserveUnicode) {
|
|
117
|
-
s = s.normalize("NFC");
|
|
118
|
-
s = s.replace(_UNICODE_NON_ALNUM, sep);
|
|
119
|
-
} else {
|
|
120
|
-
s = s.normalize("NFKD").replace(_COMBINING_MARKS, "");
|
|
121
|
-
s = s.replace(_NON_ASCII_ALNUM, sep);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (opts.lowercase) {
|
|
125
|
-
// Use locale-independent toLowerCase for both paths so slug output
|
|
126
|
-
// is deterministic across hosts (Turkish-locale dotted-i etc. would
|
|
127
|
-
// otherwise drift).
|
|
128
|
-
s = s.toLowerCase();
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Collapse runs of the chosen separator + trim leading/trailing
|
|
132
|
-
// separators, character-by-character. _validateOpts enforces
|
|
133
|
-
// sep.length === 1, so a linear scan is correct and avoids
|
|
134
|
-
// compiling a regex from operator input.
|
|
135
|
-
s = _collapseAndTrim(s, sep);
|
|
136
|
-
|
|
137
|
-
if (opts.maxLength !== null && s.length > opts.maxLength) {
|
|
138
|
-
s = _truncateAtSeparator(s, opts.maxLength, sep);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
if (s.length === 0) return opts.fallback;
|
|
142
|
-
return s;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Single-pass collapse + trim — replaces the dynamic-RegExp-on-sep pair
|
|
146
|
-
// previously used here. sep is enforced 1-char by _validateOpts.
|
|
147
|
-
function _collapseAndTrim(s, sep) {
|
|
148
|
-
if (s.length === 0) return s;
|
|
149
|
-
var out = "";
|
|
150
|
-
var lastWasSep = true; // suppress leading separators
|
|
151
|
-
for (var i = 0; i < s.length; i++) {
|
|
152
|
-
var ch = s.charAt(i);
|
|
153
|
-
if (ch === sep) {
|
|
154
|
-
if (lastWasSep) continue;
|
|
155
|
-
lastWasSep = true;
|
|
156
|
-
out += ch;
|
|
157
|
-
} else {
|
|
158
|
-
lastWasSep = false;
|
|
159
|
-
out += ch;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
// Strip a trailing separator (the loop emits one if the last
|
|
163
|
-
// non-collapsed run was separator).
|
|
164
|
-
if (out.length > 0 && out.charAt(out.length - 1) === sep) {
|
|
165
|
-
out = out.slice(0, out.length - 1);
|
|
166
|
-
}
|
|
167
|
-
return out;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Truncate at the last separator that keeps length ≤ maxLength.
|
|
171
|
-
// If there's no separator within the cap, fall back to char truncation
|
|
172
|
-
// (single-token long inputs).
|
|
173
|
-
function _truncateAtSeparator(s, maxLength, sep) {
|
|
174
|
-
if (s.length <= maxLength) return s;
|
|
175
|
-
var slice = s.slice(0, maxLength);
|
|
176
|
-
var lastSep = slice.lastIndexOf(sep);
|
|
177
|
-
if (lastSep > 0) return slice.slice(0, lastSep);
|
|
178
|
-
return slice;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ---- Public surface ----
|
|
182
|
-
|
|
183
|
-
function slug(title, callOpts) {
|
|
184
|
-
var opts = Object.assign({}, DEFAULTS, callOpts || {});
|
|
185
|
-
_validateOpts("slug", opts);
|
|
186
|
-
return _slugify(title, opts);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function create(creatorOpts) {
|
|
190
|
-
var merged = Object.assign({}, DEFAULTS, creatorOpts || {});
|
|
191
|
-
_validateOpts("slug.create", merged);
|
|
192
|
-
// Bound function: per-call opts override creator opts.
|
|
193
|
-
return function boundSlug(title, callOpts) {
|
|
194
|
-
var opts = Object.assign({}, merged, callOpts || {});
|
|
195
|
-
_validateOpts("slug", opts);
|
|
196
|
-
return _slugify(title, opts);
|
|
197
|
-
};
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
async function unique(title, isUsed, callOpts) {
|
|
201
|
-
if (typeof isUsed !== "function") {
|
|
202
|
-
throw _err("BAD_ISUSED", "slug.unique: isUsed must be a function, got " + typeof isUsed, true);
|
|
203
|
-
}
|
|
204
|
-
callOpts = callOpts || {};
|
|
205
|
-
var opts = Object.assign({}, DEFAULTS, callOpts);
|
|
206
|
-
_validateOpts("slug.unique", opts);
|
|
207
|
-
|
|
208
|
-
var maxAttempts = (callOpts.maxAttempts !== undefined) ? callOpts.maxAttempts : 100;
|
|
209
|
-
if (!_isPositiveInt(maxAttempts)) {
|
|
210
|
-
throw _err("BAD_OPT", "slug.unique: maxAttempts must be a positive integer, got " +
|
|
211
|
-
typeof maxAttempts + " " + JSON.stringify(maxAttempts), true);
|
|
212
|
-
}
|
|
213
|
-
var start = (callOpts.start !== undefined) ? callOpts.start : 2;
|
|
214
|
-
if (!_isPositiveInt(start)) {
|
|
215
|
-
throw _err("BAD_OPT", "slug.unique: start must be a positive integer, got " +
|
|
216
|
-
typeof start + " " + JSON.stringify(start), true);
|
|
217
|
-
}
|
|
218
|
-
var suffixSep = (callOpts.suffixSeparator !== undefined) ? callOpts.suffixSeparator : opts.separator;
|
|
219
|
-
if (typeof suffixSep !== "string" || suffixSep.length === 0) {
|
|
220
|
-
throw _err("BAD_OPT", "slug.unique: suffixSeparator must be a non-empty string, got " +
|
|
221
|
-
typeof suffixSep, true);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
var base = _slugify(title, opts);
|
|
225
|
-
// First attempt: bare base.
|
|
226
|
-
var used = await isUsed(base);
|
|
227
|
-
if (!used) return base;
|
|
228
|
-
|
|
229
|
-
// Subsequent attempts: base + suffixSep + n, starting at `start`.
|
|
230
|
-
// The bare base counted as attempt 1; we have maxAttempts-1 numeric tries left.
|
|
231
|
-
for (var i = 0; i < maxAttempts - 1; i++) {
|
|
232
|
-
var n = start + i;
|
|
233
|
-
var candidate = base + suffixSep + n;
|
|
234
|
-
// If maxLength would be exceeded by the suffix, truncate the base further
|
|
235
|
-
// so the final candidate fits.
|
|
236
|
-
if (opts.maxLength !== null && candidate.length > opts.maxLength) {
|
|
237
|
-
var roomForBase = opts.maxLength - (suffixSep.length + String(n).length);
|
|
238
|
-
if (roomForBase < 1) {
|
|
239
|
-
// Pathological case: caller's maxLength can't hold any base with this suffix.
|
|
240
|
-
throw _err("UNIQUE_EXHAUSTED",
|
|
241
|
-
"slug.unique: maxLength " + opts.maxLength + " too small for suffix '" +
|
|
242
|
-
suffixSep + n + "' (base would need " + roomForBase + " chars)", true);
|
|
243
|
-
}
|
|
244
|
-
var truncBase = _truncateAtSeparator(base, roomForBase, opts.separator);
|
|
245
|
-
candidate = truncBase + suffixSep + n;
|
|
246
|
-
}
|
|
247
|
-
var taken = await isUsed(candidate);
|
|
248
|
-
if (!taken) return candidate;
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
throw _err("UNIQUE_EXHAUSTED",
|
|
252
|
-
"slug.unique: exhausted " + maxAttempts + " attempts for base '" + base + "'", true);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
// b.slug is the function itself with sub-API hung off it (callable
|
|
256
|
-
// namespace pattern). Operators get the 90% case (`b.slug("title")`) in
|
|
257
|
-
// one line and reach the rest via `b.slug.create`, `b.slug.unique`,
|
|
258
|
-
// `b.slug.RESERVED`, `b.slug.DEFAULTS`, `b.slug.SlugError`.
|
|
259
|
-
//
|
|
260
|
-
// The shared RESERVED Set is intentionally mutable so an app can
|
|
261
|
-
// extend it once at boot:
|
|
262
|
-
// b.slug.RESERVED.add("my-reserved-route");
|
|
263
|
-
slug.create = create;
|
|
264
|
-
slug.unique = unique;
|
|
265
|
-
slug.RESERVED = new Set(_RESERVED);
|
|
266
|
-
slug.DEFAULTS = DEFAULTS;
|
|
267
|
-
slug.SlugError = SlugError;
|
|
268
|
-
|
|
269
|
-
module.exports = slug;
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* b.slug — URL-safe slug generation.
|
|
4
|
+
*
|
|
5
|
+
* b.slug("Hello, World!") // "hello-world"
|
|
6
|
+
* b.slug("café", { preserveUnicode: false }) // "cafe"
|
|
7
|
+
* b.slug("Привет мир", { preserveUnicode: true }) // "привет-мир"
|
|
8
|
+
*
|
|
9
|
+
* var slug = b.slug.create({ maxLength: 60 });
|
|
10
|
+
* slug("Title");
|
|
11
|
+
*
|
|
12
|
+
* var s = await b.slug.unique("Hello, World!", function (cand) {
|
|
13
|
+
* return db.bundles.exists({ slug: cand });
|
|
14
|
+
* });
|
|
15
|
+
* // → "hello-world", or "hello-world-2", "hello-world-3", ...
|
|
16
|
+
*
|
|
17
|
+
* The default ASCII path uses Unicode NFKD decomposition + combining-mark
|
|
18
|
+
* strip (`café` → `cafe`) and drops anything outside `[a-zA-Z0-9]`. The
|
|
19
|
+
* `preserveUnicode: true` path uses NFC and only drops Unicode punctuation,
|
|
20
|
+
* symbols, and separators — Cyrillic, Greek, CJK, and other scripts pass
|
|
21
|
+
* through. Operators with non-Latin user content opt into preserveUnicode.
|
|
22
|
+
*
|
|
23
|
+
* Validation policy:
|
|
24
|
+
*
|
|
25
|
+
* - Opts at first call (every public fn) → throw at call site
|
|
26
|
+
* - title not a string → throw at call site
|
|
27
|
+
* - title normalizes to empty → return opts.fallback (tolerant)
|
|
28
|
+
* - unique() exhausts maxAttempts → throw SlugError at call site
|
|
29
|
+
*
|
|
30
|
+
* Out of scope (v1):
|
|
31
|
+
* - Word-by-word transliteration tables (Russian → English, Chinese →
|
|
32
|
+
* Pinyin). Use preserveUnicode: true as the v1 escape hatch.
|
|
33
|
+
* - Stemming / lemmatization / stopword removal.
|
|
34
|
+
* - HTML-tag stripping (sanitize textually before slugging).
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
var numericChecks = require("./numeric-checks");
|
|
38
|
+
var { SlugError } = require("./framework-error");
|
|
39
|
+
var _err = SlugError.factory;
|
|
40
|
+
|
|
41
|
+
// ---- Defaults ----
|
|
42
|
+
|
|
43
|
+
// Default slug max length — 0x50 (80 chars). Long enough for nearly any
|
|
44
|
+
// title, short enough to keep URLs / file paths comfortable on every OS.
|
|
45
|
+
var DEFAULT_SLUG_MAX_LENGTH = 0x50;
|
|
46
|
+
|
|
47
|
+
var DEFAULTS = Object.freeze({
|
|
48
|
+
separator: "-",
|
|
49
|
+
lowercase: true,
|
|
50
|
+
maxLength: DEFAULT_SLUG_MAX_LENGTH,
|
|
51
|
+
preserveUnicode: false,
|
|
52
|
+
fallback: "",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Common reserved web slugs. Operators extend (e.g. via Set union with
|
|
56
|
+
// router.getReservedSlugs()) and pass into b.slug.unique's isUsed predicate.
|
|
57
|
+
// Returned as a fresh Set on each access via the getter so callers cannot
|
|
58
|
+
// mutate the shared reference.
|
|
59
|
+
var _RESERVED = Object.freeze([
|
|
60
|
+
"admin", "api", "auth", "login", "logout", "signup", "signin", "signout",
|
|
61
|
+
"register", "settings", "account", "profile", "users", "user", "me",
|
|
62
|
+
"static", "assets", "public", "favicon.ico", "robots.txt", "sitemap.xml",
|
|
63
|
+
"health", "metrics", "ping", "status",
|
|
64
|
+
"docs", "doc", "help", "support", "terms", "privacy", "legal",
|
|
65
|
+
"search", "feed", "rss", "atom",
|
|
66
|
+
"new", "edit", "delete", "create", "update",
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
// ---- Call-site validation helpers (throw on bad input) ----
|
|
70
|
+
|
|
71
|
+
var _isPositiveInt = numericChecks.isPositiveInt;
|
|
72
|
+
|
|
73
|
+
function _validateOpts(name, opts) {
|
|
74
|
+
if (typeof opts.separator !== "string" || opts.separator.length !== 1) {
|
|
75
|
+
throw _err("BAD_OPT", name + ": separator must be a single-character string, got " +
|
|
76
|
+
typeof opts.separator + " " + JSON.stringify(opts.separator), true);
|
|
77
|
+
}
|
|
78
|
+
if (typeof opts.lowercase !== "boolean") {
|
|
79
|
+
throw _err("BAD_OPT", name + ": lowercase must be a boolean, got " + typeof opts.lowercase, true);
|
|
80
|
+
}
|
|
81
|
+
if (opts.maxLength !== null && !_isPositiveInt(opts.maxLength)) {
|
|
82
|
+
throw _err("BAD_OPT", name + ": maxLength must be a positive integer or null, got " +
|
|
83
|
+
typeof opts.maxLength + " " + JSON.stringify(opts.maxLength), true);
|
|
84
|
+
}
|
|
85
|
+
if (typeof opts.preserveUnicode !== "boolean") {
|
|
86
|
+
throw _err("BAD_OPT", name + ": preserveUnicode must be a boolean, got " +
|
|
87
|
+
typeof opts.preserveUnicode, true);
|
|
88
|
+
}
|
|
89
|
+
if (typeof opts.fallback !== "string") {
|
|
90
|
+
throw _err("BAD_OPT", name + ": fallback must be a string, got " + typeof opts.fallback, true);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---- Core slugify ----
|
|
95
|
+
|
|
96
|
+
// Drops Unicode marks (combining accents) after NFKD decomposition.
|
|
97
|
+
// The two regexes are pre-compiled because slug() is on the hot path
|
|
98
|
+
// for any title-driven workflow (uploads, bundle creation, seeders).
|
|
99
|
+
var _COMBINING_MARKS = /\p{M}+/gu;
|
|
100
|
+
// Anything outside ASCII alphanumeric → separator (default ASCII path).
|
|
101
|
+
var _NON_ASCII_ALNUM = /[^a-zA-Z0-9]+/g;
|
|
102
|
+
// Unicode-preserving path: drop punctuation, symbols, separators only.
|
|
103
|
+
// \p{P} = Punctuation, \p{S} = Symbol, \p{Z} = Separator (incl. spaces),
|
|
104
|
+
// \p{C} = Control/format. Letters and Numbers in any script pass through.
|
|
105
|
+
var _UNICODE_NON_ALNUM = /[\p{P}\p{S}\p{Z}\p{C}]+/gu;
|
|
106
|
+
|
|
107
|
+
function _slugify(title, opts) {
|
|
108
|
+
if (typeof title !== "string") {
|
|
109
|
+
throw _err("BAD_TITLE", "slug: title must be a string, got " + typeof title, true);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
var sep = opts.separator;
|
|
113
|
+
|
|
114
|
+
var s = title;
|
|
115
|
+
|
|
116
|
+
if (opts.preserveUnicode) {
|
|
117
|
+
s = s.normalize("NFC");
|
|
118
|
+
s = s.replace(_UNICODE_NON_ALNUM, sep);
|
|
119
|
+
} else {
|
|
120
|
+
s = s.normalize("NFKD").replace(_COMBINING_MARKS, "");
|
|
121
|
+
s = s.replace(_NON_ASCII_ALNUM, sep);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (opts.lowercase) {
|
|
125
|
+
// Use locale-independent toLowerCase for both paths so slug output
|
|
126
|
+
// is deterministic across hosts (Turkish-locale dotted-i etc. would
|
|
127
|
+
// otherwise drift).
|
|
128
|
+
s = s.toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Collapse runs of the chosen separator + trim leading/trailing
|
|
132
|
+
// separators, character-by-character. _validateOpts enforces
|
|
133
|
+
// sep.length === 1, so a linear scan is correct and avoids
|
|
134
|
+
// compiling a regex from operator input.
|
|
135
|
+
s = _collapseAndTrim(s, sep);
|
|
136
|
+
|
|
137
|
+
if (opts.maxLength !== null && s.length > opts.maxLength) {
|
|
138
|
+
s = _truncateAtSeparator(s, opts.maxLength, sep);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (s.length === 0) return opts.fallback;
|
|
142
|
+
return s;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Single-pass collapse + trim — replaces the dynamic-RegExp-on-sep pair
|
|
146
|
+
// previously used here. sep is enforced 1-char by _validateOpts.
|
|
147
|
+
function _collapseAndTrim(s, sep) {
|
|
148
|
+
if (s.length === 0) return s;
|
|
149
|
+
var out = "";
|
|
150
|
+
var lastWasSep = true; // suppress leading separators
|
|
151
|
+
for (var i = 0; i < s.length; i++) {
|
|
152
|
+
var ch = s.charAt(i);
|
|
153
|
+
if (ch === sep) {
|
|
154
|
+
if (lastWasSep) continue;
|
|
155
|
+
lastWasSep = true;
|
|
156
|
+
out += ch;
|
|
157
|
+
} else {
|
|
158
|
+
lastWasSep = false;
|
|
159
|
+
out += ch;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Strip a trailing separator (the loop emits one if the last
|
|
163
|
+
// non-collapsed run was separator).
|
|
164
|
+
if (out.length > 0 && out.charAt(out.length - 1) === sep) {
|
|
165
|
+
out = out.slice(0, out.length - 1);
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Truncate at the last separator that keeps length ≤ maxLength.
|
|
171
|
+
// If there's no separator within the cap, fall back to char truncation
|
|
172
|
+
// (single-token long inputs).
|
|
173
|
+
function _truncateAtSeparator(s, maxLength, sep) {
|
|
174
|
+
if (s.length <= maxLength) return s;
|
|
175
|
+
var slice = s.slice(0, maxLength);
|
|
176
|
+
var lastSep = slice.lastIndexOf(sep);
|
|
177
|
+
if (lastSep > 0) return slice.slice(0, lastSep);
|
|
178
|
+
return slice;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- Public surface ----
|
|
182
|
+
|
|
183
|
+
function slug(title, callOpts) {
|
|
184
|
+
var opts = Object.assign({}, DEFAULTS, callOpts || {});
|
|
185
|
+
_validateOpts("slug", opts);
|
|
186
|
+
return _slugify(title, opts);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function create(creatorOpts) {
|
|
190
|
+
var merged = Object.assign({}, DEFAULTS, creatorOpts || {});
|
|
191
|
+
_validateOpts("slug.create", merged);
|
|
192
|
+
// Bound function: per-call opts override creator opts.
|
|
193
|
+
return function boundSlug(title, callOpts) {
|
|
194
|
+
var opts = Object.assign({}, merged, callOpts || {});
|
|
195
|
+
_validateOpts("slug", opts);
|
|
196
|
+
return _slugify(title, opts);
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function unique(title, isUsed, callOpts) {
|
|
201
|
+
if (typeof isUsed !== "function") {
|
|
202
|
+
throw _err("BAD_ISUSED", "slug.unique: isUsed must be a function, got " + typeof isUsed, true);
|
|
203
|
+
}
|
|
204
|
+
callOpts = callOpts || {};
|
|
205
|
+
var opts = Object.assign({}, DEFAULTS, callOpts);
|
|
206
|
+
_validateOpts("slug.unique", opts);
|
|
207
|
+
|
|
208
|
+
var maxAttempts = (callOpts.maxAttempts !== undefined) ? callOpts.maxAttempts : 100;
|
|
209
|
+
if (!_isPositiveInt(maxAttempts)) {
|
|
210
|
+
throw _err("BAD_OPT", "slug.unique: maxAttempts must be a positive integer, got " +
|
|
211
|
+
typeof maxAttempts + " " + JSON.stringify(maxAttempts), true);
|
|
212
|
+
}
|
|
213
|
+
var start = (callOpts.start !== undefined) ? callOpts.start : 2;
|
|
214
|
+
if (!_isPositiveInt(start)) {
|
|
215
|
+
throw _err("BAD_OPT", "slug.unique: start must be a positive integer, got " +
|
|
216
|
+
typeof start + " " + JSON.stringify(start), true);
|
|
217
|
+
}
|
|
218
|
+
var suffixSep = (callOpts.suffixSeparator !== undefined) ? callOpts.suffixSeparator : opts.separator;
|
|
219
|
+
if (typeof suffixSep !== "string" || suffixSep.length === 0) {
|
|
220
|
+
throw _err("BAD_OPT", "slug.unique: suffixSeparator must be a non-empty string, got " +
|
|
221
|
+
typeof suffixSep, true);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
var base = _slugify(title, opts);
|
|
225
|
+
// First attempt: bare base.
|
|
226
|
+
var used = await isUsed(base);
|
|
227
|
+
if (!used) return base;
|
|
228
|
+
|
|
229
|
+
// Subsequent attempts: base + suffixSep + n, starting at `start`.
|
|
230
|
+
// The bare base counted as attempt 1; we have maxAttempts-1 numeric tries left.
|
|
231
|
+
for (var i = 0; i < maxAttempts - 1; i++) {
|
|
232
|
+
var n = start + i;
|
|
233
|
+
var candidate = base + suffixSep + n;
|
|
234
|
+
// If maxLength would be exceeded by the suffix, truncate the base further
|
|
235
|
+
// so the final candidate fits.
|
|
236
|
+
if (opts.maxLength !== null && candidate.length > opts.maxLength) {
|
|
237
|
+
var roomForBase = opts.maxLength - (suffixSep.length + String(n).length);
|
|
238
|
+
if (roomForBase < 1) {
|
|
239
|
+
// Pathological case: caller's maxLength can't hold any base with this suffix.
|
|
240
|
+
throw _err("UNIQUE_EXHAUSTED",
|
|
241
|
+
"slug.unique: maxLength " + opts.maxLength + " too small for suffix '" +
|
|
242
|
+
suffixSep + n + "' (base would need " + roomForBase + " chars)", true);
|
|
243
|
+
}
|
|
244
|
+
var truncBase = _truncateAtSeparator(base, roomForBase, opts.separator);
|
|
245
|
+
candidate = truncBase + suffixSep + n;
|
|
246
|
+
}
|
|
247
|
+
var taken = await isUsed(candidate);
|
|
248
|
+
if (!taken) return candidate;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
throw _err("UNIQUE_EXHAUSTED",
|
|
252
|
+
"slug.unique: exhausted " + maxAttempts + " attempts for base '" + base + "'", true);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// b.slug is the function itself with sub-API hung off it (callable
|
|
256
|
+
// namespace pattern). Operators get the 90% case (`b.slug("title")`) in
|
|
257
|
+
// one line and reach the rest via `b.slug.create`, `b.slug.unique`,
|
|
258
|
+
// `b.slug.RESERVED`, `b.slug.DEFAULTS`, `b.slug.SlugError`.
|
|
259
|
+
//
|
|
260
|
+
// The shared RESERVED Set is intentionally mutable so an app can
|
|
261
|
+
// extend it once at boot:
|
|
262
|
+
// b.slug.RESERVED.add("my-reserved-route");
|
|
263
|
+
slug.create = create;
|
|
264
|
+
slug.unique = unique;
|
|
265
|
+
slug.RESERVED = new Set(_RESERVED);
|
|
266
|
+
slug.DEFAULTS = DEFAULTS;
|
|
267
|
+
slug.SlugError = SlugError;
|
|
268
|
+
|
|
269
|
+
module.exports = slug;
|