jsrepo 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,31 @@
1
+ import fs from 'node:fs';
2
+ import * as v from 'valibot';
3
+ import { Err, Ok, type Result } from '../blocks/types/result';
4
+
5
+ const CONFIG_NAME = 'blocks.json';
6
+
7
+ const schema = v.object({
8
+ $schema: v.string(),
9
+ repos: v.optional(v.array(v.string()), []),
10
+ includeTests: v.boolean(),
11
+ path: v.pipe(v.string(), v.minLength(1)),
12
+ watermark: v.optional(v.boolean(), true),
13
+ });
14
+
15
+ const getConfig = (): Result<Config, string> => {
16
+ if (!fs.existsSync(CONFIG_NAME)) {
17
+ return Err('Could not find your configuration file! Please run `npx jsrepo init`.');
18
+ }
19
+
20
+ const config = v.safeParse(schema, JSON.parse(fs.readFileSync(CONFIG_NAME).toString()));
21
+
22
+ if (!config.success) {
23
+ return Err('There was an error reading your `blocks.json` file!');
24
+ }
25
+
26
+ return Ok(config.output);
27
+ };
28
+
29
+ type Config = v.InferOutput<typeof schema>;
30
+
31
+ export { type Config, CONFIG_NAME, getConfig, schema };
package/src/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { program } from 'commander';
5
+ import * as commands from './commands';
6
+ import type { CLIContext } from './utils/context';
7
+
8
+ const resolveRelativeToRoot = (p: string): string => {
9
+ const dirname = fileURLToPath(import.meta.url);
10
+ return path.join(dirname, '../..', p);
11
+ };
12
+
13
+ // get version from package.json
14
+ const { version, name, description, repository } = JSON.parse(
15
+ fs.readFileSync(resolveRelativeToRoot('package.json'), 'utf-8')
16
+ );
17
+
18
+ const context: CLIContext = {
19
+ package: {
20
+ name,
21
+ description,
22
+ version,
23
+ repository,
24
+ },
25
+ resolveRelativeToRoot,
26
+ };
27
+
28
+ program
29
+ .name(name)
30
+ .description(description)
31
+ .version(version)
32
+ .addCommand(commands.add)
33
+ .addCommand(commands.init)
34
+ .addCommand(commands.test)
35
+ .addCommand(commands.build)
36
+ .addCommand(commands.diff);
37
+
38
+ program.parse();
39
+
40
+ export { context };
@@ -0,0 +1,189 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import color from 'chalk';
4
+ import { program } from 'commander';
5
+ import * as v from 'valibot';
6
+ import { WARN } from '.';
7
+ import { languages } from './language-support';
8
+
9
+ export const blockSchema = v.object({
10
+ name: v.string(),
11
+ category: v.string(),
12
+ localDependencies: v.array(v.string()),
13
+ dependencies: v.array(v.string()),
14
+ devDependencies: v.array(v.string()),
15
+ tests: v.boolean(),
16
+ /** Where to find the block relative to root */
17
+ directory: v.string(),
18
+ subdirectory: v.boolean(),
19
+ files: v.array(v.string()),
20
+ });
21
+
22
+ export const categorySchema = v.object({
23
+ name: v.string(),
24
+ blocks: v.array(blockSchema),
25
+ });
26
+
27
+ export type Category = v.InferInput<typeof categorySchema>;
28
+
29
+ export type Block = v.InferInput<typeof blockSchema>;
30
+
31
+ const TEST_SUFFIXES = ['.test.ts', '_test.ts', '.test.js', '_test.js'] as const;
32
+
33
+ const isTestFile = (file: string): boolean =>
34
+ TEST_SUFFIXES.find((suffix) => file.endsWith(suffix)) !== undefined;
35
+
36
+ /** Using the provided path to the blocks folder builds the blocks into categories and also resolves dependencies
37
+ *
38
+ * @param blocksPath
39
+ * @returns
40
+ */
41
+ const buildBlocksDirectory = (blocksPath: string, cwd: string): Category[] => {
42
+ let paths: string[];
43
+
44
+ try {
45
+ paths = fs.readdirSync(blocksPath);
46
+ } catch {
47
+ program.error(color.red(`Couldn't read the ${color.bold(blocksPath)} directory.`));
48
+ }
49
+
50
+ const categories: Category[] = [];
51
+
52
+ for (const categoryPath of paths) {
53
+ const categoryDir = path.join(blocksPath, categoryPath);
54
+
55
+ if (fs.statSync(categoryDir).isFile()) continue;
56
+
57
+ const categoryName = path.basename(categoryPath);
58
+
59
+ const category: Category = {
60
+ name: categoryName,
61
+ blocks: [],
62
+ };
63
+
64
+ const files = fs.readdirSync(categoryDir);
65
+
66
+ for (const file of files) {
67
+ const blockDir = path.join(categoryDir, file);
68
+
69
+ if (fs.statSync(blockDir).isFile()) {
70
+ if (isTestFile(file)) continue;
71
+
72
+ const lang = languages.find((resolver) => resolver.matches(file));
73
+
74
+ if (!lang) {
75
+ console.warn(
76
+ `${WARN} Skipped \`${color.bold(blockDir)}\` \`${color.bold(
77
+ path.parse(file).ext
78
+ )}\` files are not currently supported!`
79
+ );
80
+ continue;
81
+ }
82
+
83
+ const name = path.parse(path.basename(file)).name;
84
+
85
+ // tries to find a test file with the same name as the file
86
+ const testsPath = files.find((f) =>
87
+ TEST_SUFFIXES.find((suffix) => f === `${name}${suffix}`)
88
+ );
89
+
90
+ const { dependencies, devDependencies, local } = lang
91
+ .resolveDependencies(blockDir, categoryName, false)
92
+ .match(
93
+ (val) => val,
94
+ (err) => {
95
+ program.error(color.red(err));
96
+ }
97
+ );
98
+
99
+ const block: Block = {
100
+ name,
101
+ directory: path.relative(cwd, categoryDir),
102
+ category: categoryName,
103
+ tests: testsPath !== undefined,
104
+ subdirectory: false,
105
+ files: [file],
106
+ localDependencies: local,
107
+ dependencies,
108
+ devDependencies,
109
+ };
110
+
111
+ if (testsPath !== undefined) {
112
+ block.files.push(testsPath);
113
+ }
114
+
115
+ category.blocks.push(block);
116
+ } else {
117
+ const blockName = file;
118
+
119
+ const blockFiles = fs.readdirSync(blockDir);
120
+
121
+ const hasTests = blockFiles.findIndex((f) => isTestFile(f)) !== -1;
122
+
123
+ const localDepsSet = new Set<string>();
124
+ const depsSet = new Set<string>();
125
+ const devDepsSet = new Set<string>();
126
+
127
+ // if it is a directory
128
+ for (const f of blockFiles) {
129
+ if (isTestFile(f)) continue;
130
+
131
+ const lang = languages.find((resolver) => resolver.matches(f));
132
+
133
+ if (!lang) {
134
+ console.warn(
135
+ `${WARN} Skipped \`${color.bold(path.join(blockDir, f))}\` \`${color.bold(
136
+ path.parse(file).ext
137
+ )}\` files are not currently supported!`
138
+ );
139
+ continue;
140
+ }
141
+
142
+ const { local, dependencies, devDependencies } = lang
143
+ .resolveDependencies(path.join(blockDir, f), categoryName, true)
144
+ .match(
145
+ (val) => val,
146
+ (err) => {
147
+ program.error(color.red(err));
148
+ }
149
+ );
150
+
151
+ for (const dep of local) {
152
+ localDepsSet.add(dep);
153
+ }
154
+
155
+ for (const dep of dependencies) {
156
+ depsSet.add(dep);
157
+ }
158
+
159
+ for (const dep of devDependencies) {
160
+ devDepsSet.add(dep);
161
+ }
162
+ }
163
+
164
+ const block: Block = {
165
+ name: blockName,
166
+ directory: path.relative(cwd, blockDir),
167
+ category: categoryName,
168
+ tests: hasTests,
169
+ subdirectory: true,
170
+ files: [...blockFiles],
171
+ localDependencies: Array.from(localDepsSet.keys()),
172
+ dependencies: Array.from(depsSet.keys()),
173
+ devDependencies: Array.from(devDepsSet.keys()),
174
+ };
175
+
176
+ category.blocks.push(block);
177
+ }
178
+ }
179
+
180
+ categories.push(category);
181
+ }
182
+
183
+ return categories;
184
+ };
185
+
186
+ const readCategories = (outputFilePath: string): Category[] =>
187
+ v.parse(v.array(categorySchema), JSON.parse(fs.readFileSync(outputFilePath).toString()));
188
+
189
+ export { buildBlocksDirectory, readCategories, isTestFile };
@@ -0,0 +1,19 @@
1
+ import type { Block, Category } from './build';
2
+
3
+ export interface CLIContext {
4
+ /** The package.json of the CLI */
5
+ package: {
6
+ name: string;
7
+ version: string;
8
+ description: string;
9
+ repository: {
10
+ url: string;
11
+ };
12
+ };
13
+ /** Resolves the path relative to the root of the application
14
+ *
15
+ * @param path
16
+ * @returns
17
+ */
18
+ resolveRelativeToRoot: (path: string) => string;
19
+ }
@@ -0,0 +1,184 @@
1
+ import color from 'chalk';
2
+ import { type Change, diffChars } from 'diff';
3
+ import { arraySum } from '../blocks/utilities/array-sum';
4
+ import * as lines from '../blocks/utilities/lines';
5
+ import { leftPadMin } from '../blocks/utilities/pad';
6
+
7
+ type Options = {
8
+ /** The source file */
9
+ from: string;
10
+ /** The destination file */
11
+ to: string;
12
+ /** The changes to the file */
13
+ changes: Change[];
14
+ /** Expands all lines to show the entire file */
15
+ expand: boolean;
16
+ /** Maximum lines to show before collapsing */
17
+ maxUnchanged: number;
18
+ /** Color the removed lines */
19
+ colorRemoved?: (line: string) => string;
20
+ /** Color the added lines */
21
+ colorAdded?: (line: string) => string;
22
+ /** Prefixes each line with the string returned from this function. */
23
+ prefix: () => string;
24
+ intro: (options: Options) => string;
25
+ onUnchanged: (options: Options) => string;
26
+ };
27
+
28
+ const formatDiff = ({
29
+ from,
30
+ to,
31
+ changes,
32
+ expand = false,
33
+ maxUnchanged = 5,
34
+ colorRemoved = color.red,
35
+ colorAdded = color.green,
36
+ prefix,
37
+ onUnchanged,
38
+ intro,
39
+ }: Options): string => {
40
+ let result = '';
41
+
42
+ const length = arraySum(changes, (change) => change.count ?? 0).toString().length + 1;
43
+
44
+ let lineOffset = 0;
45
+
46
+ if (changes.length === 1 && !changes[0].added && !changes[0].removed) {
47
+ return onUnchanged({
48
+ from,
49
+ to,
50
+ changes,
51
+ expand,
52
+ maxUnchanged,
53
+ colorAdded,
54
+ colorRemoved,
55
+ prefix,
56
+ onUnchanged,
57
+ intro,
58
+ });
59
+ }
60
+
61
+ result += intro({
62
+ from,
63
+ to,
64
+ changes,
65
+ expand,
66
+ maxUnchanged,
67
+ colorAdded,
68
+ colorRemoved,
69
+ prefix,
70
+ onUnchanged,
71
+ intro,
72
+ });
73
+
74
+ /** Provides the line number prefix */
75
+ const linePrefix = (line: number): string =>
76
+ color.gray(`${prefix?.() ?? ''}${leftPadMin(`${line + 1 + lineOffset} `, length)} `);
77
+
78
+ for (let i = 0; i < changes.length; i++) {
79
+ const change = changes[i];
80
+
81
+ const hasPreviousChange = changes[i - 1]?.added || changes[i - 1]?.removed;
82
+ const hasNextChange = changes[i + 1]?.added || changes[i + 1]?.removed;
83
+
84
+ if (!change.added && !change.removed) {
85
+ // show collapsed
86
+ if (!expand && change.count !== undefined && change.count > maxUnchanged) {
87
+ const prevLineOffset = lineOffset;
88
+ const ls = lines.get(change.value.trimEnd());
89
+
90
+ let shownLines = 0;
91
+
92
+ if (hasNextChange) shownLines += maxUnchanged;
93
+ if (hasPreviousChange) shownLines += maxUnchanged;
94
+
95
+ // just show all if we are going to show more than we have
96
+ if (shownLines >= ls.length) {
97
+ result += `${lines.join(ls, {
98
+ prefix: linePrefix,
99
+ })}\n`;
100
+ lineOffset += ls.length;
101
+ continue;
102
+ }
103
+
104
+ // this writes the top few lines
105
+ if (hasPreviousChange) {
106
+ result += `${lines.join(ls.slice(0, maxUnchanged), {
107
+ prefix: linePrefix,
108
+ })}\n`;
109
+ }
110
+
111
+ if (ls.length > shownLines) {
112
+ const count = ls.length - shownLines;
113
+ result += `${lines.join(
114
+ lines.get(
115
+ color.gray(
116
+ `+ ${count} more unchanged (${color.italic('-E to expand')})`
117
+ )
118
+ ),
119
+ {
120
+ prefix: () => `${prefix?.() ?? ''}${leftPadMin(' ', length)} `,
121
+ }
122
+ )}\n`;
123
+ }
124
+
125
+ if (hasNextChange) {
126
+ lineOffset = lineOffset + ls.length - maxUnchanged;
127
+ result += `${lines.join(ls.slice(ls.length - maxUnchanged), {
128
+ prefix: linePrefix,
129
+ })}\n`;
130
+ }
131
+
132
+ // resets the line offset
133
+ lineOffset = prevLineOffset + change.count;
134
+ continue;
135
+ }
136
+
137
+ // show expanded
138
+
139
+ result += `${lines.join(lines.get(change.value.trimEnd()), {
140
+ prefix: linePrefix,
141
+ })}\n`;
142
+ lineOffset += change.count ?? 0;
143
+
144
+ continue;
145
+ }
146
+
147
+ const colorChange = (change: Change) => {
148
+ if (change.added) {
149
+ return colorAdded(change.value.trimEnd());
150
+ }
151
+
152
+ if (change.removed) {
153
+ return colorRemoved(change.value.trimEnd());
154
+ }
155
+
156
+ return change.value;
157
+ };
158
+
159
+ if (change.removed && change.count === 1) {
160
+ // single line change
161
+ const diffedChars = diffChars(change.value, changes[i + 1].value);
162
+
163
+ const sentence = diffedChars.map((chg) => colorChange(chg)).join('');
164
+
165
+ result += `${linePrefix(0)}${sentence}`;
166
+
167
+ lineOffset += 1;
168
+
169
+ i++;
170
+ } else {
171
+ result += `${lines.join(lines.get(colorChange(change)), {
172
+ prefix: linePrefix,
173
+ })}\n`;
174
+
175
+ if (!change.removed) {
176
+ lineOffset += change.count ?? 0;
177
+ }
178
+ }
179
+ }
180
+
181
+ return result;
182
+ };
183
+
184
+ export { formatDiff };
@@ -0,0 +1,38 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { Config } from '../config';
4
+ import type { Block } from './build';
5
+
6
+ type InstalledBlock = {
7
+ specifier: `${string}/${string}`;
8
+ path: string;
9
+ };
10
+
11
+ /** Finds installed blocks and returns them as `<category>/<name>`
12
+ *
13
+ * @param blocks
14
+ * @param config
15
+ * @returns
16
+ */
17
+ const getInstalledBlocks = (blocks: Map<string, Block>, config: Config): InstalledBlock[] => {
18
+ const installedBlocks: InstalledBlock[] = [];
19
+
20
+ for (const [_, block] of blocks) {
21
+ const baseDir = path.join(config.path, block.category);
22
+
23
+ let blockPath = path.join(baseDir, block.files[0]);
24
+ if (block.subdirectory) {
25
+ blockPath = path.join(baseDir, block.name);
26
+ }
27
+
28
+ if (fs.existsSync(blockPath))
29
+ installedBlocks.push({
30
+ specifier: `${block.category}/${block.name}`,
31
+ path: blockPath,
32
+ });
33
+ }
34
+
35
+ return installedBlocks;
36
+ };
37
+
38
+ export { getInstalledBlocks };
@@ -0,0 +1,7 @@
1
+ const getWatermark = (version: string, repoUrl: string): string => {
2
+ return `\tjsrepo ${version}\n\tInstalled from ${repoUrl}\n\t${new Date()
3
+ .toLocaleDateString()
4
+ .replaceAll('/', '-')}`;
5
+ };
6
+
7
+ export { getWatermark };
@@ -0,0 +1,140 @@
1
+ import { Octokit } from 'octokit';
2
+ import * as v from 'valibot';
3
+ import { Err, Ok, type Result } from '../blocks/types/result';
4
+ import { type Category, categorySchema } from './build';
5
+ import { OUTPUT_FILE } from './index';
6
+
7
+ const octokit = new Octokit({});
8
+
9
+ export type Info = {
10
+ refs: 'tags' | 'heads';
11
+ url: string;
12
+ name: string;
13
+ repoName: string;
14
+ owner: string;
15
+ ref: string;
16
+ provider: Provider;
17
+ };
18
+
19
+ export interface Provider {
20
+ /** Get the name of the provider
21
+ *
22
+ * @returns the name of the provider
23
+ */
24
+ name: () => string;
25
+ /** Returns a URL to the raw path of the resource provided in the resourcePath
26
+ *
27
+ * @param repoPath
28
+ * @param resourcePath
29
+ * @returns
30
+ */
31
+ resolveRaw: (repoPath: string | Info, resourcePath: string) => Promise<URL>;
32
+ /** Parses the url and gives info about the repo
33
+ *
34
+ * @param repoPath
35
+ * @returns
36
+ */
37
+ info: (repoPath: string) => Promise<Info>;
38
+ /** Returns true if this provider matches the provided url
39
+ *
40
+ * @param repoPath
41
+ * @returns
42
+ */
43
+ matches: (repoPath: string) => boolean;
44
+ }
45
+
46
+ /** Valid paths
47
+ *
48
+ * `https://github.com/<owner>/<repo>/[tree]/[ref]`
49
+ *
50
+ * `github/<owner>/<repo>/[tree]/[ref]`
51
+ */
52
+ const github: Provider = {
53
+ name: () => 'github',
54
+ resolveRaw: async (repoPath, resourcePath) => {
55
+ let info: Info;
56
+ if (typeof repoPath === 'string') {
57
+ info = await github.info(repoPath);
58
+ } else {
59
+ info = repoPath;
60
+ }
61
+
62
+ return new URL(
63
+ resourcePath,
64
+ `https://raw.githubusercontent.com/${info.owner}/${info.repoName}/refs/${info.refs}/${info.ref}/`
65
+ );
66
+ },
67
+ info: async (repoPath) => {
68
+ const repo = repoPath.replaceAll(/(https:\/\/github.com\/)|(github\/)/g, '');
69
+
70
+ const [owner, repoName, ...rest] = repo.split('/');
71
+
72
+ let ref = 'main';
73
+
74
+ if (rest[0] === 'tree') {
75
+ ref = rest[1];
76
+ }
77
+
78
+ // checks if the type of the ref is tags or heads
79
+ let refs: 'heads' | 'tags' = 'heads';
80
+ // no need to check if ref is main
81
+ if (ref !== 'main') {
82
+ try {
83
+ const { data: tags } = await octokit.rest.git.listMatchingRefs({
84
+ owner,
85
+ repo: repoName,
86
+ ref: 'tags',
87
+ });
88
+
89
+ if (tags.some((tag) => tag.ref === `refs/tags/${ref}`)) {
90
+ refs = 'tags';
91
+ }
92
+ } catch {
93
+ refs = 'heads';
94
+ }
95
+ }
96
+
97
+ return {
98
+ refs,
99
+ url: repoPath,
100
+ name: github.name(),
101
+ repoName,
102
+ owner,
103
+ ref: ref,
104
+ provider: github,
105
+ };
106
+ },
107
+ matches: (repoPath) =>
108
+ repoPath.toLowerCase().startsWith('https://github.com') ||
109
+ repoPath.toLowerCase().startsWith('github'),
110
+ };
111
+
112
+ const getProviderInfo = async (repo: string): Promise<Result<Info, string>> => {
113
+ if (github.matches(repo)) {
114
+ return Ok(await github.info(repo));
115
+ }
116
+
117
+ return Err('Only GitHub repositories are supported at this time!');
118
+ };
119
+
120
+ const getManifest = async (url: URL): Promise<Result<Category[], string>> => {
121
+ try {
122
+ const response = await fetch(url);
123
+
124
+ if (!response.ok) {
125
+ return Err(
126
+ `There was an error fetching the \`${OUTPUT_FILE}\` from the repository \`${url.href}\` make sure the target repository has a \`${OUTPUT_FILE}\` in its root?`
127
+ );
128
+ }
129
+
130
+ const categories = v.parse(v.array(categorySchema), await response.json());
131
+
132
+ return Ok(categories);
133
+ } catch {
134
+ return Err(
135
+ `There was an error fetching the \`${OUTPUT_FILE}\` from the repository \`${url.href}\` make sure the target repository has a \`${OUTPUT_FILE}\` in its root?`
136
+ );
137
+ }
138
+ };
139
+
140
+ export { github, getProviderInfo, getManifest };
@@ -0,0 +1,9 @@
1
+ import color from 'chalk';
2
+
3
+ export const OUTPUT_FILE = 'blocks-manifest.json';
4
+
5
+ const WARN = color.bgRgb(245, 149, 66).white('WARN');
6
+
7
+ const INFO = color.bgBlueBright.white('INFO');
8
+
9
+ export { WARN, INFO };