@clipr/core 0.0.5

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,281 @@
1
+ /** A shortened URL entry stored in the URL database. */
2
+ interface UrlEntry {
3
+ /** The short slug (e.g. "abc123"). */
4
+ slug: string;
5
+ /** The original long URL to redirect to. */
6
+ url: string;
7
+ /** ISO 8601 timestamp when the entry was created. */
8
+ createdAt: string;
9
+ /** Optional expiration as ISO 8601 timestamp. */
10
+ expiresAt?: string;
11
+ /** Optional human-readable description. */
12
+ description?: string;
13
+ /** UTM parameters to append on redirect. */
14
+ utm?: UtmParams;
15
+ }
16
+ /** UTM campaign tracking parameters. */
17
+ interface UtmParams {
18
+ utm_source?: string;
19
+ utm_medium?: string;
20
+ utm_campaign?: string;
21
+ utm_term?: string;
22
+ utm_content?: string;
23
+ }
24
+ /** The shape of the urls.json database file. */
25
+ interface UrlDatabase {
26
+ /** Schema version for future migrations. */
27
+ version: number;
28
+ /** Auto-incrementing counter (unused if slugs are random/custom). */
29
+ counter: number;
30
+ /** Base URL for short links (e.g. "https://clpr.sh"). */
31
+ baseUrl: string;
32
+ /** Map of slug -> URL entry. */
33
+ urls: Record<string, UrlEntry>;
34
+ }
35
+ /** Result of a validation check. */
36
+ type ValidationResult = {
37
+ valid: true;
38
+ } | {
39
+ valid: false;
40
+ reason: string;
41
+ };
42
+ /** Supported backend storage types. */
43
+ type BackendType = 'json' | 'cloudflare-kv' | 'redis';
44
+ /** Deployment mode for the CLI. */
45
+ type DeployMode = 'github' | 'api';
46
+ /** Configuration for a clipr project. */
47
+ interface CliprConfig {
48
+ /** Deployment mode. */
49
+ mode: DeployMode;
50
+ /** Where to store URL data. */
51
+ backend: BackendType;
52
+ /** Base URL for short links. */
53
+ baseUrl: string;
54
+ /** Default slug length for random slugs. */
55
+ slugLength: number;
56
+ /** Path to the urls.json file (for json backend). */
57
+ dbPath: string;
58
+ /** GitHub backend config. */
59
+ github?: {
60
+ owner: string;
61
+ repo: string;
62
+ branch: string;
63
+ path: string;
64
+ token: string;
65
+ };
66
+ /** API backend config (Workers). */
67
+ api?: {
68
+ baseUrl: string;
69
+ token: string;
70
+ };
71
+ }
72
+ /** Options for creating a short URL. */
73
+ interface CreateOptions {
74
+ /** Custom slug (auto-generated if omitted). */
75
+ slug?: string;
76
+ /** Tags for organization. */
77
+ tags?: string[];
78
+ /** Expiration date (ISO 8601). */
79
+ expiresAt?: string;
80
+ /** Password for protected links. */
81
+ password?: string;
82
+ /** Description. */
83
+ description?: string;
84
+ /** UTM parameters. */
85
+ utm?: UtmParams;
86
+ /** Custom OG title. */
87
+ ogTitle?: string;
88
+ /** Custom OG description. */
89
+ ogDescription?: string;
90
+ /** Custom OG image URL. */
91
+ ogImage?: string;
92
+ }
93
+ /** Options for listing URLs. */
94
+ interface ListOptions {
95
+ /** Filter by tag. */
96
+ tag?: string;
97
+ /** Search query (slug, URL, description). */
98
+ search?: string;
99
+ /** Max results. */
100
+ limit?: number;
101
+ /** Output format. */
102
+ format?: 'table' | 'json';
103
+ }
104
+ /** A fully resolved short URL with all metadata. */
105
+ interface ShortUrl extends UrlEntry {
106
+ /** Tags for organization. */
107
+ tags?: string[];
108
+ /** Whether the slug was custom or auto-generated. */
109
+ custom?: boolean;
110
+ /** Password hash (for protected links). */
111
+ passwordHash?: string;
112
+ /** Custom OG title. */
113
+ ogTitle?: string;
114
+ /** Custom OG description. */
115
+ ogDescription?: string;
116
+ /** Custom OG image URL. */
117
+ ogImage?: string;
118
+ }
119
+ /** Result of resolving a slug. */
120
+ interface ResolveResult {
121
+ /** The target URL to redirect to (with UTM appended if applicable). */
122
+ url: string;
123
+ /** Whether the link is password-protected. */
124
+ passwordProtected: boolean;
125
+ /** Whether the link has expired. */
126
+ expired: boolean;
127
+ }
128
+ /** Click analytics for a link (Workers API mode only). */
129
+ interface LinkStats {
130
+ /** Total clicks. */
131
+ total: number;
132
+ /** Clicks per day. */
133
+ daily: Record<string, number>;
134
+ /** Clicks by country. */
135
+ geo: Record<string, number>;
136
+ /** Clicks by device type. */
137
+ device: Record<string, number>;
138
+ /** Clicks by referrer domain. */
139
+ referrer: Record<string, number>;
140
+ }
141
+ /** The full urls.json manifest shape (alias for UrlDatabase). */
142
+ type UrlsManifest = UrlDatabase;
143
+
144
+ /**
145
+ * Simple backend interface for basic CRUD operations.
146
+ * Used by JsonBackend and KvBackend.
147
+ */
148
+ interface Backend {
149
+ /** Get a URL entry by slug. Returns undefined if not found. */
150
+ get(slug: string): Promise<ShortUrl | undefined>;
151
+ /** Store a new URL entry. Throws SlugConflictError if slug exists. */
152
+ set(entry: ShortUrl): Promise<void>;
153
+ /** Delete a URL entry by slug. Returns true if it existed. */
154
+ delete(slug: string): Promise<boolean>;
155
+ /** Check if a slug exists. */
156
+ has(slug: string): Promise<boolean>;
157
+ /** List all URL entries. */
158
+ list(): Promise<ShortUrl[]>;
159
+ }
160
+ /**
161
+ * Full backend interface (Strategy pattern) as specified in the plan.
162
+ * Includes create/resolve/update/getStats for CLI and Worker use.
163
+ */
164
+ interface UrlBackend {
165
+ /** Create a new short URL. */
166
+ create(slug: string, targetUrl: string, options?: CreateOptions): Promise<ShortUrl>;
167
+ /** Resolve a slug to its target URL. Returns null if not found. */
168
+ resolve(slug: string): Promise<ResolveResult | null>;
169
+ /** List short URLs with optional filtering. */
170
+ list(options?: ListOptions): Promise<ShortUrl[]>;
171
+ /** Delete a short URL by slug. */
172
+ delete(slug: string): Promise<void>;
173
+ /** Update an existing short URL. */
174
+ update(slug: string, updates: Partial<ShortUrl>): Promise<ShortUrl>;
175
+ /** Get click analytics for a slug. Returns null if backend doesn't support stats. */
176
+ getStats(slug: string): Promise<LinkStats | null>;
177
+ }
178
+
179
+ /** Resolve the path to ~/.clipr.json. */
180
+ declare function resolveConfigPath(): string;
181
+ /** Load config from ~/.clipr.json, falling back to defaults. */
182
+ declare function loadConfig(path?: string): CliprConfig;
183
+ /** Save config to ~/.clipr.json. */
184
+ declare function saveConfig(config: CliprConfig, path?: string): void;
185
+ /** Merge partial user config with defaults. */
186
+ declare function resolveConfig(partial?: Partial<CliprConfig>): CliprConfig;
187
+ /** Validate that a backend type is recognized. */
188
+ declare function isValidBackend(value: string): value is BackendType;
189
+
190
+ /** Default slug length when generating random slugs. */
191
+ declare const DEFAULT_SLUG_LENGTH = 6;
192
+ /** Minimum allowed slug length. */
193
+ declare const MIN_SLUG_LENGTH = 3;
194
+ /** Maximum allowed slug length. */
195
+ declare const MAX_SLUG_LENGTH = 32;
196
+ /** Characters used in random slug generation (URL-safe, no ambiguous chars). */
197
+ declare const SLUG_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
198
+ /** Maximum allowed URL length in characters. */
199
+ declare const MAX_URL_LENGTH = 2048;
200
+ /** Reserved slugs that cannot be used (common paths + locale codes). */
201
+ declare const RESERVED_SLUGS: Set<string>;
202
+ /** Default database file path relative to project root. */
203
+ declare const DEFAULT_DB_PATH = "urls.json";
204
+ /** Current URL database schema version. */
205
+ declare const DB_VERSION = 1;
206
+
207
+ /** Base error class for all clipr errors. */
208
+ declare class CliprError extends Error {
209
+ readonly code: string;
210
+ constructor(message: string, code: string);
211
+ }
212
+ /** Thrown when a slug is invalid (bad chars, reserved, wrong length). */
213
+ declare class InvalidSlugError extends CliprError {
214
+ constructor(slug: string, reason: string);
215
+ }
216
+ /** Thrown when a URL fails validation. */
217
+ declare class InvalidUrlError extends CliprError {
218
+ constructor(url: string, reason: string);
219
+ }
220
+ /** Thrown when a slug already exists in the database. */
221
+ declare class SlugConflictError extends CliprError {
222
+ constructor(slug: string);
223
+ }
224
+ /** Thrown when a slug is not found in the database. */
225
+ declare class SlugNotFoundError extends CliprError {
226
+ constructor(slug: string);
227
+ }
228
+ /** Thrown when the backend configuration is invalid or missing. */
229
+ declare class BackendError extends CliprError {
230
+ constructor(message: string);
231
+ }
232
+
233
+ declare class JsonBackend implements Backend {
234
+ private readonly path;
235
+ constructor(path: string);
236
+ private read;
237
+ private write;
238
+ get(slug: string): Promise<UrlEntry | undefined>;
239
+ set(entry: UrlEntry): Promise<void>;
240
+ delete(slug: string): Promise<boolean>;
241
+ has(slug: string): Promise<boolean>;
242
+ list(): Promise<UrlEntry[]>;
243
+ /** Update the baseUrl in the database. */
244
+ setBaseUrl(baseUrl: string): Promise<void>;
245
+ /** Get the current baseUrl. */
246
+ getBaseUrl(): Promise<string>;
247
+ }
248
+
249
+ /**
250
+ * Generate a slug from a counter value using Sqids.
251
+ * Deterministic: same counter always produces same slug.
252
+ */
253
+ declare function generateSlug(counter: number): string;
254
+ /**
255
+ * Decode a Sqids-generated slug back to its counter value.
256
+ * Returns the counter number, or null if invalid.
257
+ */
258
+ declare function decodeSlug(slug: string): number | null;
259
+ /**
260
+ * Generate a random slug using crypto.getRandomValues.
261
+ * Used when no counter is available.
262
+ */
263
+ declare function generateRandomSlug(length?: number): string;
264
+ /** Check if a string is a valid slug format. */
265
+ declare function isValidSlug(slug: string): boolean;
266
+ /** Normalize a slug to lowercase and trimmed. */
267
+ declare function normalizeSlug(slug: string): string;
268
+
269
+ /** Append UTM parameters to a URL string. Existing UTM params on the URL are overwritten. */
270
+ declare function appendUtm(url: string, utm: UtmParams): string;
271
+ /** Extract UTM parameters from a URL string. */
272
+ declare function extractUtm(url: string): UtmParams;
273
+ /** Check if a UtmParams object has any non-empty values. */
274
+ declare function hasUtm(utm: UtmParams | undefined): utm is UtmParams;
275
+
276
+ /** Validate a slug string. */
277
+ declare function validateSlug(slug: string): ValidationResult;
278
+ /** Validate a target URL. */
279
+ declare function validateUrl(url: string): ValidationResult;
280
+
281
+ export { type Backend, BackendError, type BackendType, type CliprConfig, CliprError, type CreateOptions, DB_VERSION, DEFAULT_DB_PATH, DEFAULT_SLUG_LENGTH, type DeployMode, InvalidSlugError, InvalidUrlError, JsonBackend, type LinkStats, type ListOptions, MAX_SLUG_LENGTH, MAX_URL_LENGTH, MIN_SLUG_LENGTH, RESERVED_SLUGS, type ResolveResult, SLUG_ALPHABET, type ShortUrl, SlugConflictError, SlugNotFoundError, type UrlBackend, type UrlDatabase, type UrlEntry, type UrlsManifest, type UtmParams, type ValidationResult, appendUtm, decodeSlug, extractUtm, generateRandomSlug, generateSlug, hasUtm, isValidBackend, isValidSlug, loadConfig, normalizeSlug, resolveConfig, resolveConfigPath, saveConfig, validateSlug, validateUrl };
package/dist/index.js ADDED
@@ -0,0 +1,313 @@
1
+ // src/config.ts
2
+ import { readFileSync, writeFileSync } from "fs";
3
+ import { homedir } from "os";
4
+ import { join } from "path";
5
+
6
+ // src/constants.ts
7
+ var DEFAULT_SLUG_LENGTH = 6;
8
+ var MIN_SLUG_LENGTH = 3;
9
+ var MAX_SLUG_LENGTH = 32;
10
+ var SLUG_ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
11
+ var MAX_URL_LENGTH = 2048;
12
+ var RESERVED_SLUGS = /* @__PURE__ */ new Set([
13
+ // Routes
14
+ "api",
15
+ "admin",
16
+ "dashboard",
17
+ "health",
18
+ "login",
19
+ "logout",
20
+ "signup",
21
+ "settings",
22
+ "password",
23
+ "shorten",
24
+ // Static files
25
+ "static",
26
+ "assets",
27
+ "favicon.ico",
28
+ "robots.txt",
29
+ "sitemap.xml",
30
+ "sitemap-index.xml",
31
+ // i18n locale codes (prevent conflicts with Astro SSG routes)
32
+ "en",
33
+ "es",
34
+ "fr",
35
+ "de",
36
+ "pt",
37
+ "ru",
38
+ "zh",
39
+ "hi",
40
+ "ar",
41
+ "ur",
42
+ "bn",
43
+ "ja"
44
+ ]);
45
+ var DEFAULT_DB_PATH = "urls.json";
46
+ var DB_VERSION = 1;
47
+
48
+ // src/config.ts
49
+ var DEFAULT_CONFIG = {
50
+ mode: "github",
51
+ backend: "json",
52
+ baseUrl: "",
53
+ slugLength: DEFAULT_SLUG_LENGTH,
54
+ dbPath: DEFAULT_DB_PATH
55
+ };
56
+ function resolveConfigPath() {
57
+ return join(homedir(), ".clipr.json");
58
+ }
59
+ function loadConfig(path) {
60
+ const configPath = path ?? resolveConfigPath();
61
+ try {
62
+ const raw = readFileSync(configPath, "utf-8");
63
+ const parsed = JSON.parse(raw);
64
+ return resolveConfig(parsed);
65
+ } catch {
66
+ return { ...DEFAULT_CONFIG };
67
+ }
68
+ }
69
+ function saveConfig(config, path) {
70
+ const configPath = path ?? resolveConfigPath();
71
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}
72
+ `);
73
+ }
74
+ function resolveConfig(partial = {}) {
75
+ return { ...DEFAULT_CONFIG, ...partial };
76
+ }
77
+ function isValidBackend(value) {
78
+ return value === "json" || value === "cloudflare-kv" || value === "redis";
79
+ }
80
+
81
+ // src/errors.ts
82
+ var CliprError = class extends Error {
83
+ constructor(message, code) {
84
+ super(message);
85
+ this.code = code;
86
+ this.name = "CliprError";
87
+ }
88
+ };
89
+ var InvalidSlugError = class extends CliprError {
90
+ constructor(slug, reason) {
91
+ super(`Invalid slug "${slug}": ${reason}`, "INVALID_SLUG");
92
+ this.name = "InvalidSlugError";
93
+ }
94
+ };
95
+ var InvalidUrlError = class extends CliprError {
96
+ constructor(url, reason) {
97
+ super(`Invalid URL "${url}": ${reason}`, "INVALID_URL");
98
+ this.name = "InvalidUrlError";
99
+ }
100
+ };
101
+ var SlugConflictError = class extends CliprError {
102
+ constructor(slug) {
103
+ super(`Slug "${slug}" already exists`, "SLUG_CONFLICT");
104
+ this.name = "SlugConflictError";
105
+ }
106
+ };
107
+ var SlugNotFoundError = class extends CliprError {
108
+ constructor(slug) {
109
+ super(`Slug "${slug}" not found`, "SLUG_NOT_FOUND");
110
+ this.name = "SlugNotFoundError";
111
+ }
112
+ };
113
+ var BackendError = class extends CliprError {
114
+ constructor(message) {
115
+ super(message, "BACKEND_ERROR");
116
+ this.name = "BackendError";
117
+ }
118
+ };
119
+
120
+ // src/json-backend.ts
121
+ import { readFile, writeFile } from "fs/promises";
122
+ function emptyDb() {
123
+ return { version: DB_VERSION, counter: 0, baseUrl: "", urls: {} };
124
+ }
125
+ var JsonBackend = class {
126
+ constructor(path) {
127
+ this.path = path;
128
+ }
129
+ async read() {
130
+ try {
131
+ const raw = await readFile(this.path, "utf-8");
132
+ return JSON.parse(raw);
133
+ } catch (err) {
134
+ if (err.code === "ENOENT") {
135
+ return emptyDb();
136
+ }
137
+ throw err;
138
+ }
139
+ }
140
+ async write(db) {
141
+ await writeFile(this.path, `${JSON.stringify(db, null, 2)}
142
+ `);
143
+ }
144
+ async get(slug) {
145
+ const db = await this.read();
146
+ return db.urls[slug];
147
+ }
148
+ async set(entry) {
149
+ const db = await this.read();
150
+ if (db.urls[entry.slug]) {
151
+ throw new SlugConflictError(entry.slug);
152
+ }
153
+ db.urls[entry.slug] = entry;
154
+ db.counter = Object.keys(db.urls).length;
155
+ await this.write(db);
156
+ }
157
+ async delete(slug) {
158
+ const db = await this.read();
159
+ if (!db.urls[slug]) {
160
+ return false;
161
+ }
162
+ delete db.urls[slug];
163
+ db.counter = Object.keys(db.urls).length;
164
+ await this.write(db);
165
+ return true;
166
+ }
167
+ async has(slug) {
168
+ const db = await this.read();
169
+ return slug in db.urls;
170
+ }
171
+ async list() {
172
+ const db = await this.read();
173
+ return Object.values(db.urls);
174
+ }
175
+ /** Update the baseUrl in the database. */
176
+ async setBaseUrl(baseUrl) {
177
+ const db = await this.read();
178
+ db.baseUrl = baseUrl;
179
+ await this.write(db);
180
+ }
181
+ /** Get the current baseUrl. */
182
+ async getBaseUrl() {
183
+ const db = await this.read();
184
+ return db.baseUrl;
185
+ }
186
+ };
187
+
188
+ // src/slug.ts
189
+ import Sqids from "sqids";
190
+ var sqids = new Sqids({ alphabet: SLUG_ALPHABET, minLength: 4 });
191
+ function generateSlug(counter) {
192
+ return sqids.encode([counter]);
193
+ }
194
+ function decodeSlug(slug) {
195
+ const decoded = sqids.decode(slug);
196
+ return decoded.length > 0 ? decoded[0] ?? null : null;
197
+ }
198
+ function generateRandomSlug(length = DEFAULT_SLUG_LENGTH) {
199
+ const bytes = new Uint8Array(length);
200
+ crypto.getRandomValues(bytes);
201
+ let slug = "";
202
+ for (let i = 0; i < length; i++) {
203
+ slug += SLUG_ALPHABET[bytes[i] % SLUG_ALPHABET.length];
204
+ }
205
+ return slug;
206
+ }
207
+ function isValidSlug(slug) {
208
+ if (slug.length < 1 || slug.length > 32) return false;
209
+ return /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug);
210
+ }
211
+ function normalizeSlug(slug) {
212
+ return slug.trim().toLowerCase();
213
+ }
214
+
215
+ // src/utm.ts
216
+ var UTM_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"];
217
+ function appendUtm(url, utm) {
218
+ const parsed = new URL(url);
219
+ for (const key of UTM_KEYS) {
220
+ const value = utm[key];
221
+ if (value !== void 0 && value !== "") {
222
+ parsed.searchParams.set(key, value);
223
+ }
224
+ }
225
+ return parsed.toString();
226
+ }
227
+ function extractUtm(url) {
228
+ const parsed = new URL(url);
229
+ const result = {};
230
+ for (const key of UTM_KEYS) {
231
+ const value = parsed.searchParams.get(key);
232
+ if (value !== null) {
233
+ result[key] = value;
234
+ }
235
+ }
236
+ return result;
237
+ }
238
+ function hasUtm(utm) {
239
+ if (!utm) return false;
240
+ return UTM_KEYS.some((key) => utm[key] !== void 0 && utm[key] !== "");
241
+ }
242
+
243
+ // src/validate.ts
244
+ var SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
245
+ function validateSlug(slug) {
246
+ if (slug.length < MIN_SLUG_LENGTH) {
247
+ return { valid: false, reason: `must be at least ${MIN_SLUG_LENGTH} characters` };
248
+ }
249
+ if (slug.length > MAX_SLUG_LENGTH) {
250
+ return { valid: false, reason: `must be at most ${MAX_SLUG_LENGTH} characters` };
251
+ }
252
+ if (!SLUG_PATTERN.test(slug)) {
253
+ return {
254
+ valid: false,
255
+ reason: "must contain only lowercase letters, numbers, and hyphens (no leading/trailing hyphens)"
256
+ };
257
+ }
258
+ if (RESERVED_SLUGS.has(slug)) {
259
+ return { valid: false, reason: "is a reserved slug" };
260
+ }
261
+ return { valid: true };
262
+ }
263
+ function validateUrl(url) {
264
+ if (url.length === 0) {
265
+ return { valid: false, reason: "URL cannot be empty" };
266
+ }
267
+ if (url.length > MAX_URL_LENGTH) {
268
+ return { valid: false, reason: `URL exceeds maximum length of ${MAX_URL_LENGTH} characters` };
269
+ }
270
+ let parsed;
271
+ try {
272
+ parsed = new URL(url);
273
+ } catch {
274
+ return { valid: false, reason: "not a valid URL" };
275
+ }
276
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
277
+ return { valid: false, reason: "only http and https URLs are allowed" };
278
+ }
279
+ return { valid: true };
280
+ }
281
+ export {
282
+ BackendError,
283
+ CliprError,
284
+ DB_VERSION,
285
+ DEFAULT_DB_PATH,
286
+ DEFAULT_SLUG_LENGTH,
287
+ InvalidSlugError,
288
+ InvalidUrlError,
289
+ JsonBackend,
290
+ MAX_SLUG_LENGTH,
291
+ MAX_URL_LENGTH,
292
+ MIN_SLUG_LENGTH,
293
+ RESERVED_SLUGS,
294
+ SLUG_ALPHABET,
295
+ SlugConflictError,
296
+ SlugNotFoundError,
297
+ appendUtm,
298
+ decodeSlug,
299
+ extractUtm,
300
+ generateRandomSlug,
301
+ generateSlug,
302
+ hasUtm,
303
+ isValidBackend,
304
+ isValidSlug,
305
+ loadConfig,
306
+ normalizeSlug,
307
+ resolveConfig,
308
+ resolveConfigPath,
309
+ saveConfig,
310
+ validateSlug,
311
+ validateUrl
312
+ };
313
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/config.ts","../src/constants.ts","../src/errors.ts","../src/json-backend.ts","../src/slug.ts","../src/utm.ts","../src/validate.ts"],"sourcesContent":["import { readFileSync, writeFileSync } from 'node:fs';\nimport { homedir } from 'node:os';\nimport { join } from 'node:path';\nimport { DEFAULT_DB_PATH, DEFAULT_SLUG_LENGTH } from './constants.js';\nimport type { BackendType, CliprConfig } from './types.js';\n\nconst DEFAULT_CONFIG: CliprConfig = {\n mode: 'github',\n backend: 'json',\n baseUrl: '',\n slugLength: DEFAULT_SLUG_LENGTH,\n dbPath: DEFAULT_DB_PATH,\n};\n\n/** Resolve the path to ~/.clipr.json. */\nexport function resolveConfigPath(): string {\n return join(homedir(), '.clipr.json');\n}\n\n/** Load config from ~/.clipr.json, falling back to defaults. */\nexport function loadConfig(path?: string): CliprConfig {\n const configPath = path ?? resolveConfigPath();\n try {\n const raw = readFileSync(configPath, 'utf-8');\n const parsed = JSON.parse(raw) as Partial<CliprConfig>;\n return resolveConfig(parsed);\n } catch {\n return { ...DEFAULT_CONFIG };\n }\n}\n\n/** Save config to ~/.clipr.json. */\nexport function saveConfig(config: CliprConfig, path?: string): void {\n const configPath = path ?? resolveConfigPath();\n writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\\n`);\n}\n\n/** Merge partial user config with defaults. */\nexport function resolveConfig(partial: Partial<CliprConfig> = {}): CliprConfig {\n return { ...DEFAULT_CONFIG, ...partial };\n}\n\n/** Validate that a backend type is recognized. */\nexport function isValidBackend(value: string): value is BackendType {\n return value === 'json' || value === 'cloudflare-kv' || value === 'redis';\n}\n","/** Default slug length when generating random slugs. */\nexport const DEFAULT_SLUG_LENGTH = 6;\n\n/** Minimum allowed slug length. */\nexport const MIN_SLUG_LENGTH = 3;\n\n/** Maximum allowed slug length. */\nexport const MAX_SLUG_LENGTH = 32;\n\n/** Characters used in random slug generation (URL-safe, no ambiguous chars). */\nexport const SLUG_ALPHABET = 'abcdefghijkmnpqrstuvwxyz23456789';\n\n/** Maximum allowed URL length in characters. */\nexport const MAX_URL_LENGTH = 2048;\n\n/** Reserved slugs that cannot be used (common paths + locale codes). */\nexport const RESERVED_SLUGS = new Set([\n // Routes\n 'api',\n 'admin',\n 'dashboard',\n 'health',\n 'login',\n 'logout',\n 'signup',\n 'settings',\n 'password',\n 'shorten',\n // Static files\n 'static',\n 'assets',\n 'favicon.ico',\n 'robots.txt',\n 'sitemap.xml',\n 'sitemap-index.xml',\n // i18n locale codes (prevent conflicts with Astro SSG routes)\n 'en',\n 'es',\n 'fr',\n 'de',\n 'pt',\n 'ru',\n 'zh',\n 'hi',\n 'ar',\n 'ur',\n 'bn',\n 'ja',\n]);\n\n/** Default database file path relative to project root. */\nexport const DEFAULT_DB_PATH = 'urls.json';\n\n/** Current URL database schema version. */\nexport const DB_VERSION = 1;\n","/** Base error class for all clipr errors. */\nexport class CliprError extends Error {\n constructor(\n message: string,\n public readonly code: string,\n ) {\n super(message);\n this.name = 'CliprError';\n }\n}\n\n/** Thrown when a slug is invalid (bad chars, reserved, wrong length). */\nexport class InvalidSlugError extends CliprError {\n constructor(slug: string, reason: string) {\n super(`Invalid slug \"${slug}\": ${reason}`, 'INVALID_SLUG');\n this.name = 'InvalidSlugError';\n }\n}\n\n/** Thrown when a URL fails validation. */\nexport class InvalidUrlError extends CliprError {\n constructor(url: string, reason: string) {\n super(`Invalid URL \"${url}\": ${reason}`, 'INVALID_URL');\n this.name = 'InvalidUrlError';\n }\n}\n\n/** Thrown when a slug already exists in the database. */\nexport class SlugConflictError extends CliprError {\n constructor(slug: string) {\n super(`Slug \"${slug}\" already exists`, 'SLUG_CONFLICT');\n this.name = 'SlugConflictError';\n }\n}\n\n/** Thrown when a slug is not found in the database. */\nexport class SlugNotFoundError extends CliprError {\n constructor(slug: string) {\n super(`Slug \"${slug}\" not found`, 'SLUG_NOT_FOUND');\n this.name = 'SlugNotFoundError';\n }\n}\n\n/** Thrown when the backend configuration is invalid or missing. */\nexport class BackendError extends CliprError {\n constructor(message: string) {\n super(message, 'BACKEND_ERROR');\n this.name = 'BackendError';\n }\n}\n","import { readFile, writeFile } from 'node:fs/promises';\nimport type { Backend } from './backend.js';\nimport { DB_VERSION } from './constants.js';\nimport { SlugConflictError } from './errors.js';\nimport type { UrlDatabase, UrlEntry } from './types.js';\n\nfunction emptyDb(): UrlDatabase {\n return { version: DB_VERSION, counter: 0, baseUrl: '', urls: {} };\n}\n\nexport class JsonBackend implements Backend {\n constructor(private readonly path: string) {}\n\n private async read(): Promise<UrlDatabase> {\n try {\n const raw = await readFile(this.path, 'utf-8');\n return JSON.parse(raw) as UrlDatabase;\n } catch (err: unknown) {\n if ((err as NodeJS.ErrnoException).code === 'ENOENT') {\n return emptyDb();\n }\n throw err;\n }\n }\n\n private async write(db: UrlDatabase): Promise<void> {\n await writeFile(this.path, `${JSON.stringify(db, null, 2)}\\n`);\n }\n\n async get(slug: string): Promise<UrlEntry | undefined> {\n const db = await this.read();\n return db.urls[slug];\n }\n\n async set(entry: UrlEntry): Promise<void> {\n const db = await this.read();\n if (db.urls[entry.slug]) {\n throw new SlugConflictError(entry.slug);\n }\n db.urls[entry.slug] = entry;\n db.counter = Object.keys(db.urls).length;\n await this.write(db);\n }\n\n async delete(slug: string): Promise<boolean> {\n const db = await this.read();\n if (!db.urls[slug]) {\n return false;\n }\n delete db.urls[slug];\n db.counter = Object.keys(db.urls).length;\n await this.write(db);\n return true;\n }\n\n async has(slug: string): Promise<boolean> {\n const db = await this.read();\n return slug in db.urls;\n }\n\n async list(): Promise<UrlEntry[]> {\n const db = await this.read();\n return Object.values(db.urls);\n }\n\n /** Update the baseUrl in the database. */\n async setBaseUrl(baseUrl: string): Promise<void> {\n const db = await this.read();\n db.baseUrl = baseUrl;\n await this.write(db);\n }\n\n /** Get the current baseUrl. */\n async getBaseUrl(): Promise<string> {\n const db = await this.read();\n return db.baseUrl;\n }\n}\n","import Sqids from 'sqids';\nimport { DEFAULT_SLUG_LENGTH, SLUG_ALPHABET } from './constants.js';\n\nconst sqids = new Sqids({ alphabet: SLUG_ALPHABET, minLength: 4 });\n\n/**\n * Generate a slug from a counter value using Sqids.\n * Deterministic: same counter always produces same slug.\n */\nexport function generateSlug(counter: number): string {\n return sqids.encode([counter]);\n}\n\n/**\n * Decode a Sqids-generated slug back to its counter value.\n * Returns the counter number, or null if invalid.\n */\nexport function decodeSlug(slug: string): number | null {\n const decoded = sqids.decode(slug);\n return decoded.length > 0 ? (decoded[0] ?? null) : null;\n}\n\n/**\n * Generate a random slug using crypto.getRandomValues.\n * Used when no counter is available.\n */\nexport function generateRandomSlug(length: number = DEFAULT_SLUG_LENGTH): string {\n const bytes = new Uint8Array(length);\n crypto.getRandomValues(bytes);\n let slug = '';\n for (let i = 0; i < length; i++) {\n slug += SLUG_ALPHABET[bytes[i]! % SLUG_ALPHABET.length];\n }\n return slug;\n}\n\n/** Check if a string is a valid slug format. */\nexport function isValidSlug(slug: string): boolean {\n if (slug.length < 1 || slug.length > 32) return false;\n return /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(slug);\n}\n\n/** Normalize a slug to lowercase and trimmed. */\nexport function normalizeSlug(slug: string): string {\n return slug.trim().toLowerCase();\n}\n","import type { UtmParams } from './types.js';\n\n/** All recognized UTM parameter keys. */\nconst UTM_KEYS = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content'] as const;\n\n/** Append UTM parameters to a URL string. Existing UTM params on the URL are overwritten. */\nexport function appendUtm(url: string, utm: UtmParams): string {\n const parsed = new URL(url);\n\n for (const key of UTM_KEYS) {\n const value = utm[key];\n if (value !== undefined && value !== '') {\n parsed.searchParams.set(key, value);\n }\n }\n\n return parsed.toString();\n}\n\n/** Extract UTM parameters from a URL string. */\nexport function extractUtm(url: string): UtmParams {\n const parsed = new URL(url);\n const result: UtmParams = {};\n\n for (const key of UTM_KEYS) {\n const value = parsed.searchParams.get(key);\n if (value !== null) {\n result[key] = value;\n }\n }\n\n return result;\n}\n\n/** Check if a UtmParams object has any non-empty values. */\nexport function hasUtm(utm: UtmParams | undefined): utm is UtmParams {\n if (!utm) return false;\n return UTM_KEYS.some((key) => utm[key] !== undefined && utm[key] !== '');\n}\n","import { MAX_SLUG_LENGTH, MAX_URL_LENGTH, MIN_SLUG_LENGTH, RESERVED_SLUGS } from './constants.js';\nimport type { ValidationResult } from './types.js';\n\n/** Allowed characters in a slug: lowercase alphanumeric plus hyphens. */\nconst SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;\n\n/** Validate a slug string. */\nexport function validateSlug(slug: string): ValidationResult {\n if (slug.length < MIN_SLUG_LENGTH) {\n return { valid: false, reason: `must be at least ${MIN_SLUG_LENGTH} characters` };\n }\n if (slug.length > MAX_SLUG_LENGTH) {\n return { valid: false, reason: `must be at most ${MAX_SLUG_LENGTH} characters` };\n }\n if (!SLUG_PATTERN.test(slug)) {\n return {\n valid: false,\n reason:\n 'must contain only lowercase letters, numbers, and hyphens (no leading/trailing hyphens)',\n };\n }\n if (RESERVED_SLUGS.has(slug)) {\n return { valid: false, reason: 'is a reserved slug' };\n }\n return { valid: true };\n}\n\n/** Validate a target URL. */\nexport function validateUrl(url: string): ValidationResult {\n if (url.length === 0) {\n return { valid: false, reason: 'URL cannot be empty' };\n }\n if (url.length > MAX_URL_LENGTH) {\n return { valid: false, reason: `URL exceeds maximum length of ${MAX_URL_LENGTH} characters` };\n }\n\n let parsed: URL;\n try {\n parsed = new URL(url);\n } catch {\n return { valid: false, reason: 'not a valid URL' };\n }\n\n if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {\n return { valid: false, reason: 'only http and https URLs are allowed' };\n }\n\n return { valid: true };\n}\n"],"mappings":"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","names":[]}
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@clipr/core",
3
+ "version": "0.0.5",
4
+ "description": "Shared types, validation, slug generation, and utilities for clipr URL shortener",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/index.js",
9
+ "types": "./dist/index.d.ts"
10
+ }
11
+ },
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "files": [
15
+ "dist",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "scripts": {
20
+ "build": "tsup",
21
+ "dev": "tsup --watch",
22
+ "test": "vitest run",
23
+ "test:watch": "vitest",
24
+ "test:coverage": "vitest run --coverage",
25
+ "typecheck": "tsc --noEmit",
26
+ "clean": "rm -rf dist",
27
+ "prepublishOnly": "pnpm build"
28
+ },
29
+ "keywords": [
30
+ "url-shortener",
31
+ "short-url",
32
+ "clipr",
33
+ "slug",
34
+ "validation",
35
+ "utm",
36
+ "sqids"
37
+ ],
38
+ "homepage": "https://github.com/hammadkhanxcm/clipr#readme",
39
+ "bugs": {
40
+ "url": "https://github.com/hammadkhanxcm/clipr/issues"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/hammadkhanxcm/clipr.git",
45
+ "directory": "packages/core"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "dependencies": {
51
+ "sqids": "^0.3.0"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^25.5.0",
55
+ "tsup": "^8.0.0",
56
+ "vitest": "^4.1.2"
57
+ },
58
+ "engines": {
59
+ "node": ">=22"
60
+ },
61
+ "license": "MIT",
62
+ "author": "Hammad Khan"
63
+ }