syntaxshot-cli 1.0.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/.syntaxshot-usage.json +3 -0
- package/.syntaxshotrc.json +11 -0
- package/README.md +102 -0
- package/bin/syntaxshot.js +299 -0
- package/examples/StorageModule.js +23 -0
- package/examples/api.ts +11 -0
- package/examples/style.css +14 -0
- package/fonts/JetBrainsMono-Bold.ttf +0 -0
- package/fonts/JetBrainsMono-Italic.ttf +0 -0
- package/fonts/JetBrainsMono-Regular.ttf +0 -0
- package/package.json +18 -0
- package/src/config.js +39 -0
- package/src/highlighter.js +52 -0
- package/src/license.js +95 -0
- package/src/renderer.js +152 -0
- package/src/scanner.js +63 -0
- package/src/svgRenderer.js +127 -0
- package/src/themes.js +49 -0
- package/src/usage.js +44 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# syntaxshot-cli
|
|
2
|
+
|
|
3
|
+
Generate real syntax-highlighted code screenshots from the terminal — no browser involved, using `shiki` (highlighting) + `@napi-rs/canvas` (rendering).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
cd syntaxshot
|
|
9
|
+
npm install
|
|
10
|
+
npm link # this enables the global `syntaxshot` command
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
That's it. `@napi-rs/canvas` ships prebuilt binaries for Windows, macOS, and Linux, so **you don't need to install Cairo, GTK, Visual Studio Build Tools, or any native dependency** (unlike the classic `canvas` package, which is a lot of pain on Windows).
|
|
14
|
+
|
|
15
|
+
## Basic usage (Free plan)
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
syntaxshot init # creates .syntaxshotrc.json with your defaults
|
|
19
|
+
syntaxshot path/to/file.js # generates syntaxshots/file.png
|
|
20
|
+
syntaxshot a.js b.ts c.css --theme nord # multiple files, one theme
|
|
21
|
+
syntaxshot a.py --format jpg -o outputs # jpg, custom folder
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The Free plan **always requires you to name each file**. The Pro plan can do all of the above, plus:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
syntaxshot ./src # [Pro plan] scans the whole folder
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
When you pass it a folder, syntaxshot:
|
|
31
|
+
1. Walks it recursively.
|
|
32
|
+
2. Filters "capturable" files (recognized code extension) and automatically ignores what doesn't make sense to show: `node_modules`, `.git`, `dist`/`build`, lockfiles (`package-lock.json`, `yarn.lock`...), `package.json`, `tsconfig.json`, any `.env*`, and dotfiles in general.
|
|
33
|
+
3. Tells you how many files it found and how many images it's about to generate, then **asks for confirmation** before generating anything.
|
|
34
|
+
4. Once confirmed, generates everything automatically, keeping the subfolder structure inside the output folder (so files with the same name in different folders don't overwrite each other).
|
|
35
|
+
|
|
36
|
+
Pass `-y` / `--yes` to skip the confirmation prompt (handy for scripts/CI).
|
|
37
|
+
|
|
38
|
+
If you're on the Free plan and try to pass a folder, the CLI blocks it with a clear message asking you to list files one by one — it doesn't disable normal usage, it just redirects you to the manual flow.
|
|
39
|
+
|
|
40
|
+
You can customize what the scan ignores in `.syntaxshotrc.json`:
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"excludeDirs": ["scripts", "tmp"],
|
|
44
|
+
"excludeFiles": ["seed-data.json"]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The Free plan allows **10 images**; the counter lives in `.syntaxshot-usage.json` in the folder where you run the command.
|
|
49
|
+
|
|
50
|
+
## Activating a Pro license
|
|
51
|
+
|
|
52
|
+
Once you've paid (see the `syntaxshot-api` project), you'll get a license key by email. Activate it once per machine:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
syntaxshot login SYNX-XXXX-XXXX-XXXX
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
This validates the key against the license API and caches the result in `~/.syntaxshot/license.json` (re-checked automatically every 24h; if the API is briefly unreachable, a previously-valid license keeps working for up to 7 days so you're never blocked by a network hiccup).
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
syntaxshot logout # removes the license from this machine
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
By default the CLI validates against `https://api.syntaxshot.dev` — point it at your own deployment (or `http://localhost:3000` while developing) with:
|
|
65
|
+
```bash
|
|
66
|
+
export SYNTAXSHOT_API_URL=https://your-deployment.vercel.app
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Configuration (`.syntaxshotrc.json`)
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"theme": "midnight",
|
|
74
|
+
"format": "png",
|
|
75
|
+
"output": "syntaxshots",
|
|
76
|
+
"fontSize": 16,
|
|
77
|
+
"lineNumbers": true,
|
|
78
|
+
"quality": 0.95,
|
|
79
|
+
"plan": "free"
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Set `"plan": "pro"` here only as a **local dev override** (e.g. while working on the CLI itself) — it skips license validation entirely. For real usage, activate a license with `syntaxshot login <key>` instead; see below.
|
|
84
|
+
|
|
85
|
+
## Available themes
|
|
86
|
+
|
|
87
|
+
- `midnight` — Dracula-style palette, dark violet window (the one in the reference image)
|
|
88
|
+
- `nord` — Nord palette, blue-gray window
|
|
89
|
+
- `solar` — Solarized Light, light window
|
|
90
|
+
|
|
91
|
+
Quick list: `syntaxshot themes`
|
|
92
|
+
|
|
93
|
+
## Key files if you want to extend this
|
|
94
|
+
|
|
95
|
+
- `src/themes.js` — add new themes here (syntax palette + window colors)
|
|
96
|
+
- `src/highlighter.js` — extension → language map for shiki
|
|
97
|
+
- `src/scanner.js` — folder-scan logic and exclusion rules (Pro)
|
|
98
|
+
- `src/renderer.js` — all the canvas drawing (background, window, text)
|
|
99
|
+
- `src/license.js` — license activation/validation + local caching
|
|
100
|
+
- `bin/syntaxshot.js` — CLI, plan limits, `--auto`, confirmation prompt, `login`/`logout`
|
|
101
|
+
|
|
102
|
+
The license backend itself (Stripe webhook, `/api/v1/validate`, license key generation) lives in the separate `syntaxshot-api` project.
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline/promises";
|
|
6
|
+
import { execSync } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
import { loadConfig, writeDefaultConfig } from "../src/config.js";
|
|
9
|
+
import { resolveTheme, THEMES } from "../src/themes.js";
|
|
10
|
+
import { detectLang, tokenizeCode } from "../src/highlighter.js";
|
|
11
|
+
import { renderSnippet } from "../src/renderer.js";
|
|
12
|
+
import { renderSnippetSVG } from "../src/svgRenderer.js";
|
|
13
|
+
import { checkAndConsume, FREE_LIMIT } from "../src/usage.js";
|
|
14
|
+
import { findEligibleFiles } from "../src/scanner.js";
|
|
15
|
+
import { activateLicense, logoutLicense, resolvePlan } from "../src/license.js";
|
|
16
|
+
|
|
17
|
+
const program = new Command();
|
|
18
|
+
|
|
19
|
+
program
|
|
20
|
+
.name("syntaxshot")
|
|
21
|
+
.description(
|
|
22
|
+
"Generate syntax-highlighted code screenshots from your terminal.",
|
|
23
|
+
)
|
|
24
|
+
.version("1.0.0");
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.command("login <licenseKey>")
|
|
28
|
+
.description("Activate your Pro license on this machine")
|
|
29
|
+
.action(async (licenseKey) => {
|
|
30
|
+
const result = await activateLicense(licenseKey.trim());
|
|
31
|
+
if (!result.ok) {
|
|
32
|
+
console.error(`❌ ${result.error}`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
console.log(`✅ License activated. Plan: ${result.plan}.`);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
program
|
|
39
|
+
.command("logout")
|
|
40
|
+
.description("Remove the locally stored license")
|
|
41
|
+
.action(() => {
|
|
42
|
+
logoutLicense();
|
|
43
|
+
console.log("✅ License removed from this machine.");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command("init")
|
|
48
|
+
.description(
|
|
49
|
+
"Create a .syntaxshotrc.json file with the default configuration",
|
|
50
|
+
)
|
|
51
|
+
.action(() => writeDefaultConfig());
|
|
52
|
+
|
|
53
|
+
program
|
|
54
|
+
.command("themes")
|
|
55
|
+
.description("List the available themes")
|
|
56
|
+
.action(() => {
|
|
57
|
+
console.log("Available themes:");
|
|
58
|
+
Object.entries(THEMES).forEach(([key, t]) =>
|
|
59
|
+
console.log(` - ${key} (${t.label})`),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
program
|
|
64
|
+
.argument(
|
|
65
|
+
"[paths...]",
|
|
66
|
+
"File(s) to capture (Free and Pro), or a folder to auto-scan (Pro only)",
|
|
67
|
+
)
|
|
68
|
+
.option("-t, --theme <name>", "Visual theme (overrides config)")
|
|
69
|
+
.option("-f, --format <ext>", "png | jpg (overrides config)")
|
|
70
|
+
.option("-o, --output <dir>", "Output folder (overrides config)")
|
|
71
|
+
.option("--no-line-numbers", "Hide line numbers")
|
|
72
|
+
.option("-y, --yes", "Skip the confirmation prompt when scanning a folder")
|
|
73
|
+
.option("--auto", "[Pro plan] auto-detect files changed via git")
|
|
74
|
+
.action(async (inputPaths, cmdOpts) => {
|
|
75
|
+
const cwd = process.cwd();
|
|
76
|
+
const config = loadConfig(cwd);
|
|
77
|
+
const theme = resolveTheme(cmdOpts.theme || config.theme);
|
|
78
|
+
const format = (cmdOpts.format || config.format || "png").toLowerCase();
|
|
79
|
+
const outputDir = cmdOpts.output || config.output || "syntaxshots";
|
|
80
|
+
const lineNumbers = cmdOpts.lineNumbers ?? config.lineNumbers;
|
|
81
|
+
const plan = await resolvePlan(config.plan);
|
|
82
|
+
|
|
83
|
+
const allowedFormats =
|
|
84
|
+
plan === "pro" ? ["png", "jpg", "jpeg", "svg"] : ["png", "jpg", "jpeg"];
|
|
85
|
+
if (!allowedFormats.includes(format)) {
|
|
86
|
+
console.error(
|
|
87
|
+
`❌ The "${format}" format is a Pro plan feature. The Free plan supports png or jpg.\n` +
|
|
88
|
+
` Use --format png or --format jpg, or upgrade to Pro for svg.`,
|
|
89
|
+
);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let targets = [];
|
|
94
|
+
|
|
95
|
+
if (cmdOpts.auto) {
|
|
96
|
+
if (plan !== "pro") {
|
|
97
|
+
console.error(
|
|
98
|
+
"❌ --auto is a Pro plan feature. On the Free plan, list file(s) manually:\n syntaxshot path/to/file.js",
|
|
99
|
+
);
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
const changed = getChangedFilesFromGit();
|
|
103
|
+
if (changed.length === 0) {
|
|
104
|
+
console.log("No modified files detected via git. Nothing to capture.");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
console.log(
|
|
108
|
+
`🔎 Auto-detected ${changed.length} file(s) changed via git.`,
|
|
109
|
+
);
|
|
110
|
+
targets = changed.map((f) => ({ file: path.resolve(f), scanRoot: null }));
|
|
111
|
+
} else {
|
|
112
|
+
const explicitFiles = [];
|
|
113
|
+
const directories = [];
|
|
114
|
+
|
|
115
|
+
for (const p of inputPaths) {
|
|
116
|
+
if (!fs.existsSync(p)) {
|
|
117
|
+
explicitFiles.push(p);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (fs.statSync(p).isDirectory()) {
|
|
121
|
+
directories.push(path.resolve(p));
|
|
122
|
+
} else {
|
|
123
|
+
explicitFiles.push(p);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (directories.length > 0 && plan !== "pro") {
|
|
128
|
+
console.error(
|
|
129
|
+
"❌ Scanning a whole folder is a Pro plan feature.\n" +
|
|
130
|
+
" On the Free plan, list files one by one:\n" +
|
|
131
|
+
" syntaxshot file1.js file2.css file3.ts",
|
|
132
|
+
);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
targets = explicitFiles.map((f) => ({
|
|
137
|
+
file: path.resolve(f),
|
|
138
|
+
scanRoot: null,
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
for (const dir of directories) {
|
|
142
|
+
const found = findEligibleFiles(dir, {
|
|
143
|
+
excludeDirs: config.excludeDirs,
|
|
144
|
+
excludeFiles: config.excludeFiles,
|
|
145
|
+
});
|
|
146
|
+
targets.push(...found.map((f) => ({ file: f, scanRoot: dir })));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (directories.length > 0) {
|
|
150
|
+
const scannedCount = targets.filter((t) => t.scanRoot).length;
|
|
151
|
+
|
|
152
|
+
if (scannedCount === 0) {
|
|
153
|
+
console.log(
|
|
154
|
+
`📁 Scanned: ${directories.map((d) => path.relative(cwd, d) || ".").join(", ")}\n` +
|
|
155
|
+
"No eligible files found (everything was excluded or unsupported).",
|
|
156
|
+
);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(
|
|
161
|
+
`📁 Folder(s) scanned: ${directories.map((d) => path.relative(cwd, d) || ".").join(", ")}`,
|
|
162
|
+
);
|
|
163
|
+
console.log(
|
|
164
|
+
`📄 Found ${scannedCount} eligible file(s). This will generate ${scannedCount} image(s):`,
|
|
165
|
+
);
|
|
166
|
+
targets
|
|
167
|
+
.filter((t) => t.scanRoot)
|
|
168
|
+
.slice(0, 25)
|
|
169
|
+
.forEach((t) => console.log(` - ${path.relative(cwd, t.file)}`));
|
|
170
|
+
if (scannedCount > 25)
|
|
171
|
+
console.log(` ...and ${scannedCount - 25} more`);
|
|
172
|
+
console.log("");
|
|
173
|
+
|
|
174
|
+
if (!cmdOpts.yes) {
|
|
175
|
+
const proceed = await askConfirmation(
|
|
176
|
+
`Continue and generate ${scannedCount} image(s)? (y/N) `,
|
|
177
|
+
);
|
|
178
|
+
if (!proceed) {
|
|
179
|
+
console.log("Cancelled. No images were generated.");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (targets.length === 0) {
|
|
187
|
+
console.error(
|
|
188
|
+
"❌ You must provide at least one file or one folder.\n" +
|
|
189
|
+
" Usage: syntaxshot path/to/file.js [another/file.ts ...]\n" +
|
|
190
|
+
" Pro plan: syntaxshot path/to/folder (scans and generates everything automatically)",
|
|
191
|
+
);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const check = checkAndConsume(plan, targets.length, cwd);
|
|
196
|
+
if (!check.allowed) {
|
|
197
|
+
console.error(
|
|
198
|
+
`❌ Free plan limit reached (${FREE_LIMIT} images). You have ${Math.max(
|
|
199
|
+
check.remaining,
|
|
200
|
+
0,
|
|
201
|
+
)} left. Upgrade to Pro for unlimited generation.`,
|
|
202
|
+
);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const resolvedOutputDir = path.resolve(cwd, outputDir);
|
|
207
|
+
fs.mkdirSync(resolvedOutputDir, { recursive: true });
|
|
208
|
+
|
|
209
|
+
for (const target of targets) {
|
|
210
|
+
await processFile(target.file, {
|
|
211
|
+
theme,
|
|
212
|
+
format,
|
|
213
|
+
outputDir: resolvedOutputDir,
|
|
214
|
+
lineNumbers,
|
|
215
|
+
fontSize: config.fontSize,
|
|
216
|
+
quality: config.quality,
|
|
217
|
+
scanRoot: target.scanRoot,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (plan === "free") {
|
|
222
|
+
console.log(
|
|
223
|
+
`ℹ️ Free plan: ${check.remaining} capture(s) left this month.`,
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
async function askConfirmation(question) {
|
|
229
|
+
const rl = readline.createInterface({
|
|
230
|
+
input: process.stdin,
|
|
231
|
+
output: process.stdout,
|
|
232
|
+
});
|
|
233
|
+
const answer = await rl.question(question);
|
|
234
|
+
rl.close();
|
|
235
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function processFile(absPath, opts) {
|
|
239
|
+
if (!fs.existsSync(absPath)) {
|
|
240
|
+
console.error(`⚠️ Not found, skipping: ${absPath}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const code = fs.readFileSync(absPath, "utf-8");
|
|
245
|
+
const lang = detectLang(absPath);
|
|
246
|
+
const lines = await tokenizeCode(code, lang, opts.theme.shikiTheme);
|
|
247
|
+
|
|
248
|
+
let buffer;
|
|
249
|
+
if (opts.format === "svg") {
|
|
250
|
+
const svg = renderSnippetSVG(lines, opts.theme, {
|
|
251
|
+
fileName: path.basename(absPath),
|
|
252
|
+
fontSize: opts.fontSize,
|
|
253
|
+
lineNumbers: opts.lineNumbers,
|
|
254
|
+
});
|
|
255
|
+
buffer = Buffer.from(svg, "utf-8");
|
|
256
|
+
} else {
|
|
257
|
+
const canvas = renderSnippet(lines, opts.theme, {
|
|
258
|
+
fileName: path.basename(absPath),
|
|
259
|
+
fontSize: opts.fontSize,
|
|
260
|
+
lineNumbers: opts.lineNumbers,
|
|
261
|
+
});
|
|
262
|
+
buffer =
|
|
263
|
+
opts.format === "jpg" || opts.format === "jpeg"
|
|
264
|
+
? canvas.toBuffer("image/jpeg", opts.quality ?? 0.95)
|
|
265
|
+
: canvas.toBuffer("image/png");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
let outPath;
|
|
269
|
+
if (opts.scanRoot) {
|
|
270
|
+
const relDir = path.dirname(path.relative(opts.scanRoot, absPath));
|
|
271
|
+
const baseName = path.basename(absPath, path.extname(absPath));
|
|
272
|
+
const targetDir = path.join(opts.outputDir, relDir === "." ? "" : relDir);
|
|
273
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
274
|
+
outPath = path.join(targetDir, `${baseName}.${opts.format}`);
|
|
275
|
+
} else {
|
|
276
|
+
const baseName = path.basename(absPath, path.extname(absPath));
|
|
277
|
+
outPath = path.join(opts.outputDir, `${baseName}.${opts.format}`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
fs.writeFileSync(outPath, buffer);
|
|
281
|
+
console.log(
|
|
282
|
+
`✅ ${path.relative(process.cwd(), absPath)} → ${path.relative(process.cwd(), outPath)}`,
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function getChangedFilesFromGit() {
|
|
287
|
+
try {
|
|
288
|
+
const output = execSync("git diff --name-only HEAD", { encoding: "utf-8" });
|
|
289
|
+
return output
|
|
290
|
+
.split("\n")
|
|
291
|
+
.map((l) => l.trim())
|
|
292
|
+
.filter(Boolean)
|
|
293
|
+
.filter((f) => fs.existsSync(f));
|
|
294
|
+
} catch {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
program.parse();
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export class StorageModule {
|
|
2
|
+
constructor(storage = window.localStorage) { this.storage = storage; }
|
|
3
|
+
save(key, data) { this.storage.setItem(key, JSON.stringify(data)); }
|
|
4
|
+
load(key, fallback = null) {
|
|
5
|
+
const raw = this.storage.getItem(key);
|
|
6
|
+
if (!raw) return fallback;
|
|
7
|
+
try { return JSON.parse(raw); } catch { return fallback; }
|
|
8
|
+
}
|
|
9
|
+
delete(key) { this.storage.removeItem(key); }
|
|
10
|
+
list(prefix) {
|
|
11
|
+
const values = [];
|
|
12
|
+
for (let i = 0; i < this.storage.length; i += 1) {
|
|
13
|
+
const key = this.storage.key(i);
|
|
14
|
+
if (key?.startsWith(prefix)) values.push(...this.load(key, []));
|
|
15
|
+
}
|
|
16
|
+
return values;
|
|
17
|
+
}
|
|
18
|
+
clearPomodoroData() {
|
|
19
|
+
Object.keys(this.storage)
|
|
20
|
+
.filter((key) => key.startsWith('pomodoro:'))
|
|
21
|
+
.forEach((key) => this.storage.removeItem(key));
|
|
22
|
+
}
|
|
23
|
+
}
|
package/examples/api.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
interface User {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
role: "admin" | "user";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export async function fetchUser(id: string): Promise<User> {
|
|
8
|
+
const res = await fetch(`/api/users/${id}`);
|
|
9
|
+
if (!res.ok) throw new Error("User not found");
|
|
10
|
+
return res.json();
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
.card {
|
|
2
|
+
display: flex;
|
|
3
|
+
flex-direction: column;
|
|
4
|
+
gap: 1rem;
|
|
5
|
+
padding: 24px;
|
|
6
|
+
border-radius: 12px;
|
|
7
|
+
background: linear-gradient(135deg, #1e1b3a, #0f0c29);
|
|
8
|
+
box-shadow: 0 20px 40px rgba(0,0,0,0.4);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.card:hover {
|
|
12
|
+
transform: translateY(-4px);
|
|
13
|
+
transition: transform 0.2s ease-in-out;
|
|
14
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "syntaxshot-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Genera capturas de codigo con resaltado de sintaxis desde la terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"syntaxshot": "./bin/syntaxshot.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "bin/syntaxshot.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node bin/syntaxshot.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@napi-rs/canvas": "^0.1.53",
|
|
15
|
+
"commander": "^12.0.0",
|
|
16
|
+
"shiki": "^4.3.1"
|
|
17
|
+
}
|
|
18
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const CONFIG_FILENAME = ".syntaxshotrc.json";
|
|
5
|
+
|
|
6
|
+
const DEFAULTS = {
|
|
7
|
+
theme: "midnight",
|
|
8
|
+
format: "png", // "png" | "jpg"
|
|
9
|
+
output: "syntaxshots", // output folder
|
|
10
|
+
fontSize: 16,
|
|
11
|
+
lineNumbers: true,
|
|
12
|
+
quality: 0.95, // only applies to jpg
|
|
13
|
+
excludeDirs: [], // extra folders to ignore during folder scan (Pro)
|
|
14
|
+
excludeFiles: [], // extra filenames to ignore during folder scan (Pro)
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function loadConfig(cwd = process.cwd()) {
|
|
18
|
+
const configPath = path.join(cwd, CONFIG_FILENAME);
|
|
19
|
+
if (!fs.existsSync(configPath)) return { ...DEFAULTS };
|
|
20
|
+
try {
|
|
21
|
+
const raw = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
22
|
+
return { ...DEFAULTS, ...raw };
|
|
23
|
+
} catch {
|
|
24
|
+
console.warn(`⚠️ Could not read ${CONFIG_FILENAME}, using default values.`);
|
|
25
|
+
return { ...DEFAULTS };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeDefaultConfig(cwd = process.cwd()) {
|
|
30
|
+
const configPath = path.join(cwd, CONFIG_FILENAME);
|
|
31
|
+
if (fs.existsSync(configPath)) {
|
|
32
|
+
console.log(`${CONFIG_FILENAME} already exists, it was not overwritten.`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
fs.writeFileSync(configPath, JSON.stringify(DEFAULTS, null, 2));
|
|
36
|
+
console.log(`✅ Created ${CONFIG_FILENAME} with the default configuration.`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { DEFAULTS };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { codeToTokens } from "shiki";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const EXT_TO_LANG = {
|
|
5
|
+
".js": "javascript",
|
|
6
|
+
".mjs": "javascript",
|
|
7
|
+
".cjs": "javascript",
|
|
8
|
+
".jsx": "jsx",
|
|
9
|
+
".ts": "typescript",
|
|
10
|
+
".tsx": "tsx",
|
|
11
|
+
".css": "css",
|
|
12
|
+
".scss": "scss",
|
|
13
|
+
".html": "html",
|
|
14
|
+
".json": "json",
|
|
15
|
+
".py": "python",
|
|
16
|
+
".rb": "ruby",
|
|
17
|
+
".go": "go",
|
|
18
|
+
".rs": "rust",
|
|
19
|
+
".java": "java",
|
|
20
|
+
".php": "php",
|
|
21
|
+
".sh": "shell",
|
|
22
|
+
".yml": "yaml",
|
|
23
|
+
".yaml": "yaml",
|
|
24
|
+
".md": "markdown",
|
|
25
|
+
".sql": "sql",
|
|
26
|
+
".c": "c",
|
|
27
|
+
".cpp": "cpp",
|
|
28
|
+
".cs": "csharp",
|
|
29
|
+
".vue": "vue",
|
|
30
|
+
".swift": "swift",
|
|
31
|
+
".kt": "kotlin",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function detectLang(filePath) {
|
|
35
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
36
|
+
return EXT_TO_LANG[ext] || "plaintext";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Tokenizes the code and returns an array of lines, each line is an
|
|
41
|
+
* array of tokens { content, color }.
|
|
42
|
+
*/
|
|
43
|
+
export async function tokenizeCode(code, lang, shikiTheme) {
|
|
44
|
+
const { tokens } = await codeToTokens(code, {
|
|
45
|
+
lang,
|
|
46
|
+
theme: shikiTheme,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return tokens.map((line) =>
|
|
50
|
+
line.map((tok) => ({ content: tok.content, color: tok.color || "#e6e6e6" }))
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/license.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
const LICENSE_DIR = path.join(os.homedir(), ".syntaxshot");
|
|
6
|
+
const LICENSE_FILE = path.join(LICENSE_DIR, "license.json");
|
|
7
|
+
|
|
8
|
+
// How long a validated license stays trusted before re-checking with the API.
|
|
9
|
+
const REVALIDATE_AFTER_MS = 24 * 60 * 60 * 1000; // 24h
|
|
10
|
+
// If the API is unreachable (offline), keep trusting a previously-valid
|
|
11
|
+
// license for this long before falling back to Free.
|
|
12
|
+
const OFFLINE_GRACE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
13
|
+
|
|
14
|
+
const API_URL = process.env.SYNTAXSHOT_API_URL || "https://api.syntaxshot.dev";
|
|
15
|
+
|
|
16
|
+
function readLicenseFile() {
|
|
17
|
+
if (!fs.existsSync(LICENSE_FILE)) return null;
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(fs.readFileSync(LICENSE_FILE, "utf-8"));
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeLicenseFile(data) {
|
|
26
|
+
fs.mkdirSync(LICENSE_DIR, { recursive: true });
|
|
27
|
+
fs.writeFileSync(LICENSE_FILE, JSON.stringify(data, null, 2));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function validateWithApi(licenseKey) {
|
|
31
|
+
const res = await fetch(`${API_URL}/api/v1/validate`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
headers: { "Content-Type": "application/json" },
|
|
34
|
+
body: JSON.stringify({ licenseKey }),
|
|
35
|
+
signal: AbortSignal.timeout(8000),
|
|
36
|
+
});
|
|
37
|
+
return res.json();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Called by `syntaxshot login <key>`. Validates once and stores it. */
|
|
41
|
+
export async function activateLicense(licenseKey) {
|
|
42
|
+
const result = await validateWithApi(licenseKey);
|
|
43
|
+
if (!result.valid) {
|
|
44
|
+
return { ok: false, error: result.error || "This license key is not active." };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
writeLicenseFile({
|
|
48
|
+
licenseKey,
|
|
49
|
+
plan: result.plan,
|
|
50
|
+
lastCheckedAt: new Date().toISOString(),
|
|
51
|
+
lastKnownValid: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return { ok: true, plan: result.plan };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function logoutLicense() {
|
|
58
|
+
if (fs.existsSync(LICENSE_FILE)) fs.unlinkSync(LICENSE_FILE);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Resolves the effective plan for this run.
|
|
63
|
+
* Order of precedence:
|
|
64
|
+
* 1. config.plan === "pro" in the local .syntaxshotrc.json — a manual dev
|
|
65
|
+
* override, documented as such (not meant for end users).
|
|
66
|
+
* 2. A cached, still-fresh license from `syntaxshot login`.
|
|
67
|
+
* 3. Re-validate a stale cached license against the API.
|
|
68
|
+
* 4. Fall back to Free.
|
|
69
|
+
*/
|
|
70
|
+
export async function resolvePlan(localConfigPlan) {
|
|
71
|
+
if (localConfigPlan === "pro") return "pro"; // local/dev override, see README
|
|
72
|
+
|
|
73
|
+
const cached = readLicenseFile();
|
|
74
|
+
if (!cached) return "free";
|
|
75
|
+
|
|
76
|
+
const age = Date.now() - new Date(cached.lastCheckedAt).getTime();
|
|
77
|
+
if (age < REVALIDATE_AFTER_MS) {
|
|
78
|
+
return cached.lastKnownValid ? "pro" : "free";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const result = await validateWithApi(cached.licenseKey);
|
|
83
|
+
writeLicenseFile({
|
|
84
|
+
...cached,
|
|
85
|
+
plan: result.plan,
|
|
86
|
+
lastCheckedAt: new Date().toISOString(),
|
|
87
|
+
lastKnownValid: !!result.valid,
|
|
88
|
+
});
|
|
89
|
+
return result.valid ? "pro" : "free";
|
|
90
|
+
} catch {
|
|
91
|
+
// Offline or API down: trust the last known state for a grace period.
|
|
92
|
+
if (cached.lastKnownValid && age < OFFLINE_GRACE_MS) return "pro";
|
|
93
|
+
return "free";
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/renderer.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const FONTS_DIR = path.join(__dirname, "..", "fonts");
|
|
7
|
+
|
|
8
|
+
let fontsRegistered = false;
|
|
9
|
+
function ensureFonts() {
|
|
10
|
+
if (fontsRegistered) return;
|
|
11
|
+
GlobalFonts.registerFromPath(
|
|
12
|
+
path.join(FONTS_DIR, "JetBrainsMono-Regular.ttf"),
|
|
13
|
+
"JetBrains Mono"
|
|
14
|
+
);
|
|
15
|
+
GlobalFonts.registerFromPath(
|
|
16
|
+
path.join(FONTS_DIR, "JetBrainsMono-Bold.ttf"),
|
|
17
|
+
"JetBrains Mono Bold"
|
|
18
|
+
);
|
|
19
|
+
GlobalFonts.registerFromPath(
|
|
20
|
+
path.join(FONTS_DIR, "JetBrainsMono-Italic.ttf"),
|
|
21
|
+
"JetBrains Mono Italic"
|
|
22
|
+
);
|
|
23
|
+
fontsRegistered = true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
27
|
+
ctx.beginPath();
|
|
28
|
+
ctx.moveTo(x + r, y);
|
|
29
|
+
ctx.arcTo(x + w, y, x + w, y + h, r);
|
|
30
|
+
ctx.arcTo(x + w, y + h, x, y + h, r);
|
|
31
|
+
ctx.arcTo(x, y + h, x, y, r);
|
|
32
|
+
ctx.arcTo(x, y, x + w, y, r);
|
|
33
|
+
ctx.closePath();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {Array<Array<{content:string,color:string}>>} lines
|
|
38
|
+
* @param {object} theme ver src/themes.js
|
|
39
|
+
* @param {object} opts { fileName, fontSize, lineNumbers, padding }
|
|
40
|
+
*/
|
|
41
|
+
export function renderSnippet(lines, theme, opts = {}) {
|
|
42
|
+
ensureFonts();
|
|
43
|
+
|
|
44
|
+
const fontSize = opts.fontSize || 16;
|
|
45
|
+
const lineHeight = Math.round(fontSize * 1.6);
|
|
46
|
+
const padding = opts.padding ?? 40;
|
|
47
|
+
const outerPadding = 64; // margen alrededor de la ventana (fondo con gradiente)
|
|
48
|
+
const showLineNumbers = opts.lineNumbers ?? true;
|
|
49
|
+
const fileName = opts.fileName || "snippet";
|
|
50
|
+
const tabHeight = 56;
|
|
51
|
+
const charWidth = fontSize * 0.6; // monospace approximation
|
|
52
|
+
|
|
53
|
+
const longestLine = Math.max(
|
|
54
|
+
1,
|
|
55
|
+
...lines.map((l) => l.reduce((sum, t) => sum + t.content.length, 0))
|
|
56
|
+
);
|
|
57
|
+
const gutterWidth = showLineNumbers
|
|
58
|
+
? Math.max(3, String(lines.length).length) * charWidth + 24
|
|
59
|
+
: 0;
|
|
60
|
+
|
|
61
|
+
const codeWidth = longestLine * charWidth;
|
|
62
|
+
const windowWidth = Math.ceil(gutterWidth + codeWidth + padding * 2);
|
|
63
|
+
const windowHeight = Math.ceil(tabHeight + lines.length * lineHeight + padding * 1.4);
|
|
64
|
+
|
|
65
|
+
const canvasWidth = windowWidth + outerPadding * 2;
|
|
66
|
+
const canvasHeight = windowHeight + outerPadding * 2;
|
|
67
|
+
|
|
68
|
+
const canvas = createCanvas(canvasWidth, canvasHeight);
|
|
69
|
+
const ctx = canvas.getContext("2d");
|
|
70
|
+
|
|
71
|
+
// Fondo con gradiente
|
|
72
|
+
const grad = ctx.createLinearGradient(0, 0, canvasWidth, canvasHeight);
|
|
73
|
+
grad.addColorStop(0, theme.pageGradient[0]);
|
|
74
|
+
grad.addColorStop(1, theme.pageGradient[1]);
|
|
75
|
+
ctx.fillStyle = grad;
|
|
76
|
+
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
|
77
|
+
|
|
78
|
+
// Subtle glow behind the window
|
|
79
|
+
ctx.save();
|
|
80
|
+
ctx.filter = "blur(60px)";
|
|
81
|
+
ctx.fillStyle = theme.glow;
|
|
82
|
+
roundRect(ctx, outerPadding + 20, outerPadding + 30, windowWidth - 40, windowHeight - 40, 40);
|
|
83
|
+
ctx.fill();
|
|
84
|
+
ctx.restore();
|
|
85
|
+
|
|
86
|
+
// Sombra de la ventana
|
|
87
|
+
ctx.save();
|
|
88
|
+
ctx.shadowColor = "rgba(0,0,0,0.45)";
|
|
89
|
+
ctx.shadowBlur = 40;
|
|
90
|
+
ctx.shadowOffsetY = 18;
|
|
91
|
+
ctx.fillStyle = theme.windowBg;
|
|
92
|
+
roundRect(ctx, outerPadding, outerPadding, windowWidth, windowHeight, 18);
|
|
93
|
+
ctx.fill();
|
|
94
|
+
ctx.restore();
|
|
95
|
+
|
|
96
|
+
// Borde
|
|
97
|
+
ctx.strokeStyle = theme.windowBorder;
|
|
98
|
+
ctx.lineWidth = 1;
|
|
99
|
+
roundRect(ctx, outerPadding, outerPadding, windowWidth, windowHeight, 18);
|
|
100
|
+
ctx.stroke();
|
|
101
|
+
|
|
102
|
+
// Tab superior
|
|
103
|
+
ctx.save();
|
|
104
|
+
roundRect(ctx, outerPadding, outerPadding, windowWidth, tabHeight, 18);
|
|
105
|
+
ctx.clip();
|
|
106
|
+
ctx.fillStyle = theme.tabBg;
|
|
107
|
+
ctx.fillRect(outerPadding, outerPadding, windowWidth, tabHeight);
|
|
108
|
+
ctx.restore();
|
|
109
|
+
|
|
110
|
+
// Dots estilo macOS
|
|
111
|
+
const dotY = outerPadding + tabHeight / 2;
|
|
112
|
+
theme.dots.forEach((color, i) => {
|
|
113
|
+
ctx.beginPath();
|
|
114
|
+
ctx.fillStyle = color;
|
|
115
|
+
ctx.arc(outerPadding + 26 + i * 24, dotY, 7, 0, Math.PI * 2);
|
|
116
|
+
ctx.fill();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Nombre del archivo
|
|
120
|
+
ctx.fillStyle = theme.tabText;
|
|
121
|
+
ctx.font = "600 15px 'JetBrains Mono'";
|
|
122
|
+
ctx.textBaseline = "middle";
|
|
123
|
+
ctx.textAlign = "center";
|
|
124
|
+
ctx.fillText(fileName, outerPadding + windowWidth / 2, dotY);
|
|
125
|
+
ctx.textAlign = "left";
|
|
126
|
+
|
|
127
|
+
// Code
|
|
128
|
+
const codeStartY = outerPadding + tabHeight + padding * 0.6;
|
|
129
|
+
const codeStartX = outerPadding + padding;
|
|
130
|
+
ctx.font = `${fontSize}px 'JetBrains Mono'`;
|
|
131
|
+
ctx.textBaseline = "top";
|
|
132
|
+
|
|
133
|
+
lines.forEach((tokens, i) => {
|
|
134
|
+
const y = codeStartY + i * lineHeight;
|
|
135
|
+
|
|
136
|
+
if (showLineNumbers) {
|
|
137
|
+
ctx.fillStyle = theme.lineNumberColor;
|
|
138
|
+
ctx.textAlign = "right";
|
|
139
|
+
ctx.fillText(String(i + 1), codeStartX + gutterWidth - 20, y + 2);
|
|
140
|
+
ctx.textAlign = "left";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let x = codeStartX + gutterWidth;
|
|
144
|
+
tokens.forEach((tok) => {
|
|
145
|
+
ctx.fillStyle = tok.color;
|
|
146
|
+
ctx.fillText(tok.content, x, y);
|
|
147
|
+
x += tok.content.length * charWidth;
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return canvas;
|
|
152
|
+
}
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { EXT_TO_LANG } from "./highlighter.js";
|
|
4
|
+
|
|
5
|
+
// Folders that never make sense to walk into.
|
|
6
|
+
const DEFAULT_EXCLUDED_DIRS = new Set([
|
|
7
|
+
"node_modules", ".git", ".hg", ".svn",
|
|
8
|
+
"dist", "build", "out", "coverage", ".next", ".nuxt", ".cache",
|
|
9
|
+
".vscode", ".idea", "vendor", "__pycache__", ".venv", "venv",
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
// Specific files that exist in almost any project but that
|
|
13
|
+
// nobody wants to show off in a screenshot (config, secrets, lockfiles...).
|
|
14
|
+
const DEFAULT_EXCLUDED_FILENAMES = new Set([
|
|
15
|
+
"package.json", "package-lock.json", "npm-shrinkwrap.json",
|
|
16
|
+
"yarn.lock", "pnpm-lock.yaml", "composer.lock", "Gemfile.lock", "Cargo.lock",
|
|
17
|
+
"tsconfig.json", "tsconfig.node.json", "jsconfig.json",
|
|
18
|
+
".gitignore", ".npmrc", ".nvmrc", ".editorconfig",
|
|
19
|
+
".eslintrc.json", ".eslintrc", ".prettierrc", ".prettierrc.json",
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function isExcludedFile(fileName, excludedFilenames) {
|
|
23
|
+
if (fileName.startsWith(".env")) return true; // .env, .env.local, .env.production...
|
|
24
|
+
if (fileName.startsWith(".") ) return true; // dotfiles in general
|
|
25
|
+
if (fileName.endsWith(".lock")) return true;
|
|
26
|
+
if (excludedFilenames.has(fileName)) return true;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Recursively walks `rootDir` and returns the absolute paths of
|
|
32
|
+
* "capturable" files: they have a recognized language extension and are not
|
|
33
|
+
* in the exclusion list (config, secrets, dependencies, etc).
|
|
34
|
+
*/
|
|
35
|
+
export function findEligibleFiles(rootDir, opts = {}) {
|
|
36
|
+
const excludedDirs = new Set([...DEFAULT_EXCLUDED_DIRS, ...(opts.excludeDirs || [])]);
|
|
37
|
+
const excludedFilenames = new Set([...DEFAULT_EXCLUDED_FILENAMES, ...(opts.excludeFiles || [])]);
|
|
38
|
+
|
|
39
|
+
const results = [];
|
|
40
|
+
|
|
41
|
+
function walk(dir) {
|
|
42
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
43
|
+
for (const entry of entries) {
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
if (excludedDirs.has(entry.name)) continue;
|
|
46
|
+
walk(path.join(dir, entry.name));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!entry.isFile()) continue;
|
|
50
|
+
|
|
51
|
+
const fileName = entry.name;
|
|
52
|
+
if (isExcludedFile(fileName, excludedFilenames)) continue;
|
|
53
|
+
|
|
54
|
+
const ext = path.extname(fileName).toLowerCase();
|
|
55
|
+
if (!EXT_TO_LANG[ext]) continue; // only recognized code extensions
|
|
56
|
+
|
|
57
|
+
results.push(path.join(dir, fileName));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
walk(rootDir);
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const FONTS_DIR = path.join(__dirname, "..", "fonts");
|
|
7
|
+
|
|
8
|
+
let cachedFontBase64 = null;
|
|
9
|
+
function getEmbeddedFontBase64() {
|
|
10
|
+
if (!cachedFontBase64) {
|
|
11
|
+
const bytes = fs.readFileSync(path.join(FONTS_DIR, "JetBrainsMono-Regular.ttf"));
|
|
12
|
+
cachedFontBase64 = bytes.toString("base64");
|
|
13
|
+
}
|
|
14
|
+
return cachedFontBase64;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function escapeXml(str) {
|
|
18
|
+
return str
|
|
19
|
+
.replace(/&/g, "&")
|
|
20
|
+
.replace(/</g, "<")
|
|
21
|
+
.replace(/>/g, ">")
|
|
22
|
+
.replace(/"/g, """)
|
|
23
|
+
.replace(/ /g, "\u00A0"); // non-breaking space: survives renderers that
|
|
24
|
+
// don't honor xml:space="preserve" (many SVG viewers/editors collapse
|
|
25
|
+
// regular spaces regardless of that attribute)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Same visual layout as renderer.js, but emitted as an SVG string instead of
|
|
30
|
+
* a rasterized canvas. The font is embedded as base64 so the file looks
|
|
31
|
+
* right even on a machine that doesn't have JetBrains Mono installed.
|
|
32
|
+
*
|
|
33
|
+
* @param {Array<Array<{content:string,color:string}>>} lines
|
|
34
|
+
* @param {object} theme see src/themes.js
|
|
35
|
+
* @param {object} opts { fileName, fontSize, lineNumbers, padding }
|
|
36
|
+
*/
|
|
37
|
+
export function renderSnippetSVG(lines, theme, opts = {}) {
|
|
38
|
+
const fontSize = opts.fontSize || 16;
|
|
39
|
+
const lineHeight = Math.round(fontSize * 1.6);
|
|
40
|
+
const padding = opts.padding ?? 40;
|
|
41
|
+
const outerPadding = 64;
|
|
42
|
+
const showLineNumbers = opts.lineNumbers ?? true;
|
|
43
|
+
const fileName = opts.fileName || "snippet";
|
|
44
|
+
const tabHeight = 56;
|
|
45
|
+
const charWidth = fontSize * 0.6;
|
|
46
|
+
|
|
47
|
+
const longestLine = Math.max(
|
|
48
|
+
1,
|
|
49
|
+
...lines.map((l) => l.reduce((sum, t) => sum + t.content.length, 0))
|
|
50
|
+
);
|
|
51
|
+
const gutterWidth = showLineNumbers
|
|
52
|
+
? Math.max(3, String(lines.length).length) * charWidth + 24
|
|
53
|
+
: 0;
|
|
54
|
+
|
|
55
|
+
const codeWidth = longestLine * charWidth;
|
|
56
|
+
const windowWidth = Math.ceil(gutterWidth + codeWidth + padding * 2);
|
|
57
|
+
const windowHeight = Math.ceil(tabHeight + lines.length * lineHeight + padding * 1.4);
|
|
58
|
+
|
|
59
|
+
const canvasWidth = windowWidth + outerPadding * 2;
|
|
60
|
+
const canvasHeight = windowHeight + outerPadding * 2;
|
|
61
|
+
|
|
62
|
+
const codeStartY = outerPadding + tabHeight + padding * 0.6;
|
|
63
|
+
const codeStartX = outerPadding + padding + gutterWidth;
|
|
64
|
+
const fontFamily = "'JetBrains Mono', monospace";
|
|
65
|
+
|
|
66
|
+
const dotsMarkup = theme.dots
|
|
67
|
+
.map(
|
|
68
|
+
(color, i) =>
|
|
69
|
+
`<circle cx="${outerPadding + 26 + i * 24}" cy="${outerPadding + tabHeight / 2}" r="7" fill="${color}"/>`
|
|
70
|
+
)
|
|
71
|
+
.join("");
|
|
72
|
+
|
|
73
|
+
const linesMarkup = lines
|
|
74
|
+
.map((tokens, i) => {
|
|
75
|
+
const y = codeStartY + i * lineHeight + fontSize; // SVG text y = baseline
|
|
76
|
+
const lineNumberMarkup = showLineNumbers
|
|
77
|
+
? `<text x="${codeStartX - 20}" y="${y}" text-anchor="end" font-family="${fontFamily}" font-size="${fontSize}" fill="${theme.lineNumberColor}">${i + 1}</text>`
|
|
78
|
+
: "";
|
|
79
|
+
|
|
80
|
+
const tokensMarkup = tokens
|
|
81
|
+
.map((tok) => `<tspan fill="${tok.color}">${escapeXml(tok.content)}</tspan>`)
|
|
82
|
+
.join("");
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
lineNumberMarkup +
|
|
86
|
+
`<text x="${codeStartX}" y="${y}" xml:space="preserve" font-family="${fontFamily}" font-size="${fontSize}">${tokensMarkup}</text>`
|
|
87
|
+
);
|
|
88
|
+
})
|
|
89
|
+
.join("\n");
|
|
90
|
+
|
|
91
|
+
return `<svg width="${canvasWidth}" height="${canvasHeight}" viewBox="0 0 ${canvasWidth} ${canvasHeight}" xmlns="http://www.w3.org/2000/svg">
|
|
92
|
+
<defs>
|
|
93
|
+
<style>
|
|
94
|
+
@font-face {
|
|
95
|
+
font-family: 'JetBrains Mono';
|
|
96
|
+
src: url(data:font/ttf;base64,${getEmbeddedFontBase64()}) format('truetype');
|
|
97
|
+
}
|
|
98
|
+
text { font-family: ${fontFamily}; }
|
|
99
|
+
</style>
|
|
100
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
101
|
+
<stop offset="0" stop-color="${theme.pageGradient[0]}"/>
|
|
102
|
+
<stop offset="1" stop-color="${theme.pageGradient[1]}"/>
|
|
103
|
+
</linearGradient>
|
|
104
|
+
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
|
105
|
+
<feDropShadow dx="0" dy="18" stdDeviation="20" flood-color="#000" flood-opacity="0.45"/>
|
|
106
|
+
</filter>
|
|
107
|
+
</defs>
|
|
108
|
+
|
|
109
|
+
<rect width="${canvasWidth}" height="${canvasHeight}" fill="url(#bg)"/>
|
|
110
|
+
|
|
111
|
+
<rect x="${outerPadding}" y="${outerPadding}" width="${windowWidth}" height="${windowHeight}"
|
|
112
|
+
rx="18" fill="${theme.windowBg}" stroke="${theme.windowBorder}" filter="url(#shadow)"/>
|
|
113
|
+
|
|
114
|
+
<clipPath id="tabClip">
|
|
115
|
+
<rect x="${outerPadding}" y="${outerPadding}" width="${windowWidth}" height="${tabHeight}" rx="18"/>
|
|
116
|
+
</clipPath>
|
|
117
|
+
<rect x="${outerPadding}" y="${outerPadding}" width="${windowWidth}" height="${tabHeight}"
|
|
118
|
+
fill="${theme.tabBg}" clip-path="url(#tabClip)"/>
|
|
119
|
+
|
|
120
|
+
${dotsMarkup}
|
|
121
|
+
|
|
122
|
+
<text x="${outerPadding + windowWidth / 2}" y="${outerPadding + tabHeight / 2 + 5}"
|
|
123
|
+
text-anchor="middle" font-family="${fontFamily}" font-size="15" font-weight="600" fill="${theme.tabText}">${escapeXml(fileName)}</text>
|
|
124
|
+
|
|
125
|
+
${linesMarkup}
|
|
126
|
+
</svg>`;
|
|
127
|
+
}
|
package/src/themes.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Cada tema visual define: el "shikiTheme" (paleta de sintaxis) + los
|
|
2
|
+
// colores del "chrome" (fondo, ventana, tab de archivo, glow) que dibuja el canvas.
|
|
3
|
+
export const THEMES = {
|
|
4
|
+
midnight: {
|
|
5
|
+
label: "Midnight",
|
|
6
|
+
shikiTheme: "dracula",
|
|
7
|
+
pageGradient: ["#1e1b3a", "#0f0c29"],
|
|
8
|
+
windowBg: "#161327",
|
|
9
|
+
windowBorder: "rgba(255,255,255,0.06)",
|
|
10
|
+
tabBg: "#211d3c",
|
|
11
|
+
tabText: "#c9c3e6",
|
|
12
|
+
lineNumberColor: "rgba(255,255,255,0.25)",
|
|
13
|
+
dots: ["#ff5f56", "#ffbd2e", "#27c93f"],
|
|
14
|
+
glow: "rgba(120, 80, 255, 0.35)",
|
|
15
|
+
},
|
|
16
|
+
nord: {
|
|
17
|
+
label: "Nord",
|
|
18
|
+
shikiTheme: "nord",
|
|
19
|
+
pageGradient: ["#3b4252", "#2e3440"],
|
|
20
|
+
windowBg: "#2e3440",
|
|
21
|
+
windowBorder: "rgba(255,255,255,0.06)",
|
|
22
|
+
tabBg: "#3b4252",
|
|
23
|
+
tabText: "#d8dee9",
|
|
24
|
+
lineNumberColor: "rgba(255,255,255,0.25)",
|
|
25
|
+
dots: ["#bf616a", "#ebcb8b", "#a3be8c"],
|
|
26
|
+
glow: "rgba(94, 129, 172, 0.35)",
|
|
27
|
+
},
|
|
28
|
+
solar: {
|
|
29
|
+
label: "Solar Light",
|
|
30
|
+
shikiTheme: "solarized-light",
|
|
31
|
+
pageGradient: ["#fdf6e3", "#eee8d5"],
|
|
32
|
+
windowBg: "#fdf6e3",
|
|
33
|
+
windowBorder: "rgba(0,0,0,0.08)",
|
|
34
|
+
tabBg: "#eee8d5",
|
|
35
|
+
tabText: "#586e75",
|
|
36
|
+
lineNumberColor: "rgba(0,0,0,0.3)",
|
|
37
|
+
dots: ["#ff5f56", "#ffbd2e", "#27c93f"],
|
|
38
|
+
glow: "rgba(38, 139, 210, 0.15)",
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export function resolveTheme(name) {
|
|
43
|
+
const t = THEMES[name];
|
|
44
|
+
if (!t) {
|
|
45
|
+
const names = Object.keys(THEMES).join(", ");
|
|
46
|
+
throw new Error(`Tema "${name}" no existe. Disponibles: ${names}`);
|
|
47
|
+
}
|
|
48
|
+
return t;
|
|
49
|
+
}
|
package/src/usage.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const USAGE_FILENAME = ".syntaxshot-usage.json";
|
|
5
|
+
const FREE_LIMIT = 10;
|
|
6
|
+
|
|
7
|
+
function usagePath(cwd) {
|
|
8
|
+
return path.join(cwd, USAGE_FILENAME);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readUsage(cwd) {
|
|
12
|
+
const p = usagePath(cwd);
|
|
13
|
+
if (!fs.existsSync(p)) return { count: 0 };
|
|
14
|
+
try {
|
|
15
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
16
|
+
} catch {
|
|
17
|
+
return { count: 0 };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeUsage(cwd, usage) {
|
|
22
|
+
fs.writeFileSync(usagePath(cwd), JSON.stringify(usage, null, 2));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* plan: "free" | "pro" (will eventually come from config.licenseKey validated
|
|
27
|
+
* against your backend; here it's simulated with config.plan)
|
|
28
|
+
*/
|
|
29
|
+
export function checkAndConsume(plan, howMany, cwd = process.cwd()) {
|
|
30
|
+
if (plan === "pro") return { allowed: true, remaining: Infinity };
|
|
31
|
+
|
|
32
|
+
const usage = readUsage(cwd);
|
|
33
|
+
const remaining = FREE_LIMIT - usage.count;
|
|
34
|
+
|
|
35
|
+
if (howMany > remaining) {
|
|
36
|
+
return { allowed: false, remaining };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
usage.count += howMany;
|
|
40
|
+
writeUsage(cwd, usage);
|
|
41
|
+
return { allowed: true, remaining: FREE_LIMIT - usage.count };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { FREE_LIMIT };
|