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.
@@ -0,0 +1,3 @@
1
+ {
2
+ "count": 1
3
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "theme": "solar",
3
+ "format": "png",
4
+ "output": "syntaxshots",
5
+ "fontSize": 16,
6
+ "lineNumbers": true,
7
+ "quality": 0.95,
8
+ "excludeDirs": [],
9
+ "excludeFiles": [],
10
+ "plan": "pro"
11
+ }
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
+ }
@@ -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
+ }
@@ -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, "&amp;")
20
+ .replace(/</g, "&lt;")
21
+ .replace(/>/g, "&gt;")
22
+ .replace(/"/g, "&quot;")
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 };