@fuzdev/fuz_util 0.42.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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/array.d.ts +15 -0
  4. package/dist/array.d.ts.map +1 -0
  5. package/dist/array.js +25 -0
  6. package/dist/async.d.ts +62 -0
  7. package/dist/async.d.ts.map +1 -0
  8. package/dist/async.js +147 -0
  9. package/dist/colors.d.ts +41 -0
  10. package/dist/colors.d.ts.map +1 -0
  11. package/dist/colors.js +106 -0
  12. package/dist/counter.d.ts +7 -0
  13. package/dist/counter.d.ts.map +1 -0
  14. package/dist/counter.js +7 -0
  15. package/dist/deep_equal.d.ts +18 -0
  16. package/dist/deep_equal.d.ts.map +1 -0
  17. package/dist/deep_equal.js +152 -0
  18. package/dist/dom.d.ts +35 -0
  19. package/dist/dom.d.ts.map +1 -0
  20. package/dist/dom.js +95 -0
  21. package/dist/error.d.ts +15 -0
  22. package/dist/error.d.ts.map +1 -0
  23. package/dist/error.js +18 -0
  24. package/dist/fetch.d.ts +81 -0
  25. package/dist/fetch.d.ts.map +1 -0
  26. package/dist/fetch.js +162 -0
  27. package/dist/fs.d.ts +34 -0
  28. package/dist/fs.d.ts.map +1 -0
  29. package/dist/fs.js +73 -0
  30. package/dist/function.d.ts +27 -0
  31. package/dist/function.d.ts.map +1 -0
  32. package/dist/function.js +21 -0
  33. package/dist/git.d.ts +132 -0
  34. package/dist/git.d.ts.map +1 -0
  35. package/dist/git.js +288 -0
  36. package/dist/id.d.ts +18 -0
  37. package/dist/id.d.ts.map +1 -0
  38. package/dist/id.js +18 -0
  39. package/dist/iterator.d.ts +5 -0
  40. package/dist/iterator.d.ts.map +1 -0
  41. package/dist/iterator.js +9 -0
  42. package/dist/json.d.ts +30 -0
  43. package/dist/json.d.ts.map +1 -0
  44. package/dist/json.js +44 -0
  45. package/dist/library_json.d.ts +42 -0
  46. package/dist/library_json.d.ts.map +1 -0
  47. package/dist/library_json.js +76 -0
  48. package/dist/log.d.ts +188 -0
  49. package/dist/log.d.ts.map +1 -0
  50. package/dist/log.js +393 -0
  51. package/dist/map.d.ts +12 -0
  52. package/dist/map.d.ts.map +1 -0
  53. package/dist/map.js +14 -0
  54. package/dist/maths.d.ts +85 -0
  55. package/dist/maths.d.ts.map +1 -0
  56. package/dist/maths.js +87 -0
  57. package/dist/object.d.ts +46 -0
  58. package/dist/object.d.ts.map +1 -0
  59. package/dist/object.js +89 -0
  60. package/dist/package_json.d.ts +90 -0
  61. package/dist/package_json.d.ts.map +1 -0
  62. package/dist/package_json.js +112 -0
  63. package/dist/path.d.ts +63 -0
  64. package/dist/path.d.ts.map +1 -0
  65. package/dist/path.js +83 -0
  66. package/dist/print.d.ts +52 -0
  67. package/dist/print.d.ts.map +1 -0
  68. package/dist/print.js +89 -0
  69. package/dist/process.d.ts +77 -0
  70. package/dist/process.d.ts.map +1 -0
  71. package/dist/process.js +148 -0
  72. package/dist/random.d.ts +25 -0
  73. package/dist/random.d.ts.map +1 -0
  74. package/dist/random.js +35 -0
  75. package/dist/random_alea.d.ts +23 -0
  76. package/dist/random_alea.d.ts.map +1 -0
  77. package/dist/random_alea.js +95 -0
  78. package/dist/regexp.d.ts +12 -0
  79. package/dist/regexp.d.ts.map +1 -0
  80. package/dist/regexp.js +16 -0
  81. package/dist/result.d.ts +64 -0
  82. package/dist/result.d.ts.map +1 -0
  83. package/dist/result.js +48 -0
  84. package/dist/source_json.d.ts +375 -0
  85. package/dist/source_json.d.ts.map +1 -0
  86. package/dist/source_json.js +189 -0
  87. package/dist/string.d.ts +51 -0
  88. package/dist/string.d.ts.map +1 -0
  89. package/dist/string.js +92 -0
  90. package/dist/throttle.d.ts +26 -0
  91. package/dist/throttle.d.ts.map +1 -0
  92. package/dist/throttle.js +53 -0
  93. package/dist/timings.d.ts +33 -0
  94. package/dist/timings.d.ts.map +1 -0
  95. package/dist/timings.js +75 -0
  96. package/dist/types.d.ts +77 -0
  97. package/dist/types.d.ts.map +1 -0
  98. package/dist/types.js +10 -0
  99. package/dist/url.d.ts +10 -0
  100. package/dist/url.d.ts.map +1 -0
  101. package/dist/url.js +8 -0
  102. package/package.json +125 -0
  103. package/src/lib/array.ts +30 -0
  104. package/src/lib/async.ts +182 -0
  105. package/src/lib/colors.ts +132 -0
  106. package/src/lib/counter.ts +11 -0
  107. package/src/lib/deep_equal.ts +155 -0
  108. package/src/lib/dom.ts +108 -0
  109. package/src/lib/error.ts +22 -0
  110. package/src/lib/fetch.ts +231 -0
  111. package/src/lib/fs.ts +128 -0
  112. package/src/lib/function.ts +32 -0
  113. package/src/lib/git.ts +390 -0
  114. package/src/lib/id.ts +30 -0
  115. package/src/lib/iterator.ts +8 -0
  116. package/src/lib/json.ts +61 -0
  117. package/src/lib/library_json.ts +122 -0
  118. package/src/lib/log.ts +469 -0
  119. package/src/lib/map.ts +18 -0
  120. package/src/lib/maths.ts +91 -0
  121. package/src/lib/object.ts +110 -0
  122. package/src/lib/package_json.ts +135 -0
  123. package/src/lib/path.ts +137 -0
  124. package/src/lib/print.ts +111 -0
  125. package/src/lib/process.ts +207 -0
  126. package/src/lib/random.ts +48 -0
  127. package/src/lib/random_alea.ts +107 -0
  128. package/src/lib/regexp.ts +17 -0
  129. package/src/lib/result.ts +67 -0
  130. package/src/lib/source_json.ts +209 -0
  131. package/src/lib/string.ts +99 -0
  132. package/src/lib/throttle.ts +70 -0
  133. package/src/lib/timings.ts +93 -0
  134. package/src/lib/types.ts +99 -0
  135. package/src/lib/url.ts +14 -0
