@stack-mode/site-manager-kit 1.44.0 → 1.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +618 -606
- package/audit/ts-hooks.mjs +68 -68
- package/audit/wiring-audit.d.mts +37 -37
- package/audit/wiring-audit.mjs +604 -604
- package/bin/site-manager-kit.mjs +10 -10
- package/package.json +73 -72
- package/src/announcements.ts +13 -13
- package/src/attribution-fields.ts +52 -52
- package/src/attribution.tsx +47 -47
- package/src/blog.ts +122 -122
- package/src/chat-route.ts +136 -136
- package/src/chat-types.ts +76 -76
- package/src/client.ts +116 -116
- package/src/config.ts +78 -78
- package/src/copy-keys-route.ts +21 -21
- package/src/copy-keys.ts +77 -77
- package/src/data.ts +189 -189
- package/src/downloads.ts +7 -7
- package/src/faq.ts +21 -21
- package/src/floor-plans.ts +45 -45
- package/src/form-guard.tsx +37 -37
- package/src/form-types.ts +13 -13
- package/src/forms.ts +76 -76
- package/src/glossary.ts +19 -19
- package/src/health-route.ts +34 -34
- package/src/index.ts +36 -36
- package/src/indexnow.ts +40 -40
- package/src/listings.ts +38 -38
- package/src/llms-route.ts +45 -0
- package/src/metadata.ts +55 -55
- package/src/not-found-reporter.tsx +16 -16
- package/src/not-found.ts +53 -53
- package/src/payment.ts +70 -70
- package/src/phone-input.tsx +26 -26
- package/src/phone.ts +37 -37
- package/src/preview-route.ts +48 -48
- package/src/preview.ts +94 -94
- package/src/residents.ts +23 -23
- package/src/revalidate-route.ts +34 -34
- package/src/robots.ts +5 -1
- package/src/rss-route.ts +26 -26
- package/src/services.ts +52 -52
- package/src/site-chat-panel.tsx +537 -537
- package/src/site-chat.tsx +26 -26
- package/src/sitemap.ts +26 -26
- package/src/structured-data.tsx +207 -207
- package/src/types.ts +581 -580
- package/src/version.ts +1 -1
- package/src/visual-accents.ts +126 -126
- package/src/visual-bridge-client.tsx +1070 -1070
- package/src/visual-bridge.tsx +30 -30
- package/src/visual-clicks.ts +13 -13
- package/src/visual-lists.ts +37 -37
- package/src/visual-match.ts +77 -77
- package/src/visual-messages.ts +160 -160
- package/src/visual-mode.ts +18 -18
- package/src/visual-preview.ts +69 -69
- package/src/visual-tags.ts +90 -90
- package/src/visual.ts +59 -59
- package/src/waitlist-form.ts +39 -39
- package/src/waitlist.ts +48 -48
package/src/visual-bridge.tsx
CHANGED
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
import { getCopyUntagged } from "./data";
|
|
2
|
-
import { KIT_VERSION } from "./version";
|
|
3
|
-
import { VisualBridge } from "./visual-bridge-client";
|
|
4
|
-
import { copyTexts, type CopyText } from "./visual-match";
|
|
5
|
-
import { visualMode } from "./visual-mode";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Put once inside <body> in app/layout.tsx, inside a Suspense boundary:
|
|
9
|
-
*
|
|
10
|
-
* <Suspense fallback={null}><VisualEditing /></Suspense>
|
|
11
|
-
*
|
|
12
|
-
* Renders nothing unless this browser opened the page from Site Manager's Visual Editor. Then it outlines the values
|
|
13
|
-
* Site Manager stores and passes clicks and edits to Site Manager, which saves them as drafts.
|
|
14
|
-
*
|
|
15
|
-
* Site Copy read through getCopy() carries exact tags in visual mode, so a site needs no marks of its own. Values a
|
|
16
|
-
* site reads some other way are found by their words; `autoMark={false}` turns that off for a site that marks every
|
|
17
|
-
* value itself with editMarks().
|
|
18
|
-
*/
|
|
19
|
-
export async function VisualEditing({ autoMark = true }: { autoMark?: boolean } = {}) {
|
|
20
|
-
try {
|
|
21
|
-
const ok = await visualMode();
|
|
22
|
-
if (!ok) return null;
|
|
23
|
-
// Untagged words for matching values a site reads some other way; values read through getCopy() carry exact tags.
|
|
24
|
-
let copy: CopyText[] = [];
|
|
25
|
-
if (autoMark) copy = copyTexts(await getCopyUntagged().catch(() => ({})));
|
|
26
|
-
return <VisualBridge adminOrigin={ok.adminOrigin} kit={KIT_VERSION} copy={copy} />;
|
|
27
|
-
} catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
1
|
+
import { getCopyUntagged } from "./data";
|
|
2
|
+
import { KIT_VERSION } from "./version";
|
|
3
|
+
import { VisualBridge } from "./visual-bridge-client";
|
|
4
|
+
import { copyTexts, type CopyText } from "./visual-match";
|
|
5
|
+
import { visualMode } from "./visual-mode";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Put once inside <body> in app/layout.tsx, inside a Suspense boundary:
|
|
9
|
+
*
|
|
10
|
+
* <Suspense fallback={null}><VisualEditing /></Suspense>
|
|
11
|
+
*
|
|
12
|
+
* Renders nothing unless this browser opened the page from Site Manager's Visual Editor. Then it outlines the values
|
|
13
|
+
* Site Manager stores and passes clicks and edits to Site Manager, which saves them as drafts.
|
|
14
|
+
*
|
|
15
|
+
* Site Copy read through getCopy() carries exact tags in visual mode, so a site needs no marks of its own. Values a
|
|
16
|
+
* site reads some other way are found by their words; `autoMark={false}` turns that off for a site that marks every
|
|
17
|
+
* value itself with editMarks().
|
|
18
|
+
*/
|
|
19
|
+
export async function VisualEditing({ autoMark = true }: { autoMark?: boolean } = {}) {
|
|
20
|
+
try {
|
|
21
|
+
const ok = await visualMode();
|
|
22
|
+
if (!ok) return null;
|
|
23
|
+
// Untagged words for matching values a site reads some other way; values read through getCopy() carry exact tags.
|
|
24
|
+
let copy: CopyText[] = [];
|
|
25
|
+
if (autoMark) copy = copyTexts(await getCopyUntagged().catch(() => ({})));
|
|
26
|
+
return <VisualBridge adminOrigin={ok.adminOrigin} kit={KIT_VERSION} copy={copy} />;
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/visual-clicks.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* In Edit mode the page's links, buttons and forms don't act: a click selects the words, it never follows a link or
|
|
3
|
-
* submits a form. Browse mode lets them act. Pure, so it's checked without a browser.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export const ACTIVATES =
|
|
7
|
-
'a[href], button, input[type="submit"], input[type="image"], input[type="reset"], input[type="button"], [role="link"], [role="button"], summary';
|
|
8
|
-
|
|
9
|
-
/** True when a click, Enter or Space on this target would follow a link, press a button or submit a form. */
|
|
10
|
-
export function blocksActivation(target: unknown): boolean {
|
|
11
|
-
const el = target as { closest?: (selector: string) => unknown } | null;
|
|
12
|
-
return !!el && typeof el.closest === "function" && !!el.closest(ACTIVATES);
|
|
13
|
-
}
|
|
1
|
+
/*
|
|
2
|
+
* In Edit mode the page's links, buttons and forms don't act: a click selects the words, it never follows a link or
|
|
3
|
+
* submits a form. Browse mode lets them act. Pure, so it's checked without a browser.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const ACTIVATES =
|
|
7
|
+
'a[href], button, input[type="submit"], input[type="image"], input[type="reset"], input[type="button"], [role="link"], [role="button"], summary';
|
|
8
|
+
|
|
9
|
+
/** True when a click, Enter or Space on this target would follow a link, press a button or submit a form. */
|
|
10
|
+
export function blocksActivation(target: unknown): boolean {
|
|
11
|
+
const el = target as { closest?: (selector: string) => unknown } | null;
|
|
12
|
+
return !!el && typeof el.closest === "function" && !!el.closest(ACTIVATES);
|
|
13
|
+
}
|
package/src/visual-lists.ts
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* List controls in the Visual Editor: small Move Up, Move Down, Remove and Add buttons on an item of a list the site
|
|
3
|
-
* lets Site Manager reorder or resize (copy list rules, kit 1.26.0). Site Manager says which lists are on the page and
|
|
4
|
-
* which values each item holds; the page only finds where each item is and shows the buttons. Every change is made
|
|
5
|
-
* and saved by Site Manager.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export type ListAction = "up" | "down" | "remove" | "add";
|
|
9
|
-
export type PageListItem = { refs: string[] };
|
|
10
|
-
export type PageList = { path: string; reorder: boolean; addRemove: boolean; shown: number; items: PageListItem[] };
|
|
11
|
-
|
|
12
|
-
/** The buttons an item gets: by the list's rules, its place, and whether the list has room for another item. */
|
|
13
|
-
export function actionsFor(list: PageList, index: number): ListAction[] {
|
|
14
|
-
const out: ListAction[] = [];
|
|
15
|
-
const count = Math.min(list.shown, list.items.length);
|
|
16
|
-
if (index >= count) return out;
|
|
17
|
-
if (list.reorder && index > 0) out.push("up");
|
|
18
|
-
if (list.reorder && index < count - 1) out.push("down");
|
|
19
|
-
if (list.addRemove && count > 1) out.push("remove");
|
|
20
|
-
if (list.addRemove && count < list.items.length) out.push("add");
|
|
21
|
-
return out;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
type Containing = { contains(other: Containing | null): boolean; parentElement: Containing | null };
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* The element that is an item: the nearest one holding every place its values show and none of another item's.
|
|
28
|
-
* Null when no such element exists (the values are spread among other items' elements).
|
|
29
|
-
*/
|
|
30
|
-
export function itemElement<E extends Containing>(mine: E[], others: E[]): E | null {
|
|
31
|
-
if (!mine.length) return null;
|
|
32
|
-
for (let el: E | null = mine[0]!; el; el = el.parentElement as E | null) {
|
|
33
|
-
if (!mine.every((m) => el!.contains(m))) continue;
|
|
34
|
-
return others.some((o) => el!.contains(o)) ? null : el;
|
|
35
|
-
}
|
|
36
|
-
return null;
|
|
37
|
-
}
|
|
1
|
+
/*
|
|
2
|
+
* List controls in the Visual Editor: small Move Up, Move Down, Remove and Add buttons on an item of a list the site
|
|
3
|
+
* lets Site Manager reorder or resize (copy list rules, kit 1.26.0). Site Manager says which lists are on the page and
|
|
4
|
+
* which values each item holds; the page only finds where each item is and shows the buttons. Every change is made
|
|
5
|
+
* and saved by Site Manager.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type ListAction = "up" | "down" | "remove" | "add";
|
|
9
|
+
export type PageListItem = { refs: string[] };
|
|
10
|
+
export type PageList = { path: string; reorder: boolean; addRemove: boolean; shown: number; items: PageListItem[] };
|
|
11
|
+
|
|
12
|
+
/** The buttons an item gets: by the list's rules, its place, and whether the list has room for another item. */
|
|
13
|
+
export function actionsFor(list: PageList, index: number): ListAction[] {
|
|
14
|
+
const out: ListAction[] = [];
|
|
15
|
+
const count = Math.min(list.shown, list.items.length);
|
|
16
|
+
if (index >= count) return out;
|
|
17
|
+
if (list.reorder && index > 0) out.push("up");
|
|
18
|
+
if (list.reorder && index < count - 1) out.push("down");
|
|
19
|
+
if (list.addRemove && count > 1) out.push("remove");
|
|
20
|
+
if (list.addRemove && count < list.items.length) out.push("add");
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Containing = { contains(other: Containing | null): boolean; parentElement: Containing | null };
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The element that is an item: the nearest one holding every place its values show and none of another item's.
|
|
28
|
+
* Null when no such element exists (the values are spread among other items' elements).
|
|
29
|
+
*/
|
|
30
|
+
export function itemElement<E extends Containing>(mine: E[], others: E[]): E | null {
|
|
31
|
+
if (!mine.length) return null;
|
|
32
|
+
for (let el: E | null = mine[0]!; el; el = el.parentElement as E | null) {
|
|
33
|
+
if (!mine.every((m) => el!.contains(m))) continue;
|
|
34
|
+
return others.some((o) => el!.contains(o)) ? null : el;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
package/src/visual-match.ts
CHANGED
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Automatic marks for the Visual Editor. In visual mode the bridge gets the site's Site Copy text (drafts included,
|
|
3
|
-
* so it matches what the page shows) and marks the elements and text nodes on the page whose words are exactly one
|
|
4
|
-
* value. Sites then need no marks of their own; marks a site does add with editMarks() always win.
|
|
5
|
-
*
|
|
6
|
-
* Pure functions here, so they can be tested without a browser; the bridge walks the page with them.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import type { CopyValue } from "./types";
|
|
10
|
-
|
|
11
|
-
/** One Site Copy value the bridge can look for: its key, its words as plain text, and the page it belongs to. */
|
|
12
|
-
export type CopyText = { k: string; t: string; p: string | null };
|
|
13
|
-
|
|
14
|
-
// Shorter values ("Yes", "OK") are too likely to match words that aren't that value.
|
|
15
|
-
const MIN_CHARS = 3;
|
|
16
|
-
|
|
17
|
-
const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
|
18
|
-
|
|
19
|
-
function decode(s: string): string {
|
|
20
|
-
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, e: string) => {
|
|
21
|
-
if (e[0] === "#") {
|
|
22
|
-
const n = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
23
|
-
return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : whole;
|
|
24
|
-
}
|
|
25
|
-
return ENTITIES[e.toLowerCase()] ?? whole;
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Rich text's words as the page shows them: tags dropped, entities read. */
|
|
30
|
-
export function htmlText(html: string): string {
|
|
31
|
-
return decode(html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, "").replace(/<[^>]*>/g, " "));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* The form words are compared in. All whitespace is dropped, since the page and the stored value break lines and
|
|
36
|
-
* space blocks differently (rich text's paragraphs have no space between them in the page's text).
|
|
37
|
-
*/
|
|
38
|
-
export const comparable = (s: string): string => s.replace(/[\s ]+/g, "");
|
|
39
|
-
|
|
40
|
-
/** The text and rich values of getCopy(), ready to send to the bridge. Photos, links and record picks aren't words on the page. */
|
|
41
|
-
export function copyTexts(all: Record<string, CopyValue>): CopyText[] {
|
|
42
|
-
const out: CopyText[] = [];
|
|
43
|
-
for (const [k, v] of Object.entries(all)) {
|
|
44
|
-
const t = v.kind === "text" ? v.text : v.kind === "rich" ? (v.html ? htmlText(v.html) : null) : null;
|
|
45
|
-
if (t && comparable(t).length >= MIN_CHARS) out.push({ k, t, p: v.pagePath ?? null });
|
|
46
|
-
}
|
|
47
|
-
return out;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const trimPath = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Words to ref, for one page. Only values that belong to this page or to no page (header, footer, site-wide wording)
|
|
54
|
-
* count: another page's value with the same words (a nav label that matches a page's eyebrow) is a different value.
|
|
55
|
-
* Where the same words are more than one value, this page's wins over a site-wide one; still more than one at that
|
|
56
|
-
* level, the words are left unmarked, since there is no telling which one this is.
|
|
57
|
-
*/
|
|
58
|
-
export function matchIndex(values: CopyText[], path: string): Map<string, string> {
|
|
59
|
-
const here = trimPath(path);
|
|
60
|
-
const best = new Map<string, { tier: number; ref: string | null }>();
|
|
61
|
-
for (const v of values) {
|
|
62
|
-
const words = comparable(v.t);
|
|
63
|
-
if (words.length < MIN_CHARS) continue;
|
|
64
|
-
const tier = v.p === null ? 1 : trimPath(v.p) === here ? 0 : -1;
|
|
65
|
-
if (tier < 0) continue;
|
|
66
|
-
const ref = `copy/${v.k}`;
|
|
67
|
-
const seen = best.get(words);
|
|
68
|
-
if (!seen || tier < seen.tier) best.set(words, { tier, ref });
|
|
69
|
-
else if (tier === seen.tier && seen.ref !== ref) seen.ref = null;
|
|
70
|
-
}
|
|
71
|
-
const out = new Map<string, string>();
|
|
72
|
-
for (const [words, { ref }] of best) if (ref) out.set(words, ref);
|
|
73
|
-
return out;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** The longest words in the index, so the walk can skip comparing anything longer. */
|
|
77
|
-
export const longest = (index: Map<string, string>): number => Math.max(0, ...Array.from(index.keys(), (k) => k.length));
|
|
1
|
+
/*
|
|
2
|
+
* Automatic marks for the Visual Editor. In visual mode the bridge gets the site's Site Copy text (drafts included,
|
|
3
|
+
* so it matches what the page shows) and marks the elements and text nodes on the page whose words are exactly one
|
|
4
|
+
* value. Sites then need no marks of their own; marks a site does add with editMarks() always win.
|
|
5
|
+
*
|
|
6
|
+
* Pure functions here, so they can be tested without a browser; the bridge walks the page with them.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { CopyValue } from "./types";
|
|
10
|
+
|
|
11
|
+
/** One Site Copy value the bridge can look for: its key, its words as plain text, and the page it belongs to. */
|
|
12
|
+
export type CopyText = { k: string; t: string; p: string | null };
|
|
13
|
+
|
|
14
|
+
// Shorter values ("Yes", "OK") are too likely to match words that aren't that value.
|
|
15
|
+
const MIN_CHARS = 3;
|
|
16
|
+
|
|
17
|
+
const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
|
18
|
+
|
|
19
|
+
function decode(s: string): string {
|
|
20
|
+
return s.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, e: string) => {
|
|
21
|
+
if (e[0] === "#") {
|
|
22
|
+
const n = e[1] === "x" || e[1] === "X" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
|
|
23
|
+
return Number.isFinite(n) && n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : whole;
|
|
24
|
+
}
|
|
25
|
+
return ENTITIES[e.toLowerCase()] ?? whole;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Rich text's words as the page shows them: tags dropped, entities read. */
|
|
30
|
+
export function htmlText(html: string): string {
|
|
31
|
+
return decode(html.replace(/<(script|style)[\s\S]*?<\/\1>/gi, "").replace(/<[^>]*>/g, " "));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The form words are compared in. All whitespace is dropped, since the page and the stored value break lines and
|
|
36
|
+
* space blocks differently (rich text's paragraphs have no space between them in the page's text).
|
|
37
|
+
*/
|
|
38
|
+
export const comparable = (s: string): string => s.replace(/[\s ]+/g, "");
|
|
39
|
+
|
|
40
|
+
/** The text and rich values of getCopy(), ready to send to the bridge. Photos, links and record picks aren't words on the page. */
|
|
41
|
+
export function copyTexts(all: Record<string, CopyValue>): CopyText[] {
|
|
42
|
+
const out: CopyText[] = [];
|
|
43
|
+
for (const [k, v] of Object.entries(all)) {
|
|
44
|
+
const t = v.kind === "text" ? v.text : v.kind === "rich" ? (v.html ? htmlText(v.html) : null) : null;
|
|
45
|
+
if (t && comparable(t).length >= MIN_CHARS) out.push({ k, t, p: v.pagePath ?? null });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const trimPath = (p: string) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Words to ref, for one page. Only values that belong to this page or to no page (header, footer, site-wide wording)
|
|
54
|
+
* count: another page's value with the same words (a nav label that matches a page's eyebrow) is a different value.
|
|
55
|
+
* Where the same words are more than one value, this page's wins over a site-wide one; still more than one at that
|
|
56
|
+
* level, the words are left unmarked, since there is no telling which one this is.
|
|
57
|
+
*/
|
|
58
|
+
export function matchIndex(values: CopyText[], path: string): Map<string, string> {
|
|
59
|
+
const here = trimPath(path);
|
|
60
|
+
const best = new Map<string, { tier: number; ref: string | null }>();
|
|
61
|
+
for (const v of values) {
|
|
62
|
+
const words = comparable(v.t);
|
|
63
|
+
if (words.length < MIN_CHARS) continue;
|
|
64
|
+
const tier = v.p === null ? 1 : trimPath(v.p) === here ? 0 : -1;
|
|
65
|
+
if (tier < 0) continue;
|
|
66
|
+
const ref = `copy/${v.k}`;
|
|
67
|
+
const seen = best.get(words);
|
|
68
|
+
if (!seen || tier < seen.tier) best.set(words, { tier, ref });
|
|
69
|
+
else if (tier === seen.tier && seen.ref !== ref) seen.ref = null;
|
|
70
|
+
}
|
|
71
|
+
const out = new Map<string, string>();
|
|
72
|
+
for (const [words, { ref }] of best) if (ref) out.set(words, ref);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The longest words in the index, so the walk can skip comparing anything longer. */
|
|
77
|
+
export const longest = (index: Map<string, string>): number => Math.max(0, ...Array.from(index.keys(), (k) => k.length));
|