@ogpipe/next 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/README.md +164 -0
- package/dist/bin/ogpipe.d.mts +1 -0
- package/dist/bin/ogpipe.d.ts +1 -0
- package/dist/bin/ogpipe.js +667 -0
- package/dist/bin/ogpipe.js.map +1 -0
- package/dist/bin/ogpipe.mjs +665 -0
- package/dist/bin/ogpipe.mjs.map +1 -0
- package/dist/client/index.d.mts +73 -0
- package/dist/client/index.d.ts +73 -0
- package/dist/client/index.js +101 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/index.mjs +76 -0
- package/dist/client/index.mjs.map +1 -0
- package/dist/index.d.mts +120 -0
- package/dist/index.d.ts +120 -0
- package/dist/index.js +168 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +139 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +61 -0
- package/templates/blog.html +32 -0
- package/templates/changelog.html +32 -0
- package/templates/docs.html +25 -0
- package/templates/minimal.html +12 -0
- package/templates/product.html +29 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __esm = (fn, res) => function __init() {
|
|
5
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
6
|
+
};
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/client/index.ts
|
|
13
|
+
var OGPipeClient;
|
|
14
|
+
var init_client = __esm({
|
|
15
|
+
"src/client/index.ts"() {
|
|
16
|
+
"use strict";
|
|
17
|
+
OGPipeClient = class {
|
|
18
|
+
apiKey;
|
|
19
|
+
baseUrl;
|
|
20
|
+
timeout;
|
|
21
|
+
constructor(options = {}) {
|
|
22
|
+
this.apiKey = options.apiKey || process.env.OGPIPE_API_KEY || "";
|
|
23
|
+
this.baseUrl = options.baseUrl || process.env.OGPIPE_BASE_URL || "https://api.ogpipe.dev";
|
|
24
|
+
this.timeout = options.timeout || 3e4;
|
|
25
|
+
if (!this.apiKey) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
"[OGPipe] Missing API key. Set OGPIPE_API_KEY environment variable or pass apiKey option."
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Render HTML to an image. Returns the CDN URL.
|
|
33
|
+
*/
|
|
34
|
+
async render(request) {
|
|
35
|
+
const controller = new AbortController();
|
|
36
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
37
|
+
try {
|
|
38
|
+
const res = await fetch(`${this.baseUrl}/images`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: {
|
|
41
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
42
|
+
"Content-Type": "application/json"
|
|
43
|
+
},
|
|
44
|
+
body: JSON.stringify({
|
|
45
|
+
html: request.html,
|
|
46
|
+
width: request.width || 1200,
|
|
47
|
+
height: request.height || 630,
|
|
48
|
+
format: request.format || "png"
|
|
49
|
+
}),
|
|
50
|
+
signal: controller.signal
|
|
51
|
+
});
|
|
52
|
+
const body = await res.json();
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
error: body.error || `HTTP ${res.status}`,
|
|
57
|
+
statusCode: res.status
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
success: true,
|
|
62
|
+
data: body
|
|
63
|
+
};
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
66
|
+
return { success: false, error: "Request timed out", statusCode: 408 };
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
success: false,
|
|
70
|
+
error: err instanceof Error ? err.message : "Unknown error",
|
|
71
|
+
statusCode: 500
|
|
72
|
+
};
|
|
73
|
+
} finally {
|
|
74
|
+
clearTimeout(timer);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Render HTML and return the raw image buffer (for writing to disk).
|
|
79
|
+
*/
|
|
80
|
+
async renderToBuffer(request) {
|
|
81
|
+
const result = await this.render(request);
|
|
82
|
+
if (!result.success) return null;
|
|
83
|
+
const res = await fetch(result.data.url);
|
|
84
|
+
if (!res.ok) return null;
|
|
85
|
+
return Buffer.from(await res.arrayBuffer());
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// src/next/config.ts
|
|
92
|
+
import { readFileSync } from "fs";
|
|
93
|
+
import { resolve } from "path";
|
|
94
|
+
function resolveTemplateHtml(template, configDir) {
|
|
95
|
+
if (template.html) {
|
|
96
|
+
return template.html;
|
|
97
|
+
}
|
|
98
|
+
if (template.file) {
|
|
99
|
+
const filePath = resolve(configDir, template.file);
|
|
100
|
+
try {
|
|
101
|
+
return readFileSync(filePath, "utf-8");
|
|
102
|
+
} catch (err) {
|
|
103
|
+
throw new Error(`[OGPipe] Template file not found: ${filePath}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
throw new Error("[OGPipe] Template must have either 'html' or 'file' property.");
|
|
107
|
+
}
|
|
108
|
+
function injectVariables(html, variables) {
|
|
109
|
+
return html.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
110
|
+
return variables[key] ?? "";
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
function matchRoute(path, pattern) {
|
|
114
|
+
if (pattern === "*") return true;
|
|
115
|
+
const regexStr = pattern.replace(/\[\.\.\.[\w]+\]/g, ".*").replace(/\[[\w]+\]/g, "[^/]+").replace(/\*/g, "[^/]+");
|
|
116
|
+
const regex = new RegExp(`^${regexStr}$`);
|
|
117
|
+
return regex.test(path);
|
|
118
|
+
}
|
|
119
|
+
function findRouteConfig(path, routes) {
|
|
120
|
+
const sortedPatterns = Object.keys(routes).sort((a, b) => {
|
|
121
|
+
if (a === "*") return 1;
|
|
122
|
+
if (b === "*") return -1;
|
|
123
|
+
return b.length - a.length;
|
|
124
|
+
});
|
|
125
|
+
for (const pattern of sortedPatterns) {
|
|
126
|
+
if (matchRoute(path, pattern)) {
|
|
127
|
+
return routes[pattern];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
var init_config = __esm({
|
|
133
|
+
"src/next/config.ts"() {
|
|
134
|
+
"use strict";
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// src/preview/server.ts
|
|
139
|
+
var server_exports = {};
|
|
140
|
+
__export(server_exports, {
|
|
141
|
+
startPreviewServer: () => startPreviewServer
|
|
142
|
+
});
|
|
143
|
+
import { createServer } from "http";
|
|
144
|
+
import { watch, existsSync as existsSync2 } from "fs";
|
|
145
|
+
import { resolve as resolve3 } from "path";
|
|
146
|
+
async function startPreviewServer(options) {
|
|
147
|
+
const { config, configDir, port = DEFAULT_PORT } = options;
|
|
148
|
+
const routes = buildPreviewRoutes(config);
|
|
149
|
+
const clients = [];
|
|
150
|
+
watchTemplateFiles(config, configDir, () => {
|
|
151
|
+
for (const client of clients) {
|
|
152
|
+
client.write("data: reload\n\n");
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
const server = createServer(async (req, res) => {
|
|
156
|
+
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
157
|
+
const pathname = url.pathname;
|
|
158
|
+
if (pathname === "/__ogpipe/events") {
|
|
159
|
+
res.writeHead(200, {
|
|
160
|
+
"Content-Type": "text/event-stream",
|
|
161
|
+
"Cache-Control": "no-cache",
|
|
162
|
+
Connection: "keep-alive",
|
|
163
|
+
"Access-Control-Allow-Origin": "*"
|
|
164
|
+
});
|
|
165
|
+
clients.push(res);
|
|
166
|
+
req.on("close", () => {
|
|
167
|
+
const idx = clients.indexOf(res);
|
|
168
|
+
if (idx >= 0) clients.splice(idx, 1);
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (pathname === "/__ogpipe/render") {
|
|
173
|
+
const routePath = url.searchParams.get("route") || "/";
|
|
174
|
+
const templateId = url.searchParams.get("template");
|
|
175
|
+
await handleRender(req, res, config, configDir, routePath, templateId);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (pathname === "/__ogpipe/routes") {
|
|
179
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
180
|
+
res.end(JSON.stringify(routes));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
184
|
+
res.end(generateDashboardHtml(routes, port));
|
|
185
|
+
});
|
|
186
|
+
server.listen(port, () => {
|
|
187
|
+
console.log(`
|
|
188
|
+
\u26A1 OGPipe Dev Preview`);
|
|
189
|
+
console.log(` \u2192 http://localhost:${port}
|
|
190
|
+
`);
|
|
191
|
+
console.log(` ${routes.length} routes configured`);
|
|
192
|
+
console.log(` Watching templates for changes...
|
|
193
|
+
`);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function buildPreviewRoutes(config) {
|
|
197
|
+
const routes = [];
|
|
198
|
+
for (const [pattern, routeConfig] of Object.entries(config.routes)) {
|
|
199
|
+
const samplePath = patternToSamplePath(pattern);
|
|
200
|
+
const vars = typeof routeConfig.vars === "function" ? routeConfig.vars({ path: samplePath, title: "Sample Title", description: "Sample description", params: {} }) : { title: "Sample Title", description: "Sample description" };
|
|
201
|
+
routes.push({
|
|
202
|
+
path: samplePath,
|
|
203
|
+
template: routeConfig.template,
|
|
204
|
+
vars
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return routes;
|
|
208
|
+
}
|
|
209
|
+
function patternToSamplePath(pattern) {
|
|
210
|
+
if (pattern === "*") return "/";
|
|
211
|
+
return pattern.replace(/\[\.\.\.(\w+)\]/g, "example-$1").replace(/\[(\w+)\]/g, "example-$1").replace(/\*/g, "example");
|
|
212
|
+
}
|
|
213
|
+
async function handleRender(req, res, config, configDir, routePath, templateId) {
|
|
214
|
+
try {
|
|
215
|
+
const tplId = templateId || Object.keys(config.templates)[0];
|
|
216
|
+
const template = config.templates[tplId];
|
|
217
|
+
if (!template) {
|
|
218
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
219
|
+
res.end(JSON.stringify({ error: `Template '${tplId}' not found` }));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
let html = resolveTemplateHtml(template, configDir);
|
|
223
|
+
const vars = { title: "Sample Blog Post Title", description: "A sample description for preview", author: "Developer", date: "June 2026", site: "mysite.dev", category: "Guide" };
|
|
224
|
+
html = injectVariables(html, vars);
|
|
225
|
+
const apiKey = config.apiKey || process.env.OGPIPE_API_KEY;
|
|
226
|
+
if (apiKey) {
|
|
227
|
+
const client = new OGPipeClient({ apiKey, baseUrl: config.baseUrl });
|
|
228
|
+
const result = await client.render({ html, width: template.width || 1200, height: template.height || 630 });
|
|
229
|
+
if (result.success) {
|
|
230
|
+
const imgRes = await fetch(result.data.url);
|
|
231
|
+
const buffer = Buffer.from(await imgRes.arrayBuffer());
|
|
232
|
+
res.writeHead(200, {
|
|
233
|
+
"Content-Type": `image/${result.data.format}`,
|
|
234
|
+
"Cache-Control": "no-cache"
|
|
235
|
+
});
|
|
236
|
+
res.end(buffer);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
241
|
+
res.end(html);
|
|
242
|
+
} catch (err) {
|
|
243
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
244
|
+
res.end(JSON.stringify({ error: err instanceof Error ? err.message : "Render failed" }));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
function watchTemplateFiles(config, configDir, onChange) {
|
|
248
|
+
const filesToWatch = [];
|
|
249
|
+
for (const template of Object.values(config.templates)) {
|
|
250
|
+
if (template.file) {
|
|
251
|
+
const fullPath = resolve3(configDir, template.file);
|
|
252
|
+
if (existsSync2(fullPath)) {
|
|
253
|
+
filesToWatch.push(fullPath);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const filePath of filesToWatch) {
|
|
258
|
+
watch(filePath, { persistent: false }, (eventType) => {
|
|
259
|
+
if (eventType === "change") {
|
|
260
|
+
console.log(` \u21BB Template changed: ${filePath}`);
|
|
261
|
+
onChange();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function generateDashboardHtml(routes, port) {
|
|
267
|
+
return `<!DOCTYPE html>
|
|
268
|
+
<html lang="en">
|
|
269
|
+
<head>
|
|
270
|
+
<meta charset="utf-8">
|
|
271
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
272
|
+
<title>OGPipe Dev Preview</title>
|
|
273
|
+
<style>
|
|
274
|
+
:root { --bg: #09090b; --surface: #18181b; --surface-2: #27272a; --border: #3f3f46; --text: #fafafa; --text-muted: #a1a1aa; --accent: #3b82f6; }
|
|
275
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
276
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); padding: 32px; }
|
|
277
|
+
h1 { font-size: 24px; font-weight: 700; margin-bottom: 8px; }
|
|
278
|
+
.subtitle { color: var(--text-muted); font-size: 14px; margin-bottom: 32px; }
|
|
279
|
+
.platforms { display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; }
|
|
280
|
+
.platform-btn { padding: 8px 16px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--text-muted); cursor: pointer; font-size: 13px; transition: all 0.15s; }
|
|
281
|
+
.platform-btn.active { border-color: var(--accent); color: var(--accent); background: rgba(59,130,246,0.1); }
|
|
282
|
+
.preview-container { display: grid; grid-template-columns: 1fr; gap: 24px; }
|
|
283
|
+
.preview-card { background: var(--surface); border: 1px solid var(--surface-2); border-radius: 12px; padding: 24px; }
|
|
284
|
+
.preview-card h3 { font-size: 14px; color: var(--text-muted); margin-bottom: 12px; }
|
|
285
|
+
.route-info { font-family: monospace; font-size: 12px; color: var(--accent); margin-bottom: 16px; }
|
|
286
|
+
|
|
287
|
+
/* Platform mockups */
|
|
288
|
+
.mockup { border-radius: 8px; overflow: hidden; border: 1px solid var(--border); }
|
|
289
|
+
.mockup-twitter { max-width: 506px; }
|
|
290
|
+
.mockup-linkedin { max-width: 552px; }
|
|
291
|
+
.mockup-slack { max-width: 400px; }
|
|
292
|
+
.mockup-discord { max-width: 432px; }
|
|
293
|
+
|
|
294
|
+
.mockup img, .mockup iframe { width: 100%; aspect-ratio: 1200/630; display: block; border: none; background: var(--surface-2); }
|
|
295
|
+
|
|
296
|
+
.mockup-frame { padding: 12px; background: var(--surface-2); border-radius: 8px; }
|
|
297
|
+
.mockup-meta { padding: 8px 12px; font-size: 12px; color: var(--text-muted); }
|
|
298
|
+
.mockup-meta .title { font-size: 14px; font-weight: 600; color: var(--text); margin-bottom: 2px; }
|
|
299
|
+
.mockup-meta .desc { font-size: 12px; color: var(--text-muted); }
|
|
300
|
+
.mockup-meta .domain { font-size: 11px; color: var(--text-muted); margin-top: 4px; opacity: 0.7; }
|
|
301
|
+
|
|
302
|
+
.routes-list { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 24px; }
|
|
303
|
+
.route-pill { padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface); color: var(--text-muted); cursor: pointer; font-size: 13px; font-family: monospace; }
|
|
304
|
+
.route-pill.active { border-color: var(--accent); color: var(--accent); }
|
|
305
|
+
|
|
306
|
+
.reload-badge { position: fixed; top: 16px; right: 16px; background: var(--accent); color: white; padding: 6px 12px; border-radius: 6px; font-size: 12px; display: none; animation: fadeIn 0.2s; }
|
|
307
|
+
@keyframes fadeIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
|
|
308
|
+
</style>
|
|
309
|
+
</head>
|
|
310
|
+
<body>
|
|
311
|
+
<div class="reload-badge" id="reload-badge">\u21BB Reloading...</div>
|
|
312
|
+
<h1>\u26A1 OGPipe Dev Preview</h1>
|
|
313
|
+
<p class="subtitle">Live preview of your OG images across social platforms. Edit templates \u2192 see changes instantly.</p>
|
|
314
|
+
|
|
315
|
+
<div class="routes-list" id="routes-list">
|
|
316
|
+
${routes.map((r, i) => `<button class="route-pill ${i === 0 ? "active" : ""}" data-route="${r.path}" data-template="${r.template}">${r.path} (${r.template})</button>`).join("\n ")}
|
|
317
|
+
</div>
|
|
318
|
+
|
|
319
|
+
<div class="platforms">
|
|
320
|
+
<button class="platform-btn active" data-platform="twitter">\u{1D54F} / Twitter</button>
|
|
321
|
+
<button class="platform-btn" data-platform="linkedin">LinkedIn</button>
|
|
322
|
+
<button class="platform-btn" data-platform="slack">Slack</button>
|
|
323
|
+
<button class="platform-btn" data-platform="discord">Discord</button>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<div class="preview-container" id="preview-container">
|
|
327
|
+
<div class="preview-card">
|
|
328
|
+
<div class="mockup mockup-twitter">
|
|
329
|
+
<div class="mockup-frame">
|
|
330
|
+
<img id="og-preview" src="/__ogpipe/render?route=/&template=${routes[0]?.template || "default"}" alt="OG Image Preview" />
|
|
331
|
+
<div class="mockup-meta">
|
|
332
|
+
<div class="title">Sample Blog Post Title</div>
|
|
333
|
+
<div class="desc">A sample description for preview</div>
|
|
334
|
+
<div class="domain">mysite.dev</div>
|
|
335
|
+
</div>
|
|
336
|
+
</div>
|
|
337
|
+
</div>
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<script>
|
|
342
|
+
const preview = document.getElementById('og-preview');
|
|
343
|
+
const reloadBadge = document.getElementById('reload-badge');
|
|
344
|
+
|
|
345
|
+
// Route selection
|
|
346
|
+
document.querySelectorAll('.route-pill').forEach(btn => {
|
|
347
|
+
btn.addEventListener('click', () => {
|
|
348
|
+
document.querySelectorAll('.route-pill').forEach(b => b.classList.remove('active'));
|
|
349
|
+
btn.classList.add('active');
|
|
350
|
+
const route = btn.dataset.route;
|
|
351
|
+
const template = btn.dataset.template;
|
|
352
|
+
preview.src = '/__ogpipe/render?route=' + encodeURIComponent(route) + '&template=' + encodeURIComponent(template) + '&t=' + Date.now();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// Platform selection (visual only for now \u2014 frame styling)
|
|
357
|
+
document.querySelectorAll('.platform-btn').forEach(btn => {
|
|
358
|
+
btn.addEventListener('click', () => {
|
|
359
|
+
document.querySelectorAll('.platform-btn').forEach(b => b.classList.remove('active'));
|
|
360
|
+
btn.classList.add('active');
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// SSE hot-reload
|
|
365
|
+
const evtSource = new EventSource('/__ogpipe/events');
|
|
366
|
+
evtSource.onmessage = (event) => {
|
|
367
|
+
if (event.data === 'reload') {
|
|
368
|
+
reloadBadge.style.display = 'block';
|
|
369
|
+
// Cache-bust the image
|
|
370
|
+
preview.src = preview.src.replace(/[&?]t=\\d+/, '') + '&t=' + Date.now();
|
|
371
|
+
setTimeout(() => { reloadBadge.style.display = 'none'; }, 1500);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
</script>
|
|
375
|
+
</body>
|
|
376
|
+
</html>`;
|
|
377
|
+
}
|
|
378
|
+
var DEFAULT_PORT;
|
|
379
|
+
var init_server = __esm({
|
|
380
|
+
"src/preview/server.ts"() {
|
|
381
|
+
"use strict";
|
|
382
|
+
init_client();
|
|
383
|
+
init_config();
|
|
384
|
+
DEFAULT_PORT = 3010;
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// src/bin/ogpipe.ts
|
|
389
|
+
import { resolve as resolve4 } from "path";
|
|
390
|
+
import { existsSync as existsSync3 } from "fs";
|
|
391
|
+
|
|
392
|
+
// src/next/generator.ts
|
|
393
|
+
init_client();
|
|
394
|
+
init_config();
|
|
395
|
+
import { readFileSync as readFileSync2, writeFileSync, mkdirSync, existsSync } from "fs";
|
|
396
|
+
import { resolve as resolve2, join, dirname as dirname2 } from "path";
|
|
397
|
+
async function generateOGImages(options) {
|
|
398
|
+
const { projectDir, config, configDir, concurrency = 5 } = options;
|
|
399
|
+
const startTime = Date.now();
|
|
400
|
+
const manifestPath = join(projectDir, ".next", "prerender-manifest.json");
|
|
401
|
+
if (!existsSync(manifestPath)) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`[OGPipe] Cannot find .next/prerender-manifest.json. Run 'next build' first.`
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
const manifest = JSON.parse(
|
|
407
|
+
readFileSync2(manifestPath, "utf-8")
|
|
408
|
+
);
|
|
409
|
+
const routes = Object.keys(manifest.routes).filter(
|
|
410
|
+
(route) => route !== "/_not-found" && !route.startsWith("/_")
|
|
411
|
+
);
|
|
412
|
+
if (routes.length === 0) {
|
|
413
|
+
return { success: [], failed: [], totalDurationMs: 0 };
|
|
414
|
+
}
|
|
415
|
+
const outDir = resolve2(projectDir, config.outDir || "public/og");
|
|
416
|
+
mkdirSync(outDir, { recursive: true });
|
|
417
|
+
const client = new OGPipeClient({
|
|
418
|
+
apiKey: config.apiKey,
|
|
419
|
+
baseUrl: config.baseUrl
|
|
420
|
+
});
|
|
421
|
+
const results = [];
|
|
422
|
+
const failures = [];
|
|
423
|
+
for (let i = 0; i < routes.length; i += concurrency) {
|
|
424
|
+
const batch = routes.slice(i, i + concurrency);
|
|
425
|
+
const batchPromises = batch.map(
|
|
426
|
+
(route) => generateSingleImage({ route, config, configDir, client, outDir })
|
|
427
|
+
);
|
|
428
|
+
const batchResults = await Promise.allSettled(batchPromises);
|
|
429
|
+
for (let j = 0; j < batchResults.length; j++) {
|
|
430
|
+
const result = batchResults[j];
|
|
431
|
+
const route = batch[j];
|
|
432
|
+
if (result.status === "fulfilled" && result.value) {
|
|
433
|
+
results.push(result.value);
|
|
434
|
+
} else if (result.status === "rejected") {
|
|
435
|
+
failures.push({ route, error: result.reason?.message || "Unknown error" });
|
|
436
|
+
} else if (result.status === "fulfilled" && result.value === null) {
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const ogManifest = Object.fromEntries(
|
|
441
|
+
results.map((r) => [r.route, { path: r.outputPath, url: r.imageUrl }])
|
|
442
|
+
);
|
|
443
|
+
writeFileSync(
|
|
444
|
+
join(outDir, "og-manifest.json"),
|
|
445
|
+
JSON.stringify(ogManifest, null, 2)
|
|
446
|
+
);
|
|
447
|
+
return {
|
|
448
|
+
success: results,
|
|
449
|
+
failed: failures,
|
|
450
|
+
totalDurationMs: Date.now() - startTime
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
async function generateSingleImage(options) {
|
|
454
|
+
const { route, config, configDir, client, outDir } = options;
|
|
455
|
+
const startTime = Date.now();
|
|
456
|
+
const routeConfig = findRouteConfig(route, config.routes);
|
|
457
|
+
if (!routeConfig) return null;
|
|
458
|
+
const template = config.templates[routeConfig.template];
|
|
459
|
+
if (!template) {
|
|
460
|
+
throw new Error(`[OGPipe] Template '${routeConfig.template}' not found for route ${route}`);
|
|
461
|
+
}
|
|
462
|
+
let html = resolveTemplateHtml(template, configDir);
|
|
463
|
+
const metadata = {
|
|
464
|
+
path: route,
|
|
465
|
+
title: extractTitleFromRoute(route),
|
|
466
|
+
description: "",
|
|
467
|
+
params: extractParamsFromRoute(route)
|
|
468
|
+
};
|
|
469
|
+
const vars = routeConfig.vars ? routeConfig.vars(metadata) : { title: metadata.title || "", description: metadata.description || "" };
|
|
470
|
+
html = injectVariables(html, vars);
|
|
471
|
+
const result = await client.render({
|
|
472
|
+
html,
|
|
473
|
+
width: template.width || 1200,
|
|
474
|
+
height: template.height || 630,
|
|
475
|
+
format: template.format || "png"
|
|
476
|
+
});
|
|
477
|
+
if (!result.success) {
|
|
478
|
+
throw new Error(`API error for ${route}: ${result.error}`);
|
|
479
|
+
}
|
|
480
|
+
const filename = routeToFilename(route, template.format || "png");
|
|
481
|
+
const outputPath = join(outDir, filename);
|
|
482
|
+
const imageBuffer = await client.renderToBuffer({
|
|
483
|
+
html,
|
|
484
|
+
width: template.width || 1200,
|
|
485
|
+
height: template.height || 630,
|
|
486
|
+
format: template.format || "png"
|
|
487
|
+
});
|
|
488
|
+
if (imageBuffer) {
|
|
489
|
+
mkdirSync(dirname2(outputPath), { recursive: true });
|
|
490
|
+
writeFileSync(outputPath, imageBuffer);
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
route,
|
|
494
|
+
outputPath: `/og/${filename}`,
|
|
495
|
+
imageUrl: result.data.url,
|
|
496
|
+
durationMs: Date.now() - startTime
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
function routeToFilename(route, format) {
|
|
500
|
+
if (route === "/") return `index.${format}`;
|
|
501
|
+
const clean = route.replace(/^\//, "");
|
|
502
|
+
return `${clean}.${format}`;
|
|
503
|
+
}
|
|
504
|
+
function extractTitleFromRoute(route) {
|
|
505
|
+
const segments = route.split("/").filter(Boolean);
|
|
506
|
+
const last = segments[segments.length - 1] || "Home";
|
|
507
|
+
return last.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
508
|
+
}
|
|
509
|
+
function extractParamsFromRoute(route) {
|
|
510
|
+
const segments = route.split("/").filter(Boolean);
|
|
511
|
+
if (segments.length >= 2) {
|
|
512
|
+
return { slug: segments[segments.length - 1] };
|
|
513
|
+
}
|
|
514
|
+
return {};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/bin/ogpipe.ts
|
|
518
|
+
var args = process.argv.slice(2);
|
|
519
|
+
var command = args[0];
|
|
520
|
+
async function main() {
|
|
521
|
+
switch (command) {
|
|
522
|
+
case "generate":
|
|
523
|
+
await runGenerate();
|
|
524
|
+
break;
|
|
525
|
+
case "dev":
|
|
526
|
+
await runDev();
|
|
527
|
+
break;
|
|
528
|
+
case "--help":
|
|
529
|
+
case "-h":
|
|
530
|
+
case void 0:
|
|
531
|
+
printHelp();
|
|
532
|
+
break;
|
|
533
|
+
default:
|
|
534
|
+
console.error(`Unknown command: ${command}`);
|
|
535
|
+
printHelp();
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
async function runGenerate() {
|
|
540
|
+
const isDry = args.includes("--dry");
|
|
541
|
+
const projectDir = process.cwd();
|
|
542
|
+
console.log("\n\u26A1 OGPipe \u2014 Generating OG images...\n");
|
|
543
|
+
const config = await loadConfig(projectDir);
|
|
544
|
+
if (!config) {
|
|
545
|
+
console.error("\u274C No ogpipe.config.ts found in project root.");
|
|
546
|
+
console.error(" Create one with: import { defineConfig } from '@ogpipe/next'");
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
if (isDry) {
|
|
550
|
+
console.log("\u{1F50D} Dry run \u2014 showing what would be generated:\n");
|
|
551
|
+
console.log(" (dry run not yet implemented \u2014 run without --dry to generate)");
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (!existsSync3(resolve4(projectDir, ".next"))) {
|
|
555
|
+
console.error("\u274C No .next directory found. Run 'next build' first.");
|
|
556
|
+
process.exit(1);
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
const configDir = projectDir;
|
|
560
|
+
const report = await generateOGImages({
|
|
561
|
+
projectDir,
|
|
562
|
+
config,
|
|
563
|
+
configDir
|
|
564
|
+
});
|
|
565
|
+
console.log(`\u2705 Generated ${report.success.length} OG images in ${report.totalDurationMs}ms
|
|
566
|
+
`);
|
|
567
|
+
for (const result of report.success) {
|
|
568
|
+
console.log(` ${result.route} \u2192 ${result.outputPath} (${result.durationMs}ms)`);
|
|
569
|
+
}
|
|
570
|
+
if (report.failed.length > 0) {
|
|
571
|
+
console.log(`
|
|
572
|
+
\u26A0\uFE0F ${report.failed.length} failed:
|
|
573
|
+
`);
|
|
574
|
+
for (const failure of report.failed) {
|
|
575
|
+
console.log(` ${failure.route}: ${failure.error}`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
console.log(`
|
|
579
|
+
\u{1F4C1} Output: ${config.outDir || "public/og/"}`);
|
|
580
|
+
console.log(`\u{1F4CB} Manifest: ${config.outDir || "public/og"}/og-manifest.json
|
|
581
|
+
`);
|
|
582
|
+
} catch (err) {
|
|
583
|
+
console.error(`
|
|
584
|
+
\u274C Generation failed: ${err instanceof Error ? err.message : err}`);
|
|
585
|
+
process.exit(1);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
async function runDev() {
|
|
589
|
+
const projectDir = process.cwd();
|
|
590
|
+
console.log("\n\u26A1 OGPipe Dev Preview \u2014 starting...\n");
|
|
591
|
+
const config = await loadConfig(projectDir);
|
|
592
|
+
if (!config) {
|
|
593
|
+
console.error("\u274C No ogpipe.config.ts found in project root.");
|
|
594
|
+
console.error(" Create one with: import { defineConfig } from '@ogpipe/next'");
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
const { startPreviewServer: startPreviewServer2 } = await Promise.resolve().then(() => (init_server(), server_exports));
|
|
598
|
+
await startPreviewServer2({
|
|
599
|
+
config,
|
|
600
|
+
configDir: projectDir,
|
|
601
|
+
port: parseInt(process.env.OGPIPE_PORT || "3010")
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
async function loadConfig(projectDir) {
|
|
605
|
+
const configPaths = [
|
|
606
|
+
resolve4(projectDir, "ogpipe.config.ts"),
|
|
607
|
+
resolve4(projectDir, "ogpipe.config.js"),
|
|
608
|
+
resolve4(projectDir, "ogpipe.config.mjs")
|
|
609
|
+
];
|
|
610
|
+
for (const configPath of configPaths) {
|
|
611
|
+
if (existsSync3(configPath)) {
|
|
612
|
+
try {
|
|
613
|
+
const module = await import(configPath);
|
|
614
|
+
return module.default || module;
|
|
615
|
+
} catch (err) {
|
|
616
|
+
try {
|
|
617
|
+
const module = await import(`file://${configPath}`);
|
|
618
|
+
return module.default || module;
|
|
619
|
+
} catch {
|
|
620
|
+
console.error(`\u274C Failed to load config: ${configPath}`);
|
|
621
|
+
console.error(` ${err instanceof Error ? err.message : err}`);
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
function printHelp() {
|
|
630
|
+
console.log(`
|
|
631
|
+
\u26A1 OGPipe CLI \u2014 Pixel-perfect OG images for Next.js
|
|
632
|
+
|
|
633
|
+
COMMANDS:
|
|
634
|
+
generate Generate OG images for all static routes (run after next build)
|
|
635
|
+
generate --dry Show what would be generated without calling the API
|
|
636
|
+
dev Start local preview server with hot-reload
|
|
637
|
+
|
|
638
|
+
SETUP:
|
|
639
|
+
1. Create ogpipe.config.ts in your project root
|
|
640
|
+
2. Add to package.json scripts: "build": "next build && ogpipe generate"
|
|
641
|
+
3. Set OGPIPE_API_KEY environment variable
|
|
642
|
+
|
|
643
|
+
DOCS:
|
|
644
|
+
https://ogpipe.dev/docs.html
|
|
645
|
+
|
|
646
|
+
EXAMPLE CONFIG:
|
|
647
|
+
import { defineConfig } from '@ogpipe/next'
|
|
648
|
+
|
|
649
|
+
export default defineConfig({
|
|
650
|
+
templates: {
|
|
651
|
+
blog: { file: './og-templates/blog.html' },
|
|
652
|
+
default: { html: '<div style="...">{{title}}</div>' },
|
|
653
|
+
},
|
|
654
|
+
routes: {
|
|
655
|
+
'/blog/[slug]': { template: 'blog', vars: (meta) => ({ title: meta.title }) },
|
|
656
|
+
'*': { template: 'default' },
|
|
657
|
+
},
|
|
658
|
+
})
|
|
659
|
+
`);
|
|
660
|
+
}
|
|
661
|
+
main().catch((err) => {
|
|
662
|
+
console.error("Fatal error:", err);
|
|
663
|
+
process.exit(1);
|
|
664
|
+
});
|
|
665
|
+
//# sourceMappingURL=ogpipe.mjs.map
|