@takazudo/zfb-slugify 0.0.0-stage → 4.3.1

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 ADDED
@@ -0,0 +1,6 @@
1
+ # Changelog
2
+
3
+ ## 4.2.1
4
+
5
+ Initial standalone package release, extracted from the existing zfb heading-anchor
6
+ slug utilities. Its output is covered by the shared Rust parity fixture.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Takeshi Takatsudo
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 CHANGED
@@ -1,3 +1,22 @@
1
- # Temporary Holding Version
1
+ # @takazudo/zfb-slugify
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Standalone, zero-dependency heading-anchor slug utilities used by zfb. The
4
+ package exports only `slugify` and `SlugAllocator`.
5
+
6
+ ```sh
7
+ npm install @takazudo/zfb-slugify
8
+ ```
9
+
10
+ ```ts
11
+ import { SlugAllocator, slugify } from "@takazudo/zfb-slugify";
12
+
13
+ const allocator = new SlugAllocator("hierarchical");
14
+ const slug = allocator.allocate(2, slugify("Hello World")); // "hello-world"
15
+ ```
16
+
17
+ The output matches zfb's Rust implementation through the shared fixture at
18
+ `crates/zfb-content/tests/fixtures/slugify-parity.json`. The existing
19
+ `@takazudo/zfb/slugify` export remains available for compatibility.
20
+
21
+ `SlugAllocator` accepts `"flat"` (the default) or `"hierarchical"`; call
22
+ `reset()` between documents to clear deduplication and ancestor state.
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Heading-anchor slug utilities — a pure-TypeScript port of the Rust
3
+ * `slugify` / `next_slug` / `SlugAllocator` in
4
+ * `crates/zfb-content/src/plugins/heading_links.rs`.
5
+ *
6
+ * These functions produce the `id` attribute values that appear on
7
+ * `<h2>`–`<h6>` elements in the rendered HTML. They are intentionally
8
+ * **not** compatible with npm `github-slugger`: stripped punctuation
9
+ * collapses to a `-` separator (`"a,b"` → `"a-b"`), and leading/trailing
10
+ * whitespace / stripped chars emit nothing rather than a stray dash.
11
+ *
12
+ * ⚠️ These functions produce heading-anchor slugs (e.g. `"hello-world"`
13
+ * for `<h2 id="hello-world">`). They are **different** from
14
+ * `_relPathToSlug` in `content.ts`, which converts a file-system relative
15
+ * path into a URL slug for `CollectionEntry.slug`.
16
+ *
17
+ * Parity is enforced by the shared fixture at
18
+ * `crates/zfb-content/tests/fixtures/slugify-parity.json`, consumed by
19
+ * both the Rust integration test and this package's `slugify.test.ts` suite.
20
+ */
21
+ /**
22
+ * Produce a heading-anchor slug from `input`, matching the Rust
23
+ * `slugify()` in `crates/zfb-content/src/plugins/heading_links.rs`.
24
+ *
25
+ * Algorithm (per Unicode code point):
26
+ * - whitespace or stripped punctuation → emit ONE `-` unless the last
27
+ * emitted character was already a `-` (leading separators are skipped
28
+ * because `lastDash` starts `true`).
29
+ * - anything else → push `ch.toLowerCase()` (per-code-point, not
30
+ * whole-string, to avoid Greek final-sigma context collapsing).
31
+ * - finally pop ONE trailing `-` if present.
32
+ *
33
+ * Divergence from npm github-slugger (intentional — parity target is
34
+ * the Rust function, not npm):
35
+ * - `"a,b"` → `"a-b"` (stripped punctuation emits a dash, not nothing)
36
+ * - `" --weird-- "` → `"--weird--"` (existing dashes/underscores pass
37
+ * through unchanged)
38
+ */
39
+ export declare function slugify(input: string): string;
40
+ /**
41
+ * Strategy-aware slug allocator for a single document.
42
+ *
43
+ * Mirrors Rust `SlugAllocator` in heading_links.rs:70-118.
44
+ *
45
+ * @param strategy `"flat"` (default) — per-document dedup counter, mirrors
46
+ * github-slugger numbering. `"hierarchical"` — each slug is prefixed with
47
+ * its ancestor chain and deduped on the full candidate path.
48
+ *
49
+ * Depth contract: callers pass `depth` values 2–6 (h2–h6). h1 is never
50
+ * allocated — it is handled by the page layout, not the markdown body.
51
+ */
52
+ export declare class SlugAllocator {
53
+ private readonly strategy;
54
+ private seen;
55
+ /** Hierarchical ancestor stack of `[depth, finalId]` pairs. Unused in flat mode. */
56
+ private stack;
57
+ constructor(strategy?: "flat" | "hierarchical");
58
+ /**
59
+ * Allocate the slug for a heading of `depth` (2–6) whose slugified text
60
+ * is `base`. Returns `""` for an empty `base` without mutating state.
61
+ */
62
+ allocate(depth: number, base: string): string;
63
+ /**
64
+ * Clear all per-document state (dedup counters AND the hierarchical
65
+ * ancestor stack). Call between documents to avoid slug counter leakage.
66
+ */
67
+ reset(): void;
68
+ }
package/dist/index.js ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Heading-anchor slug utilities — a pure-TypeScript port of the Rust
3
+ * `slugify` / `next_slug` / `SlugAllocator` in
4
+ * `crates/zfb-content/src/plugins/heading_links.rs`.
5
+ *
6
+ * These functions produce the `id` attribute values that appear on
7
+ * `<h2>`–`<h6>` elements in the rendered HTML. They are intentionally
8
+ * **not** compatible with npm `github-slugger`: stripped punctuation
9
+ * collapses to a `-` separator (`"a,b"` → `"a-b"`), and leading/trailing
10
+ * whitespace / stripped chars emit nothing rather than a stray dash.
11
+ *
12
+ * ⚠️ These functions produce heading-anchor slugs (e.g. `"hello-world"`
13
+ * for `<h2 id="hello-world">`). They are **different** from
14
+ * `_relPathToSlug` in `content.ts`, which converts a file-system relative
15
+ * path into a URL slug for `CollectionEntry.slug`.
16
+ *
17
+ * Parity is enforced by the shared fixture at
18
+ * `crates/zfb-content/tests/fixtures/slugify-parity.json`, consumed by
19
+ * both the Rust integration test and this package's `slugify.test.ts` suite.
20
+ */
21
+ // ---------------------------------------------------------------------------
22
+ // Helpers — mirrors the Rust `is_stripped` predicate
23
+ // ---------------------------------------------------------------------------
24
+ /** Unicode General Category Cc (control characters). */
25
+ function isCc(ch) {
26
+ // \p{Cc} = general category "Control" (C0 + C1 blocks: U+0000–U+001F, U+007F–U+009F)
27
+ return /^\p{Cc}$/u.test(ch);
28
+ }
29
+ /**
30
+ * Returns true when the character should be stripped (maps to a dash
31
+ * separator in the output). Mirrors Rust's `is_stripped`:
32
+ * - Unicode Cc control characters
33
+ * - Explicit ASCII punctuation set: `! " # $ % & ' ( ) * + , . / : ; < = > ? @ [ \ ] ^ ` { | } ~`
34
+ */
35
+ function isStripped(ch) {
36
+ if (isCc(ch))
37
+ return true;
38
+ return (ch === "!" ||
39
+ ch === '"' ||
40
+ ch === "#" ||
41
+ ch === "$" ||
42
+ ch === "%" ||
43
+ ch === "&" ||
44
+ ch === "'" ||
45
+ ch === "(" ||
46
+ ch === ")" ||
47
+ ch === "*" ||
48
+ ch === "+" ||
49
+ ch === "," ||
50
+ ch === "." ||
51
+ ch === "/" ||
52
+ ch === ":" ||
53
+ ch === ";" ||
54
+ ch === "<" ||
55
+ ch === "=" ||
56
+ ch === ">" ||
57
+ ch === "?" ||
58
+ ch === "@" ||
59
+ ch === "[" ||
60
+ ch === "\\" ||
61
+ ch === "]" ||
62
+ ch === "^" ||
63
+ ch === "`" ||
64
+ ch === "{" ||
65
+ ch === "|" ||
66
+ ch === "}" ||
67
+ ch === "~");
68
+ }
69
+ /**
70
+ * Returns true when `ch` is a Unicode whitespace character.
71
+ *
72
+ * Mirrors Rust `char::is_whitespace` which is `\p{White_Space}/u` — NOT
73
+ * the JS `\s` class (`\s` wrongly matches U+FEFF BYTE ORDER MARK and
74
+ * misses U+0085 NEXT LINE).
75
+ */
76
+ function isWhitespace(ch) {
77
+ return /^\p{White_Space}$/u.test(ch);
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // slugify
81
+ // ---------------------------------------------------------------------------
82
+ /**
83
+ * Produce a heading-anchor slug from `input`, matching the Rust
84
+ * `slugify()` in `crates/zfb-content/src/plugins/heading_links.rs`.
85
+ *
86
+ * Algorithm (per Unicode code point):
87
+ * - whitespace or stripped punctuation → emit ONE `-` unless the last
88
+ * emitted character was already a `-` (leading separators are skipped
89
+ * because `lastDash` starts `true`).
90
+ * - anything else → push `ch.toLowerCase()` (per-code-point, not
91
+ * whole-string, to avoid Greek final-sigma context collapsing).
92
+ * - finally pop ONE trailing `-` if present.
93
+ *
94
+ * Divergence from npm github-slugger (intentional — parity target is
95
+ * the Rust function, not npm):
96
+ * - `"a,b"` → `"a-b"` (stripped punctuation emits a dash, not nothing)
97
+ * - `" --weird-- "` → `"--weird--"` (existing dashes/underscores pass
98
+ * through unchanged)
99
+ */
100
+ export function slugify(input) {
101
+ let out = "";
102
+ let lastDash = true;
103
+ for (const ch of input) {
104
+ if (isWhitespace(ch) || isStripped(ch)) {
105
+ if (!lastDash) {
106
+ out += "-";
107
+ lastDash = true;
108
+ }
109
+ }
110
+ else {
111
+ // Per-code-point lowercase — avoids Greek final-sigma context:
112
+ // "ΦΟΣ" → "φοσ" not "φος".
113
+ out += ch.toLowerCase();
114
+ lastDash = false;
115
+ }
116
+ }
117
+ if (out.endsWith("-")) {
118
+ out = out.slice(0, -1);
119
+ }
120
+ return out;
121
+ }
122
+ // ---------------------------------------------------------------------------
123
+ // next_slug
124
+ // ---------------------------------------------------------------------------
125
+ /**
126
+ * Allocate the next deduplicated slug for `base` against a per-document
127
+ * counter map.
128
+ *
129
+ * Mirrors Rust `next_slug`: first occurrence returns `base`, second
130
+ * returns `base-1`, third `base-2`, … Empty `base` short-circuits to
131
+ * `""` without mutating the map.
132
+ */
133
+ function nextSlug(seen, base) {
134
+ if (base === "")
135
+ return "";
136
+ const count = seen.get(base) ?? 0;
137
+ const slug = count === 0 ? base : `${base}-${count}`;
138
+ seen.set(base, count + 1);
139
+ return slug;
140
+ }
141
+ // ---------------------------------------------------------------------------
142
+ // SlugAllocator
143
+ // ---------------------------------------------------------------------------
144
+ /**
145
+ * Strategy-aware slug allocator for a single document.
146
+ *
147
+ * Mirrors Rust `SlugAllocator` in heading_links.rs:70-118.
148
+ *
149
+ * @param strategy `"flat"` (default) — per-document dedup counter, mirrors
150
+ * github-slugger numbering. `"hierarchical"` — each slug is prefixed with
151
+ * its ancestor chain and deduped on the full candidate path.
152
+ *
153
+ * Depth contract: callers pass `depth` values 2–6 (h2–h6). h1 is never
154
+ * allocated — it is handled by the page layout, not the markdown body.
155
+ */
156
+ export class SlugAllocator {
157
+ strategy;
158
+ seen;
159
+ /** Hierarchical ancestor stack of `[depth, finalId]` pairs. Unused in flat mode. */
160
+ stack;
161
+ constructor(strategy = "flat") {
162
+ this.strategy = strategy;
163
+ this.seen = new Map();
164
+ this.stack = [];
165
+ }
166
+ /**
167
+ * Allocate the slug for a heading of `depth` (2–6) whose slugified text
168
+ * is `base`. Returns `""` for an empty `base` without mutating state.
169
+ */
170
+ allocate(depth, base) {
171
+ if (this.strategy === "flat") {
172
+ return nextSlug(this.seen, base);
173
+ }
174
+ // Hierarchical mode
175
+ if (base === "")
176
+ return "";
177
+ // Pop ancestors whose depth >= this heading's depth
178
+ let top = this.stack.at(-1);
179
+ while (top !== undefined && top[0] >= depth) {
180
+ this.stack.pop();
181
+ top = this.stack.at(-1);
182
+ }
183
+ const candidate = top !== undefined ? `${top[1]}-${base}` : base;
184
+ const slug = nextSlug(this.seen, candidate);
185
+ this.stack.push([depth, slug]);
186
+ return slug;
187
+ }
188
+ /**
189
+ * Clear all per-document state (dedup counters AND the hierarchical
190
+ * ancestor stack). Call between documents to avoid slug counter leakage.
191
+ */
192
+ reset() {
193
+ this.seen.clear();
194
+ this.stack = [];
195
+ }
196
+ }
197
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Heading-anchor slug utilities — a pure-TypeScript port of the Rust\n * `slugify` / `next_slug` / `SlugAllocator` in\n * `crates/zfb-content/src/plugins/heading_links.rs`.\n *\n * These functions produce the `id` attribute values that appear on\n * `<h2>`–`<h6>` elements in the rendered HTML. They are intentionally\n * **not** compatible with npm `github-slugger`: stripped punctuation\n * collapses to a `-` separator (`\"a,b\"` → `\"a-b\"`), and leading/trailing\n * whitespace / stripped chars emit nothing rather than a stray dash.\n *\n * ⚠️ These functions produce heading-anchor slugs (e.g. `\"hello-world\"`\n * for `<h2 id=\"hello-world\">`). They are **different** from\n * `_relPathToSlug` in `content.ts`, which converts a file-system relative\n * path into a URL slug for `CollectionEntry.slug`.\n *\n * Parity is enforced by the shared fixture at\n * `crates/zfb-content/tests/fixtures/slugify-parity.json`, consumed by\n * both the Rust integration test and this package's `slugify.test.ts` suite.\n */\n\n// ---------------------------------------------------------------------------\n// Helpers — mirrors the Rust `is_stripped` predicate\n// ---------------------------------------------------------------------------\n\n/** Unicode General Category Cc (control characters). */\nfunction isCc(ch: string): boolean {\n // \\p{Cc} = general category \"Control\" (C0 + C1 blocks: U+0000–U+001F, U+007F–U+009F)\n return /^\\p{Cc}$/u.test(ch);\n}\n\n/**\n * Returns true when the character should be stripped (maps to a dash\n * separator in the output). Mirrors Rust's `is_stripped`:\n * - Unicode Cc control characters\n * - Explicit ASCII punctuation set: `! \" # $ % & ' ( ) * + , . / : ; < = > ? @ [ \\ ] ^ ` { | } ~`\n */\nfunction isStripped(ch: string): boolean {\n if (isCc(ch)) return true;\n return (\n ch === \"!\" ||\n ch === '\"' ||\n ch === \"#\" ||\n ch === \"$\" ||\n ch === \"%\" ||\n ch === \"&\" ||\n ch === \"'\" ||\n ch === \"(\" ||\n ch === \")\" ||\n ch === \"*\" ||\n ch === \"+\" ||\n ch === \",\" ||\n ch === \".\" ||\n ch === \"/\" ||\n ch === \":\" ||\n ch === \";\" ||\n ch === \"<\" ||\n ch === \"=\" ||\n ch === \">\" ||\n ch === \"?\" ||\n ch === \"@\" ||\n ch === \"[\" ||\n ch === \"\\\\\" ||\n ch === \"]\" ||\n ch === \"^\" ||\n ch === \"`\" ||\n ch === \"{\" ||\n ch === \"|\" ||\n ch === \"}\" ||\n ch === \"~\"\n );\n}\n\n/**\n * Returns true when `ch` is a Unicode whitespace character.\n *\n * Mirrors Rust `char::is_whitespace` which is `\\p{White_Space}/u` — NOT\n * the JS `\\s` class (`\\s` wrongly matches U+FEFF BYTE ORDER MARK and\n * misses U+0085 NEXT LINE).\n */\nfunction isWhitespace(ch: string): boolean {\n return /^\\p{White_Space}$/u.test(ch);\n}\n\n// ---------------------------------------------------------------------------\n// slugify\n// ---------------------------------------------------------------------------\n\n/**\n * Produce a heading-anchor slug from `input`, matching the Rust\n * `slugify()` in `crates/zfb-content/src/plugins/heading_links.rs`.\n *\n * Algorithm (per Unicode code point):\n * - whitespace or stripped punctuation → emit ONE `-` unless the last\n * emitted character was already a `-` (leading separators are skipped\n * because `lastDash` starts `true`).\n * - anything else → push `ch.toLowerCase()` (per-code-point, not\n * whole-string, to avoid Greek final-sigma context collapsing).\n * - finally pop ONE trailing `-` if present.\n *\n * Divergence from npm github-slugger (intentional — parity target is\n * the Rust function, not npm):\n * - `\"a,b\"` → `\"a-b\"` (stripped punctuation emits a dash, not nothing)\n * - `\" --weird-- \"` → `\"--weird--\"` (existing dashes/underscores pass\n * through unchanged)\n */\nexport function slugify(input: string): string {\n let out = \"\";\n let lastDash = true;\n\n for (const ch of input) {\n if (isWhitespace(ch) || isStripped(ch)) {\n if (!lastDash) {\n out += \"-\";\n lastDash = true;\n }\n } else {\n // Per-code-point lowercase — avoids Greek final-sigma context:\n // \"ΦΟΣ\" → \"φοσ\" not \"φος\".\n out += ch.toLowerCase();\n lastDash = false;\n }\n }\n\n if (out.endsWith(\"-\")) {\n out = out.slice(0, -1);\n }\n\n return out;\n}\n\n// ---------------------------------------------------------------------------\n// next_slug\n// ---------------------------------------------------------------------------\n\n/**\n * Allocate the next deduplicated slug for `base` against a per-document\n * counter map.\n *\n * Mirrors Rust `next_slug`: first occurrence returns `base`, second\n * returns `base-1`, third `base-2`, … Empty `base` short-circuits to\n * `\"\"` without mutating the map.\n */\nfunction nextSlug(seen: Map<string, number>, base: string): string {\n if (base === \"\") return \"\";\n const count = seen.get(base) ?? 0;\n const slug = count === 0 ? base : `${base}-${count}`;\n seen.set(base, count + 1);\n return slug;\n}\n\n// ---------------------------------------------------------------------------\n// SlugAllocator\n// ---------------------------------------------------------------------------\n\n/**\n * Strategy-aware slug allocator for a single document.\n *\n * Mirrors Rust `SlugAllocator` in heading_links.rs:70-118.\n *\n * @param strategy `\"flat\"` (default) — per-document dedup counter, mirrors\n * github-slugger numbering. `\"hierarchical\"` — each slug is prefixed with\n * its ancestor chain and deduped on the full candidate path.\n *\n * Depth contract: callers pass `depth` values 2–6 (h2–h6). h1 is never\n * allocated — it is handled by the page layout, not the markdown body.\n */\nexport class SlugAllocator {\n private readonly strategy: \"flat\" | \"hierarchical\";\n private seen: Map<string, number>;\n /** Hierarchical ancestor stack of `[depth, finalId]` pairs. Unused in flat mode. */\n private stack: Array<[number, string]>;\n\n constructor(strategy: \"flat\" | \"hierarchical\" = \"flat\") {\n this.strategy = strategy;\n this.seen = new Map();\n this.stack = [];\n }\n\n /**\n * Allocate the slug for a heading of `depth` (2–6) whose slugified text\n * is `base`. Returns `\"\"` for an empty `base` without mutating state.\n */\n allocate(depth: number, base: string): string {\n if (this.strategy === \"flat\") {\n return nextSlug(this.seen, base);\n }\n\n // Hierarchical mode\n if (base === \"\") return \"\";\n\n // Pop ancestors whose depth >= this heading's depth\n let top = this.stack.at(-1);\n while (top !== undefined && top[0] >= depth) {\n this.stack.pop();\n top = this.stack.at(-1);\n }\n\n const candidate = top !== undefined ? `${top[1]}-${base}` : base;\n const slug = nextSlug(this.seen, candidate);\n this.stack.push([depth, slug]);\n return slug;\n }\n\n /**\n * Clear all per-document state (dedup counters AND the hierarchical\n * ancestor stack). Call between documents to avoid slug counter leakage.\n */\n reset(): void {\n this.seen.clear();\n this.stack = [];\n }\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,58 @@
1
1
  {
2
2
  "name": "@takazudo/zfb-slugify",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "4.3.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Zero-dependency heading-anchor slug utilities for zfb.",
7
+ "keywords": [
8
+ "zfb",
9
+ "slugify",
10
+ "heading",
11
+ "anchor"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Takeshi Takatsudo <takazudo@gmail.com> (https://github.com/takazudo)",
15
+ "homepage": "https://takazudomodular.com/pj/zudo-front-builder/",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/Takazudo/zudo-front-builder.git",
19
+ "directory": "packages/zfb-slugify"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/Takazudo/zudo-front-builder/issues"
23
+ },
24
+ "main": "./dist/index.js",
25
+ "types": "./dist/index.d.ts",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "default": "./dist/index.js"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "README.md",
35
+ "CHANGELOG.md",
36
+ "LICENSE"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "engines": {
42
+ "node": ">=22.0.0",
43
+ "pnpm": ">=10.0.0"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^22.0.0",
47
+ "typescript": "7.0.2",
48
+ "typescript-5.9": "npm:typescript@5.9.3",
49
+ "typescript-6.0": "npm:typescript@6.0.3",
50
+ "vite-plus": "1.0.0"
51
+ },
52
+ "scripts": {
53
+ "build": "tsc",
54
+ "test": "vp test run",
55
+ "test:watch": "vp test",
56
+ "typecheck": "tsc --noEmit"
57
+ }
6
58
  }