@ali.mazaheri/nanocat 0.0.0-stage → 0.3.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 +50 -2
- package/dist/index.d.ts +13 -0
- package/dist/index.js +138 -0
- package/package.json +12 -4
- package/src/index.ts +150 -0
- package/tests/nanocat.test.mjs +22 -0
- package/tsconfig.json +12 -0
package/README.md
CHANGED
|
@@ -1,3 +1,51 @@
|
|
|
1
|
-
#
|
|
1
|
+
# nanocat
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Syntax-highlight files or piped `stdin` directly in your terminal using GNU nano's `.nanorc` syntax definitions—so your CLI output matches the exact colours you see inside `nano`.
|
|
4
|
+
|
|
5
|
+
Zero external dependencies (Python 3 standard library only).
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Evaluates `.nanorc` `color` and `icolor` rules (including multi-line `start=... end=...` blocks) in nano's exact top-to-bottom precedence order
|
|
10
|
+
- Reads `~/.nanorc` `include` directives and `numbercolor` / `titlecolor` theme settings (plus `--theme` overrides)
|
|
11
|
+
- Line numbers (`-n` / `--number`), Git diff gutter markers (`-g` / `--git`), and line-range slicing (`-r 10:40`)
|
|
12
|
+
- Soft-wrapping (`-w` / `--wrap`), multi-file headers, and auto-paging through `less -R` (`--paging=auto|always|never`)
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
### Quick Install (One-Liner)
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
curl -fsSL https://githubusercontent.com | bash
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### Via pipx or uv
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pipx install git+https://github.com
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Manual Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://github.com
|
|
32
|
+
mkdir -p ~/.local/bin
|
|
33
|
+
ln -sf "\$(pwd)/nanocat/nanocat" ~/.local/bin/nanocat
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## Usage
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Highlight one or more files directly (auto-detects extension or shebang)
|
|
40
|
+
nanocat path/to/file.tsx
|
|
41
|
+
nanocat -n -g src/App.tsx src/index.tsx
|
|
42
|
+
|
|
43
|
+
# Show line numbers, git diff markers, and slice lines 10 to 40
|
|
44
|
+
nanocat -n -g -r 10:40 path/to/file.go
|
|
45
|
+
|
|
46
|
+
# Highlight piped stdin using a specific language
|
|
47
|
+
grep -n "func " main.go | nanocat -l go
|
|
48
|
+
|
|
49
|
+
# List all detected .nanorc syntax definitions
|
|
50
|
+
nanocat --list
|
|
51
|
+
```
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface NanorcRule {
|
|
2
|
+
type: "single" | "block";
|
|
3
|
+
color: string;
|
|
4
|
+
start: RegExp;
|
|
5
|
+
end?: RegExp;
|
|
6
|
+
}
|
|
7
|
+
export interface NanorcSyntax {
|
|
8
|
+
name: string;
|
|
9
|
+
extensions: RegExp[];
|
|
10
|
+
rules: NanorcRule[];
|
|
11
|
+
}
|
|
12
|
+
export declare function parseNanorc(content: string): NanorcSyntax[];
|
|
13
|
+
export declare function highlight(code: string, syntax: NanorcSyntax, format?: "ansi" | "html"): string;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
const ANSI_FG = {
|
|
2
|
+
black: "30", red: "31", green: "32", yellow: "33",
|
|
3
|
+
blue: "34", magenta: "35", cyan: "36", white: "37",
|
|
4
|
+
brightblack: "90", brightred: "91", brightgreen: "92",
|
|
5
|
+
brightyellow: "93", brightblue: "94", brightmagenta: "95",
|
|
6
|
+
brightcyan: "96", brightwhite: "97", orange: "38;5;208",
|
|
7
|
+
};
|
|
8
|
+
function posixToJsRegex(pat) {
|
|
9
|
+
return pat
|
|
10
|
+
.replace(/\\</g, "\\b")
|
|
11
|
+
.replace(/\\>/g, "\\b")
|
|
12
|
+
.replace(/\[\[:space:\]\]|\[:space:\]/g, "\\s")
|
|
13
|
+
.replace(/\[\[:digit:\]\]|\[:digit:\]/g, "[0-9]")
|
|
14
|
+
.replace(/\[\[:alpha:\]\]|\[:alpha:\]/g, "[A-Za-z]")
|
|
15
|
+
.replace(/\[\[:alnum:\]\]|\[:alnum:\]/g, "[A-Za-z0-9]");
|
|
16
|
+
}
|
|
17
|
+
function colorToAnsi(spec) {
|
|
18
|
+
const parts = spec.toLowerCase().split(",").map((s) => s.trim());
|
|
19
|
+
const codes = [];
|
|
20
|
+
for (const p of parts) {
|
|
21
|
+
if (p === "bold")
|
|
22
|
+
codes.push("1");
|
|
23
|
+
else if (ANSI_FG[p])
|
|
24
|
+
codes.push(ANSI_FG[p]);
|
|
25
|
+
}
|
|
26
|
+
return codes.length ? `\x1b[${codes.join(";")}m` : "";
|
|
27
|
+
}
|
|
28
|
+
function extractQuoted(line) {
|
|
29
|
+
const out = [];
|
|
30
|
+
const re = /"((?:\\"|[^"])*)"/g;
|
|
31
|
+
let m;
|
|
32
|
+
while ((m = re.exec(line)) !== null) {
|
|
33
|
+
out.push(m[1].replace(/\\"/g, '"'));
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
export function parseNanorc(content) {
|
|
38
|
+
const syntaxes = [];
|
|
39
|
+
let current = null;
|
|
40
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
41
|
+
const line = raw.trim();
|
|
42
|
+
if (!line || line.startsWith("#"))
|
|
43
|
+
continue;
|
|
44
|
+
if (line.startsWith("syntax ")) {
|
|
45
|
+
const quoted = extractQuoted(line.slice(7));
|
|
46
|
+
current = {
|
|
47
|
+
name: quoted[0] ?? "unknown",
|
|
48
|
+
extensions: quoted.slice(1).map((p) => new RegExp(posixToJsRegex(p))),
|
|
49
|
+
rules: [],
|
|
50
|
+
};
|
|
51
|
+
syntaxes.push(current);
|
|
52
|
+
}
|
|
53
|
+
else if (current && (line.startsWith("color ") || line.startsWith("icolor "))) {
|
|
54
|
+
const icase = line.startsWith("icolor ");
|
|
55
|
+
const rest = line.slice(icase ? 7 : 6).trim();
|
|
56
|
+
const spaceIdx = rest.search(/\s/);
|
|
57
|
+
if (spaceIdx === -1)
|
|
58
|
+
continue;
|
|
59
|
+
const colorSpec = rest.slice(0, spaceIdx);
|
|
60
|
+
const body = rest.slice(spaceIdx).trim();
|
|
61
|
+
const flags = icase ? "gm" : "gm";
|
|
62
|
+
const quoted = extractQuoted(body);
|
|
63
|
+
if (body.startsWith("start=") && quoted.length >= 2) {
|
|
64
|
+
current.rules.push({
|
|
65
|
+
type: "block",
|
|
66
|
+
color: colorSpec,
|
|
67
|
+
start: new RegExp(posixToJsRegex(quoted[0]), icase ? "gim" : "gm"),
|
|
68
|
+
end: new RegExp(posixToJsRegex(quoted[1]), icase ? "gim" : "gm"),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
for (const pat of quoted) {
|
|
73
|
+
current.rules.push({
|
|
74
|
+
type: "single",
|
|
75
|
+
color: colorSpec,
|
|
76
|
+
start: new RegExp(posixToJsRegex(pat), icase ? "gim" : "gm"),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return syntaxes;
|
|
83
|
+
}
|
|
84
|
+
export function highlight(code, syntax, format = "ansi") {
|
|
85
|
+
const n = code.length;
|
|
86
|
+
const styles = new Array(n).fill("");
|
|
87
|
+
for (const rule of syntax.rules) {
|
|
88
|
+
if (rule.type === "single") {
|
|
89
|
+
rule.start.lastIndex = 0;
|
|
90
|
+
let m;
|
|
91
|
+
while ((m = rule.start.exec(code)) !== null) {
|
|
92
|
+
if (m[0].length === 0) {
|
|
93
|
+
rule.start.lastIndex++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
for (let i = m.index; i < m.index + m[0].length; i++)
|
|
97
|
+
styles[i] = rule.color;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
else if (rule.type === "block" && rule.end) {
|
|
101
|
+
let pos = 0;
|
|
102
|
+
while (pos < n) {
|
|
103
|
+
rule.start.lastIndex = pos;
|
|
104
|
+
const ms = rule.start.exec(code);
|
|
105
|
+
if (!ms)
|
|
106
|
+
break;
|
|
107
|
+
rule.end.lastIndex = ms.index + ms[0].length;
|
|
108
|
+
const me = rule.end.exec(code);
|
|
109
|
+
const endPos = me ? me.index + me[0].length : n;
|
|
110
|
+
for (let i = ms.index; i < endPos; i++)
|
|
111
|
+
styles[i] = rule.color;
|
|
112
|
+
pos = Math.max(endPos, ms.index + 1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
let out = "";
|
|
117
|
+
let cur = "";
|
|
118
|
+
for (let i = 0; i < n; i++) {
|
|
119
|
+
const st = styles[i];
|
|
120
|
+
if (st !== cur) {
|
|
121
|
+
if (cur)
|
|
122
|
+
out += format === "ansi" ? "\x1b[0m" : "</span>";
|
|
123
|
+
if (st) {
|
|
124
|
+
out += format === "ansi"
|
|
125
|
+
? colorToAnsi(st)
|
|
126
|
+
: `<span class="nc-${st.replace(/[^a-z0-9]/gi, "-")}">`;
|
|
127
|
+
}
|
|
128
|
+
cur = st;
|
|
129
|
+
}
|
|
130
|
+
const ch = code[i];
|
|
131
|
+
out += format === "html"
|
|
132
|
+
? ch.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
133
|
+
: ch;
|
|
134
|
+
}
|
|
135
|
+
if (cur)
|
|
136
|
+
out += format === "ansi" ? "\x1b[0m" : "</span>";
|
|
137
|
+
return out;
|
|
138
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ali.mazaheri/nanocat",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Zero-dependency .nanorc syntax highlighter for Node.js CI and browser HTML output.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"build": "tsc",
|
|
10
|
+
"test": "node --test tests/nanocat.test.mjs"
|
|
11
|
+
},
|
|
12
|
+
"keywords": ["nano", "nanorc", "syntax-highlighting", "ansi", "ci", "typescript"],
|
|
13
|
+
"license": "MIT"
|
|
14
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
export interface NanorcRule {
|
|
2
|
+
type: "single" | "block";
|
|
3
|
+
color: string;
|
|
4
|
+
start: RegExp;
|
|
5
|
+
end?: RegExp;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface NanorcSyntax {
|
|
9
|
+
name: string;
|
|
10
|
+
extensions: RegExp[];
|
|
11
|
+
rules: NanorcRule[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ANSI_FG: Record<string, string> = {
|
|
15
|
+
black: "30", red: "31", green: "32", yellow: "33",
|
|
16
|
+
blue: "34", magenta: "35", cyan: "36", white: "37",
|
|
17
|
+
brightblack: "90", brightred: "91", brightgreen: "92",
|
|
18
|
+
brightyellow: "93", brightblue: "94", brightmagenta: "95",
|
|
19
|
+
brightcyan: "96", brightwhite: "97", orange: "38;5;208",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function posixToJsRegex(pat: string): string {
|
|
23
|
+
return pat
|
|
24
|
+
.replace(/\\</g, "\\b")
|
|
25
|
+
.replace(/\\>/g, "\\b")
|
|
26
|
+
.replace(/\[\[:space:\]\]|\[:space:\]/g, "\\s")
|
|
27
|
+
.replace(/\[\[:digit:\]\]|\[:digit:\]/g, "[0-9]")
|
|
28
|
+
.replace(/\[\[:alpha:\]\]|\[:alpha:\]/g, "[A-Za-z]")
|
|
29
|
+
.replace(/\[\[:alnum:\]\]|\[:alnum:\]/g, "[A-Za-z0-9]");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function colorToAnsi(spec: string): string {
|
|
33
|
+
const parts = spec.toLowerCase().split(",").map((s) => s.trim());
|
|
34
|
+
const codes: string[] = [];
|
|
35
|
+
for (const p of parts) {
|
|
36
|
+
if (p === "bold") codes.push("1");
|
|
37
|
+
else if (ANSI_FG[p]) codes.push(ANSI_FG[p]);
|
|
38
|
+
}
|
|
39
|
+
return codes.length ? `\x1b[${codes.join(";")}m` : "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractQuoted(line: string): string[] {
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
const re = /"((?:\\"|[^"])*)"/g;
|
|
45
|
+
let m: RegExpExecArray | null;
|
|
46
|
+
while ((m = re.exec(line)) !== null) {
|
|
47
|
+
out.push(m[1].replace(/\\"/g, '"'));
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseNanorc(content: string): NanorcSyntax[] {
|
|
53
|
+
const syntaxes: NanorcSyntax[] = [];
|
|
54
|
+
let current: NanorcSyntax | null = null;
|
|
55
|
+
|
|
56
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
57
|
+
const line = raw.trim();
|
|
58
|
+
if (!line || line.startsWith("#")) continue;
|
|
59
|
+
|
|
60
|
+
if (line.startsWith("syntax ")) {
|
|
61
|
+
const quoted = extractQuoted(line.slice(7));
|
|
62
|
+
current = {
|
|
63
|
+
name: quoted[0] ?? "unknown",
|
|
64
|
+
extensions: quoted.slice(1).map((p) => new RegExp(posixToJsRegex(p))),
|
|
65
|
+
rules: [],
|
|
66
|
+
};
|
|
67
|
+
syntaxes.push(current);
|
|
68
|
+
} else if (current && (line.startsWith("color ") || line.startsWith("icolor "))) {
|
|
69
|
+
const icase = line.startsWith("icolor ");
|
|
70
|
+
const rest = line.slice(icase ? 7 : 6).trim();
|
|
71
|
+
const spaceIdx = rest.search(/\s/);
|
|
72
|
+
if (spaceIdx === -1) continue;
|
|
73
|
+
const colorSpec = rest.slice(0, spaceIdx);
|
|
74
|
+
const body = rest.slice(spaceIdx).trim();
|
|
75
|
+
const flags = icase ? "gm" : "gm";
|
|
76
|
+
const quoted = extractQuoted(body);
|
|
77
|
+
|
|
78
|
+
if (body.startsWith("start=") && quoted.length >= 2) {
|
|
79
|
+
current.rules.push({
|
|
80
|
+
type: "block",
|
|
81
|
+
color: colorSpec,
|
|
82
|
+
start: new RegExp(posixToJsRegex(quoted[0]), icase ? "gim" : "gm"),
|
|
83
|
+
end: new RegExp(posixToJsRegex(quoted[1]), icase ? "gim" : "gm"),
|
|
84
|
+
});
|
|
85
|
+
} else {
|
|
86
|
+
for (const pat of quoted) {
|
|
87
|
+
current.rules.push({
|
|
88
|
+
type: "single",
|
|
89
|
+
color: colorSpec,
|
|
90
|
+
start: new RegExp(posixToJsRegex(pat), icase ? "gim" : "gm"),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return syntaxes;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function highlight(
|
|
100
|
+
code: string,
|
|
101
|
+
syntax: NanorcSyntax,
|
|
102
|
+
format: "ansi" | "html" = "ansi"
|
|
103
|
+
): string {
|
|
104
|
+
const n = code.length;
|
|
105
|
+
const styles = new Array<string>(n).fill("");
|
|
106
|
+
|
|
107
|
+
for (const rule of syntax.rules) {
|
|
108
|
+
if (rule.type === "single") {
|
|
109
|
+
rule.start.lastIndex = 0;
|
|
110
|
+
let m: RegExpExecArray | null;
|
|
111
|
+
while ((m = rule.start.exec(code)) !== null) {
|
|
112
|
+
if (m[0].length === 0) { rule.start.lastIndex++; continue; }
|
|
113
|
+
for (let i = m.index; i < m.index + m[0].length; i++) styles[i] = rule.color;
|
|
114
|
+
}
|
|
115
|
+
} else if (rule.type === "block" && rule.end) {
|
|
116
|
+
let pos = 0;
|
|
117
|
+
while (pos < n) {
|
|
118
|
+
rule.start.lastIndex = pos;
|
|
119
|
+
const ms = rule.start.exec(code);
|
|
120
|
+
if (!ms) break;
|
|
121
|
+
rule.end.lastIndex = ms.index + ms[0].length;
|
|
122
|
+
const me = rule.end.exec(code);
|
|
123
|
+
const endPos = me ? me.index + me[0].length : n;
|
|
124
|
+
for (let i = ms.index; i < endPos; i++) styles[i] = rule.color;
|
|
125
|
+
pos = Math.max(endPos, ms.index + 1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let out = "";
|
|
131
|
+
let cur = "";
|
|
132
|
+
for (let i = 0; i < n; i++) {
|
|
133
|
+
const st = styles[i];
|
|
134
|
+
if (st !== cur) {
|
|
135
|
+
if (cur) out += format === "ansi" ? "\x1b[0m" : "</span>";
|
|
136
|
+
if (st) {
|
|
137
|
+
out += format === "ansi"
|
|
138
|
+
? colorToAnsi(st)
|
|
139
|
+
: `<span class="nc-${st.replace(/[^a-z0-9]/gi, "-")}">`;
|
|
140
|
+
}
|
|
141
|
+
cur = st;
|
|
142
|
+
}
|
|
143
|
+
const ch = code[i];
|
|
144
|
+
out += format === "html"
|
|
145
|
+
? ch.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
146
|
+
: ch;
|
|
147
|
+
}
|
|
148
|
+
if (cur) out += format === "ansi" ? "\x1b[0m" : "</span>";
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { parseNanorc, highlight } from "../dist/index.js";
|
|
4
|
+
|
|
5
|
+
test("parseNanorc and highlight ANSI and HTML", () => {
|
|
6
|
+
const rc = `
|
|
7
|
+
syntax "demo" "\\.demo$"
|
|
8
|
+
color green "\\<(func|return)\\>"
|
|
9
|
+
color brightblue start="/\\*" end="\\*/"
|
|
10
|
+
`;
|
|
11
|
+
const syntaxes = parseNanorc(rc);
|
|
12
|
+
assert.equal(syntaxes.length, 1);
|
|
13
|
+
|
|
14
|
+
const code = "func main() { /* return */ return }";
|
|
15
|
+
const ansi = highlight(code, syntaxes[0], "ansi");
|
|
16
|
+
const html = highlight(code, syntaxes[0], "html");
|
|
17
|
+
|
|
18
|
+
assert.match(ansi, /\x1b\[32mfunc\x1b\[0m/);
|
|
19
|
+
assert.match(ansi, /\x1b\[94m\/\* return \*\/\x1b\[0m/);
|
|
20
|
+
assert.match(html, /<span class="nc-green">func<\/span>/);
|
|
21
|
+
assert.match(html, /<span class="nc-brightblue">\/\* return \*\/<\/span>/);
|
|
22
|
+
});
|