package/src/lib/git.ts ADDED
@@ -0,0 +1,390 @@
1
+ import type {SpawnOptions} from 'node:child_process';
2
+ import {z} from 'zod';
3
+
4
+ import {spawn, spawn_out} from './process.js';
5
+ import type {Flavored} from './types.js';
6
+ import {to_file_path} from './path.js';
7
+ import {fs_exists} from './fs.js';
8
+
9
+ export const GitOrigin = z.string();
10
+ export type GitOrigin = Flavored<string, 'GitOrigin'>;
11
+
12
+ export const GitBranch = z.string();
13
+ export type GitBranch = Flavored<string, 'GitBranch'>;
14
+
15
+ /**
16
+ * Returns the current git branch name or throws if something goes wrong.
17
+ */
18
+ export const git_current_branch_name = async (options?: SpawnOptions): Promise<GitBranch> => {
19
+ const {stdout} = await spawn_out('git', ['rev-parse', '--abbrev-ref', 'HEAD'], options);
20
+ if (!stdout) throw Error('git_current_branch_name failed');
21
+ const branch_name = stdout.trim() as GitBranch;
22
+ return branch_name;
23
+ };
24
+
25
+ /**
26
+ * @returns a boolean indicating if the remote git branch exists
27
+ */
28
+ export const git_remote_branch_exists = async (
29
+ origin: GitOrigin = 'origin' as GitOrigin,
30
+ branch?: GitBranch,
31
+ options?: SpawnOptions,
32
+ ): Promise<boolean> => {
33
+ const final_branch = branch ?? (await git_current_branch_name(options));
34
+ if (options?.cwd && !(await fs_exists(to_file_path(options.cwd)))) {
35
+ return false;
36
+ }
37
+ const result = await spawn(
38
+ 'git',
39
+ ['ls-remote', '--exit-code', '--heads', origin, 'refs/heads/' + final_branch],
40
+ options,
41
+ );
42
+ if (result.ok) {
43
+ return true;
44
+ } else if (result.code === 2) {
45
+ return false;
46
+ } else {
47
+ throw Error(
48
+ `git_remote_branch_exists failed for origin '${origin}' and branch '${final_branch}' with code ${result.code}`,
49
+ );
50
+ }
51
+ };
52
+
53
+ /**
54
+ * @returns a boolean indicating if the local git branch exists
55
+ */
56
+ export const git_local_branch_exists = async (
57
+ branch: GitBranch,
58
+ options?: SpawnOptions,
59
+ ): Promise<boolean> => {
60
+ if (options?.cwd && !(await fs_exists(to_file_path(options.cwd)))) {
61
+ return false;
62
+ }
63
+ const result = await spawn('git', ['show-ref', '--quiet', 'refs/heads/' + branch], options);
64
+ return result.ok;
65
+ };
66
+
67
+ /**
68
+ * Git workspace status flags indicating which types of changes are present.
69
+ */
70
+ export interface GitWorkspaceStatus {
71
+ unstaged_changes: boolean;
72
+ staged_changes: boolean;
73
+ untracked_files: boolean;
74
+ }
75
+
76
+ /**
77
+ * Parses the output of `git status --porcelain -z` (v1 format) into a status object.
78
+ * This is a pure function that can be tested independently.
79
+ *
80
+ * Format: XY path\0 where:
81
+ * - X = staged status (index)
82
+ * - Y = unstaged status (work tree)
83
+ * - path = file path (unescaped with -z)
84
+ *
85
+ * Supported status codes:
86
+ * - M = modified
87
+ * - A = added
88
+ * - D = deleted
89
+ * - R = renamed
90
+ * - C = copied
91
+ * - T = type changed (regular file, symbolic link or submodule)
92
+ * - U = unmerged
93
+ * - ? = untracked
94
+ * - ! = ignored
95
+ *
96
+ * For renames/copies: XY new\0old\0 (two NUL-separated paths)
97
+ *
98
+ * Note: This implementation treats submodules the same as regular files.
99
+ * Submodule-specific status codes (lowercase m, ?) are interpreted as changes.
100
+ *
101
+ * @param stdout - The raw output from `git status --porcelain -z`
102
+ * @returns status object with flags for unstaged changes, staged changes, and untracked files
103
+ */
104
+ export const git_parse_workspace_status = (stdout: string | null): GitWorkspaceStatus => {
105
+ // Split on NUL character (\0) for -z format
106
+ // Filter out empty strings (last element after final \0)
107
+ const entries = stdout?.split('\0').filter(Boolean) ?? [];
108
+
109
+ // For rename/copy operations, we need to skip the old filename entry
110
+ // Format: R new\0old\0 or C new\0old\0
111
+ let skipNext = false;
112
+ const lines: Array<string> = [];
113
+
114
+ for (const entry of entries) {
115
+ if (skipNext) {
116
+ skipNext = false;
117
+ continue;
118
+ }
119
+
120
+ // Check if this is a rename/copy operation in either position
121
+ // R = renamed in index, RM = renamed in index + modified in work tree
122
+ // R = renamed in work tree (rare but possible with certain configs)
123
+ if (
124
+ entry.length >= 3 &&
125
+ (entry[0] === 'R' || entry[0] === 'C' || entry[1] === 'R' || entry[1] === 'C')
126
+ ) {
127
+ skipNext = true;
128
+ }
129
+
130
+ lines.push(entry);
131
+ }
132
+
133
+ return {
134
+ // Y position (index 1) - any non-space, non-?, non-! means unstaged changes
135
+ // Minimum length is 3: XY followed by at least one space (e.g., "M ")
136
+ unstaged_changes: lines.some(
137
+ (line) => line.length >= 3 && line[1] !== ' ' && line[1] !== '?' && line[1] !== '!',
138
+ ),
139
+ // X position (index 0) - any non-space, non-?, non-! means staged changes
140
+ // Minimum length is 3: XY followed by at least one space (e.g., "M ")
141
+ staged_changes: lines.some(
142
+ (line) => line.length >= 3 && line[0] !== ' ' && line[0] !== '?' && line[0] !== '!',
143
+ ),
144
+ // ?? prefix means untracked files
145
+ untracked_files: lines.some((line) => line.startsWith('??')),
146
+ };
147
+ };
148
+
149
+ /**
150
+ * Checks the git workspace status using a single `git status --porcelain -z` call.
151
+ * The -z format provides more reliable parsing by using NUL separators and avoiding escaping.
152
+ * @returns status object with flags for unstaged changes, staged changes, and untracked files
153
+ */
154
+ export const git_check_workspace = async (options?: SpawnOptions): Promise<GitWorkspaceStatus> => {
155
+ const {stdout} = await spawn_out('git', ['status', '--porcelain', '-z'], options);
156
+ return git_parse_workspace_status(stdout);
157
+ };
158
+
159
+ /**
160
+ * @returns `true` if the workspace has no changes at all
161
+ */
162
+ export const git_workspace_is_clean = (status: GitWorkspaceStatus): boolean =>
163
+ !status.unstaged_changes && !status.staged_changes && !status.untracked_files;
164
+
165
+ /**
166
+ * @returns `true` if the workspace has no unstaged changes and no untracked files (staged changes are OK)
167
+ */
168
+ export const git_workspace_is_fully_staged = (status: GitWorkspaceStatus): boolean =>
169
+ !status.unstaged_changes && !status.untracked_files;
170
+
171
+ /**
172
+ * Converts a workspace status to a human-readable message.
173
+ */
174
+ export const git_workspace_status_message = (status: GitWorkspaceStatus): string => {
175
+ if (git_workspace_is_clean(status)) return 'workspace is clean';
176
+ const issues: Array<string> = [];
177
+ if (status.unstaged_changes) issues.push('unstaged changes');
178
+ if (status.staged_changes) issues.push('staged but uncommitted changes');
179
+ if (status.untracked_files) issues.push('untracked files');
180
+ return `git has ${issues.join(', ')}`;
181
+ };
182
+
183
+ /**
184
+ * @returns an error message if the git workspace has any unstaged or uncommitted changes, or `null` if it's clean
185
+ */
186
+ export const git_check_clean_workspace = async (options?: SpawnOptions): Promise<string | null> => {
187
+ const status = await git_check_workspace(options);
188
+ return git_workspace_is_clean(status) ? null : git_workspace_status_message(status);
189
+ };
190
+
191
+ /**
192
+ * @returns an error message if the git workspace has any unstaged changes or untracked files, or `null` if fully staged
193
+ */
194
+ export const git_check_fully_staged_workspace = async (
195
+ options?: SpawnOptions,
196
+ ): Promise<string | null> => {
197
+ const status = await git_check_workspace(options);
198
+ return git_workspace_is_fully_staged(status) ? null : git_workspace_status_message(status);
199
+ };
200
+
201
+ /**
202
+ * Calls `git fetch` and throws if anything goes wrong.
203
+ */
204
+ export const git_fetch = async (
205
+ origin: GitOrigin = 'origin' as GitOrigin,
206
+ branch?: GitBranch,
207
+ options?: SpawnOptions,
208
+ ): Promise<void> => {
209
+ const args = ['fetch', origin];
210
+ if (branch) args.push(branch);
211
+ const result = await spawn('git', args, options);
212
+ if (!result.ok) {
213
+ throw Error(
214
+ `git_fetch failed for origin '${origin}' and branch '${branch}' with code ${result.code}`,
215
+ );
216
+ }
217
+ };
218
+
219
+ /**
220
+ * Calls `git checkout` and throws if anything goes wrong.
221
+ * @returns the previous branch name, if it changed
222
+ */
223
+ export const git_checkout = async (
224
+ branch: GitBranch,
225
+ options?: SpawnOptions,
226
+ ): Promise<GitBranch | null> => {
227
+ const current_branch = await git_current_branch_name(options);
228
+ if (branch === current_branch) {
229
+ return null;
230
+ }
231
+ const result = await spawn('git', ['checkout', branch], options);
232
+ if (!result.ok) {
233
+ throw Error(`git_checkout failed for branch '${branch}' with code ${result.code}`);
234
+ }
235
+ return current_branch;
236
+ };
237
+
238
+ /**
239
+ * Calls `git pull` and throws if anything goes wrong.
240
+ */
241
+ export const git_pull = async (
242
+ origin: GitOrigin = 'origin' as GitOrigin,
243
+ branch?: GitBranch,
244
+ options?: SpawnOptions,
245
+ ): Promise<void> => {
246
+ const args = ['pull', origin];
247
+ if (branch) args.push(branch);
248
+ const result = await spawn('git', args, options);
249
+ if (!result.ok) {
250
+ throw Error(`git_pull failed for branch '${branch}' with code ${result.code}`);
251
+ }
252
+ };
253
+
254
+ /**
255
+ * Calls `git push` and throws if anything goes wrong.
256
+ */
257
+ export const git_push = async (
258
+ origin: GitOrigin,
259
+ branch?: GitBranch,
260
+ options?: SpawnOptions,
261
+ set_upstream = false,
262
+ ): Promise<void> => {
263
+ const final_branch = branch ?? (await git_current_branch_name(options));
264
+ const args = ['push', origin, final_branch];
265
+ if (set_upstream) args.push('-u');
266
+ const result = await spawn('git', args, options);
267
+ if (!result.ok) {
268
+ throw Error(`git_push failed for branch '${final_branch}' with code ${result.code}`);
269
+ }
270
+ };
271
+
272
+ /**
273
+ * Calls `git push` and throws if anything goes wrong.
274
+ */
275
+ export const git_push_to_create = async (
276
+ origin: GitOrigin = 'origin' as GitOrigin,
277
+ branch?: GitBranch,
278
+ options?: SpawnOptions,
279
+ ): Promise<void> => {
280
+ const final_branch = branch ?? (await git_current_branch_name(options));
281
+ const push_args = ['push'];
282
+ if (await git_remote_branch_exists(origin, final_branch, options)) {
283
+ push_args.push(origin);
284
+ } else {
285
+ push_args.push('-u', origin);
286
+ }
287
+ push_args.push(final_branch);
288
+ const result = await spawn('git', push_args, options);
289
+ if (!result.ok) {
290
+ throw Error(`git_push failed for branch '${final_branch}' with code ${result.code}`);
291
+ }
292
+ };
293
+
294
+ /**
295
+ * Deletes a branch locally and throws if anything goes wrong.
296
+ */
297
+ export const git_delete_local_branch = async (
298
+ branch: GitBranch,
299
+ options?: SpawnOptions,
300
+ ): Promise<void> => {
301
+ const result = await spawn('git', ['branch', '-D', branch], options);
302
+ if (!result.ok) {
303
+ throw Error(`git_delete_local_branch failed for branch '${branch}' with code ${result.code}`);
304
+ }
305
+ };
306
+
307
+ /**
308
+ * Deletes a branch remotely and throws if anything goes wrong.
309
+ */
310
+ export const git_delete_remote_branch = async (
311
+ origin: GitOrigin,
312
+ branch: GitBranch,
313
+ options?: SpawnOptions,
314
+ ): Promise<void> => {
315
+ const result = await spawn('git', ['push', origin, ':' + branch], options);
316
+ if (!result.ok) {
317
+ throw Error(`git_delete_remote_branch failed for branch '${branch}' with code ${result.code}`);
318
+ }
319
+ };
320
+
321
+ /**
322
+ * Resets the `target` branch back to its first commit both locally and remotely.
323
+ */
324
+ export const git_reset_branch_to_first_commit = async (
325
+ origin: GitOrigin,
326
+ branch: GitBranch,
327
+ options?: SpawnOptions,
328
+ ): Promise<void> => {
329
+ const previous_branch = await git_checkout(branch, options);
330
+ const first_commit_hash = await git_current_branch_first_commit_hash(options);
331
+ await spawn('git', ['reset', '--hard', first_commit_hash], options);
332
+ await spawn('git', ['push', origin, branch, '--force'], options);
333
+ if (previous_branch) {
334
+ await git_checkout(previous_branch, options);
335
+ }
336
+ };
337
+
338
+ /**
339
+ * Returns the branch's latest commit hash or throws if something goes wrong.
340
+ */
341
+ export const git_current_commit_hash = async (
342
+ branch?: string,
343
+ options?: SpawnOptions,
344
+ ): Promise<string | null> => {
345
+ const final_branch = branch ?? (await git_current_branch_name(options));
346
+ const {stdout} = await spawn_out('git', ['show-ref', '-s', final_branch], options);
347
+ if (!stdout) return null; // TODO hack for CI
348
+ return stdout.split('\n')[0]!.trim();
349
+ };
350
+
351
+ /**
352
+ * Returns the hash of the current branch's first commit or throws if something goes wrong.
353
+ */
354
+ export const git_current_branch_first_commit_hash = async (
355
+ options?: SpawnOptions,
356
+ ): Promise<string> => {
357
+ const {stdout} = await spawn_out(
358
+ 'git',
359
+ ['rev-list', '--max-parents=0', '--abbrev-commit', 'HEAD'],
360
+ options,
361
+ );
362
+ if (!stdout) throw Error('git_current_branch_first_commit_hash failed');
363
+ return stdout.trim();
364
+ };
365
+
366
+ /**
367
+ * Returns the global git config setting for `pull.rebase`.
368
+ */
369
+ export const git_check_setting_pull_rebase = async (options?: SpawnOptions): Promise<boolean> => {
370
+ const value = await spawn_out('git', ['config', '--global', 'pull.rebase'], options);
371
+ return value.stdout?.trim() === 'true';
372
+ };
373
+
374
+ /**
375
+ * Clones a branch locally to another directory and updates the origin to match the source.
376
+ */
377
+ export const git_clone_locally = async (
378
+ origin: GitOrigin,
379
+ branch: GitBranch,
380
+ source_dir: string,
381
+ target_dir: string,
382
+ options?: SpawnOptions,
383
+ ): Promise<void> => {
384
+ await spawn('git', ['clone', '-b', branch, '--single-branch', source_dir, target_dir], options);
385
+ const origin_url = (
386
+ await spawn_out('git', ['remote', 'get-url', origin], {...options, cwd: source_dir})
387
+ ).stdout?.trim();
388
+ if (!origin_url) throw Error('Failed to get the origin url with git in ' + source_dir);
389
+ await spawn('git', ['remote', 'set-url', origin, origin_url], {...options, cwd: target_dir});
390
+ };
package/src/lib/id.ts ADDED
@@ -0,0 +1,30 @@
1
+ import type {Flavored} from './types.js';
2
+ import {create_counter} from './counter.js';
3
+
4
+ export type Uuid = Flavored<string, 'Uuid'>;
5
+
6
+ /**
7
+ * Loosely validates a UUID string.
8
+ */
9
+ export const is_uuid = (str: string): str is Uuid => UUID_MATCHER.test(str);
10
+
11
+ /**
12
+ * Postgres doesn't support the namespace prefix, so neither does Felt.
13
+ * For more see the UUID RFC - https://tools.ietf.org/html/rfc4122
14
+ * The Ajv validator does support the namespace, hence this custom implementation.
15
+ */
16
+ export const UUID_MATCHER = /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/iu;
17
+
18
+ export type ClientIdCreator = () => string;
19
+
20
+ /**
21
+ * Creates a string id generator function, outputting `${name}_${count}` by default.
22
+ */
23
+ export const create_client_id_creator = (
24
+ name: string,
25
+ count?: number,
26
+ separator = '_',
27
+ ): ClientIdCreator => {
28
+ const counter = create_counter(count);
29
+ return () => `${name}${separator}${counter()}`;
30
+ };
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Useful for fast counting when `.length` is not available.
3
+ */
4
+ export const count_iterator = (iterator: Iterable<unknown>): number => {
5
+ var count = 0;
6
+ for (var _ of iterator) count++;
7
+ return count;
8
+ };
@@ -0,0 +1,61 @@
1
+ export type Json = JsonPrimitive | JsonObject | JsonArray;
2
+
3
+ export type JsonPrimitive = string | number | boolean | null;
4
+
5
+ export interface JsonObject extends Record<string, Json> {} // eslint-disable-line @typescript-eslint/no-empty-object-type
6
+
7
+ export interface JsonArray extends Array<Json> {} // eslint-disable-line @typescript-eslint/no-empty-object-type
8
+
9
+ /**
10
+ * Like `typeof json`, but includes arrays. Excludes `'undefined'` because it's not valid JSON.
11
+ */
12
+ export type JsonType = 'string' | 'number' | 'boolean' | 'null' | 'object' | 'array';
13
+
14
+ /**
15
+ * Returns the `JsonType` of `value`, which is like `typeof json`
16
+ * but includes `'array'` and omits `'undefined'`.
17
+ */
18
+ export const json_type_of = (value: Json): JsonType | undefined => {
19
+ const type = typeof value;
20
+ switch (type) {
21
+ case 'string':
22
+ case 'number':
23
+ case 'boolean':
24
+ return type;
25
+ case 'object': {
26
+ return value === null ? 'null' : Array.isArray(value) ? 'array' : 'object';
27
+ }
28
+ default: {
29
+ // "undefined" | "function" | "bigint" | "symbol"
30
+ return undefined;
31
+ }
32
+ }
33
+ };
34
+
35
+ /**
36
+ * Embeds `data` as a JSON string, escaping single quotes.
37
+ * Useful for optimizing JSON in JS because it parses faster.
38
+ */
39
+ export const json_embed = <T>(data: T, stringify: (data: T) => string = JSON.stringify): string =>
40
+ `JSON.parse('${stringify(data).replaceAll("'", "\\'").replaceAll('\n', '\\\n')}')`;
41
+
42
+ /**
43
+ * Serializes a value to JSON with deterministic key ordering.
44
+ * Recursively sorts object keys alphabetically for consistent hashing.
45
+ * Arrays and primitives are serialized as-is.
46
+ *
47
+ * @param value Any JSON-serializable value
48
+ * @returns Deterministic JSON string representation
49
+ */
50
+ export const json_stringify_deterministic = (value: unknown): string =>
51
+ JSON.stringify(value, (_key, val) => {
52
+ if (val !== null && typeof val === 'object' && !Array.isArray(val)) {
53
+ const sorted: Record<string, unknown> = {};
54
+ const keys = Object.keys(val).sort();
55
+ for (const k of keys) {
56
+ sorted[k] = val[k];
57
+ }
58
+ return sorted;
59
+ }
60
+ return val;
61
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Library metadata combining package.json with analyzed source.
3
+ */
4
+
5
+ import {ensure_end, strip_end, strip_start} from './string.js';
6
+ import type {PackageJson} from './package_json.js';
7
+ import type {SourceJson} from './source_json.js';
8
+ import type {Url} from './url.js';
9
+
10
+ /**
11
+ * A library's package.json and source metadata with computed properties.
12
+ */
13
+ export interface LibraryJson {
14
+ package_json: PackageJson;
15
+ source_json: SourceJson;
16
+ /** Package name, e.g. `@ryanatkn/fuz`. */
17
+ name: string;
18
+ /** Name without scope, e.g. `fuz`. */
19
+ repo_name: string;
20
+ /** GitHub repo URL, e.g. `https://github.com/ryanatkn/fuz`. */
21
+ repo_url: Url;
22
+ /** GitHub user/org, e.g. `ryanatkn`. */
23
+ owner_name: string | null;
24
+ homepage_url: Url | null;
25
+ /** Logo URL, falls back to `favicon.png`. */
26
+ logo_url: Url | null;
27
+ logo_alt: string;
28
+ npm_url: Url | null;
29
+ changelog_url: Url | null;
30
+ /** True if has exports and version is not `0.0.1`. */
31
+ published: boolean;
32
+ }
33
+
34
+ /**
35
+ * Creates a `LibraryJson` with computed properties from package.json and source metadata.
36
+ */
37
+ export const library_json_parse = (
38
+ package_json: PackageJson,
39
+ source_json: SourceJson,
40
+ ): LibraryJson => {
41
+ const {name} = package_json;
42
+
43
+ // TODO hacky
44
+ const parse_repo = (r: string | null | undefined) => {
45
+ if (!r) return null;
46
+ return strip_end(strip_start(strip_end(r, '.git'), 'git+'), '/');
47
+ };
48
+
49
+ const repo_url = parse_repo(
50
+ package_json.repository
51
+ ? typeof package_json.repository === 'string'
52
+ ? package_json.repository
53
+ : package_json.repository.url
54
+ : null,
55
+ );
56
+ if (!repo_url) {
57
+ throw Error('failed to parse library_json - `repo_url` is required in package_json');
58
+ }
59
+
60
+ const homepage_url = package_json.homepage ?? null;
61
+
62
+ const published =
63
+ !package_json.private && !!package_json.exports && package_json.version !== '0.0.1';
64
+
65
+ // TODO generic registries
66
+ const npm_url = published ? 'https://www.npmjs.com/package/' + package_json.name : null;
67
+
68
+ const changelog_url = published && repo_url ? repo_url + '/blob/main/CHANGELOG.md' : null;
69
+
70
+ const repo_name = library_repo_name_parse(name);
71
+
72
+ const owner_name = repo_url ? strip_start(repo_url, 'https://github.com/').split('/')[0]! : null;
73
+
74
+ const logo_url = homepage_url
75
+ ? ensure_end(homepage_url, '/') +
76
+ (package_json.logo ? strip_start(package_json.logo, '/') : 'favicon.png')
77
+ : null;
78
+
79
+ const logo_alt = package_json.logo_alt ?? `logo for ${repo_name}`;
80
+
81
+ return {
82
+ package_json,
83
+ source_json,
84
+ name,
85
+ repo_name,
86
+ repo_url,
87
+ owner_name,
88
+ homepage_url,
89
+ logo_url,
90
+ logo_alt,
91
+ npm_url,
92
+ changelog_url,
93
+ published,
94
+ };
95
+ };
96
+
97
+ /**
98
+ * Extracts repo name from a package name, e.g. `@ryanatkn/fuz` → `fuz`.
99
+ */
100
+ export const library_repo_name_parse = (name: string): string => {
101
+ if (name[0] === '@') {
102
+ const parts = name.split('/');
103
+ if (parts.length < 2) {
104
+ throw new Error(`invalid scoped package name: "${name}" (expected format: @org/package)`);
105
+ }
106
+ return parts[1]!;
107
+ }
108
+ return name;
109
+ };
110
+
111
+ /**
112
+ * Extracts GitHub org URL from a library, e.g. `https://github.com/ryanatkn`.
113
+ */
114
+ export const library_org_url_parse = (library: LibraryJson): string | null => {
115
+ const {repo_name, repo_url} = library;
116
+ if (!repo_url) return null;
117
+ const suffix = '/' + repo_name;
118
+ if (repo_url.endsWith(suffix)) {
119
+ return strip_end(repo_url, suffix);
120
+ }
121
+ return null;
122
+ };