repotrailer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/analyze.js ADDED
@@ -0,0 +1,405 @@
1
+ import { mkdtemp, readFile, readdir, stat } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { run, stripMarkdown, truncate } from "./utils.js";
7
+
8
+ const SKIP_DIRS = new Set([
9
+ ".git",
10
+ ".next",
11
+ ".turbo",
12
+ ".venv",
13
+ "build",
14
+ "coverage",
15
+ "dist",
16
+ "node_modules",
17
+ "target",
18
+ "vendor",
19
+ ]);
20
+
21
+ const LANGUAGE_BY_EXTENSION = new Map([
22
+ [".c", "C"],
23
+ [".cc", "C++"],
24
+ [".cpp", "C++"],
25
+ [".cs", "C#"],
26
+ [".css", "CSS"],
27
+ [".dart", "Dart"],
28
+ [".ex", "Elixir"],
29
+ [".exs", "Elixir"],
30
+ [".go", "Go"],
31
+ [".html", "HTML"],
32
+ [".java", "Java"],
33
+ [".js", "JavaScript"],
34
+ [".jsx", "JavaScript"],
35
+ [".kt", "Kotlin"],
36
+ [".lua", "Lua"],
37
+ [".m", "Objective-C"],
38
+ [".php", "PHP"],
39
+ [".py", "Python"],
40
+ [".rb", "Ruby"],
41
+ [".rs", "Rust"],
42
+ [".scala", "Scala"],
43
+ [".sh", "Shell"],
44
+ [".sol", "Solidity"],
45
+ [".swift", "Swift"],
46
+ [".ts", "TypeScript"],
47
+ [".tsx", "TypeScript"],
48
+ [".vue", "Vue"],
49
+ ]);
50
+
51
+ const STACK_SIGNALS = [
52
+ ["next", "Next.js"],
53
+ ["react", "React"],
54
+ ["vue", "Vue"],
55
+ ["svelte", "Svelte"],
56
+ ["astro", "Astro"],
57
+ ["vite", "Vite"],
58
+ ["express", "Express"],
59
+ ["fastify", "Fastify"],
60
+ ["tailwindcss", "Tailwind CSS"],
61
+ ["remotion", "Remotion"],
62
+ ["hyperframes", "HyperFrames"],
63
+ ["playwright", "Playwright"],
64
+ ["vitest", "Vitest"],
65
+ ["jest", "Jest"],
66
+ ];
67
+
68
+ async function exists(target) {
69
+ try {
70
+ await stat(target);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ function normalizeGithubSource(source) {
78
+ if (
79
+ !source.startsWith(".")
80
+ && !source.startsWith("/")
81
+ && /^[A-Za-z0-9_-]+\/[A-Za-z0-9_.-]+$/.test(source)
82
+ ) {
83
+ return `https://github.com/${source}.git`;
84
+ }
85
+ if (/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/?$/.test(source)) {
86
+ return `${source.replace(/\/$/, "")}.git`;
87
+ }
88
+ if (/^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\.git$/.test(source)) {
89
+ return source;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ async function resolveRepository(source) {
95
+ const localPath = path.resolve(source);
96
+ if (await exists(localPath)) {
97
+ return {
98
+ root: localPath,
99
+ source,
100
+ temporary: false,
101
+ nameHint: path.basename(localPath),
102
+ };
103
+ }
104
+
105
+ const githubUrl = normalizeGithubSource(source);
106
+ if (!githubUrl) {
107
+ return {
108
+ root: localPath,
109
+ source,
110
+ temporary: false,
111
+ nameHint: path.basename(localPath),
112
+ };
113
+ }
114
+
115
+ const root = await mkdtemp(path.join(tmpdir(), "repotrailer-"));
116
+ const repositoryName = githubUrl
117
+ .replace(/\.git$/, "")
118
+ .split("/")
119
+ .filter(Boolean)
120
+ .at(-1);
121
+ const clone = await run(
122
+ "git",
123
+ ["clone", "--depth", "50", "--quiet", githubUrl, root],
124
+ { timeout: 120_000 },
125
+ );
126
+ if (!clone.ok) {
127
+ throw new Error(`could not clone ${source}: ${clone.stderr}`);
128
+ }
129
+ return {
130
+ root,
131
+ source: githubUrl.replace(/\.git$/, ""),
132
+ temporary: true,
133
+ nameHint: repositoryName || path.basename(root),
134
+ };
135
+ }
136
+
137
+ async function findReadme(root) {
138
+ const entries = await readdir(root);
139
+ const candidate = entries.find((entry) => /^readme(?:\.[^.]+)?$/i.test(entry));
140
+ if (!candidate) {
141
+ return { path: null, content: "" };
142
+ }
143
+ const readmePath = path.join(root, candidate);
144
+ return {
145
+ path: candidate,
146
+ content: await readFile(readmePath, "utf8"),
147
+ };
148
+ }
149
+
150
+ function readmeMetadata(markdown, fallbackName) {
151
+ const proseMarkdown = markdown.replace(/```[\s\S]*?```/g, "");
152
+ const introLines = proseMarkdown.split(/\r?\n/).slice(0, 40);
153
+ const cleanHeading = (value) => {
154
+ if (!value) return null;
155
+ const withoutImages = value.replace(/<img\b[^>]*>/gi, "");
156
+ const text = stripMarkdown(withoutImages.replace(/<[^>]+>/g, " ")).trim();
157
+ return text.length >= 2 ? text : null;
158
+ };
159
+ const heading = introLines
160
+ .map((line) => (
161
+ cleanHeading(line.match(/^#\s+(.+)$/)?.[1])
162
+ ))
163
+ .find(Boolean)
164
+ || cleanHeading(proseMarkdown.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i)?.[1]);
165
+
166
+ const paragraphs = proseMarkdown
167
+ .split(/\n\s*\n/)
168
+ .map(stripMarkdown)
169
+ .filter((value) => value.length >= 24)
170
+ .filter((value) => !/^(build|install|usage|features|license)\b/i.test(value));
171
+
172
+ const bullets = proseMarkdown.split(/\r?\n/)
173
+ .map((line) => line.match(/^\s*[-*]\s+(.+)$/)?.[1])
174
+ .filter(Boolean)
175
+ .map(stripMarkdown)
176
+ .filter((value) => value.length >= 8 && value.length <= 140);
177
+
178
+ return {
179
+ title: truncate(heading ? stripMarkdown(heading) : fallbackName, 64),
180
+ description: truncate(
181
+ paragraphs[0] || `A project called ${fallbackName}.`,
182
+ 180,
183
+ ),
184
+ features: [...new Set(bullets)].slice(0, 4),
185
+ };
186
+ }
187
+
188
+ async function walkFiles(root, current = root, result = []) {
189
+ const entries = await readdir(current, { withFileTypes: true });
190
+ for (const entry of entries) {
191
+ if (entry.name.startsWith(".") && entry.name !== ".github") {
192
+ continue;
193
+ }
194
+ if (SKIP_DIRS.has(entry.name)) {
195
+ continue;
196
+ }
197
+ const fullPath = path.join(current, entry.name);
198
+ if (entry.isDirectory()) {
199
+ await walkFiles(root, fullPath, result);
200
+ continue;
201
+ }
202
+ if (entry.isFile()) {
203
+ const info = await stat(fullPath);
204
+ result.push({
205
+ path: path.relative(root, fullPath).split(path.sep).join("/"),
206
+ bytes: info.size,
207
+ extension: path.extname(entry.name).toLowerCase(),
208
+ });
209
+ }
210
+ }
211
+ return result;
212
+ }
213
+
214
+ function summarizeLanguages(files) {
215
+ const totals = new Map();
216
+ for (const file of files) {
217
+ const language = LANGUAGE_BY_EXTENSION.get(file.extension);
218
+ if (!language) {
219
+ continue;
220
+ }
221
+ totals.set(language, (totals.get(language) || 0) + file.bytes);
222
+ }
223
+ const sum = [...totals.values()].reduce((total, value) => total + value, 0);
224
+ return [...totals.entries()]
225
+ .sort((a, b) => b[1] - a[1])
226
+ .slice(0, 5)
227
+ .map(([name, bytes]) => ({
228
+ name,
229
+ percent: sum === 0 ? 0 : Math.max(1, Math.round((bytes / sum) * 100)),
230
+ }));
231
+ }
232
+
233
+ async function packageMetadata(root) {
234
+ const packagePath = path.join(root, "package.json");
235
+ if (!(await exists(packagePath))) {
236
+ return { package: null, stack: [], install: null };
237
+ }
238
+
239
+ try {
240
+ const pkg = JSON.parse(await readFile(packagePath, "utf8"));
241
+ const dependencies = {
242
+ ...pkg.dependencies,
243
+ ...pkg.devDependencies,
244
+ ...pkg.peerDependencies,
245
+ };
246
+ const stack = STACK_SIGNALS
247
+ .filter(([dependency]) => dependency in dependencies)
248
+ .map(([, label]) => label)
249
+ .slice(0, 6);
250
+ const install = (await exists(path.join(root, "pnpm-lock.yaml")))
251
+ ? "pnpm install"
252
+ : (await exists(path.join(root, "yarn.lock")))
253
+ ? "yarn"
254
+ : (await exists(path.join(root, "bun.lock")))
255
+ ? "bun install"
256
+ : "npm install";
257
+
258
+ return { package: pkg, stack, install };
259
+ } catch {
260
+ return { package: null, stack: [], install: null };
261
+ }
262
+ }
263
+
264
+ async function detectStack(root, files, packageInfo) {
265
+ const stack = [...packageInfo.stack];
266
+ const names = new Set(files.map((file) => file.path.toLowerCase()));
267
+ const add = (condition, value) => {
268
+ if (condition && !stack.includes(value)) {
269
+ stack.push(value);
270
+ }
271
+ };
272
+
273
+ add(names.has("pyproject.toml") || names.has("requirements.txt"), "Python");
274
+ add(names.has("cargo.toml"), "Rust");
275
+ add(names.has("go.mod"), "Go");
276
+ add(names.has("dockerfile"), "Docker");
277
+ add(names.has("compose.yml") || names.has("docker-compose.yml"), "Compose");
278
+ add(names.has(".github/workflows/ci.yml"), "GitHub Actions");
279
+
280
+ if (stack.length === 0) {
281
+ stack.push(...summarizeLanguages(files).map((item) => item.name));
282
+ }
283
+ return stack.slice(0, 6);
284
+ }
285
+
286
+ async function gitMetadata(root) {
287
+ const branch = await run("git", ["branch", "--show-current"], { cwd: root });
288
+ if (!branch.ok) {
289
+ return {
290
+ isGit: false,
291
+ branch: null,
292
+ commits: 0,
293
+ contributors: 0,
294
+ latest: [],
295
+ remote: null,
296
+ };
297
+ }
298
+
299
+ const [commitCount, contributors, latest, remote] = await Promise.all([
300
+ run("git", ["rev-list", "--count", "HEAD"], { cwd: root }),
301
+ run("git", ["shortlog", "-sne", "HEAD"], { cwd: root }),
302
+ run("git", ["log", "-5", "--pretty=format:%h%x09%s"], { cwd: root }),
303
+ run("git", ["remote", "get-url", "origin"], { cwd: root }),
304
+ ]);
305
+
306
+ return {
307
+ isGit: true,
308
+ branch: branch.stdout || "main",
309
+ commits: Number.parseInt(commitCount.stdout, 10) || 0,
310
+ contributors: contributors.stdout
311
+ ? contributors.stdout.split(/\r?\n/).filter(Boolean).length
312
+ : 0,
313
+ latest: latest.stdout
314
+ ? latest.stdout.split(/\r?\n/).map((line) => {
315
+ const [hash, ...subject] = line.split("\t");
316
+ return { hash, subject: subject.join("\t") };
317
+ })
318
+ : [],
319
+ remote: remote.ok ? remote.stdout.replace(/\.git$/, "") : null,
320
+ };
321
+ }
322
+
323
+ function suggestedCommand(root, packageInfo, files) {
324
+ const names = new Set(files.map((file) => file.path.toLowerCase()));
325
+ if (packageInfo.package?.bin) {
326
+ const bin = typeof packageInfo.package.bin === "string"
327
+ ? packageInfo.package.name
328
+ : Object.keys(packageInfo.package.bin)[0];
329
+ if (bin) {
330
+ return `npx ${bin}`;
331
+ }
332
+ }
333
+ if (packageInfo.package) {
334
+ return `${packageInfo.install || "npm install"} && npm run dev`;
335
+ }
336
+ if (names.has("pyproject.toml")) {
337
+ return "pip install -e .";
338
+ }
339
+ if (names.has("cargo.toml")) {
340
+ return "cargo run";
341
+ }
342
+ if (names.has("go.mod")) {
343
+ return "go run .";
344
+ }
345
+ return `git clone ${path.basename(root)}`;
346
+ }
347
+
348
+ export async function analyzeRepository(source = ".", overrides = {}) {
349
+ const repository = await resolveRepository(source);
350
+ if (!(await exists(repository.root))) {
351
+ throw new Error(`repository path does not exist: ${repository.root}`);
352
+ }
353
+
354
+ const fallbackName = repository.nameHint || path.basename(repository.root);
355
+ const [readme, files, git, packageInfo] = await Promise.all([
356
+ findReadme(repository.root),
357
+ walkFiles(repository.root),
358
+ gitMetadata(repository.root),
359
+ packageMetadata(repository.root),
360
+ ]);
361
+ const readmeInfo = readmeMetadata(readme.content, fallbackName);
362
+ const languages = summarizeLanguages(files);
363
+ const stack = await detectStack(repository.root, files, packageInfo);
364
+
365
+ return {
366
+ schemaVersion: 1,
367
+ generatedAt: new Date().toISOString(),
368
+ source: repository.source,
369
+ root: repository.root,
370
+ temporary: repository.temporary,
371
+ name: overrides.title || packageInfo.package?.name || readmeInfo.title,
372
+ title: overrides.title || readmeInfo.title,
373
+ tagline: overrides.tagline || packageInfo.package?.description || readmeInfo.description,
374
+ description: readmeInfo.description,
375
+ features: readmeInfo.features.length
376
+ ? readmeInfo.features
377
+ : [
378
+ "Built from real repository metadata",
379
+ "Ready to share in minutes",
380
+ "Runs locally with no API key",
381
+ ],
382
+ stack,
383
+ languages,
384
+ installCommand: overrides.install || suggestedCommand(
385
+ repository.root,
386
+ packageInfo,
387
+ files,
388
+ ),
389
+ files: {
390
+ total: files.length,
391
+ source: files.filter((file) => LANGUAGE_BY_EXTENSION.has(file.extension)).length,
392
+ },
393
+ readme: {
394
+ path: readme.path,
395
+ characters: readme.content.length,
396
+ },
397
+ git,
398
+ };
399
+ }
400
+
401
+ export const __test = {
402
+ normalizeGithubSource,
403
+ readmeMetadata,
404
+ summarizeLanguages,
405
+ };
package/src/cli.js ADDED
@@ -0,0 +1,156 @@
1
+ import path from "node:path";
2
+
3
+ import { analyzeRepository } from "./analyze.js";
4
+ import { renderHyperframesProject } from "./hyperframes.js";
5
+ import { writeLaunchKit } from "./launch-kit.js";
6
+ import { buildStoryboard } from "./storyboard.js";
7
+
8
+ const HELP = `
9
+ RepoTrailer
10
+ Turn a GitHub repository into a launch trailer and share kit.
11
+
12
+ Usage:
13
+ repotrailer [path | owner/repo | GitHub URL] [options]
14
+
15
+ Options:
16
+ -o, --out <directory> Output directory (default: ./repotrailer-out)
17
+ --title <text> Override the detected project title
18
+ --tagline <text> Override the detected tagline
19
+ --install <command> Override the detected install command
20
+ --accent <hex> Accent color for generated assets
21
+ --quality <level> Video quality: draft, standard, high
22
+ --workers <number> Render workers (default: 1)
23
+ --no-video Generate preview assets without rendering MP4
24
+ --json Print the manifest JSON path only
25
+ -h, --help Show help
26
+ -v, --version Show version
27
+
28
+ Examples:
29
+ repotrailer .
30
+ repotrailer openai/openai-agents-js
31
+ repotrailer https://github.com/owner/repo --out ./launch
32
+ `;
33
+
34
+ function parseArgs(argv) {
35
+ const options = {
36
+ source: ".",
37
+ output: "repotrailer-out",
38
+ video: true,
39
+ json: false,
40
+ overrides: {},
41
+ palette: {},
42
+ quality: "standard",
43
+ workers: 1,
44
+ };
45
+
46
+ for (let index = 0; index < argv.length; index += 1) {
47
+ const argument = argv[index];
48
+ if (!argument.startsWith("-")) {
49
+ options.source = argument;
50
+ continue;
51
+ }
52
+ if (argument === "--no-video") {
53
+ options.video = false;
54
+ continue;
55
+ }
56
+ if (argument === "--json") {
57
+ options.json = true;
58
+ continue;
59
+ }
60
+ if (argument === "-h" || argument === "--help") {
61
+ options.help = true;
62
+ continue;
63
+ }
64
+ if (argument === "-v" || argument === "--version") {
65
+ options.version = true;
66
+ continue;
67
+ }
68
+
69
+ const value = argv[index + 1];
70
+ if (!value || value.startsWith("-")) {
71
+ throw new Error(`missing value for ${argument}`);
72
+ }
73
+ index += 1;
74
+ if (argument === "-o" || argument === "--out") {
75
+ options.output = value;
76
+ } else if (argument === "--title") {
77
+ options.overrides.title = value;
78
+ } else if (argument === "--tagline") {
79
+ options.overrides.tagline = value;
80
+ } else if (argument === "--install") {
81
+ options.overrides.install = value;
82
+ } else if (argument === "--accent") {
83
+ options.palette.accent = value;
84
+ } else if (argument === "--quality") {
85
+ if (!["draft", "standard", "high"].includes(value)) {
86
+ throw new Error("--quality must be draft, standard, or high");
87
+ }
88
+ options.quality = value;
89
+ } else if (argument === "--workers") {
90
+ options.workers = Number.parseInt(value, 10);
91
+ if (!Number.isInteger(options.workers) || options.workers < 1 || options.workers > 8) {
92
+ throw new Error("--workers must be an integer from 1 to 8");
93
+ }
94
+ } else {
95
+ throw new Error(`unknown option: ${argument}`);
96
+ }
97
+ }
98
+
99
+ return options;
100
+ }
101
+
102
+ async function version() {
103
+ const packageJson = new URL("../package.json", import.meta.url);
104
+ const data = await import(packageJson, { with: { type: "json" } });
105
+ return data.default.version;
106
+ }
107
+
108
+ export async function runCli(argv) {
109
+ const options = parseArgs(argv);
110
+ if (options.help) {
111
+ console.log(HELP.trim());
112
+ return;
113
+ }
114
+ if (options.version) {
115
+ console.log(await version());
116
+ return;
117
+ }
118
+
119
+ const repo = await analyzeRepository(options.source, options.overrides);
120
+ const scenes = buildStoryboard(repo);
121
+ const kit = await writeLaunchKit(
122
+ repo,
123
+ scenes,
124
+ path.resolve(options.output),
125
+ { palette: options.palette },
126
+ );
127
+
128
+ if (options.video) {
129
+ await renderHyperframesProject(
130
+ kit.hyperframes.project,
131
+ kit.files.trailer,
132
+ {
133
+ quality: options.quality,
134
+ workers: options.workers,
135
+ },
136
+ );
137
+ }
138
+
139
+ if (options.json) {
140
+ console.log(kit.files.manifest);
141
+ return;
142
+ }
143
+
144
+ console.log(`\nRepoTrailer built ${repo.title}`);
145
+ console.log(` Preview ${kit.files.preview}`);
146
+ console.log(` Social card ${kit.files.socialCard}`);
147
+ console.log(` Launch copy ${kit.files.launchCopy}`);
148
+ console.log(` Manifest ${kit.files.manifest}\n`);
149
+ console.log(` HyperFrames ${kit.hyperframes.project}`);
150
+ if (options.video) {
151
+ console.log(` Trailer ${kit.files.trailer}`);
152
+ }
153
+ console.log("");
154
+ }
155
+
156
+ export const __test = { parseArgs };