@pi-unipi/utility 2.6.1 → 2.6.2

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.
@@ -1,311 +0,0 @@
1
- /**
2
- * @pi-unipi/utility — TTL Cache
3
- *
4
- * General-purpose TTL cache with memory + SQLite backends.
5
- */
6
-
7
- import type { CacheEntry, CacheBackend, TTLCacheOptions } from "../types.js";
8
-
9
- // ─── Memory Backend ──────────────────────────────────────────────────────────
10
-
11
- class MemoryBackend<K, V> implements CacheBackend<K, V> {
12
- private store = new Map<K, CacheEntry<V>>();
13
- private maxEntries: number;
14
-
15
- constructor(maxEntries: number = 1000) {
16
- this.maxEntries = maxEntries;
17
- }
18
-
19
- async get(key: K): Promise<V | undefined> {
20
- const entry = this.store.get(key);
21
- if (!entry) return undefined;
22
- if (Date.now() > entry.expiresAt) {
23
- this.store.delete(key);
24
- return undefined;
25
- }
26
- return entry.value;
27
- }
28
-
29
- async set(key: K, value: V, ttlMs: number): Promise<void> {
30
- // Evict oldest if at capacity
31
- if (this.store.size >= this.maxEntries && !this.store.has(key)) {
32
- const firstKey = this.store.keys().next().value;
33
- if (firstKey !== undefined) {
34
- this.store.delete(firstKey);
35
- }
36
- }
37
-
38
- const now = Date.now();
39
- this.store.set(key, {
40
- value,
41
- expiresAt: now + ttlMs,
42
- createdAt: now,
43
- });
44
- }
45
-
46
- async has(key: K): Promise<boolean> {
47
- const entry = this.store.get(key);
48
- if (!entry) return false;
49
- if (Date.now() > entry.expiresAt) {
50
- this.store.delete(key);
51
- return false;
52
- }
53
- return true;
54
- }
55
-
56
- async delete(key: K): Promise<boolean> {
57
- return this.store.delete(key);
58
- }
59
-
60
- async cleanupExpired(): Promise<number> {
61
- const now = Date.now();
62
- let count = 0;
63
- for (const [key, entry] of this.store) {
64
- if (now > entry.expiresAt) {
65
- this.store.delete(key);
66
- count++;
67
- }
68
- }
69
- return count;
70
- }
71
-
72
- async clear(): Promise<void> {
73
- this.store.clear();
74
- }
75
- }
76
-
77
- // ─── SQLite Backend ──────────────────────────────────────────────────────────
78
-
79
- // Minimal sqlite3 type declarations for lazy loading
80
- interface Sqlite3Db {
81
- run(sql: string, callback?: (err: Error | null) => void): Sqlite3Db;
82
- run(sql: string, params: unknown[], callback?: (err: Error | null) => void): Sqlite3Db;
83
- get(sql: string, params: unknown[], callback: (err: Error | null, row: unknown) => void): Sqlite3Db;
84
- close(callback?: (err: Error | null) => void): void;
85
- }
86
-
87
- interface Sqlite3 {
88
- Database: new (path: string) => Sqlite3Db;
89
- }
90
-
91
- // Lazy-load sqlite to avoid hard dependency
92
- let sqlite3: Sqlite3 | null = null;
93
- let sqliteLoadAttempted = false;
94
-
95
- async function loadSqlite(): Promise<Sqlite3 | null> {
96
- if (sqliteLoadAttempted) return sqlite3;
97
- sqliteLoadAttempted = true;
98
- try {
99
- // Use dynamic import with type assertion to bypass module resolution
100
- const mod = await eval("import('sqlite3')") as { default?: Sqlite3; Database?: unknown } | Sqlite3;
101
- if (mod && typeof mod === "object") {
102
- // Handle both ESM default export and CJS-style export
103
- sqlite3 = (mod as { default?: Sqlite3 }).default ?? (mod as Sqlite3);
104
- }
105
- } catch {
106
- sqlite3 = null;
107
- }
108
- return sqlite3;
109
- }
110
-
111
- class SQLiteBackend<K, V> implements CacheBackend<K, V> {
112
- private db: Sqlite3Db | null = null;
113
- private dbPath: string;
114
- private ready: Promise<void>;
115
-
116
- constructor(dbPath: string) {
117
- this.dbPath = dbPath;
118
- this.ready = this.init();
119
- }
120
-
121
- private async init(): Promise<void> {
122
- const sqlite = await loadSqlite();
123
- if (!sqlite) {
124
- throw new Error("sqlite3 not available for persistent cache");
125
- }
126
- this.db = new sqlite.Database(this.dbPath);
127
-
128
- await new Promise<void>((resolve, reject) => {
129
- this.db!.run(
130
- `CREATE TABLE IF NOT EXISTS cache (
131
- key TEXT PRIMARY KEY,
132
- value TEXT NOT NULL,
133
- expires_at INTEGER NOT NULL,
134
- created_at INTEGER NOT NULL
135
- )`,
136
- (err: Error | null) => (err ? reject(err) : resolve()),
137
- );
138
- });
139
-
140
- // Create index for fast expiration queries
141
- await new Promise<void>((resolve, reject) => {
142
- this.db!.run(
143
- `CREATE INDEX IF NOT EXISTS idx_expires ON cache(expires_at)`,
144
- (err: Error | null) => (err ? reject(err) : resolve()),
145
- );
146
- });
147
- }
148
-
149
- private async ensureReady(): Promise<void> {
150
- await this.ready;
151
- }
152
-
153
- async get(key: K): Promise<V | undefined> {
154
- await this.ensureReady();
155
- if (!this.db) return undefined;
156
-
157
- const row = await new Promise<{ value: string; expires_at: number } | undefined>(
158
- (resolve, reject) => {
159
- this.db!.get(
160
- "SELECT value, expires_at FROM cache WHERE key = ?",
161
- [String(key)],
162
- (err: Error | null, row: unknown) => {
163
- if (err) reject(err);
164
- else resolve(row as { value: string; expires_at: number } | undefined);
165
- },
166
- );
167
- },
168
- );
169
-
170
- if (!row) return undefined;
171
- if (Date.now() > row.expires_at) {
172
- await this.delete(key);
173
- return undefined;
174
- }
175
-
176
- try {
177
- return JSON.parse(row.value) as V;
178
- } catch {
179
- return undefined;
180
- }
181
- }
182
-
183
- async set(key: K, value: V, ttlMs: number): Promise<void> {
184
- await this.ensureReady();
185
- if (!this.db) return;
186
-
187
- const now = Date.now();
188
- const expiresAt = now + ttlMs;
189
- const serialized = JSON.stringify(value);
190
-
191
- await new Promise<void>((resolve, reject) => {
192
- this.db!.run(
193
- `INSERT INTO cache (key, value, expires_at, created_at)
194
- VALUES (?, ?, ?, ?)
195
- ON CONFLICT(key) DO UPDATE SET
196
- value = excluded.value,
197
- expires_at = excluded.expires_at,
198
- created_at = excluded.created_at`,
199
- [String(key), serialized, expiresAt, now],
200
- (err: Error | null) => (err ? reject(err) : resolve()),
201
- );
202
- });
203
- }
204
-
205
- async has(key: K): Promise<boolean> {
206
- const value = await this.get(key);
207
- return value !== undefined;
208
- }
209
-
210
- async delete(key: K): Promise<boolean> {
211
- await this.ensureReady();
212
- if (!this.db) return false;
213
-
214
- return new Promise<boolean>((resolve, reject) => {
215
- this.db!.run(
216
- "DELETE FROM cache WHERE key = ?",
217
- [String(key)],
218
- function (this: { changes: number }, err: Error | null) {
219
- if (err) reject(err);
220
- else resolve(this.changes > 0);
221
- },
222
- );
223
- });
224
- }
225
-
226
- async cleanupExpired(): Promise<number> {
227
- await this.ensureReady();
228
- if (!this.db) return 0;
229
-
230
- return new Promise<number>((resolve, reject) => {
231
- this.db!.run(
232
- "DELETE FROM cache WHERE expires_at <= ?",
233
- [Date.now()],
234
- function (this: { changes: number }, err: Error | null) {
235
- if (err) reject(err);
236
- else resolve(this.changes);
237
- },
238
- );
239
- });
240
- }
241
-
242
- async clear(): Promise<void> {
243
- await this.ensureReady();
244
- if (!this.db) return;
245
-
246
- await new Promise<void>((resolve, reject) => {
247
- this.db!.run("DELETE FROM cache", (err: Error | null) => (err ? reject(err) : resolve()));
248
- });
249
- }
250
- }
251
-
252
- // ─── TTL Cache ───────────────────────────────────────────────────────────────
253
-
254
- /** Default options */
255
- const DEFAULTS: Required<TTLCacheOptions> = {
256
- persistent: false,
257
- dbPath: "",
258
- defaultTtlMs: 3600000, // 1 hour
259
- maxMemoryEntries: 1000,
260
- };
261
-
262
- /**
263
- * General-purpose TTL cache with optional SQLite persistence.
264
- */
265
- export class TTLCache<K = string, V = unknown> {
266
- private backend: CacheBackend<K, V>;
267
- private opts: Required<TTLCacheOptions>;
268
-
269
- constructor(options: TTLCacheOptions = {}) {
270
- this.opts = { ...DEFAULTS, ...options };
271
-
272
- if (this.opts.persistent) {
273
- const dbPath =
274
- this.opts.dbPath ||
275
- new URL("~/.unipi/cache/ttl-cache.db", import.meta.url).pathname;
276
- this.backend = new SQLiteBackend<K, V>(dbPath);
277
- } else {
278
- this.backend = new MemoryBackend<K, V>(this.opts.maxMemoryEntries);
279
- }
280
- }
281
-
282
- /** Get a value by key */
283
- async get(key: K): Promise<V | undefined> {
284
- return this.backend.get(key);
285
- }
286
-
287
- /** Set a value with TTL */
288
- async set(key: K, value: V, ttlMs?: number): Promise<void> {
289
- return this.backend.set(key, value, ttlMs ?? this.opts.defaultTtlMs);
290
- }
291
-
292
- /** Check if key exists and is not expired */
293
- async has(key: K): Promise<boolean> {
294
- return this.backend.has(key);
295
- }
296
-
297
- /** Delete a key */
298
- async delete(key: K): Promise<boolean> {
299
- return this.backend.delete(key);
300
- }
301
-
302
- /** Clean up all expired entries */
303
- async cleanupExpired(): Promise<number> {
304
- return this.backend.cleanupExpired();
305
- }
306
-
307
- /** Clear all entries */
308
- async clear(): Promise<void> {
309
- return this.backend.clear();
310
- }
311
- }
@@ -1,352 +0,0 @@
1
- /**
2
- * @pi-unipi/utility — Shiki Highlighter
3
- *
4
- * Singleton Shiki ANSI highlighter with LRU cache, language detection,
5
- * and contrast normalization.
6
- */
7
-
8
- // ─── Constants ──────────────────────────────────────────────────────────────────
9
-
10
- /** Maximum number of cached highlight results */
11
- export const CACHE_LIMIT = 192;
12
-
13
- /** Maximum characters to highlight (skip Shiki above this) */
14
- export const MAX_HL_CHARS = 80_000;
15
-
16
- // ─── LRU Cache ──────────────────────────────────────────────────────────────────
17
-
18
- /**
19
- * Simple LRU cache with string keys.
20
- * Evicts oldest entries when capacity is reached.
21
- */
22
- export class LruCache<V> {
23
- private map = new Map<string, V>();
24
- private capacity: number;
25
-
26
- constructor(capacity: number) {
27
- this.capacity = capacity;
28
- }
29
-
30
- get(key: string): V | undefined {
31
- const value = this.map.get(key);
32
- if (value !== undefined) {
33
- // Move to end (most recently used)
34
- this.map.delete(key);
35
- this.map.set(key, value);
36
- }
37
- return value;
38
- }
39
-
40
- set(key: string, value: V): void {
41
- if (this.map.has(key)) {
42
- this.map.delete(key);
43
- } else if (this.map.size >= this.capacity) {
44
- // Evict oldest (first entry)
45
- const firstKey = this.map.keys().next().value;
46
- if (firstKey !== undefined) {
47
- this.map.delete(firstKey);
48
- }
49
- }
50
- this.map.set(key, value);
51
- }
52
-
53
- has(key: string): boolean {
54
- return this.map.has(key);
55
- }
56
-
57
- get size(): number {
58
- return this.map.size;
59
- }
60
-
61
- clear(): void {
62
- this.map.clear();
63
- }
64
- }
65
-
66
- // ─── Language Detection ─────────────────────────────────────────────────────────
67
-
68
- /** File extension → Shiki language mapping */
69
- export const EXT_LANG: Record<string, string> = {
70
- ".ts": "typescript",
71
- ".tsx": "tsx",
72
- ".js": "javascript",
73
- ".jsx": "jsx",
74
- ".mjs": "javascript",
75
- ".cjs": "javascript",
76
- ".mts": "typescript",
77
- ".cts": "typescript",
78
- ".json": "json",
79
- ".jsonc": "jsonc",
80
- ".json5": "json5",
81
- ".html": "html",
82
- ".htm": "html",
83
- ".css": "css",
84
- ".scss": "scss",
85
- ".sass": "sass",
86
- ".less": "less",
87
- ".md": "markdown",
88
- ".mdx": "mdx",
89
- ".py": "python",
90
- ".rb": "ruby",
91
- ".go": "go",
92
- ".rs": "rust",
93
- ".java": "java",
94
- ".kt": "kotlin",
95
- ".kts": "kotlin",
96
- ".c": "c",
97
- ".h": "c",
98
- ".cpp": "cpp",
99
- ".hpp": "cpp",
100
- ".cc": "cpp",
101
- ".cs": "csharp",
102
- ".swift": "swift",
103
- ".php": "php",
104
- ".sql": "sql",
105
- ".sh": "bash",
106
- ".bash": "bash",
107
- ".zsh": "bash",
108
- ".fish": "fish",
109
- ".yaml": "yaml",
110
- ".yml": "yaml",
111
- ".toml": "toml",
112
- ".xml": "xml",
113
- ".svg": "xml",
114
- ".graphql": "graphql",
115
- ".gql": "graphql",
116
- ".vue": "vue",
117
- ".svelte": "svelte",
118
- ".astro": "astro",
119
- ".prisma": "prisma",
120
- ".dockerfile": "dockerfile",
121
- ".tf": "hcl",
122
- ".hcl": "hcl",
123
- ".lua": "lua",
124
- ".r": "r",
125
- ".R": "r",
126
- ".dart": "dart",
127
- ".ex": "elixir",
128
- ".exs": "elixir",
129
- ".erl": "erlang",
130
- ".hrl": "erlang",
131
- ".clj": "clojure",
132
- ".cljs": "clojure",
133
- ".hs": "haskell",
134
- ".elm": "elm",
135
- ".nim": "nim",
136
- ".zig": "zig",
137
- ".v": "v",
138
- ".jl": "julia",
139
- ".ml": "ocaml",
140
- ".mli": "ocaml",
141
- ".fs": "fsharp",
142
- ".fsx": "fsharp",
143
- ".fsi": "fsharp",
144
- };
145
-
146
- /**
147
- * Detect the Shiki language from a file extension.
148
- * Returns "text" if the extension is unknown.
149
- */
150
- export function detectLanguage(extension: string): string {
151
- const ext = extension.startsWith(".") ? extension.toLowerCase() : `.${extension.toLowerCase()}`;
152
- return EXT_LANG[ext] ?? "text";
153
- }
154
-
155
- /**
156
- * Detect language from a file path.
157
- */
158
- export function detectLanguageFromPath(filePath: string): string {
159
- const ext = filePath.lastIndexOf(".") >= 0 ? filePath.substring(filePath.lastIndexOf(".")) : "";
160
- return detectLanguage(ext);
161
- }
162
-
163
- // ─── Contrast Normalization ─────────────────────────────────────────────────────
164
-
165
- /**
166
- * Calculate the relative luminance of a hex color.
167
- * Used for contrast ratio calculations.
168
- */
169
- function relativeLuminance(hex: string): number {
170
- const h = hex.replace(/^#/, "");
171
- const r = parseInt(h.substring(0, 2), 16) / 255;
172
- const g = parseInt(h.substring(2, 4), 16) / 255;
173
- const b = parseInt(h.substring(4, 6), 16) / 255;
174
-
175
- const toLinear = (c: number) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
176
- return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
177
- }
178
-
179
- /**
180
- * Calculate contrast ratio between two colors.
181
- */
182
- function contrastRatio(fg: string, bg: string): number {
183
- const l1 = relativeLuminance(fg);
184
- const l2 = relativeLuminance(bg);
185
- const lighter = Math.max(l1, l2);
186
- const darker = Math.min(l1, l2);
187
- return (lighter + 0.05) / (darker + 0.05);
188
- }
189
-
190
- /**
191
- * Extract the foreground color from an ANSI 24-bit escape sequence.
192
- * Returns the hex color and the full match for replacement.
193
- */
194
- function extractAnsiFg(ansi: string): { hex: string; match: string } | null {
195
- const match = ansi.match(/\x1b\[38;2;(\d+);(\d+);(\d+)m/);
196
- if (!match) return null;
197
- const r = parseInt(match[1]);
198
- const g = parseInt(match[2]);
199
- const b = parseInt(match[3]);
200
- const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
201
- return { hex, match: match[0] };
202
- }
203
-
204
- /**
205
- * Normalize low-contrast Shiki foregrounds against a dark background.
206
- *
207
- * Shiki themes sometimes produce foreground colors with poor contrast
208
- * against diff backgrounds. This function bumps the brightness of any
209
- * foreground that falls below the minimum contrast ratio.
210
- *
211
- * @param ansi - ANSI string with 24-bit color codes
212
- * @param bgHex - Background hex color to test against (default: dark bg)
213
- * @param minRatio - Minimum contrast ratio (default: 3.0)
214
- */
215
- export function normalizeShikiContrast(
216
- ansi: string,
217
- bgHex: string = "#1a1a2e",
218
- minRatio: number = 3.0,
219
- ): string {
220
- // Find all 24-bit foreground sequences
221
- const fgRegex = /\x1b\[38;2;\d+;\d+;\d+m/g;
222
- let result = ansi;
223
- let match: RegExpExecArray | null;
224
-
225
- while ((match = fgRegex.exec(ansi)) !== null) {
226
- const fgInfo = extractAnsiFg(match[0] + ansi.substring(match.index + match[0].length));
227
- if (!fgInfo) continue;
228
-
229
- const ratio = contrastRatio(fgInfo.hex, bgHex);
230
- if (ratio < minRatio) {
231
- // Brighten the foreground by mixing with white
232
- const [r, g, b] = [
233
- parseInt(fgInfo.hex.slice(1, 3), 16),
234
- parseInt(fgInfo.hex.slice(3, 5), 16),
235
- parseInt(fgInfo.hex.slice(5, 7), 16),
236
- ];
237
- const factor = minRatio / Math.max(ratio, 0.01);
238
- const nr = Math.min(255, Math.round(r + (255 - r) * Math.min(1, factor * 0.5)));
239
- const ng = Math.min(255, Math.round(g + (255 - g) * Math.min(1, factor * 0.5)));
240
- const nb = Math.min(255, Math.round(b + (255 - b) * Math.min(1, factor * 0.5)));
241
- const newFg = `\x1b[38;2;${nr};${ng};${nb}m`;
242
- result = result.replace(match[0], newFg);
243
- }
244
- }
245
-
246
- return result;
247
- }
248
-
249
- // ─── Shiki Highlighter ──────────────────────────────────────────────────────────
250
-
251
- /** Shiki highlighter instance (lazy singleton) */
252
- let shikiHighlighter: import("shiki").Highlighter | null = null;
253
- let shikiInitPromise: Promise<any> | null = null;
254
-
255
- /**
256
- * Initialize the Shiki highlighter (singleton).
257
- * Returns the highlighter instance.
258
- */
259
- export async function getShikiHighlighter(): Promise<any> {
260
- if (shikiHighlighter) return shikiHighlighter;
261
- if (shikiInitPromise) return shikiInitPromise;
262
-
263
- shikiInitPromise = (async () => {
264
- try {
265
- const { createHighlighter } = await import("shiki");
266
- shikiHighlighter = await createHighlighter({
267
- themes: ["github-dark"],
268
- langs: [
269
- "typescript", "javascript", "tsx", "jsx", "json", "jsonc",
270
- "html", "css", "scss", "markdown", "python", "go", "rust",
271
- "java", "c", "cpp", "csharp", "ruby", "php", "swift", "kotlin",
272
- "bash", "yaml", "toml", "xml", "sql", "graphql", "vue", "svelte",
273
- ],
274
- });
275
- return shikiHighlighter;
276
- } catch {
277
- // If Shiki fails to load, return null — we'll use plain text
278
- shikiInitPromise = null;
279
- return null;
280
- }
281
- })();
282
-
283
- return shikiInitPromise;
284
- }
285
-
286
- /**
287
- * Pre-warm the Shiki highlighter.
288
- * Call this early in the extension lifecycle to avoid first-render delay.
289
- */
290
- export async function preWarmHighlighter(): Promise<void> {
291
- await getShikiHighlighter();
292
- }
293
-
294
- /** LRU cache for highlighted blocks */
295
- const hlCache = new LruCache<string[]>(CACHE_LIMIT);
296
-
297
- /**
298
- * Generate a cache key for a code block.
299
- */
300
- function hlCacheKey(code: string, language: string): string {
301
- // Use first 200 chars + length + language for cache key
302
- const prefix = code.substring(0, 200);
303
- return `${language}:${code.length}:${prefix}`;
304
- }
305
-
306
- /**
307
- * Highlight a code block to ANSI using Shiki.
308
- * Results are cached in an LRU cache (192 entries).
309
- *
310
- * @param code - Code to highlight
311
- * @param language - Shiki language identifier
312
- * @returns Array of ANSI-highlighted lines, or plain lines if Shiki unavailable
313
- */
314
- export async function hlBlock(code: string, language: string): Promise<string[]> {
315
- // Skip highlighting for very large content
316
- if (code.length > MAX_HL_CHARS) {
317
- return code.split("\n");
318
- }
319
-
320
- // Check cache
321
- const key = hlCacheKey(code, language);
322
- const cached = hlCache.get(key);
323
- if (cached) return cached;
324
-
325
- // Get highlighter
326
- const highlighter = await getShikiHighlighter();
327
- if (!highlighter) {
328
- // Shiki not available — return plain text
329
- return code.split("\n");
330
- }
331
-
332
- try {
333
- // Highlight with Shiki
334
- const ansi = highlighter.codeToANSI(code, {
335
- lang: language === "text" ? "text" : language,
336
- theme: "github-dark",
337
- });
338
-
339
- const lines = ansi.split("\n");
340
-
341
- // Normalize contrast
342
- const normalized = lines.map((line: string) => normalizeShikiContrast(line));
343
-
344
- // Cache result
345
- hlCache.set(key, normalized);
346
-
347
- return normalized;
348
- } catch {
349
- // Fallback on error
350
- return code.split("\n");
351
- }
352
- }