@aladac/hu 0.1.0-a1
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/.tool-versions +1 -0
- package/CLAUDE.md +122 -0
- package/HOOKS-DATA-INTEGRATION.md +457 -0
- package/SAMPLE.md +378 -0
- package/TODO.md +25 -0
- package/biome.json +51 -0
- package/commands/bootstrap.md +13 -0
- package/commands/c.md +1 -0
- package/commands/check-name.md +62 -0
- package/commands/disk.md +141 -0
- package/commands/docs/archive.md +27 -0
- package/commands/docs/check-internal.md +53 -0
- package/commands/docs/cleanup.md +65 -0
- package/commands/docs/consolidate.md +72 -0
- package/commands/docs/get.md +101 -0
- package/commands/docs/list.md +61 -0
- package/commands/docs/sync.md +64 -0
- package/commands/docs/update.md +49 -0
- package/commands/plans/clear.md +23 -0
- package/commands/plans/create.md +71 -0
- package/commands/plans/list.md +21 -0
- package/commands/plans/sync.md +38 -0
- package/commands/reinstall.md +20 -0
- package/commands/replicate.md +303 -0
- package/commands/warp.md +0 -0
- package/doc/README.md +35 -0
- package/doc/claude-code/capabilities.md +202 -0
- package/doc/claude-code/directory-structure.md +246 -0
- package/doc/claude-code/hooks.md +348 -0
- package/doc/claude-code/overview.md +109 -0
- package/doc/claude-code/plugins.md +273 -0
- package/doc/claude-code/sdk-protocols.md +202 -0
- package/document-manifest.toml +29 -0
- package/justfile +39 -0
- package/package.json +33 -0
- package/plans/compiled-watching-feather.md +217 -0
- package/plans/crispy-crafting-pnueli.md +103 -0
- package/plans/greedy-booping-coral.md +146 -0
- package/plans/imperative-sleeping-flamingo.md +192 -0
- package/plans/jaunty-sprouting-marble.md +171 -0
- package/plans/jiggly-discovering-lake.md +68 -0
- package/plans/magical-nibbling-spark.md +144 -0
- package/plans/mellow-kindling-acorn.md +110 -0
- package/plans/recursive-questing-engelbart.md +65 -0
- package/plans/serialized-roaming-kernighan.md +227 -0
- package/plans/structured-wondering-wirth.md +230 -0
- package/plans/vectorized-dreaming-iverson.md +191 -0
- package/plans/velvety-enchanting-ocean.md +92 -0
- package/plans/wiggly-sparking-pixel.md +48 -0
- package/plans/zippy-shimmying-fox.md +188 -0
- package/plugins/installed_plugins.json +4 -0
- package/sample-hooks.json +298 -0
- package/settings.json +24 -0
- package/settings.local.json +7 -0
- package/src/commands/bump.ts +130 -0
- package/src/commands/disk.ts +419 -0
- package/src/commands/docs.ts +729 -0
- package/src/commands/plans.ts +259 -0
- package/src/commands/utils.ts +299 -0
- package/src/index.ts +26 -0
- package/src/lib/colors.ts +87 -0
- package/src/lib/exec.ts +25 -0
- package/src/lib/fs.ts +119 -0
- package/src/lib/html.ts +205 -0
- package/src/lib/spinner.ts +42 -0
- package/src/types/index.ts +61 -0
- package/tests/lib/colors.test.ts +69 -0
- package/tests/lib/fs.test.ts +65 -0
- package/tsconfig.json +20 -0
- package/vitest.config.ts +15 -0
package/src/lib/fs.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const HOME = process.env.HOME || process.env.USERPROFILE || '';
|
|
5
|
+
export const CLAUDE_DIR = path.join(HOME, '.claude');
|
|
6
|
+
export const PLANS_DIR = path.join(CLAUDE_DIR, 'plans');
|
|
7
|
+
export const GLOBAL_DOCS = path.join(CLAUDE_DIR, 'documents');
|
|
8
|
+
|
|
9
|
+
export const SYSTEM_DOCS = [
|
|
10
|
+
'MODELS.md',
|
|
11
|
+
'PLAN.md',
|
|
12
|
+
'TODO.md',
|
|
13
|
+
'TEST.md',
|
|
14
|
+
'REFACTOR.md',
|
|
15
|
+
'README.md',
|
|
16
|
+
'CLAUDE.md',
|
|
17
|
+
'LICENSE.md',
|
|
18
|
+
'CHANGELOG.md',
|
|
19
|
+
'CONTRIBUTING.md',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Find the git repository root from current directory
|
|
24
|
+
*/
|
|
25
|
+
export function findRepoRoot(): string {
|
|
26
|
+
let dir = process.cwd();
|
|
27
|
+
while (dir !== '/') {
|
|
28
|
+
if (fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
29
|
+
dir = path.dirname(dir);
|
|
30
|
+
}
|
|
31
|
+
return process.cwd();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Recursively find all markdown files in a directory
|
|
36
|
+
*/
|
|
37
|
+
export function findMarkdownFiles(dir: string, recursive = true): string[] {
|
|
38
|
+
const files: string[] = [];
|
|
39
|
+
if (!fs.existsSync(dir)) return files;
|
|
40
|
+
|
|
41
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
const fullPath = path.join(dir, entry.name);
|
|
44
|
+
if (entry.isDirectory() && recursive) {
|
|
45
|
+
files.push(...findMarkdownFiles(fullPath, recursive));
|
|
46
|
+
} else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
47
|
+
files.push(fullPath);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return files;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Parse YAML frontmatter from markdown content
|
|
55
|
+
*/
|
|
56
|
+
export function parseFrontmatter(content: string): Record<string, string> {
|
|
57
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
58
|
+
if (!match) return {};
|
|
59
|
+
|
|
60
|
+
const fm: Record<string, string> = {};
|
|
61
|
+
const lines = match[1].split('\n');
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const [key, ...rest] = line.split(':');
|
|
64
|
+
if (key && rest.length) {
|
|
65
|
+
fm[key.trim()] = rest.join(':').trim();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return fm;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Count checkboxes in markdown content
|
|
73
|
+
*/
|
|
74
|
+
export function countCheckboxes(content: string): { pending: number; completed: number } {
|
|
75
|
+
const pending = (content.match(/- \[ \]/g) || []).length;
|
|
76
|
+
const completed = (content.match(/- \[x\]/gi) || []).length;
|
|
77
|
+
return { pending, completed };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Safe file read - returns null if file doesn't exist
|
|
82
|
+
*/
|
|
83
|
+
export function readFile(filePath: string): string | null {
|
|
84
|
+
try {
|
|
85
|
+
return fs.readFileSync(filePath, 'utf-8');
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Safe file write
|
|
93
|
+
*/
|
|
94
|
+
export function writeFile(filePath: string, content: string): boolean {
|
|
95
|
+
try {
|
|
96
|
+
fs.writeFileSync(filePath, content);
|
|
97
|
+
return true;
|
|
98
|
+
} catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Check if path exists
|
|
105
|
+
*/
|
|
106
|
+
export function exists(filePath: string): boolean {
|
|
107
|
+
return fs.existsSync(filePath);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Get file stats or null
|
|
112
|
+
*/
|
|
113
|
+
export function getStats(filePath: string): fs.Stats | null {
|
|
114
|
+
try {
|
|
115
|
+
return fs.statSync(filePath);
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
package/src/lib/html.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio';
|
|
2
|
+
import TurndownService from 'turndown';
|
|
3
|
+
|
|
4
|
+
// Content selectors to try (in order of preference)
|
|
5
|
+
const CONTENT_SELECTORS = [
|
|
6
|
+
'article',
|
|
7
|
+
'main',
|
|
8
|
+
'[role="main"]',
|
|
9
|
+
'.main-content',
|
|
10
|
+
'.content',
|
|
11
|
+
'.post-content',
|
|
12
|
+
'.article-content',
|
|
13
|
+
'.documentation',
|
|
14
|
+
'.docs-content',
|
|
15
|
+
'.markdown-body',
|
|
16
|
+
'#content',
|
|
17
|
+
'#main',
|
|
18
|
+
'.prose',
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// Elements to remove for cleaner output
|
|
22
|
+
const REMOVE_SELECTORS = [
|
|
23
|
+
'script',
|
|
24
|
+
'style',
|
|
25
|
+
'noscript',
|
|
26
|
+
'iframe',
|
|
27
|
+
'svg',
|
|
28
|
+
'nav',
|
|
29
|
+
'header:not(article header)',
|
|
30
|
+
'footer:not(article footer)',
|
|
31
|
+
'.nav',
|
|
32
|
+
'.navigation',
|
|
33
|
+
'.sidebar',
|
|
34
|
+
'.menu',
|
|
35
|
+
'.toc',
|
|
36
|
+
'.table-of-contents',
|
|
37
|
+
'.breadcrumb',
|
|
38
|
+
'.breadcrumbs',
|
|
39
|
+
'.ads',
|
|
40
|
+
'.advertisement',
|
|
41
|
+
'.ad-container',
|
|
42
|
+
'.social-share',
|
|
43
|
+
'.share-buttons',
|
|
44
|
+
'.comments',
|
|
45
|
+
'.comment-section',
|
|
46
|
+
'[aria-hidden="true"]',
|
|
47
|
+
'.sr-only',
|
|
48
|
+
'.visually-hidden',
|
|
49
|
+
'.cookie-banner',
|
|
50
|
+
'.popup',
|
|
51
|
+
'.modal',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Extra selectors to remove when clean mode is enabled
|
|
55
|
+
const EXTRA_CLEAN_SELECTORS = [
|
|
56
|
+
'aside',
|
|
57
|
+
'.related',
|
|
58
|
+
'.related-posts',
|
|
59
|
+
'.author-bio',
|
|
60
|
+
'.newsletter',
|
|
61
|
+
'.subscribe',
|
|
62
|
+
'.cta',
|
|
63
|
+
'.banner',
|
|
64
|
+
'.promo',
|
|
65
|
+
'.feedback',
|
|
66
|
+
'.edit-page',
|
|
67
|
+
'.page-nav',
|
|
68
|
+
'.pagination',
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
export interface FetchOptions {
|
|
72
|
+
selector?: string;
|
|
73
|
+
includeTitle?: boolean;
|
|
74
|
+
clean?: boolean;
|
|
75
|
+
raw?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Fetch a URL and convert HTML to markdown
|
|
80
|
+
*/
|
|
81
|
+
export async function fetchAndConvert(url: string, opts: FetchOptions = {}): Promise<string> {
|
|
82
|
+
const response = await fetch(url, {
|
|
83
|
+
headers: {
|
|
84
|
+
'User-Agent':
|
|
85
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
86
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
87
|
+
'Accept-Language': 'en-US,en;q=0.5',
|
|
88
|
+
},
|
|
89
|
+
redirect: 'follow',
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const html = await response.text();
|
|
97
|
+
const $ = cheerio.load(html);
|
|
98
|
+
|
|
99
|
+
// Get page title
|
|
100
|
+
const pageTitle = $('title').text().trim() || $('h1').first().text().trim();
|
|
101
|
+
|
|
102
|
+
// Remove unwanted elements
|
|
103
|
+
const selectorsToRemove = opts.clean
|
|
104
|
+
? [...REMOVE_SELECTORS, ...EXTRA_CLEAN_SELECTORS]
|
|
105
|
+
: REMOVE_SELECTORS;
|
|
106
|
+
|
|
107
|
+
for (const sel of selectorsToRemove) {
|
|
108
|
+
try {
|
|
109
|
+
$(sel).remove();
|
|
110
|
+
} catch {
|
|
111
|
+
// Ignore invalid selectors
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Find main content
|
|
116
|
+
// biome-ignore lint/suspicious/noExplicitAny: cheerio types are complex
|
|
117
|
+
let $content: cheerio.Cheerio<any>;
|
|
118
|
+
if (opts.selector) {
|
|
119
|
+
$content = $(opts.selector);
|
|
120
|
+
if ($content.length === 0) {
|
|
121
|
+
console.error(`Warning: Selector "${opts.selector}" not found, using body`);
|
|
122
|
+
$content = $('body');
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
// Try content selectors in order
|
|
126
|
+
$content = $('body'); // Default
|
|
127
|
+
for (const sel of CONTENT_SELECTORS) {
|
|
128
|
+
const $el = $(sel);
|
|
129
|
+
if ($el.length > 0 && $el.text().trim().length > 100) {
|
|
130
|
+
$content = $el;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Configure Turndown
|
|
137
|
+
const turndown = new TurndownService({
|
|
138
|
+
headingStyle: 'atx',
|
|
139
|
+
codeBlockStyle: 'fenced',
|
|
140
|
+
bulletListMarker: '-',
|
|
141
|
+
emDelimiter: '*',
|
|
142
|
+
strongDelimiter: '**',
|
|
143
|
+
linkStyle: 'inlined',
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Custom rules for better output
|
|
147
|
+
turndown.addRule('preserveCodeBlocks', {
|
|
148
|
+
filter: ['pre'],
|
|
149
|
+
replacement: (content: string, node) => {
|
|
150
|
+
const element = node as unknown as {
|
|
151
|
+
querySelector: (s: string) => { className?: string; textContent?: string } | null;
|
|
152
|
+
textContent?: string;
|
|
153
|
+
};
|
|
154
|
+
const code = element.querySelector('code');
|
|
155
|
+
const lang = code?.className?.match(/language-(\w+)/)?.[1] || '';
|
|
156
|
+
const text = code?.textContent || element.textContent || content;
|
|
157
|
+
return `\n\n\`\`\`${lang}\n${text.trim()}\n\`\`\`\n\n`;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
turndown.addRule('removeEmptyLinks', {
|
|
162
|
+
filter: (node): boolean => {
|
|
163
|
+
return node.nodeName === 'A' && !node.textContent?.trim();
|
|
164
|
+
},
|
|
165
|
+
replacement: (): string => '',
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Convert to markdown
|
|
169
|
+
let markdown = turndown.turndown($content.html() || '');
|
|
170
|
+
|
|
171
|
+
// Post-processing (unless raw mode)
|
|
172
|
+
if (!opts.raw) {
|
|
173
|
+
markdown = postProcess(markdown);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Add title if requested
|
|
177
|
+
if (opts.includeTitle && pageTitle) {
|
|
178
|
+
markdown = `# ${pageTitle}\n\n${markdown}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return markdown;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Post-process markdown for cleaner output
|
|
186
|
+
*/
|
|
187
|
+
function postProcess(md: string): string {
|
|
188
|
+
const cleaned = md
|
|
189
|
+
// Remove excessive blank lines
|
|
190
|
+
.replace(/\n{4,}/g, '\n\n\n')
|
|
191
|
+
// Remove trailing whitespace
|
|
192
|
+
.replace(/[ \t]+$/gm, '')
|
|
193
|
+
// Remove empty list items
|
|
194
|
+
.replace(/^[-*]\s*$/gm, '')
|
|
195
|
+
// Clean up code blocks
|
|
196
|
+
.replace(/```\n\n+/g, '```\n')
|
|
197
|
+
.replace(/\n\n+```/g, '\n```')
|
|
198
|
+
// Remove lone bullets
|
|
199
|
+
.replace(/^\s*[-*]\s*\n/gm, '')
|
|
200
|
+
// Normalize headers (ensure space after #)
|
|
201
|
+
.replace(/^(#{1,6})([^#\s])/gm, '$1 $2')
|
|
202
|
+
// Trim
|
|
203
|
+
.trim();
|
|
204
|
+
return `${cleaned}\n`;
|
|
205
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { c } from './colors.ts';
|
|
2
|
+
|
|
3
|
+
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
4
|
+
|
|
5
|
+
let spinnerIndex = 0;
|
|
6
|
+
let spinnerInterval: ReturnType<typeof setInterval> | null = null;
|
|
7
|
+
let currentSpinnerText = '';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Start an animated spinner with text
|
|
11
|
+
*/
|
|
12
|
+
export function startSpinner(text: string): void {
|
|
13
|
+
currentSpinnerText = text;
|
|
14
|
+
spinnerIndex = 0;
|
|
15
|
+
process.stdout.write('\x1b[?25l'); // Hide cursor
|
|
16
|
+
spinnerInterval = setInterval(() => {
|
|
17
|
+
const frame = spinnerFrames[spinnerIndex % spinnerFrames.length];
|
|
18
|
+
process.stdout.write(`\r${c.cyan}${frame}${c.reset} ${currentSpinnerText}`);
|
|
19
|
+
spinnerIndex++;
|
|
20
|
+
}, 80);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stop the spinner and show final status
|
|
25
|
+
*/
|
|
26
|
+
export function stopSpinner(success = true, finalText: string | null = null): void {
|
|
27
|
+
if (spinnerInterval) {
|
|
28
|
+
clearInterval(spinnerInterval);
|
|
29
|
+
spinnerInterval = null;
|
|
30
|
+
}
|
|
31
|
+
const icon = success ? `${c.green}✓${c.reset}` : `${c.red}✗${c.reset}`;
|
|
32
|
+
const text = finalText || currentSpinnerText;
|
|
33
|
+
process.stdout.write(`\r${icon} ${text}\x1b[K\n`);
|
|
34
|
+
process.stdout.write('\x1b[?25h'); // Show cursor
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Update spinner text without stopping
|
|
39
|
+
*/
|
|
40
|
+
export function updateSpinner(text: string): void {
|
|
41
|
+
currentSpinnerText = text;
|
|
42
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common type definitions for hu
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export interface DocInfo {
|
|
6
|
+
path: string;
|
|
7
|
+
name: string;
|
|
8
|
+
title: string;
|
|
9
|
+
size: number;
|
|
10
|
+
sizeHuman: string;
|
|
11
|
+
lines: number;
|
|
12
|
+
modified: string;
|
|
13
|
+
source: string | null;
|
|
14
|
+
fetched: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PlanInfo {
|
|
18
|
+
filename: string;
|
|
19
|
+
path: string;
|
|
20
|
+
title: string;
|
|
21
|
+
bullets: string[];
|
|
22
|
+
phases: string[];
|
|
23
|
+
size: number;
|
|
24
|
+
modified: string;
|
|
25
|
+
resumeCmd: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SystemDocInfo {
|
|
29
|
+
name: string;
|
|
30
|
+
found: boolean;
|
|
31
|
+
path: string | null;
|
|
32
|
+
size: number | null;
|
|
33
|
+
sizeHuman: string | null;
|
|
34
|
+
lines: number | null;
|
|
35
|
+
modified: string | null;
|
|
36
|
+
pending: number | null;
|
|
37
|
+
completed: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface CheckboxResult {
|
|
41
|
+
file: string;
|
|
42
|
+
exists: boolean;
|
|
43
|
+
changed: boolean;
|
|
44
|
+
delete?: boolean;
|
|
45
|
+
reason?: string;
|
|
46
|
+
before?: { pending: number; completed: number };
|
|
47
|
+
after?: { pending: number; completed: number };
|
|
48
|
+
removed?: number;
|
|
49
|
+
newContent?: string | null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SpaceHogCheck {
|
|
53
|
+
name: string;
|
|
54
|
+
cmd: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface CleanupSuggestion {
|
|
58
|
+
item: string;
|
|
59
|
+
size: string;
|
|
60
|
+
cmd: string;
|
|
61
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { formatSize, parseSize, progressBar } from '../../src/lib/colors.ts';
|
|
3
|
+
|
|
4
|
+
describe('parseSize', () => {
|
|
5
|
+
it('parses bytes', () => {
|
|
6
|
+
expect(parseSize('100')).toBe(100);
|
|
7
|
+
// Note: 'B' is not recognized as a unit, only K/M/G/T/P
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('parses kilobytes', () => {
|
|
11
|
+
expect(parseSize('1K')).toBe(1024);
|
|
12
|
+
expect(parseSize('2.5K')).toBe(2560);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('parses megabytes', () => {
|
|
16
|
+
expect(parseSize('1M')).toBe(1024 * 1024);
|
|
17
|
+
expect(parseSize('10M')).toBe(10 * 1024 * 1024);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('parses gigabytes', () => {
|
|
21
|
+
expect(parseSize('1G')).toBe(1024 ** 3);
|
|
22
|
+
expect(parseSize('50G')).toBe(50 * 1024 ** 3);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('handles invalid input', () => {
|
|
26
|
+
expect(parseSize('')).toBe(0);
|
|
27
|
+
expect(parseSize('abc')).toBe(0);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('formatSize', () => {
|
|
32
|
+
it('formats bytes', () => {
|
|
33
|
+
expect(formatSize(100)).toBe('100 B');
|
|
34
|
+
expect(formatSize(500)).toBe('500 B');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('formats kilobytes', () => {
|
|
38
|
+
expect(formatSize(1024)).toBe('1.0 KB');
|
|
39
|
+
expect(formatSize(2048)).toBe('2.0 KB');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('formats megabytes', () => {
|
|
43
|
+
expect(formatSize(1024 * 1024)).toBe('1.0 MB');
|
|
44
|
+
expect(formatSize(5 * 1024 * 1024)).toBe('5.0 MB');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('formats gigabytes', () => {
|
|
48
|
+
expect(formatSize(1024 ** 3)).toBe('1.0 GB');
|
|
49
|
+
expect(formatSize(10 * 1024 ** 3)).toBe('10.0 GB');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('progressBar', () => {
|
|
54
|
+
it('creates green bar for low percentage', () => {
|
|
55
|
+
const bar = progressBar(30, 10);
|
|
56
|
+
expect(bar).toContain('███');
|
|
57
|
+
expect(bar).toContain('\x1b[32m'); // green
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('creates yellow bar for medium percentage', () => {
|
|
61
|
+
const bar = progressBar(70, 10);
|
|
62
|
+
expect(bar).toContain('\x1b[33m'); // yellow
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('creates red bar for high percentage', () => {
|
|
66
|
+
const bar = progressBar(90, 10);
|
|
67
|
+
expect(bar).toContain('\x1b[31m'); // red
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { countCheckboxes, parseFrontmatter } from '../../src/lib/fs.ts';
|
|
3
|
+
|
|
4
|
+
describe('parseFrontmatter', () => {
|
|
5
|
+
it('parses valid frontmatter', () => {
|
|
6
|
+
const content = `---
|
|
7
|
+
source: https://example.com
|
|
8
|
+
fetched: 2024-01-01
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Title
|
|
12
|
+
|
|
13
|
+
Content here`;
|
|
14
|
+
|
|
15
|
+
const result = parseFrontmatter(content);
|
|
16
|
+
expect(result.source).toBe('https://example.com');
|
|
17
|
+
expect(result.fetched).toBe('2024-01-01');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('returns empty object for no frontmatter', () => {
|
|
21
|
+
const content = '# Just a title\n\nNo frontmatter here';
|
|
22
|
+
const result = parseFrontmatter(content);
|
|
23
|
+
expect(result).toEqual({});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('handles frontmatter with colons in values', () => {
|
|
27
|
+
const content = `---
|
|
28
|
+
source: https://example.com:8080/path
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
Content`;
|
|
32
|
+
|
|
33
|
+
const result = parseFrontmatter(content);
|
|
34
|
+
expect(result.source).toBe('https://example.com:8080/path');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('countCheckboxes', () => {
|
|
39
|
+
it('counts pending checkboxes', () => {
|
|
40
|
+
const content = `
|
|
41
|
+
- [ ] Task 1
|
|
42
|
+
- [ ] Task 2
|
|
43
|
+
- [x] Done task
|
|
44
|
+
`;
|
|
45
|
+
const result = countCheckboxes(content);
|
|
46
|
+
expect(result.pending).toBe(2);
|
|
47
|
+
expect(result.completed).toBe(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('handles case-insensitive completed checkboxes', () => {
|
|
51
|
+
const content = `
|
|
52
|
+
- [x] lowercase
|
|
53
|
+
- [X] uppercase
|
|
54
|
+
`;
|
|
55
|
+
const result = countCheckboxes(content);
|
|
56
|
+
expect(result.completed).toBe(2);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns zeros for no checkboxes', () => {
|
|
60
|
+
const content = 'Just plain text';
|
|
61
|
+
const result = countCheckboxes(content);
|
|
62
|
+
expect(result.pending).toBe(0);
|
|
63
|
+
expect(result.completed).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"declaration": false,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"outDir": "./dist",
|
|
15
|
+
"types": ["node"],
|
|
16
|
+
"lib": ["ES2022"]
|
|
17
|
+
},
|
|
18
|
+
"include": ["src/**/*", "tests/**/*", "vitest.config.ts"],
|
|
19
|
+
"exclude": ["node_modules", "tools", "dist"]
|
|
20
|
+
}
|
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
test: {
|
|
5
|
+
globals: true,
|
|
6
|
+
environment: 'node',
|
|
7
|
+
include: ['tests/**/*.test.ts'],
|
|
8
|
+
coverage: {
|
|
9
|
+
provider: 'v8',
|
|
10
|
+
reporter: ['text', 'html'],
|
|
11
|
+
include: ['src/**/*.ts'],
|
|
12
|
+
exclude: ['src/index.ts'],
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
});
|