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/.codex-plugin/plugin.json +22 -0
- package/LICENSE +21 -0
- package/README.md +225 -0
- package/action.yml +38 -0
- package/assets/cover.svg +16 -0
- package/assets/demo-preview.png +0 -0
- package/bin/repotrailer.js +9 -0
- package/llms.txt +54 -0
- package/package.json +61 -0
- package/scripts/growth-check.mjs +337 -0
- package/scripts/publish-readiness.mjs +104 -0
- package/skills/repotrailer/SKILL.md +52 -0
- package/src/analyze.js +405 -0
- package/src/cli.js +156 -0
- package/src/hyperframes.js +588 -0
- package/src/index.js +7 -0
- package/src/launch-kit.js +217 -0
- package/src/storyboard.js +71 -0
- package/src/utils.js +73 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { writeHyperframesProject } from "./hyperframes.js";
|
|
5
|
+
import { escapeHtml, escapeXml, slugify } from "./utils.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_PALETTE = {
|
|
8
|
+
ink: "#0a0b0d",
|
|
9
|
+
paper: "#f5f2e9",
|
|
10
|
+
accent: "#c7ff45",
|
|
11
|
+
hot: "#ff6247",
|
|
12
|
+
muted: "#9aa0aa",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function wrapText(value, maxCharacters = 48, maxLines = 3) {
|
|
16
|
+
const words = String(value).trim().split(/\s+/);
|
|
17
|
+
const lines = [];
|
|
18
|
+
let current = "";
|
|
19
|
+
|
|
20
|
+
for (const word of words) {
|
|
21
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
22
|
+
if (candidate.length <= maxCharacters || current.length === 0) {
|
|
23
|
+
current = candidate;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
lines.push(current);
|
|
27
|
+
current = word;
|
|
28
|
+
if (lines.length === maxLines - 1) {
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (current && lines.length < maxLines) {
|
|
33
|
+
const consumed = lines.join(" ").split(/\s+/).filter(Boolean).length;
|
|
34
|
+
const remaining = words.slice(consumed).join(" ");
|
|
35
|
+
lines.push(
|
|
36
|
+
remaining.length > maxCharacters
|
|
37
|
+
? `${remaining.slice(0, maxCharacters - 1).trimEnd()}…`
|
|
38
|
+
: remaining,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return lines.slice(0, maxLines);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function socialCard(repo, palette) {
|
|
45
|
+
const stack = repo.stack.slice(0, 4);
|
|
46
|
+
const tagline = wrapText(repo.tagline);
|
|
47
|
+
const chips = stack.map((item, index) => {
|
|
48
|
+
const width = Math.max(112, item.length * 12 + 36);
|
|
49
|
+
const x = 72 + stack
|
|
50
|
+
.slice(0, index)
|
|
51
|
+
.reduce((sum, value) => sum + Math.max(112, value.length * 12 + 36) + 14, 0);
|
|
52
|
+
return `
|
|
53
|
+
<g transform="translate(${x} 492)">
|
|
54
|
+
<rect width="${width}" height="42" rx="21" fill="#17191e" stroke="#343842"/>
|
|
55
|
+
<text x="${width / 2}" y="27" text-anchor="middle" fill="${palette.paper}" font-size="17" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">${escapeXml(item)}</text>
|
|
56
|
+
</g>`;
|
|
57
|
+
}).join("");
|
|
58
|
+
|
|
59
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="630" viewBox="0 0 1200 630" role="img" aria-label="${escapeXml(repo.title)}">
|
|
60
|
+
<rect width="1200" height="630" fill="${palette.ink}"/>
|
|
61
|
+
<circle cx="1075" cy="92" r="190" fill="${palette.hot}" opacity=".92"/>
|
|
62
|
+
<circle cx="1030" cy="130" r="118" fill="${palette.accent}"/>
|
|
63
|
+
<path d="M0 574L1200 424V630H0Z" fill="#111319"/>
|
|
64
|
+
<text x="72" y="86" fill="${palette.accent}" font-size="18" font-weight="800" letter-spacing="5" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">REPOTRAILER / OPEN SOURCE</text>
|
|
65
|
+
<text x="72" y="235" fill="${palette.paper}" font-size="84" font-weight="900" font-family="Inter, ui-sans-serif, system-ui, sans-serif">${escapeXml(repo.title)}</text>
|
|
66
|
+
${tagline.map((line, index) => `<text x="72" y="${306 + index * 40}" fill="${palette.muted}" font-size="31" font-weight="500" font-family="Inter, ui-sans-serif, system-ui, sans-serif">${escapeXml(line)}</text>`).join("")}
|
|
67
|
+
${chips}
|
|
68
|
+
<text x="72" y="590" fill="${palette.paper}" font-size="18" font-weight="700" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">README → TRAILER → LAUNCH</text>
|
|
69
|
+
<text x="1128" y="590" text-anchor="end" fill="${palette.muted}" font-size="18" font-family="ui-monospace, SFMono-Regular, Menlo, monospace">${repo.files.source} SOURCE FILES</text>
|
|
70
|
+
</svg>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sceneMarkup(scene) {
|
|
74
|
+
const metrics = scene.metrics
|
|
75
|
+
? `<div class="metrics">${scene.metrics.map((metric) => `
|
|
76
|
+
<div><strong>${escapeHtml(metric.value)}</strong><span>${escapeHtml(metric.label)}</span></div>`).join("")}</div>`
|
|
77
|
+
: "";
|
|
78
|
+
return `<article class="scene scene-${escapeHtml(scene.kind)}" id="${escapeHtml(scene.id)}">
|
|
79
|
+
<div class="scene-number">${String(scene.start).padStart(4, "0")}s</div>
|
|
80
|
+
<p class="eyebrow">${escapeHtml(scene.eyebrow)}</p>
|
|
81
|
+
<h2>${escapeHtml(scene.title)}</h2>
|
|
82
|
+
<p class="body">${escapeHtml(scene.body)}</p>
|
|
83
|
+
${metrics}
|
|
84
|
+
<div class="duration">${scene.duration.toFixed(1)} sec</div>
|
|
85
|
+
</article>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function previewHtml(repo, scenes, palette) {
|
|
89
|
+
const payload = JSON.stringify({ repo, scenes }).replaceAll("<", "\\u003c");
|
|
90
|
+
return `<!doctype html>
|
|
91
|
+
<html lang="en">
|
|
92
|
+
<head>
|
|
93
|
+
<meta charset="utf-8">
|
|
94
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
95
|
+
<title>${escapeHtml(repo.title)} · RepoTrailer</title>
|
|
96
|
+
<style>
|
|
97
|
+
:root { --ink:${palette.ink}; --paper:${palette.paper}; --accent:${palette.accent}; --hot:${palette.hot}; --muted:${palette.muted}; }
|
|
98
|
+
* { box-sizing:border-box; }
|
|
99
|
+
body { margin:0; color:var(--paper); background:var(--ink); font-family:Inter,ui-sans-serif,system-ui,sans-serif; }
|
|
100
|
+
header { min-height:78vh; padding:9vw 7vw 7vw; display:flex; flex-direction:column; justify-content:flex-end; overflow:hidden; position:relative; border-bottom:1px solid #282b32; }
|
|
101
|
+
header::after { content:""; width:38vw; height:38vw; border-radius:50%; background:var(--accent); position:absolute; right:-8vw; top:-14vw; box-shadow:-7vw 7vw 0 var(--hot); }
|
|
102
|
+
.kicker,.eyebrow,.scene-number,.duration { font:800 13px/1 ui-monospace,SFMono-Regular,Menlo,monospace; letter-spacing:.18em; text-transform:uppercase; }
|
|
103
|
+
.kicker { color:var(--accent); position:relative; z-index:1; }
|
|
104
|
+
h1 { font-size:clamp(64px,12vw,164px); line-height:.82; letter-spacing:-.075em; margin:28px 0; max-width:900px; position:relative; z-index:1; }
|
|
105
|
+
header p { max-width:780px; color:var(--muted); font-size:clamp(22px,3vw,38px); margin:0; position:relative; z-index:1; }
|
|
106
|
+
main { padding:7vw; display:grid; gap:28px; grid-template-columns:repeat(auto-fit,minmax(320px,1fr)); }
|
|
107
|
+
.scene { min-height:420px; border:1px solid #2a2d34; padding:36px; display:flex; flex-direction:column; position:relative; background:#111319; overflow:hidden; }
|
|
108
|
+
.scene::before { content:""; position:absolute; width:180px; height:180px; border-radius:50%; right:-80px; top:-80px; background:var(--accent); opacity:.13; }
|
|
109
|
+
.scene-number { color:var(--muted); margin-bottom:auto; }
|
|
110
|
+
.eyebrow { color:var(--accent); margin:48px 0 18px; }
|
|
111
|
+
h2 { font-size:clamp(38px,5vw,70px); line-height:.94; letter-spacing:-.045em; margin:0; overflow-wrap:anywhere; }
|
|
112
|
+
.body { color:var(--muted); font-size:20px; line-height:1.5; max-width:640px; }
|
|
113
|
+
.duration { color:var(--hot); margin-top:auto; padding-top:28px; }
|
|
114
|
+
.metrics { display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-top:20px; }
|
|
115
|
+
.metrics div { border-top:1px solid #343842; padding-top:14px; }
|
|
116
|
+
.metrics strong { font-size:34px; display:block; }
|
|
117
|
+
.metrics span { color:var(--muted); text-transform:uppercase; font-size:11px; letter-spacing:.1em; }
|
|
118
|
+
footer { padding:36px 7vw 72px; color:var(--muted); border-top:1px solid #282b32; }
|
|
119
|
+
@media (max-width:700px) { header { min-height:70vh; } main { padding:24px; } .scene { min-height:360px; } }
|
|
120
|
+
</style>
|
|
121
|
+
</head>
|
|
122
|
+
<body>
|
|
123
|
+
<header>
|
|
124
|
+
<div class="kicker">RepoTrailer · generated launch kit</div>
|
|
125
|
+
<h1>${escapeHtml(repo.title)}</h1>
|
|
126
|
+
<p>${escapeHtml(repo.tagline)}</p>
|
|
127
|
+
</header>
|
|
128
|
+
<main>${scenes.map(sceneMarkup).join("")}</main>
|
|
129
|
+
<footer>Generated locally from real repository metadata. No API key. No invented metrics.</footer>
|
|
130
|
+
<script type="application/json" id="repotrailer-data">${payload}</script>
|
|
131
|
+
</body>
|
|
132
|
+
</html>`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function launchCopy(repo) {
|
|
136
|
+
const features = repo.features.slice(0, 3).map((item) => `- ${item}`).join("\n");
|
|
137
|
+
const stack = repo.stack.join(", ") || repo.languages.map((item) => item.name).join(", ");
|
|
138
|
+
const url = repo.git.remote || repo.source;
|
|
139
|
+
return `# Launch copy for ${repo.title}
|
|
140
|
+
|
|
141
|
+
## Short post
|
|
142
|
+
|
|
143
|
+
I just shipped **${repo.title}**: ${repo.tagline}
|
|
144
|
+
|
|
145
|
+
${repo.installCommand}
|
|
146
|
+
|
|
147
|
+
${url}
|
|
148
|
+
|
|
149
|
+
## Show HN
|
|
150
|
+
|
|
151
|
+
**Title:** Show HN: ${repo.title} – ${repo.tagline}
|
|
152
|
+
|
|
153
|
+
I built ${repo.title} because a README often explains a project but does not make people feel it.
|
|
154
|
+
|
|
155
|
+
What it does:
|
|
156
|
+
|
|
157
|
+
${features}
|
|
158
|
+
|
|
159
|
+
The project uses ${stack || "a small local-first toolchain"}. Feedback on the first-run experience and generated assets would be especially useful.
|
|
160
|
+
|
|
161
|
+
${url}
|
|
162
|
+
|
|
163
|
+
## README hero
|
|
164
|
+
|
|
165
|
+
\`\`\`markdown
|
|
166
|
+

|
|
167
|
+
\`\`\`
|
|
168
|
+
|
|
169
|
+
## Suggested topics
|
|
170
|
+
|
|
171
|
+
\`github\` \`open-source\` \`developer-tools\` \`video\` \`hyperframes\` \`cli\`
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function writeLaunchKit(repo, scenes, outputDirectory, options = {}) {
|
|
176
|
+
const palette = { ...DEFAULT_PALETTE, ...options.palette };
|
|
177
|
+
const output = path.resolve(outputDirectory);
|
|
178
|
+
await mkdir(output, { recursive: true });
|
|
179
|
+
|
|
180
|
+
const files = {
|
|
181
|
+
manifest: path.join(output, "repotrailer.json"),
|
|
182
|
+
preview: path.join(output, "index.html"),
|
|
183
|
+
socialCard: path.join(output, "social-card.svg"),
|
|
184
|
+
launchCopy: path.join(output, "launch-copy.md"),
|
|
185
|
+
trailer: path.join(output, "trailer.mp4"),
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
await Promise.all([
|
|
189
|
+
writeFile(
|
|
190
|
+
files.manifest,
|
|
191
|
+
`${JSON.stringify({ repo, scenes, palette }, null, 2)}\n`,
|
|
192
|
+
),
|
|
193
|
+
writeFile(files.preview, previewHtml(repo, scenes, palette)),
|
|
194
|
+
writeFile(files.socialCard, socialCard(repo, palette)),
|
|
195
|
+
writeFile(files.launchCopy, launchCopy(repo)),
|
|
196
|
+
]);
|
|
197
|
+
const hyperframes = await writeHyperframesProject(
|
|
198
|
+
repo,
|
|
199
|
+
scenes,
|
|
200
|
+
output,
|
|
201
|
+
palette,
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
output,
|
|
206
|
+
slug: slugify(repo.name),
|
|
207
|
+
files,
|
|
208
|
+
hyperframes,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export const __test = {
|
|
213
|
+
socialCard,
|
|
214
|
+
previewHtml,
|
|
215
|
+
launchCopy,
|
|
216
|
+
wrapText,
|
|
217
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { formatNumber, truncate } from "./utils.js";
|
|
2
|
+
|
|
3
|
+
function featureScene(feature, index) {
|
|
4
|
+
return {
|
|
5
|
+
id: `feature-${index + 1}`,
|
|
6
|
+
kind: "feature",
|
|
7
|
+
eyebrow: `0${index + 1}`,
|
|
8
|
+
title: truncate(feature, 72),
|
|
9
|
+
body: index === 0
|
|
10
|
+
? "Lead with the clearest reason to care."
|
|
11
|
+
: "One idea per beat. No feature-list blur.",
|
|
12
|
+
duration: 2.4,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function countLabel(value, singular, plural = `${singular}s`) {
|
|
17
|
+
return `${formatNumber(value)} ${value === 1 ? singular : plural}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function buildStoryboard(repo) {
|
|
21
|
+
const signalCount = repo.stack.length || repo.languages.length;
|
|
22
|
+
const scenes = [
|
|
23
|
+
{
|
|
24
|
+
id: "hook",
|
|
25
|
+
kind: "hook",
|
|
26
|
+
eyebrow: "OPEN SOURCE",
|
|
27
|
+
title: repo.title,
|
|
28
|
+
body: truncate(repo.tagline, 140),
|
|
29
|
+
duration: 3,
|
|
30
|
+
},
|
|
31
|
+
...repo.features.slice(0, 3).map(featureScene),
|
|
32
|
+
{
|
|
33
|
+
id: "proof",
|
|
34
|
+
kind: "proof",
|
|
35
|
+
eyebrow: "THE RECEIPT",
|
|
36
|
+
title: `${countLabel(repo.files.source, "source file")}. ${countLabel(signalCount, "signal")}.`,
|
|
37
|
+
body: repo.stack.length
|
|
38
|
+
? repo.stack.join(" · ")
|
|
39
|
+
: repo.languages.map((item) => item.name).join(" · "),
|
|
40
|
+
metrics: [
|
|
41
|
+
{ label: "commits", value: formatNumber(repo.git.commits) },
|
|
42
|
+
{ label: "contributors", value: formatNumber(repo.git.contributors) },
|
|
43
|
+
{ label: "source files", value: formatNumber(repo.files.source) },
|
|
44
|
+
],
|
|
45
|
+
duration: 3,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: "install",
|
|
49
|
+
kind: "install",
|
|
50
|
+
eyebrow: "TRY IT",
|
|
51
|
+
title: repo.installCommand,
|
|
52
|
+
body: "Copy. Run. See it work.",
|
|
53
|
+
duration: 2.8,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "outro",
|
|
57
|
+
kind: "outro",
|
|
58
|
+
eyebrow: "SHIP YOUR README",
|
|
59
|
+
title: repo.name,
|
|
60
|
+
body: repo.git.remote || repo.source,
|
|
61
|
+
duration: 2.8,
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
let start = 0;
|
|
66
|
+
return scenes.map((scene) => {
|
|
67
|
+
const timed = { ...scene, start };
|
|
68
|
+
start = Number((start + scene.duration).toFixed(2));
|
|
69
|
+
return timed;
|
|
70
|
+
});
|
|
71
|
+
}
|
package/src/utils.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export function escapeHtml(value) {
|
|
7
|
+
return String(value)
|
|
8
|
+
.replaceAll("&", "&")
|
|
9
|
+
.replaceAll("<", "<")
|
|
10
|
+
.replaceAll(">", ">")
|
|
11
|
+
.replaceAll('"', """)
|
|
12
|
+
.replaceAll("'", "'");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function escapeXml(value) {
|
|
16
|
+
return escapeHtml(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function slugify(value) {
|
|
20
|
+
return String(value)
|
|
21
|
+
.toLowerCase()
|
|
22
|
+
.trim()
|
|
23
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
24
|
+
.replace(/^-+|-+$/g, "") || "repo";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function stripMarkdown(value) {
|
|
28
|
+
return String(value)
|
|
29
|
+
.replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1")
|
|
30
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
31
|
+
.replace(/<[^>]+>/g, " ")
|
|
32
|
+
.replace(/[`*_~>#|]/g, " ")
|
|
33
|
+
.replace(/\s+/g, " ")
|
|
34
|
+
.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function truncate(value, maxLength) {
|
|
38
|
+
const text = String(value).trim();
|
|
39
|
+
if (text.length <= maxLength) {
|
|
40
|
+
return text;
|
|
41
|
+
}
|
|
42
|
+
return `${text.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function run(command, args, options = {}) {
|
|
46
|
+
try {
|
|
47
|
+
const result = await execFileAsync(command, args, {
|
|
48
|
+
cwd: options.cwd,
|
|
49
|
+
timeout: options.timeout ?? 15_000,
|
|
50
|
+
maxBuffer: options.maxBuffer ?? 4 * 1024 * 1024,
|
|
51
|
+
env: {
|
|
52
|
+
...process.env,
|
|
53
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
54
|
+
...options.env,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
return {
|
|
58
|
+
ok: true,
|
|
59
|
+
stdout: result.stdout.trim(),
|
|
60
|
+
stderr: result.stderr.trim(),
|
|
61
|
+
};
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return {
|
|
64
|
+
ok: false,
|
|
65
|
+
stdout: error.stdout?.trim?.() ?? "",
|
|
66
|
+
stderr: error.stderr?.trim?.() ?? error.message,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatNumber(value) {
|
|
72
|
+
return new Intl.NumberFormat("en", { notation: "compact" }).format(value);
|
|
73
|
+
}
|