@nbtca/prompt 1.1.0 → 1.1.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.
package/dist/core/logo.js CHANGED
@@ -1,70 +1,47 @@
1
1
  /**
2
- * Smart logo display module
3
- * Attempts to display iTerm2 image format logo, falls back to ASCII art
2
+ * Startup logo: a high-precision braille dot-matrix render of the NBTCA emblem
3
+ * (generated from CA-logo.svg), shown with the brand blue->cyan gradient.
4
+ * Falls back to plain ASCII on terminals without Unicode/braille support.
4
5
  */
5
6
  import { readFileSync } from 'fs';
6
7
  import { fileURLToPath } from 'url';
7
8
  import { dirname, join } from 'path';
8
9
  import gradient from 'gradient-string';
9
- const __filename = fileURLToPath(import.meta.url);
10
- const __dirname = dirname(__filename);
11
- /**
12
- * Create blue-toned gradient effect
13
- */
14
- function createBlueGradient(text) {
15
- const blueGradient = gradient([
16
- { color: '#1e3a8a', pos: 0 }, // Deep blue
17
- { color: '#0ea5e9', pos: 0.5 }, // Sky blue
18
- { color: '#06b6d4', pos: 1 } // Cyan
19
- ]);
20
- return blueGradient(text);
21
- }
22
- /**
23
- * Display description text (instant, no animation)
24
- */
25
- function printDescription() {
26
- const tagline = 'To be at the intersection of technology and liberal arts.';
27
- console.log();
28
- if (process.env['NO_COLOR']) {
29
- console.log(tagline);
10
+ import { useUnicodeIcons } from './icons.js';
11
+ const __dirname = dirname(fileURLToPath(import.meta.url));
12
+ const TAGLINE = 'To be at the intersection of technology and liberal arts.';
13
+ // Brand gradient: emblem blue -> sky -> cyan.
14
+ const brand = gradient([
15
+ { color: '#124689', pos: 0 },
16
+ { color: '#0ea5e9', pos: 0.55 },
17
+ { color: '#06b6d4', pos: 1 },
18
+ ]);
19
+ function readArt(file) {
20
+ try {
21
+ return readFileSync(join(__dirname, '../logo', file), 'utf-8').replace(/\s+$/, '');
30
22
  }
31
- else {
32
- console.log(createBlueGradient(tagline));
23
+ catch {
24
+ return null;
33
25
  }
34
- console.log();
35
26
  }
36
- /**
37
- * Attempt to read and display logo file
38
- */
27
+ function paint(text, color) {
28
+ if (!color)
29
+ return text;
30
+ // multiline keeps the gradient aligned down the whole block; fall back to a
31
+ // per-line gradient if the installed gradient-string lacks .multiline.
32
+ const fn = brand;
33
+ return typeof fn.multiline === 'function'
34
+ ? fn.multiline(text)
35
+ : text.split('\n').map((line) => brand(line)).join('\n');
36
+ }
39
37
  export function printLogo() {
40
- if (!process.stdout.isTTY) {
38
+ if (!process.stdout.isTTY)
41
39
  return;
42
- }
43
- try {
44
- const logoPath = join(__dirname, '../logo/logo.txt');
45
- const logoContent = readFileSync(logoPath, 'utf-8');
46
- if (logoContent && logoContent.length > 100) {
47
- console.log(logoContent);
48
- printDescription();
49
- return;
50
- }
51
- }
52
- catch {
53
- // iTerm2 logo read failed, continue trying ASCII logo
54
- }
55
- try {
56
- const asciiLogoPath = join(__dirname, '../logo/ascii-logo.txt');
57
- const asciiContent = readFileSync(asciiLogoPath, 'utf-8');
58
- console.log();
59
- const lines = asciiContent.split('\n').filter(line => line.trim());
60
- lines.forEach(line => {
61
- console.log(createBlueGradient(line));
62
- });
63
- printDescription();
64
- }
65
- catch {
66
- console.log();
67
- console.log(createBlueGradient(' NBTCA'));
68
- printDescription();
69
- }
40
+ const color = !process.env['NO_COLOR'];
41
+ const art = useUnicodeIcons() ? readArt('ca-dotmatrix.txt') : readArt('ascii-logo.txt');
42
+ console.log();
43
+ console.log(paint(art ?? 'NBTCA', color));
44
+ console.log();
45
+ console.log(color ? brand(TAGLINE) : TAGLINE);
46
+ console.log();
70
47
  }
@@ -1,7 +1,3 @@
1
- /**
2
- * 知识库终端查看模块
3
- * 获取并渲染Markdown文档
4
- */
5
1
  import { marked } from 'marked';
6
2
  import { markedTerminal } from 'marked-terminal';
7
3
  import chalk from 'chalk';
@@ -10,9 +6,10 @@ import { select, isCancel, confirm, text } from '@clack/prompts';
10
6
  import { error, warning, success, createSpinner } from '../core/ui.js';
11
7
  import { pickIcon } from '../core/icons.js';
12
8
  import { spawn, execFileSync } from 'child_process';
13
- import { APP_INFO, GITHUB_REPO, URLS } from '../config/data.js';
9
+ import { URLS } from '../config/data.js';
14
10
  import { t, fmt } from '../i18n/index.js';
15
11
  import { setVimKeysActive } from '../core/vim-keys.js';
12
+ import { createDocsClient, DocsFetchError } from '@nbtca/docs';
16
13
  function detectTerminalType() {
17
14
  const term = (process.env['TERM'] || '').toLowerCase();
18
15
  const termProgram = (process.env['TERM_PROGRAM'] || '').toLowerCase();
@@ -100,15 +97,10 @@ function getRendererOptions(type) {
100
97
  }
101
98
  };
102
99
  }
103
- // ─── GitHub data layer ────────────────────────────────────────────────────────
104
- const GITHUB_TOKEN = process.env['GITHUB_TOKEN'] || process.env['GH_TOKEN'];
105
- const SKIP_NAMES = new Set(['node_modules', 'package.json', 'pnpm-lock.yaml']);
106
- const DIR_CACHE_TTL_MS = 5 * 60 * 1000;
107
- const FILE_CACHE_TTL_MS = 10 * 60 * 1000;
108
100
  const RENDER_CACHE_TTL_MS = 10 * 60 * 1000;
109
- const dirCache = new Map();
110
- const fileCache = new Map();
101
+ const RENDER_CACHE_MAX = 50;
111
102
  const renderCache = new Map();
103
+ let docsClient = createDocsClient();
112
104
  function getDocCategories() {
113
105
  const trans = t();
114
106
  return [
@@ -120,143 +112,44 @@ function getDocCategories() {
120
112
  { name: trans.docs.categoryArchived, path: 'archived' },
121
113
  ];
122
114
  }
123
- function getFreshCacheValue(cache, key) {
124
- const entry = cache.get(key);
125
- if (!entry)
126
- return null;
127
- if (entry.expiresAt > Date.now())
128
- return entry.value;
129
- return null;
130
- }
131
- function getAnyCacheValue(cache, key) {
132
- const entry = cache.get(key);
133
- return entry?.value ?? null;
115
+ function getFreshRender(key) {
116
+ const entry = renderCache.get(key);
117
+ return entry && entry.expiresAt > Date.now() ? entry.value : null;
134
118
  }
135
- function setCacheValue(cache, key, value, ttlMs) {
136
- cache.set(key, { value, expiresAt: Date.now() + ttlMs });
137
- }
138
- const DIR_CACHE_MAX = 30;
139
- const FILE_CACHE_MAX = 50;
140
- const RENDER_CACHE_MAX = 50;
141
- function evictStalest(cache, maxSize) {
142
- if (cache.size <= maxSize)
143
- return;
144
- const oldest = [...cache.entries()]
145
- .sort((a, b) => a[1].expiresAt - b[1].expiresAt)
146
- .slice(0, cache.size - maxSize);
147
- for (const [key] of oldest)
148
- cache.delete(key);
119
+ function setRender(key, value) {
120
+ renderCache.set(key, { value, expiresAt: Date.now() + RENDER_CACHE_TTL_MS });
121
+ if (renderCache.size > RENDER_CACHE_MAX) {
122
+ const oldest = [...renderCache.entries()].sort((a, b) => a[1].expiresAt - b[1].expiresAt)[0];
123
+ if (oldest)
124
+ renderCache.delete(oldest[0]);
125
+ }
149
126
  }
150
127
  function contentFingerprint(content) {
151
- const head = content.slice(0, 80);
152
- const tail = content.slice(-80);
153
- return `${content.length}:${head}:${tail}`;
128
+ return `${content.length}:${content.slice(0, 80)}:${content.slice(-80)}`;
154
129
  }
155
130
  export function clearDocsCache() {
156
- dirCache.clear();
157
- fileCache.clear();
131
+ docsClient = createDocsClient(); // fresh instance resets dir + file caches
158
132
  renderCache.clear();
159
133
  }
160
- async function fetchGitHubDirectory(path = '', options = {}) {
161
- const cacheKey = path || '__root__';
162
- if (!options.forceRefresh) {
163
- const cached = getFreshCacheValue(dirCache, cacheKey);
164
- if (cached) {
165
- return { data: cached, fromCache: true, staleFallback: false };
166
- }
167
- }
168
- const url = `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/contents/${path}?ref=${GITHUB_REPO.branch}`;
134
+ async function fetchDirectory(path = '') {
169
135
  try {
170
- const headers = {
171
- 'Accept': 'application/vnd.github.v3+json',
172
- 'User-Agent': `NBTCA-CLI/${APP_INFO.version}`,
173
- };
174
- if (GITHUB_TOKEN)
175
- headers['Authorization'] = `Bearer ${GITHUB_TOKEN}`;
176
- const controller = new AbortController();
177
- const timeout = setTimeout(() => controller.abort(), 10000);
178
- const response = await fetch(url, { signal: controller.signal, headers });
179
- clearTimeout(timeout);
180
- if (!response.ok) {
181
- const trans = t();
182
- if (response.status === 403) {
183
- const rateLimitRemaining = response.headers.get('x-ratelimit-remaining');
184
- const rateLimitReset = response.headers.get('x-ratelimit-reset');
185
- if (rateLimitRemaining === '0' && rateLimitReset) {
186
- const resetDate = new Date(Number.parseInt(rateLimitReset, 10) * 1000);
187
- throw new Error(`${fmt(trans.docs.githubRateLimited, { time: resetDate.toLocaleTimeString() })}\n${trans.docs.githubTokenHint}`);
188
- }
189
- throw new Error(`${trans.docs.githubForbidden}\n${trans.docs.githubTokenHint}`);
190
- }
191
- throw new Error(`HTTP ${response.status}`);
192
- }
193
- const data = (await response.json());
194
- const items = data
195
- .filter((item) => !item.name.startsWith('.') &&
196
- !SKIP_NAMES.has(item.name) &&
197
- !(item.type === 'file' && !item.name.endsWith('.md')))
198
- .map((item) => ({
199
- name: item.name,
200
- path: item.path,
201
- type: (item.type === 'dir' ? 'dir' : 'file'),
202
- sha: item.sha
203
- }))
204
- .sort((a, b) => {
205
- if (a.type === 'dir' && b.type === 'file')
206
- return -1;
207
- if (a.type === 'file' && b.type === 'dir')
208
- return 1;
209
- return a.name.localeCompare(b.name);
210
- });
211
- setCacheValue(dirCache, cacheKey, items, DIR_CACHE_TTL_MS);
212
- evictStalest(dirCache, DIR_CACHE_MAX);
213
- return { data: items, fromCache: false, staleFallback: false };
136
+ return await docsClient.listDir(path);
214
137
  }
215
138
  catch (err) {
216
- const staleCached = getAnyCacheValue(dirCache, cacheKey);
217
- if (staleCached) {
218
- return { data: staleCached, fromCache: true, staleFallback: true };
219
- }
220
139
  const trans = t();
221
- const errorMessage = err instanceof Error
222
- ? (err.name === 'AbortError' ? 'Request timed out' : err.message)
140
+ const msg = err instanceof DocsFetchError
141
+ ? (err.status === 403 ? `${trans.docs.githubForbidden}\n${trans.docs.githubTokenHint}` : `HTTP ${err.status}`)
223
142
  : String(err);
224
- throw new Error(fmt(trans.docs.fetchDirFailed, { error: errorMessage }));
143
+ throw new Error(fmt(trans.docs.fetchDirFailed, { error: msg }));
225
144
  }
226
145
  }
227
- async function fetchGitHubRawContent(path, options = {}) {
228
- if (!options.forceRefresh) {
229
- const cached = getFreshCacheValue(fileCache, path);
230
- if (cached) {
231
- return { data: cached, fromCache: true, staleFallback: false };
232
- }
233
- }
234
- const url = `https://raw.githubusercontent.com/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/${GITHUB_REPO.branch}/${path}`;
146
+ async function fetchFileContent(path) {
235
147
  try {
236
- const controller = new AbortController();
237
- const timeout = setTimeout(() => controller.abort(), 15000);
238
- const response = await fetch(url, {
239
- signal: controller.signal,
240
- headers: { 'User-Agent': `NBTCA-CLI/${APP_INFO.version}` },
241
- });
242
- clearTimeout(timeout);
243
- if (!response.ok)
244
- throw new Error(`HTTP ${response.status}`);
245
- const content = await response.text();
246
- setCacheValue(fileCache, path, content, FILE_CACHE_TTL_MS);
247
- evictStalest(fileCache, FILE_CACHE_MAX);
248
- return { data: content, fromCache: false, staleFallback: false };
148
+ return await docsClient.getFile(path);
249
149
  }
250
150
  catch (err) {
251
- const staleCached = getAnyCacheValue(fileCache, path);
252
- if (staleCached) {
253
- return { data: staleCached, fromCache: true, staleFallback: true };
254
- }
255
151
  const trans = t();
256
- const errorMessage = err instanceof Error
257
- ? (err.name === 'AbortError' ? 'Request timed out' : err.message)
258
- : String(err);
259
- throw new Error(fmt(trans.docs.fetchFileFailed, { error: errorMessage }));
152
+ throw new Error(fmt(trans.docs.fetchFileFailed, { error: String(err) }));
260
153
  }
261
154
  }
262
155
  // ─── Content cleaning ─────────────────────────────────────────────────────────
@@ -401,12 +294,8 @@ async function browseDirectory(initialPath = '') {
401
294
  let items;
402
295
  try {
403
296
  const s = createSpinner(currentPath ? `${trans.docs.loadingDir}: ${currentPath}` : trans.docs.loading);
404
- const result = await fetchGitHubDirectory(currentPath);
405
- items = result.data;
297
+ items = await fetchDirectory(currentPath);
406
298
  s.stop(currentPath || trans.docs.chooseDoc);
407
- if (result.staleFallback) {
408
- warning(trans.docs.usingCachedData);
409
- }
410
299
  }
411
300
  catch (err) {
412
301
  error(trans.docs.loadError);
@@ -465,13 +354,9 @@ async function viewMarkdownFile(filePath) {
465
354
  try {
466
355
  ensureMarkedConfigured();
467
356
  const s = createSpinner(`${trans.docs.loadingFile}: ${filePath}`);
468
- const rawResult = await fetchGitHubRawContent(filePath);
469
- if (rawResult.staleFallback) {
470
- warning(trans.docs.usingCachedData);
471
- }
472
- const rawContent = rawResult.data;
357
+ const rawContent = await fetchFileContent(filePath);
473
358
  const fingerprint = contentFingerprint(rawContent);
474
- const cachedRendered = getFreshCacheValue(renderCache, filePath);
359
+ const cachedRendered = getFreshRender(filePath);
475
360
  let renderedDoc;
476
361
  if (cachedRendered && cachedRendered.fingerprint === fingerprint) {
477
362
  renderedDoc = cachedRendered;
@@ -482,8 +367,7 @@ async function viewMarkdownFile(filePath) {
482
367
  const readTime = estimateReadTime(cleaned);
483
368
  const rendered = await marked(cleaned);
484
369
  renderedDoc = { fingerprint, cleaned, rendered, title, readTime };
485
- setCacheValue(renderCache, filePath, renderedDoc, RENDER_CACHE_TTL_MS);
486
- evictStalest(renderCache, RENDER_CACHE_MAX);
370
+ setRender(filePath, renderedDoc);
487
371
  }
488
372
  s.stop(`${chalk.bold(renderedDoc.title)} ${chalk.dim(renderedDoc.readTime)}`);
489
373
  if (hasGlow()) {
@@ -560,30 +444,18 @@ async function searchDocs() {
560
444
  const results = [];
561
445
  try {
562
446
  const fetches = await Promise.allSettled(categories.map(async (cat) => {
563
- const res = await fetchGitHubDirectory(cat.path);
564
- return { items: res.data, category: cat.name };
447
+ const items = await fetchDirectory(cat.path);
448
+ return { items, category: cat.name };
565
449
  }));
566
450
  for (const result of fetches) {
567
451
  if (result.status !== 'fulfilled')
568
452
  continue;
569
453
  for (const item of result.value.items) {
570
- const nameLC = item.name.toLowerCase();
571
- if (nameLC.includes(keyword)) {
454
+ if (item.name.toLowerCase().includes(keyword)) {
572
455
  results.push({ name: item.name, path: item.path, category: result.value.category });
573
456
  }
574
457
  }
575
458
  }
576
- // Also search already-cached file content
577
- for (const [cachedPath, entry] of fileCache) {
578
- if (results.some(r => r.path === cachedPath))
579
- continue;
580
- if (entry.value.toLowerCase().includes(keyword)) {
581
- const name = cachedPath.split('/').pop() || cachedPath;
582
- const parentDir = cachedPath.split('/').slice(0, -1).join('/');
583
- const matchedCat = categories.find(c => parentDir.startsWith(c.path));
584
- results.push({ name, path: cachedPath, category: matchedCat?.name ?? parentDir });
585
- }
586
- }
587
459
  s.stop(`${results.length} ${trans.docs.searchResults}`);
588
460
  }
589
461
  catch {
@@ -0,0 +1,16 @@
1
+ ⠀⠀⠀⠀⠀⠀⠀⠀⢀⣠⣴⡶⢞⢛⣛⣛⣛⣛⣛⡳⠶⣦⣄⡀⠀⠀⠀⠀⠀⠀⠀⠀
2
+ ⠀⠀⠀⠀⠀⢀⣤⠾⡋⠁⠘⢽⣛⣿⣯⣿⣭⣯⣿⣛⡁⢴⣮⡝⠷⣤⡀⠀⠀⠀⠀⠀
3
+ ⠀⠀⠀⢀⣴⢟⣵⡿⣻⡵⠞⠋⠉⠀⠀⠀⠀⠀⠀⠉⠙⠳⢮⣟⢷⣎⡻⣦⡀⠀⠀⠀
4
+ ⠀⠀⣠⡟⣵⡦⣩⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠳⣝⢿⣎⣻⣄⠀⠀
5
+ ⠀⣰⠏⣼⣎⣾⡥⠶⠶⠶⠶⢤⣀⠀⠀⠀⠀⠀⣠⡤⠶⠶⠶⠦⣄⡈⢷⣻⣯⠹⣆⠀
6
+ ⢰⣟⣾⣿⣾⣥⠀⢀⣀⣀⣀⡀⠈⠳⡄⠀⣴⣯⣅⠀⢀⣀⣀⣀⠀⠙⢮⣷⡹⣃⢻⡆
7
+ ⣾⢱⣬⣿⣿⣿⠟⠋⠉⠀⠈⠉⠳⣄⣿⠐⣿⣿⣿⠞⠉⠉⠉⠉⠙⢦⣀⡟⣧⠿⠬⣷
8
+ ⣿⢸⡅⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⢰⣶⣿
9
+ ⣿⢨⣁⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⢸⢿⣿
10
+ ⢿⡸⣿⢿⡄⣧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣸⠃⢠⡟⠈⢉⡿
11
+ ⠸⣧⢷⣏⢷⠘⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠏⠀⡾⣭⡷⣿⠇
12
+ ⠀⠹⣎⢽⣎⢷⡘⢧⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠋⢀⡾⣵⡦⣰⠏⠀
13
+ ⠀⠀⠙⣧⡻⣷⣝⢦⡙⠲⣤⣀⠀⠀⠀⠀⠀⠀⠀⢀⣠⡴⠋⢁⡴⣫⣌⢛⣼⠋⠀⠀
14
+ ⠀⠀⠀⠈⠻⣮⡿⣡⣽⡳⢤⣍⡙⠓⠒⠶⠖⠒⠛⢉⣡⡤⣞⣫⣦⢙⣵⠟⠁⠀⠀⠀
15
+ ⠀⠀⠀⠀⠀⠈⠛⢷⣍⡁⠷⣞⣭⣟⣟⣟⣛⢛⣿⣭⣵⠆⢛⣩⡷⠛⠁⠀⠀⠀⠀⠀
16
+ ⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⠶⢯⣭⣭⣭⣭⣭⣭⡽⠶⠟⠋⠁⠀⠀⠀⠀⠀⠀⠀⠀
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nbtca/prompt",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "exports": {
@@ -39,6 +39,7 @@
39
39
  ],
40
40
  "dependencies": {
41
41
  "@clack/prompts": "^1.2.0",
42
+ "@nbtca/docs": "^0.1.0",
42
43
  "@nbtca/nbtcal": "^0.2.1",
43
44
  "chalk": "^5.6.2",
44
45
  "gradient-string": "^3.0.0",
@@ -1,12 +0,0 @@
1
- ⠀⠀⠀⠀⠀⠀⣀⠤⡐⣒⣈⡉⡉⠁⠒⠂⠤⣀⠀⠀⠀⠀⠀⠀
2
- ⠀⠀⠀⢀⠔⣋⠄⣀⠤⠜⠒⠒⠒⠒⠣⠤⣁⢤⢙⠢⡀⠀⠀⠀
3
- ⠀⠀⡴⣡⠈⡥⠊⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠑⢬⡠⡈⢦⠀⠀
4
- ⠀⡜⡀⢪⠮⠐⠒⠂⠠⢀⠀⠀⠀⡀⠄⠐⠒⠀⠄⡑⡔⢀⢣⠀
5
- ⠸⠀⢳⣷⣆⠠⠀⠀⠠⡀⠱⠀⣾⣶⣀⠤⠀⠠⢄⠈⠞⡈⡀⠇
6
- ⡇⠄⠸⠉⠁⠀⠀⠀⠀⠈⠈⠀⠉⠉⠀⠀⠀⠀⠀⠁⠀⠇⣠⢸
7
- ⡇⠍⢠⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡄⢋⢸
8
- ⢱⠀⠘⡄⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠇⢠⠃⠀⡎
9
- ⠀⢆⢺⠐⡌⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⠎⢠⢢⡁⡰⠀
10
- ⠀⠈⠣⡂⢪⠢⡁⠢⢀⡀⠀⠀⠀⠀⣀⠠⠒⢁⠔⠅⢈⠜⠁⠀
11
- ⠀⠀⠀⠙⠢⡀⠴⠑⢂⠤⠉⢉⡉⠁⠤⠐⠊⠤⢅⠔⠋⠀⠀⠀
12
- ⠀⠀⠀⠀⠀⠈⠑⠢⠤⣐⣘⣒⣁⣒⣐⠤⠔⠊⠁⠀⠀⠀⠀⠀