@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 +6 -0
- package/LICENSE +21 -0
- package/README.md +21 -2
- package/dist/index.d.ts +68 -0
- package/dist/index.js +197 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -3
package/CHANGELOG.md
ADDED
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
|
-
#
|
|
1
|
+
# @takazudo/zfb-slugify
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
package/dist/index.d.ts
ADDED
|
@@ -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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
}
|