@wildwinter/toolkit 0.1.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/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/archive.cjs +31 -0
- package/dist/archive.cjs.map +1 -0
- package/dist/archive.d.cts +32 -0
- package/dist/archive.d.ts +32 -0
- package/dist/archive.js +25 -0
- package/dist/archive.js.map +1 -0
- package/dist/index.cjs +58 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +30 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ian Thomas
|
|
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/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @wildwinter/toolkit
|
|
2
|
+
|
|
3
|
+
Small shared pieces of the **authoring tooling** behind
|
|
4
|
+
[Storylet Studio](https://github.com/storylet-studio) and
|
|
5
|
+
[Patter](https://patterkit.com): opaque ids, a stable hash, JSON5 source parsing,
|
|
6
|
+
find/replace matching, and the archive guards.
|
|
7
|
+
|
|
8
|
+
**Nothing here runs in a game.** This is the editor/CLI/pipeline side. The
|
|
9
|
+
runtime language lives in [`@wildwinter/expr`](../expr), and unlike that package
|
|
10
|
+
these pieces carry no shared semantics between the two product families: they
|
|
11
|
+
are simply the same code, so a fix lands once.
|
|
12
|
+
|
|
13
|
+
Each was found duplicated, almost but not exactly, across both families. That
|
|
14
|
+
"almost" is the point. A zip-slip guard that is correct in two places today is
|
|
15
|
+
one careless edit away from being correct in one, and nobody reads the diff of a
|
|
16
|
+
file they believe is a copy.
|
|
17
|
+
|
|
18
|
+
ESM + CJS + types. `json5` is the only dependency.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { newId, slug, fnv32, parseSource, findMatcher } from "@wildwinter/toolkit";
|
|
22
|
+
|
|
23
|
+
newId("scn"); // "scn_8f3kq2z1"
|
|
24
|
+
slug("The Long Dark!"); // "the-long-dark"
|
|
25
|
+
fnv32("hello"); // 1335831723
|
|
26
|
+
parseSource('{ a: 1, /* ok */ }'); // JSON5, tolerating a byte-order mark
|
|
27
|
+
findMatcher({ query: "a.b" }); // /a\.b/gi (the query is escaped, not a pattern)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Ids
|
|
31
|
+
|
|
32
|
+
`newId(prefix = "", length = 8)` generates a stable, immutable, **opaque** id.
|
|
33
|
+
It is never derived from content or position, so it survives moving, renaming
|
|
34
|
+
and reordering the thing it names. The base-36 token is rejection-sampled, so
|
|
35
|
+
every character is equally likely: a plain `byte % 36` over-weights the first
|
|
36
|
+
four.
|
|
37
|
+
|
|
38
|
+
`slug(name, fallback = "project")` is the human-facing counterpart, a url-ish
|
|
39
|
+
label with a fallback for when nothing usable is left.
|
|
40
|
+
|
|
41
|
+
## Hash
|
|
42
|
+
|
|
43
|
+
`fnv32(text)` is FNV-1a over a string's UTF-16 code units, as an unsigned 32-bit
|
|
44
|
+
integer. A published, fixed algorithm, deliberately **not** cryptographic: it is
|
|
45
|
+
for short stable handles and change detection, where the properties that matter
|
|
46
|
+
are that it is the same everywhere and does not move between runs.
|
|
47
|
+
|
|
48
|
+
## Source
|
|
49
|
+
|
|
50
|
+
`parseSource(text)` parses JSON5, tolerating a leading byte-order mark, because
|
|
51
|
+
editors on Windows add one and JSON5 will not accept it. Throws on malformed
|
|
52
|
+
input.
|
|
53
|
+
|
|
54
|
+
## Find
|
|
55
|
+
|
|
56
|
+
`findMatcher({ query, caseSensitive?, wholeWord? })` builds the find/replace
|
|
57
|
+
matcher, or `null` for an empty query. The query is **escaped**: somebody typing
|
|
58
|
+
`a.b` means those three characters, not "a, anything, b". Always global, so
|
|
59
|
+
every occurrence in a string is replaced rather than only the first.
|
|
60
|
+
|
|
61
|
+
## Archive
|
|
62
|
+
|
|
63
|
+
A separate, node-only entry point, because it imports `node:path`:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import {
|
|
67
|
+
isUnsafeEntry, escapesTarget, ARCHIVE_ENTRY_OPTS, ARCHIVE_GENERATE_OPTS,
|
|
68
|
+
} from "@wildwinter/toolkit/archive";
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`isUnsafeEntry(name)` is the cheap first pass on a name alone: absolute,
|
|
72
|
+
drive-lettered, or climbing out with `..`. `escapesTarget(dir, name)` is the
|
|
73
|
+
resolved check against a target directory. Together they are the zip-slip
|
|
74
|
+
guard, and unpacking should refuse an entry either one rejects.
|
|
75
|
+
|
|
76
|
+
`ARCHIVE_ENTRY_OPTS` and `ARCHIVE_GENERATE_OPTS` are the settings that make a
|
|
77
|
+
pack **deterministic**: a fixed entry timestamp, no folder entries, no streaming.
|
|
78
|
+
A pack of an unchanged project must be byte-identical to the last one, or every
|
|
79
|
+
delivery churns. That is easy to leave out, and silent when you do.
|
|
80
|
+
|
|
81
|
+
## Licence
|
|
82
|
+
|
|
83
|
+
MIT
|
package/dist/archive.cjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var path = require('path');
|
|
4
|
+
|
|
5
|
+
// src/archive.ts
|
|
6
|
+
function isUnsafeEntry(name) {
|
|
7
|
+
if (path.isAbsolute(name) || /^[a-zA-Z]:/.test(name)) return true;
|
|
8
|
+
const norm = path.normalize(name);
|
|
9
|
+
return norm === ".." || norm.startsWith(`..${path.sep}`) || norm.startsWith("../");
|
|
10
|
+
}
|
|
11
|
+
function escapesTarget(targetDir, name) {
|
|
12
|
+
const target = path.resolve(targetDir);
|
|
13
|
+
const full = path.resolve(path.join(target, name));
|
|
14
|
+
const rel = path.relative(target, full);
|
|
15
|
+
return rel === "" || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel);
|
|
16
|
+
}
|
|
17
|
+
var ARCHIVE_ENTRY_DATE = /* @__PURE__ */ new Date("2000-01-01T00:00:00Z");
|
|
18
|
+
var ARCHIVE_ENTRY_OPTS = { date: ARCHIVE_ENTRY_DATE, createFolders: false };
|
|
19
|
+
var ARCHIVE_GENERATE_OPTS = {
|
|
20
|
+
type: "nodebuffer",
|
|
21
|
+
compression: "DEFLATE",
|
|
22
|
+
streamFiles: false
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
exports.ARCHIVE_ENTRY_DATE = ARCHIVE_ENTRY_DATE;
|
|
26
|
+
exports.ARCHIVE_ENTRY_OPTS = ARCHIVE_ENTRY_OPTS;
|
|
27
|
+
exports.ARCHIVE_GENERATE_OPTS = ARCHIVE_GENERATE_OPTS;
|
|
28
|
+
exports.escapesTarget = escapesTarget;
|
|
29
|
+
exports.isUnsafeEntry = isUnsafeEntry;
|
|
30
|
+
//# sourceMappingURL=archive.cjs.map
|
|
31
|
+
//# sourceMappingURL=archive.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/archive.ts"],"names":["isAbsolute","normalize","sep","resolve","join","relative"],"mappings":";;;;;AAwBO,SAAS,cAAc,IAAA,EAAuB;AACnD,EAAA,IAAIA,gBAAW,IAAI,CAAA,IAAK,aAAa,IAAA,CAAK,IAAI,GAAG,OAAO,IAAA;AACxD,EAAA,MAAM,IAAA,GAAOC,eAAU,IAAI,CAAA;AAC3B,EAAA,OAAO,IAAA,KAAS,IAAA,IAAQ,IAAA,CAAK,UAAA,CAAW,CAAA,EAAA,EAAKC,QAAG,CAAA,CAAE,CAAA,IAAK,IAAA,CAAK,UAAA,CAAW,KAAK,CAAA;AAC9E;AAWO,SAAS,aAAA,CAAc,WAAmB,IAAA,EAAuB;AACtE,EAAA,MAAM,MAAA,GAASC,aAAQ,SAAS,CAAA;AAChC,EAAA,MAAM,IAAA,GAAOA,YAAA,CAAQC,SAAA,CAAK,MAAA,EAAQ,IAAI,CAAC,CAAA;AACvC,EAAA,MAAM,GAAA,GAAMC,aAAA,CAAS,MAAA,EAAQ,IAAI,CAAA;AACjC,EAAA,OAAO,GAAA,KAAQ,EAAA,IAAM,GAAA,KAAQ,IAAA,IAAQ,GAAA,CAAI,UAAA,CAAW,CAAA,EAAA,EAAKH,QAAG,CAAA,CAAE,CAAA,IAAKF,eAAA,CAAW,GAAG,CAAA;AACnF;AAIO,IAAM,kBAAA,mBAAqB,IAAI,IAAA,CAAK,sBAAsB;AAI1D,IAAM,kBAAA,GAAqB,EAAE,IAAA,EAAM,kBAAA,EAAoB,eAAe,KAAA;AAItE,IAAM,qBAAA,GAAwB;AAAA,EACnC,IAAA,EAAM,YAAA;AAAA,EACN,WAAA,EAAa,SAAA;AAAA,EACb,WAAA,EAAa;AACf","file":"archive.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// Archive guards and settings, for the pack / unpack ops.\n//\n// Two concerns that both product families had solved, identically, in their own\n// copies:\n//\n// - REFUSING A HOSTILE ENTRY. A zip can name `../../etc/passwd`, and writing\n// it where it asks is the \"zip slip\" class of bug. Both guards below were\n// correct in both families, which is the state BEFORE the drift rather than\n// evidence there will not be one: a subtle weakening of one copy is a\n// vulnerability nobody reads a diff for.\n// - DETERMINISTIC BYTES. A pack of an unchanged project must be byte-identical\n// to the last one, or every delivery churns. That needs a fixed entry\n// timestamp and no folder entries, which is easy to leave out and silent\n// when you do.\n//\n// Nothing here knows what is being packed.\n// ---------------------------------------------------------------------------\n\nimport { isAbsolute, join, normalize, relative, resolve, sep } from \"node:path\";\n\n/** An entry whose name refuses to be trusted at all: absolute, drive-lettered,\n * or climbing out with `..`. A cheap first pass, used where a NAME is all that\n * is available. */\nexport function isUnsafeEntry(name: string): boolean {\n if (isAbsolute(name) || /^[a-zA-Z]:/.test(name)) return true;\n const norm = normalize(name);\n return norm === \"..\" || norm.startsWith(`..${sep}`) || norm.startsWith(\"../\");\n}\n\n/**\n * True when `join(targetDir, name)` would land outside `targetDir`.\n *\n * This is the guard that actually holds, and it is deliberately about the\n * RESOLVED PATH rather than the name: containment of a resolved path is a fact\n * about the write, where a judgement about a name is a guess about one. The\n * target itself counts as outside, because an entry that resolves to the\n * directory is not a file to write.\n */\nexport function escapesTarget(targetDir: string, name: string): boolean {\n const target = resolve(targetDir);\n const full = resolve(join(target, name));\n const rel = relative(target, full);\n return rel === \"\" || rel === \"..\" || rel.startsWith(`..${sep}`) || isAbsolute(rel);\n}\n\n/** The fixed timestamp every archive entry carries, so an unchanged project\n * packs to the same bytes. Any constant would do; it must not be \"now\". */\nexport const ARCHIVE_ENTRY_DATE = new Date(\"2000-01-01T00:00:00Z\");\n\n/** Entry options for a reproducible archive: the fixed date, and no folder\n * entries (whose presence and order vary by writer). */\nexport const ARCHIVE_ENTRY_OPTS = { date: ARCHIVE_ENTRY_DATE, createFolders: false } as const;\n\n/** Generation options for a reproducible archive. `streamFiles: false` matters:\n * streamed entries carry data descriptors, which differ run to run. */\nexport const ARCHIVE_GENERATE_OPTS = {\n type: \"nodebuffer\",\n compression: \"DEFLATE\",\n streamFiles: false,\n} as const;\n"]}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** An entry whose name refuses to be trusted at all: absolute, drive-lettered,
|
|
2
|
+
* or climbing out with `..`. A cheap first pass, used where a NAME is all that
|
|
3
|
+
* is available. */
|
|
4
|
+
declare function isUnsafeEntry(name: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* True when `join(targetDir, name)` would land outside `targetDir`.
|
|
7
|
+
*
|
|
8
|
+
* This is the guard that actually holds, and it is deliberately about the
|
|
9
|
+
* RESOLVED PATH rather than the name: containment of a resolved path is a fact
|
|
10
|
+
* about the write, where a judgement about a name is a guess about one. The
|
|
11
|
+
* target itself counts as outside, because an entry that resolves to the
|
|
12
|
+
* directory is not a file to write.
|
|
13
|
+
*/
|
|
14
|
+
declare function escapesTarget(targetDir: string, name: string): boolean;
|
|
15
|
+
/** The fixed timestamp every archive entry carries, so an unchanged project
|
|
16
|
+
* packs to the same bytes. Any constant would do; it must not be "now". */
|
|
17
|
+
declare const ARCHIVE_ENTRY_DATE: Date;
|
|
18
|
+
/** Entry options for a reproducible archive: the fixed date, and no folder
|
|
19
|
+
* entries (whose presence and order vary by writer). */
|
|
20
|
+
declare const ARCHIVE_ENTRY_OPTS: {
|
|
21
|
+
readonly date: Date;
|
|
22
|
+
readonly createFolders: false;
|
|
23
|
+
};
|
|
24
|
+
/** Generation options for a reproducible archive. `streamFiles: false` matters:
|
|
25
|
+
* streamed entries carry data descriptors, which differ run to run. */
|
|
26
|
+
declare const ARCHIVE_GENERATE_OPTS: {
|
|
27
|
+
readonly type: "nodebuffer";
|
|
28
|
+
readonly compression: "DEFLATE";
|
|
29
|
+
readonly streamFiles: false;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export { ARCHIVE_ENTRY_DATE, ARCHIVE_ENTRY_OPTS, ARCHIVE_GENERATE_OPTS, escapesTarget, isUnsafeEntry };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** An entry whose name refuses to be trusted at all: absolute, drive-lettered,
|
|
2
|
+
* or climbing out with `..`. A cheap first pass, used where a NAME is all that
|
|
3
|
+
* is available. */
|
|
4
|
+
declare function isUnsafeEntry(name: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* True when `join(targetDir, name)` would land outside `targetDir`.
|
|
7
|
+
*
|
|
8
|
+
* This is the guard that actually holds, and it is deliberately about the
|
|
9
|
+
* RESOLVED PATH rather than the name: containment of a resolved path is a fact
|
|
10
|
+
* about the write, where a judgement about a name is a guess about one. The
|
|
11
|
+
* target itself counts as outside, because an entry that resolves to the
|
|
12
|
+
* directory is not a file to write.
|
|
13
|
+
*/
|
|
14
|
+
declare function escapesTarget(targetDir: string, name: string): boolean;
|
|
15
|
+
/** The fixed timestamp every archive entry carries, so an unchanged project
|
|
16
|
+
* packs to the same bytes. Any constant would do; it must not be "now". */
|
|
17
|
+
declare const ARCHIVE_ENTRY_DATE: Date;
|
|
18
|
+
/** Entry options for a reproducible archive: the fixed date, and no folder
|
|
19
|
+
* entries (whose presence and order vary by writer). */
|
|
20
|
+
declare const ARCHIVE_ENTRY_OPTS: {
|
|
21
|
+
readonly date: Date;
|
|
22
|
+
readonly createFolders: false;
|
|
23
|
+
};
|
|
24
|
+
/** Generation options for a reproducible archive. `streamFiles: false` matters:
|
|
25
|
+
* streamed entries carry data descriptors, which differ run to run. */
|
|
26
|
+
declare const ARCHIVE_GENERATE_OPTS: {
|
|
27
|
+
readonly type: "nodebuffer";
|
|
28
|
+
readonly compression: "DEFLATE";
|
|
29
|
+
readonly streamFiles: false;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export { ARCHIVE_ENTRY_DATE, ARCHIVE_ENTRY_OPTS, ARCHIVE_GENERATE_OPTS, escapesTarget, isUnsafeEntry };
|
package/dist/archive.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { isAbsolute, normalize, sep, resolve, join, relative } from 'path';
|
|
2
|
+
|
|
3
|
+
// src/archive.ts
|
|
4
|
+
function isUnsafeEntry(name) {
|
|
5
|
+
if (isAbsolute(name) || /^[a-zA-Z]:/.test(name)) return true;
|
|
6
|
+
const norm = normalize(name);
|
|
7
|
+
return norm === ".." || norm.startsWith(`..${sep}`) || norm.startsWith("../");
|
|
8
|
+
}
|
|
9
|
+
function escapesTarget(targetDir, name) {
|
|
10
|
+
const target = resolve(targetDir);
|
|
11
|
+
const full = resolve(join(target, name));
|
|
12
|
+
const rel = relative(target, full);
|
|
13
|
+
return rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
14
|
+
}
|
|
15
|
+
var ARCHIVE_ENTRY_DATE = /* @__PURE__ */ new Date("2000-01-01T00:00:00Z");
|
|
16
|
+
var ARCHIVE_ENTRY_OPTS = { date: ARCHIVE_ENTRY_DATE, createFolders: false };
|
|
17
|
+
var ARCHIVE_GENERATE_OPTS = {
|
|
18
|
+
type: "nodebuffer",
|
|
19
|
+
compression: "DEFLATE",
|
|
20
|
+
streamFiles: false
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export { ARCHIVE_ENTRY_DATE, ARCHIVE_ENTRY_OPTS, ARCHIVE_GENERATE_OPTS, escapesTarget, isUnsafeEntry };
|
|
24
|
+
//# sourceMappingURL=archive.js.map
|
|
25
|
+
//# sourceMappingURL=archive.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/archive.ts"],"names":[],"mappings":";;;AAwBO,SAAS,cAAc,IAAA,EAAuB;AACnD,EAAA,IAAI,WAAW,IAAI,CAAA,IAAK,aAAa,IAAA,CAAK,IAAI,GAAG,OAAO,IAAA;AACxD,EAAA,MAAM,IAAA,GAAO,UAAU,IAAI,CAAA;AAC3B,EAAA,OAAO,IAAA,KAAS,IAAA,IAAQ,IAAA,CAAK,UAAA,CAAW,CAAA,EAAA,EAAK,GAAG,CAAA,CAAE,CAAA,IAAK,IAAA,CAAK,UAAA,CAAW,KAAK,CAAA;AAC9E;AAWO,SAAS,aAAA,CAAc,WAAmB,IAAA,EAAuB;AACtE,EAAA,MAAM,MAAA,GAAS,QAAQ,SAAS,CAAA;AAChC,EAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,IAAA,CAAK,MAAA,EAAQ,IAAI,CAAC,CAAA;AACvC,EAAA,MAAM,GAAA,GAAM,QAAA,CAAS,MAAA,EAAQ,IAAI,CAAA;AACjC,EAAA,OAAO,GAAA,KAAQ,EAAA,IAAM,GAAA,KAAQ,IAAA,IAAQ,GAAA,CAAI,UAAA,CAAW,CAAA,EAAA,EAAK,GAAG,CAAA,CAAE,CAAA,IAAK,UAAA,CAAW,GAAG,CAAA;AACnF;AAIO,IAAM,kBAAA,mBAAqB,IAAI,IAAA,CAAK,sBAAsB;AAI1D,IAAM,kBAAA,GAAqB,EAAE,IAAA,EAAM,kBAAA,EAAoB,eAAe,KAAA;AAItE,IAAM,qBAAA,GAAwB;AAAA,EACnC,IAAA,EAAM,YAAA;AAAA,EACN,WAAA,EAAa,SAAA;AAAA,EACb,WAAA,EAAa;AACf","file":"archive.js","sourcesContent":["// ---------------------------------------------------------------------------\n// Archive guards and settings, for the pack / unpack ops.\n//\n// Two concerns that both product families had solved, identically, in their own\n// copies:\n//\n// - REFUSING A HOSTILE ENTRY. A zip can name `../../etc/passwd`, and writing\n// it where it asks is the \"zip slip\" class of bug. Both guards below were\n// correct in both families, which is the state BEFORE the drift rather than\n// evidence there will not be one: a subtle weakening of one copy is a\n// vulnerability nobody reads a diff for.\n// - DETERMINISTIC BYTES. A pack of an unchanged project must be byte-identical\n// to the last one, or every delivery churns. That needs a fixed entry\n// timestamp and no folder entries, which is easy to leave out and silent\n// when you do.\n//\n// Nothing here knows what is being packed.\n// ---------------------------------------------------------------------------\n\nimport { isAbsolute, join, normalize, relative, resolve, sep } from \"node:path\";\n\n/** An entry whose name refuses to be trusted at all: absolute, drive-lettered,\n * or climbing out with `..`. A cheap first pass, used where a NAME is all that\n * is available. */\nexport function isUnsafeEntry(name: string): boolean {\n if (isAbsolute(name) || /^[a-zA-Z]:/.test(name)) return true;\n const norm = normalize(name);\n return norm === \"..\" || norm.startsWith(`..${sep}`) || norm.startsWith(\"../\");\n}\n\n/**\n * True when `join(targetDir, name)` would land outside `targetDir`.\n *\n * This is the guard that actually holds, and it is deliberately about the\n * RESOLVED PATH rather than the name: containment of a resolved path is a fact\n * about the write, where a judgement about a name is a guess about one. The\n * target itself counts as outside, because an entry that resolves to the\n * directory is not a file to write.\n */\nexport function escapesTarget(targetDir: string, name: string): boolean {\n const target = resolve(targetDir);\n const full = resolve(join(target, name));\n const rel = relative(target, full);\n return rel === \"\" || rel === \"..\" || rel.startsWith(`..${sep}`) || isAbsolute(rel);\n}\n\n/** The fixed timestamp every archive entry carries, so an unchanged project\n * packs to the same bytes. Any constant would do; it must not be \"now\". */\nexport const ARCHIVE_ENTRY_DATE = new Date(\"2000-01-01T00:00:00Z\");\n\n/** Entry options for a reproducible archive: the fixed date, and no folder\n * entries (whose presence and order vary by writer). */\nexport const ARCHIVE_ENTRY_OPTS = { date: ARCHIVE_ENTRY_DATE, createFolders: false } as const;\n\n/** Generation options for a reproducible archive. `streamFiles: false` matters:\n * streamed entries carry data descriptors, which differ run to run. */\nexport const ARCHIVE_GENERATE_OPTS = {\n type: \"nodebuffer\",\n compression: \"DEFLATE\",\n streamFiles: false,\n} as const;\n"]}
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var JSON5 = require('json5');
|
|
4
|
+
|
|
5
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
6
|
+
|
|
7
|
+
var JSON5__default = /*#__PURE__*/_interopDefault(JSON5);
|
|
8
|
+
|
|
9
|
+
// src/ids.ts
|
|
10
|
+
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
11
|
+
function newId(prefix = "", length = 8) {
|
|
12
|
+
const limit = 256 - 256 % ALPHABET.length;
|
|
13
|
+
let token = "";
|
|
14
|
+
while (token.length < length) {
|
|
15
|
+
const bytes = new Uint8Array(length * 2);
|
|
16
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
17
|
+
for (const b of bytes) {
|
|
18
|
+
if (b >= limit) continue;
|
|
19
|
+
token += ALPHABET[b % ALPHABET.length];
|
|
20
|
+
if (token.length === length) break;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return prefix ? `${prefix}_${token}` : token;
|
|
24
|
+
}
|
|
25
|
+
function slug(name, fallback = "project") {
|
|
26
|
+
const s = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
27
|
+
return s || fallback;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/hash.ts
|
|
31
|
+
function fnv32(input) {
|
|
32
|
+
let h = 2166136261;
|
|
33
|
+
for (let i = 0; i < input.length; i++) {
|
|
34
|
+
h ^= input.charCodeAt(i);
|
|
35
|
+
h = Math.imul(h, 16777619);
|
|
36
|
+
}
|
|
37
|
+
return h >>> 0;
|
|
38
|
+
}
|
|
39
|
+
function parseSource(text) {
|
|
40
|
+
const body = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
41
|
+
return JSON5__default.default.parse(body);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// src/find.ts
|
|
45
|
+
function findMatcher(opts) {
|
|
46
|
+
if (!opts.query) return null;
|
|
47
|
+
const esc = opts.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
48
|
+
const body = opts.wholeWord ? `\\b${esc}\\b` : esc;
|
|
49
|
+
return new RegExp(body, opts.caseSensitive ? "g" : "gi");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
exports.findMatcher = findMatcher;
|
|
53
|
+
exports.fnv32 = fnv32;
|
|
54
|
+
exports.newId = newId;
|
|
55
|
+
exports.parseSource = parseSource;
|
|
56
|
+
exports.slug = slug;
|
|
57
|
+
//# sourceMappingURL=index.cjs.map
|
|
58
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/ids.ts","../src/hash.ts","../src/source.ts","../src/find.ts"],"names":["JSON5"],"mappings":";;;;;;;;;AASA,IAAM,QAAA,GAAW,sCAAA;AAGV,SAAS,KAAA,CAAM,MAAA,GAAS,EAAA,EAAI,MAAA,GAAS,CAAA,EAAW;AAGrD,EAAA,MAAM,KAAA,GAAQ,GAAA,GAAO,GAAA,GAAM,QAAA,CAAS,MAAA;AACpC,EAAA,IAAI,KAAA,GAAQ,EAAA;AACZ,EAAA,OAAO,KAAA,CAAM,SAAS,MAAA,EAAQ;AAC5B,IAAA,MAAM,KAAA,GAAQ,IAAI,UAAA,CAAW,MAAA,GAAS,CAAC,CAAA;AACvC,IAAA,UAAA,CAAW,MAAA,CAAO,gBAAgB,KAAK,CAAA;AACvC,IAAA,KAAA,MAAW,KAAK,KAAA,EAAO;AACrB,MAAA,IAAI,KAAK,KAAA,EAAO;AAChB,MAAA,KAAA,IAAS,QAAA,CAAS,CAAA,GAAI,QAAA,CAAS,MAAM,CAAA;AACrC,MAAA,IAAI,KAAA,CAAM,WAAW,MAAA,EAAQ;AAAA,IAC/B;AAAA,EACF;AACA,EAAA,OAAO,MAAA,GAAS,CAAA,EAAG,MAAM,CAAA,CAAA,EAAI,KAAK,CAAA,CAAA,GAAK,KAAA;AACzC;AAGO,SAAS,IAAA,CAAK,IAAA,EAAc,QAAA,GAAW,SAAA,EAAmB;AAC/D,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,IAAA,EAAK,CAAE,WAAA,EAAY,CAAE,OAAA,CAAQ,aAAA,EAAe,GAAG,CAAA,CAAE,OAAA,CAAQ,UAAA,EAAY,EAAE,CAAA;AACtF,EAAA,OAAO,CAAA,IAAK,QAAA;AACd;;;ACxBO,SAAS,MAAM,KAAA,EAAuB;AAC3C,EAAA,IAAI,CAAA,GAAI,UAAA;AACR,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,KAAA,CAAM,QAAQ,CAAA,EAAA,EAAK;AACrC,IAAA,CAAA,IAAK,KAAA,CAAM,WAAW,CAAC,CAAA;AACvB,IAAA,CAAA,GAAI,IAAA,CAAK,IAAA,CAAK,CAAA,EAAG,QAAU,CAAA;AAAA,EAC7B;AACA,EAAA,OAAO,CAAA,KAAM,CAAA;AACf;ACLO,SAAS,YAAY,IAAA,EAAuB;AACjD,EAAA,MAAM,IAAA,GAAO,KAAK,UAAA,CAAW,CAAC,MAAM,KAAA,GAAS,IAAA,CAAK,KAAA,CAAM,CAAC,CAAA,GAAI,IAAA;AAC7D,EAAA,OAAOA,sBAAA,CAAM,MAAM,IAAI,CAAA;AACzB;;;ACGO,SAAS,YAAY,IAAA,EAAkC;AAC5D,EAAA,IAAI,CAAC,IAAA,CAAK,KAAA,EAAO,OAAO,IAAA;AACxB,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,KAAA,CAAM,OAAA,CAAQ,uBAAuB,MAAM,CAAA;AAC5D,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,SAAA,GAAY,CAAA,GAAA,EAAM,GAAG,CAAA,GAAA,CAAA,GAAQ,GAAA;AAC/C,EAAA,OAAO,IAAI,MAAA,CAAO,IAAA,EAAM,IAAA,CAAK,aAAA,GAAgB,MAAM,IAAI,CAAA;AACzD","file":"index.cjs","sourcesContent":["// ---------------------------------------------------------------------------\n// Stable, immutable, opaque ids.\n//\n// Generated at creation, never derived from content or position, so an id\n// survives moving, renaming and reordering the thing it names. A short,\n// collision-resistant base-36 token with an optional type prefix that aids\n// debugging but carries no meaning.\n// ---------------------------------------------------------------------------\n\nconst ALPHABET = \"0123456789abcdefghijklmnopqrstuvwxyz\";\n\n/** Generate a new opaque id, e.g. `newId(\"scn\") -> \"scn_8f3kq2z1\"`. */\nexport function newId(prefix = \"\", length = 8): string {\n // Rejection-sample so every alphabet character is equally likely: a plain\n // `byte % 36` over-weights the first four characters.\n const limit = 256 - (256 % ALPHABET.length); // 252\n let token = \"\";\n while (token.length < length) {\n const bytes = new Uint8Array(length * 2);\n globalThis.crypto.getRandomValues(bytes);\n for (const b of bytes) {\n if (b >= limit) continue;\n token += ALPHABET[b % ALPHABET.length];\n if (token.length === length) break;\n }\n }\n return prefix ? `${prefix}_${token}` : token;\n}\n\n/** A url-ish slug from a human label; `\"project\"` when nothing usable is left. */\nexport function slug(name: string, fallback = \"project\"): string {\n const s = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-+|-+$/g, \"\");\n return s || fallback;\n}\n","// ---------------------------------------------------------------------------\n// FNV-1a, 32-bit.\n//\n// A published, fixed algorithm. Not cryptographic: it is for short stable\n// handles and change detection, where the properties that matter are that it is\n// the same everywhere and does not move between runs.\n// ---------------------------------------------------------------------------\n\n/** FNV-1a over a string's UTF-16 code units, as an unsigned 32-bit integer. */\nexport function fnv32(input: string): number {\n let h = 0x811c9dc5;\n for (let i = 0; i < input.length; i++) {\n h ^= input.charCodeAt(i);\n h = Math.imul(h, 0x01000193);\n }\n return h >>> 0;\n}\n","// ---------------------------------------------------------------------------\n// Reading authored source files.\n// ---------------------------------------------------------------------------\n\nimport JSON5 from \"json5\";\n\n/**\n * Parse JSON5 source text. A leading byte-order mark is tolerated, because\n * editors on Windows add one and JSON5 will not accept it. Throws on malformed\n * input.\n */\nexport function parseSource(text: string): unknown {\n const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n return JSON5.parse(body);\n}\n","// ---------------------------------------------------------------------------\n// Find and replace over authored text.\n// ---------------------------------------------------------------------------\n\nexport interface FindOptions {\n query: string;\n caseSensitive?: boolean;\n wholeWord?: boolean;\n}\n\n/**\n * Build the find matcher, or null for an empty query. Global, so every\n * occurrence in a string is replaced rather than only the first.\n *\n * The query is escaped: a person typing `a.b` means those three characters, not\n * \"a, anything, b\".\n */\nexport function findMatcher(opts: FindOptions): RegExp | null {\n if (!opts.query) return null;\n const esc = opts.query.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n const body = opts.wholeWord ? `\\\\b${esc}\\\\b` : esc;\n return new RegExp(body, opts.caseSensitive ? \"g\" : \"gi\");\n}\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Generate a new opaque id, e.g. `newId("scn") -> "scn_8f3kq2z1"`. */
|
|
2
|
+
declare function newId(prefix?: string, length?: number): string;
|
|
3
|
+
/** A url-ish slug from a human label; `"project"` when nothing usable is left. */
|
|
4
|
+
declare function slug(name: string, fallback?: string): string;
|
|
5
|
+
|
|
6
|
+
/** FNV-1a over a string's UTF-16 code units, as an unsigned 32-bit integer. */
|
|
7
|
+
declare function fnv32(input: string): number;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse JSON5 source text. A leading byte-order mark is tolerated, because
|
|
11
|
+
* editors on Windows add one and JSON5 will not accept it. Throws on malformed
|
|
12
|
+
* input.
|
|
13
|
+
*/
|
|
14
|
+
declare function parseSource(text: string): unknown;
|
|
15
|
+
|
|
16
|
+
interface FindOptions {
|
|
17
|
+
query: string;
|
|
18
|
+
caseSensitive?: boolean;
|
|
19
|
+
wholeWord?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build the find matcher, or null for an empty query. Global, so every
|
|
23
|
+
* occurrence in a string is replaced rather than only the first.
|
|
24
|
+
*
|
|
25
|
+
* The query is escaped: a person typing `a.b` means those three characters, not
|
|
26
|
+
* "a, anything, b".
|
|
27
|
+
*/
|
|
28
|
+
declare function findMatcher(opts: FindOptions): RegExp | null;
|
|
29
|
+
|
|
30
|
+
export { type FindOptions, findMatcher, fnv32, newId, parseSource, slug };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Generate a new opaque id, e.g. `newId("scn") -> "scn_8f3kq2z1"`. */
|
|
2
|
+
declare function newId(prefix?: string, length?: number): string;
|
|
3
|
+
/** A url-ish slug from a human label; `"project"` when nothing usable is left. */
|
|
4
|
+
declare function slug(name: string, fallback?: string): string;
|
|
5
|
+
|
|
6
|
+
/** FNV-1a over a string's UTF-16 code units, as an unsigned 32-bit integer. */
|
|
7
|
+
declare function fnv32(input: string): number;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse JSON5 source text. A leading byte-order mark is tolerated, because
|
|
11
|
+
* editors on Windows add one and JSON5 will not accept it. Throws on malformed
|
|
12
|
+
* input.
|
|
13
|
+
*/
|
|
14
|
+
declare function parseSource(text: string): unknown;
|
|
15
|
+
|
|
16
|
+
interface FindOptions {
|
|
17
|
+
query: string;
|
|
18
|
+
caseSensitive?: boolean;
|
|
19
|
+
wholeWord?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Build the find matcher, or null for an empty query. Global, so every
|
|
23
|
+
* occurrence in a string is replaced rather than only the first.
|
|
24
|
+
*
|
|
25
|
+
* The query is escaped: a person typing `a.b` means those three characters, not
|
|
26
|
+
* "a, anything, b".
|
|
27
|
+
*/
|
|
28
|
+
declare function findMatcher(opts: FindOptions): RegExp | null;
|
|
29
|
+
|
|
30
|
+
export { type FindOptions, findMatcher, fnv32, newId, parseSource, slug };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import JSON5 from 'json5';
|
|
2
|
+
|
|
3
|
+
// src/ids.ts
|
|
4
|
+
var ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
5
|
+
function newId(prefix = "", length = 8) {
|
|
6
|
+
const limit = 256 - 256 % ALPHABET.length;
|
|
7
|
+
let token = "";
|
|
8
|
+
while (token.length < length) {
|
|
9
|
+
const bytes = new Uint8Array(length * 2);
|
|
10
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
11
|
+
for (const b of bytes) {
|
|
12
|
+
if (b >= limit) continue;
|
|
13
|
+
token += ALPHABET[b % ALPHABET.length];
|
|
14
|
+
if (token.length === length) break;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return prefix ? `${prefix}_${token}` : token;
|
|
18
|
+
}
|
|
19
|
+
function slug(name, fallback = "project") {
|
|
20
|
+
const s = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
21
|
+
return s || fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// src/hash.ts
|
|
25
|
+
function fnv32(input) {
|
|
26
|
+
let h = 2166136261;
|
|
27
|
+
for (let i = 0; i < input.length; i++) {
|
|
28
|
+
h ^= input.charCodeAt(i);
|
|
29
|
+
h = Math.imul(h, 16777619);
|
|
30
|
+
}
|
|
31
|
+
return h >>> 0;
|
|
32
|
+
}
|
|
33
|
+
function parseSource(text) {
|
|
34
|
+
const body = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
35
|
+
return JSON5.parse(body);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// src/find.ts
|
|
39
|
+
function findMatcher(opts) {
|
|
40
|
+
if (!opts.query) return null;
|
|
41
|
+
const esc = opts.query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
42
|
+
const body = opts.wholeWord ? `\\b${esc}\\b` : esc;
|
|
43
|
+
return new RegExp(body, opts.caseSensitive ? "g" : "gi");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export { findMatcher, fnv32, newId, parseSource, slug };
|
|
47
|
+
//# sourceMappingURL=index.js.map
|
|
48
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/ids.ts","../src/hash.ts","../src/source.ts","../src/find.ts"],"names":[],"mappings":";;;AASA,IAAM,QAAA,GAAW,sCAAA;AAGV,SAAS,KAAA,CAAM,MAAA,GAAS,EAAA,EAAI,MAAA,GAAS,CAAA,EAAW;AAGrD,EAAA,MAAM,KAAA,GAAQ,GAAA,GAAO,GAAA,GAAM,QAAA,CAAS,MAAA;AACpC,EAAA,IAAI,KAAA,GAAQ,EAAA;AACZ,EAAA,OAAO,KAAA,CAAM,SAAS,MAAA,EAAQ;AAC5B,IAAA,MAAM,KAAA,GAAQ,IAAI,UAAA,CAAW,MAAA,GAAS,CAAC,CAAA;AACvC,IAAA,UAAA,CAAW,MAAA,CAAO,gBAAgB,KAAK,CAAA;AACvC,IAAA,KAAA,MAAW,KAAK,KAAA,EAAO;AACrB,MAAA,IAAI,KAAK,KAAA,EAAO;AAChB,MAAA,KAAA,IAAS,QAAA,CAAS,CAAA,GAAI,QAAA,CAAS,MAAM,CAAA;AACrC,MAAA,IAAI,KAAA,CAAM,WAAW,MAAA,EAAQ;AAAA,IAC/B;AAAA,EACF;AACA,EAAA,OAAO,MAAA,GAAS,CAAA,EAAG,MAAM,CAAA,CAAA,EAAI,KAAK,CAAA,CAAA,GAAK,KAAA;AACzC;AAGO,SAAS,IAAA,CAAK,IAAA,EAAc,QAAA,GAAW,SAAA,EAAmB;AAC/D,EAAA,MAAM,CAAA,GAAI,IAAA,CAAK,IAAA,EAAK,CAAE,WAAA,EAAY,CAAE,OAAA,CAAQ,aAAA,EAAe,GAAG,CAAA,CAAE,OAAA,CAAQ,UAAA,EAAY,EAAE,CAAA;AACtF,EAAA,OAAO,CAAA,IAAK,QAAA;AACd;;;ACxBO,SAAS,MAAM,KAAA,EAAuB;AAC3C,EAAA,IAAI,CAAA,GAAI,UAAA;AACR,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,KAAA,CAAM,QAAQ,CAAA,EAAA,EAAK;AACrC,IAAA,CAAA,IAAK,KAAA,CAAM,WAAW,CAAC,CAAA;AACvB,IAAA,CAAA,GAAI,IAAA,CAAK,IAAA,CAAK,CAAA,EAAG,QAAU,CAAA;AAAA,EAC7B;AACA,EAAA,OAAO,CAAA,KAAM,CAAA;AACf;ACLO,SAAS,YAAY,IAAA,EAAuB;AACjD,EAAA,MAAM,IAAA,GAAO,KAAK,UAAA,CAAW,CAAC,MAAM,KAAA,GAAS,IAAA,CAAK,KAAA,CAAM,CAAC,CAAA,GAAI,IAAA;AAC7D,EAAA,OAAO,KAAA,CAAM,MAAM,IAAI,CAAA;AACzB;;;ACGO,SAAS,YAAY,IAAA,EAAkC;AAC5D,EAAA,IAAI,CAAC,IAAA,CAAK,KAAA,EAAO,OAAO,IAAA;AACxB,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,KAAA,CAAM,OAAA,CAAQ,uBAAuB,MAAM,CAAA;AAC5D,EAAA,MAAM,IAAA,GAAO,IAAA,CAAK,SAAA,GAAY,CAAA,GAAA,EAAM,GAAG,CAAA,GAAA,CAAA,GAAQ,GAAA;AAC/C,EAAA,OAAO,IAAI,MAAA,CAAO,IAAA,EAAM,IAAA,CAAK,aAAA,GAAgB,MAAM,IAAI,CAAA;AACzD","file":"index.js","sourcesContent":["// ---------------------------------------------------------------------------\n// Stable, immutable, opaque ids.\n//\n// Generated at creation, never derived from content or position, so an id\n// survives moving, renaming and reordering the thing it names. A short,\n// collision-resistant base-36 token with an optional type prefix that aids\n// debugging but carries no meaning.\n// ---------------------------------------------------------------------------\n\nconst ALPHABET = \"0123456789abcdefghijklmnopqrstuvwxyz\";\n\n/** Generate a new opaque id, e.g. `newId(\"scn\") -> \"scn_8f3kq2z1\"`. */\nexport function newId(prefix = \"\", length = 8): string {\n // Rejection-sample so every alphabet character is equally likely: a plain\n // `byte % 36` over-weights the first four characters.\n const limit = 256 - (256 % ALPHABET.length); // 252\n let token = \"\";\n while (token.length < length) {\n const bytes = new Uint8Array(length * 2);\n globalThis.crypto.getRandomValues(bytes);\n for (const b of bytes) {\n if (b >= limit) continue;\n token += ALPHABET[b % ALPHABET.length];\n if (token.length === length) break;\n }\n }\n return prefix ? `${prefix}_${token}` : token;\n}\n\n/** A url-ish slug from a human label; `\"project\"` when nothing usable is left. */\nexport function slug(name: string, fallback = \"project\"): string {\n const s = name.trim().toLowerCase().replace(/[^a-z0-9]+/g, \"-\").replace(/^-+|-+$/g, \"\");\n return s || fallback;\n}\n","// ---------------------------------------------------------------------------\n// FNV-1a, 32-bit.\n//\n// A published, fixed algorithm. Not cryptographic: it is for short stable\n// handles and change detection, where the properties that matter are that it is\n// the same everywhere and does not move between runs.\n// ---------------------------------------------------------------------------\n\n/** FNV-1a over a string's UTF-16 code units, as an unsigned 32-bit integer. */\nexport function fnv32(input: string): number {\n let h = 0x811c9dc5;\n for (let i = 0; i < input.length; i++) {\n h ^= input.charCodeAt(i);\n h = Math.imul(h, 0x01000193);\n }\n return h >>> 0;\n}\n","// ---------------------------------------------------------------------------\n// Reading authored source files.\n// ---------------------------------------------------------------------------\n\nimport JSON5 from \"json5\";\n\n/**\n * Parse JSON5 source text. A leading byte-order mark is tolerated, because\n * editors on Windows add one and JSON5 will not accept it. Throws on malformed\n * input.\n */\nexport function parseSource(text: string): unknown {\n const body = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;\n return JSON5.parse(body);\n}\n","// ---------------------------------------------------------------------------\n// Find and replace over authored text.\n// ---------------------------------------------------------------------------\n\nexport interface FindOptions {\n query: string;\n caseSensitive?: boolean;\n wholeWord?: boolean;\n}\n\n/**\n * Build the find matcher, or null for an empty query. Global, so every\n * occurrence in a string is replaced rather than only the first.\n *\n * The query is escaped: a person typing `a.b` means those three characters, not\n * \"a, anything, b\".\n */\nexport function findMatcher(opts: FindOptions): RegExp | null {\n if (!opts.query) return null;\n const esc = opts.query.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n const body = opts.wholeWord ? `\\\\b${esc}\\\\b` : esc;\n return new RegExp(body, opts.caseSensitive ? \"g\" : \"gi\");\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wildwinter/toolkit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Small shared pieces of the authoring tooling: opaque ids, a stable hash, JSON5 source parsing, find/replace matching, and the archive guards. Nothing here runs in a game.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Ian Thomas",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/wildwinter/expr.git",
|
|
11
|
+
"directory": "packages/toolkit"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"ids",
|
|
15
|
+
"hash",
|
|
16
|
+
"json5",
|
|
17
|
+
"zip",
|
|
18
|
+
"tooling"
|
|
19
|
+
],
|
|
20
|
+
"main": "./dist/index.cjs",
|
|
21
|
+
"module": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js",
|
|
27
|
+
"require": "./dist/index.cjs"
|
|
28
|
+
},
|
|
29
|
+
"./archive": {
|
|
30
|
+
"types": "./dist/archive.d.ts",
|
|
31
|
+
"import": "./dist/archive.js",
|
|
32
|
+
"require": "./dist/archive.cjs"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"sideEffects": false,
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"registry": "https://registry.npmjs.org",
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsup",
|
|
47
|
+
"test": "vitest run",
|
|
48
|
+
"typecheck": "tsc --noEmit",
|
|
49
|
+
"prepublishOnly": "npm run build && npm test"
|
|
50
|
+
},
|
|
51
|
+
"dependencies": {
|
|
52
|
+
"json5": "^2.2.3"
|
|
53
|
+
}
|
|
54
|
+
}
|