ogthing 0.0.0-alpha.1
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/LICENSE +21 -0
- package/README.md +141 -0
- package/bin/dev-named.mjs +24 -0
- package/bin/ogthing.mjs +387 -0
- package/index.html +17 -0
- package/package.json +72 -0
- package/portless.json +4 -0
- package/server/metadata.ts +365 -0
- package/server/ogthing-api.ts +130 -0
- package/server/types.ts +53 -0
- package/src/App.tsx +311 -0
- package/src/icons.tsx +84 -0
- package/src/main.tsx +10 -0
- package/src/previews.tsx +242 -0
- package/src/styles.css +865 -0
- package/src/tabs.tsx +374 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +21 -0
- package/vite.config.ts +16 -0
package/src/tabs.tsx
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { MetaData } from "../server/types";
|
|
3
|
+
import { CheckIcon, CopyIcon, ExternalIcon } from "./icons";
|
|
4
|
+
|
|
5
|
+
export interface TabItem {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Tabs({
|
|
11
|
+
tabs,
|
|
12
|
+
active,
|
|
13
|
+
onChange,
|
|
14
|
+
}: {
|
|
15
|
+
tabs: TabItem[];
|
|
16
|
+
active: string;
|
|
17
|
+
onChange: (id: string) => void;
|
|
18
|
+
}) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="tabs">
|
|
21
|
+
{tabs.map((tab) => (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
key={tab.id}
|
|
25
|
+
className={`tab ${active === tab.id ? "active" : ""}`}
|
|
26
|
+
onMouseDown={() => onChange(tab.id)}
|
|
27
|
+
>
|
|
28
|
+
{tab.label}
|
|
29
|
+
</button>
|
|
30
|
+
))}
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const Card = ({ title, children }: { title: string; children: React.ReactNode }) => (
|
|
36
|
+
<section className="card">
|
|
37
|
+
<div className="card-head">{title}</div>
|
|
38
|
+
<div className="card-body">{children}</div>
|
|
39
|
+
</section>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const lengthStatus = (length: number, limit?: number): "ok" | "warn" | "error" => {
|
|
43
|
+
if (!limit || length <= limit) return "ok";
|
|
44
|
+
if (length <= limit * 1.2) return "warn";
|
|
45
|
+
return "error";
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const Field = ({
|
|
49
|
+
label,
|
|
50
|
+
value,
|
|
51
|
+
isImage,
|
|
52
|
+
limit,
|
|
53
|
+
characterCount,
|
|
54
|
+
missing,
|
|
55
|
+
}: {
|
|
56
|
+
label: string;
|
|
57
|
+
value?: string;
|
|
58
|
+
isImage?: boolean;
|
|
59
|
+
limit?: number;
|
|
60
|
+
characterCount?: boolean;
|
|
61
|
+
missing?: boolean;
|
|
62
|
+
}) => {
|
|
63
|
+
const status = value ? lengthStatus(value.length, limit) : "ok";
|
|
64
|
+
return (
|
|
65
|
+
<div className="field-row">
|
|
66
|
+
<div className="field-label">{label}</div>
|
|
67
|
+
<div className="field-value">
|
|
68
|
+
{isImage && value ? (
|
|
69
|
+
<a href={value} target="_blank" rel="noopener noreferrer" className="image-link">
|
|
70
|
+
<img
|
|
71
|
+
src={value}
|
|
72
|
+
alt={label}
|
|
73
|
+
loading="lazy"
|
|
74
|
+
onError={(event) => {
|
|
75
|
+
event.currentTarget.style.display = "none";
|
|
76
|
+
event.currentTarget.parentElement?.append("unavailable");
|
|
77
|
+
}}
|
|
78
|
+
/>
|
|
79
|
+
</a>
|
|
80
|
+
) : (
|
|
81
|
+
<>
|
|
82
|
+
{value ? <span className="field-text">{value}</span> : <span className="dim">—</span>}
|
|
83
|
+
{characterCount && value && (
|
|
84
|
+
<div className={`char-count ${status}`}>
|
|
85
|
+
{value.length}
|
|
86
|
+
{limit ? ` / ${limit}` : ""} chars
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
{missing && !value && <span className="missing">missing</span>}
|
|
90
|
+
</>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const LinkPresence = ({ label, url }: { label: string; url?: string }) => (
|
|
98
|
+
<div className="field-row">
|
|
99
|
+
<div className="field-label">{label}</div>
|
|
100
|
+
<div className="field-value">
|
|
101
|
+
{url ? (
|
|
102
|
+
<a href={url} target="_blank" rel="noopener noreferrer" className="found">
|
|
103
|
+
found <ExternalIcon size={10} />
|
|
104
|
+
</a>
|
|
105
|
+
) : (
|
|
106
|
+
<span className="missing">not found</span>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const scoreOf = (metadata: MetaData) => {
|
|
113
|
+
const checks = [
|
|
114
|
+
{ label: "Title", ok: !!metadata.title },
|
|
115
|
+
{ label: "Title Length", ok: !!metadata.title && metadata.title.length <= 60 },
|
|
116
|
+
{ label: "Description", ok: !!metadata.description },
|
|
117
|
+
{ label: "Desc Length", ok: !!metadata.description && metadata.description.length <= 160 },
|
|
118
|
+
{ label: "OG Title", ok: !!metadata.ogTitle },
|
|
119
|
+
{ label: "OG Description", ok: !!metadata.ogDescription },
|
|
120
|
+
{ label: "OG Image", ok: !!metadata.ogImage },
|
|
121
|
+
{ label: "Twitter Card", ok: !!metadata.twitterCard },
|
|
122
|
+
{ label: "Twitter Image", ok: !!metadata.twitterImage || !!metadata.ogImage },
|
|
123
|
+
{ label: "Canonical", ok: !!metadata.canonical },
|
|
124
|
+
{ label: "Favicon", ok: !!metadata.favicon },
|
|
125
|
+
{ label: "Viewport", ok: !!metadata.viewport },
|
|
126
|
+
{ label: "robots.txt", ok: !!metadata.robotsFile },
|
|
127
|
+
{ label: "sitemap.xml", ok: !!metadata.sitemap },
|
|
128
|
+
];
|
|
129
|
+
return {
|
|
130
|
+
checks,
|
|
131
|
+
score: Math.round((checks.filter((check) => check.ok).length / checks.length) * 100),
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export function ScoreTab({ metadata }: { metadata: MetaData }) {
|
|
136
|
+
const { score, checks } = scoreOf(metadata);
|
|
137
|
+
const tone = score >= 80 ? "good" : score >= 50 ? "mid" : "bad";
|
|
138
|
+
const circumference = 2 * Math.PI * 36;
|
|
139
|
+
const offset = circumference - (score / 100) * circumference;
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<div className="score-wrap">
|
|
143
|
+
<div className={`score-ring ${tone}`}>
|
|
144
|
+
<svg viewBox="0 0 80 80" aria-hidden="true">
|
|
145
|
+
<circle cx="40" cy="40" r="36" className="ring-bg" strokeWidth="4" fill="none" />
|
|
146
|
+
<circle
|
|
147
|
+
cx="40"
|
|
148
|
+
cy="40"
|
|
149
|
+
r="36"
|
|
150
|
+
className="ring-fg"
|
|
151
|
+
strokeWidth="4"
|
|
152
|
+
strokeLinecap="round"
|
|
153
|
+
fill="none"
|
|
154
|
+
strokeDasharray={circumference}
|
|
155
|
+
strokeDashoffset={offset}
|
|
156
|
+
transform="rotate(-90 40 40)"
|
|
157
|
+
/>
|
|
158
|
+
</svg>
|
|
159
|
+
<span className="score-number">{score}</span>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="checks-grid">
|
|
162
|
+
{checks.map((check) => (
|
|
163
|
+
<div key={check.label} className="check-item">
|
|
164
|
+
<span className={`dot ${check.ok ? "ok" : "bad"}`} />
|
|
165
|
+
{check.label}
|
|
166
|
+
</div>
|
|
167
|
+
))}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function BasicTab({ metadata, url }: { metadata: MetaData; url: string }) {
|
|
174
|
+
const fallbackFavicon =
|
|
175
|
+
metadata.favicon ||
|
|
176
|
+
(() => {
|
|
177
|
+
try {
|
|
178
|
+
return new URL("/favicon.ico", url).toString();
|
|
179
|
+
} catch {
|
|
180
|
+
return "";
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="stack">
|
|
186
|
+
<Card title="Essential">
|
|
187
|
+
<Field label="Favicon" value={fallbackFavicon} isImage />
|
|
188
|
+
<Field label="Title" value={metadata.title} characterCount limit={60} missing />
|
|
189
|
+
<Field
|
|
190
|
+
label="Description"
|
|
191
|
+
value={metadata.description}
|
|
192
|
+
characterCount
|
|
193
|
+
limit={160}
|
|
194
|
+
missing
|
|
195
|
+
/>
|
|
196
|
+
<Field label="Canonical" value={metadata.canonical} />
|
|
197
|
+
<LinkPresence label="robots.txt" url={metadata.robotsFile} />
|
|
198
|
+
<LinkPresence label="sitemap.xml" url={metadata.sitemap} />
|
|
199
|
+
</Card>
|
|
200
|
+
|
|
201
|
+
<Card title="Technical">
|
|
202
|
+
<Field label="Language" value={metadata.language} />
|
|
203
|
+
<Field label="Charset" value={metadata.charset} />
|
|
204
|
+
<Field label="Viewport" value={metadata.viewport} />
|
|
205
|
+
<Field label="Robots" value={metadata.robots} />
|
|
206
|
+
<Field label="Generator" value={metadata.generator} />
|
|
207
|
+
<Field label="Theme Color" value={metadata.themeColor} />
|
|
208
|
+
{metadata.themeColor && (
|
|
209
|
+
<span
|
|
210
|
+
className="swatch"
|
|
211
|
+
style={{ backgroundColor: metadata.themeColor }}
|
|
212
|
+
aria-hidden="true"
|
|
213
|
+
/>
|
|
214
|
+
)}
|
|
215
|
+
<Field
|
|
216
|
+
label="Status"
|
|
217
|
+
value={metadata.statusCode ? String(metadata.statusCode) : undefined}
|
|
218
|
+
/>
|
|
219
|
+
</Card>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function OpenGraphTab({ metadata }: { metadata: MetaData }) {
|
|
225
|
+
return (
|
|
226
|
+
<Card title="Open Graph">
|
|
227
|
+
<Field label="Title" value={metadata.ogTitle} characterCount limit={60} missing />
|
|
228
|
+
<Field
|
|
229
|
+
label="Description"
|
|
230
|
+
value={metadata.ogDescription}
|
|
231
|
+
characterCount
|
|
232
|
+
limit={160}
|
|
233
|
+
missing
|
|
234
|
+
/>
|
|
235
|
+
<Field label="Image" value={metadata.ogImage} isImage missing />
|
|
236
|
+
{(metadata.ogImageAll?.length ?? 0) > 1 && (
|
|
237
|
+
<Field label="All Images" value={metadata.ogImageAll?.join("\n")} />
|
|
238
|
+
)}
|
|
239
|
+
<Field label="Type" value={metadata.ogType} />
|
|
240
|
+
<Field label="URL" value={metadata.ogUrl} />
|
|
241
|
+
<Field label="Site Name" value={metadata.ogSiteName} />
|
|
242
|
+
<Field label="Locale" value={metadata.ogLocale} />
|
|
243
|
+
</Card>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function TwitterTab({ metadata }: { metadata: MetaData }) {
|
|
248
|
+
return (
|
|
249
|
+
<Card title="X Card">
|
|
250
|
+
<Field label="Card Type" value={metadata.twitterCard} missing />
|
|
251
|
+
<Field
|
|
252
|
+
label="Title"
|
|
253
|
+
value={metadata.twitterTitle || metadata.ogTitle}
|
|
254
|
+
characterCount
|
|
255
|
+
limit={70}
|
|
256
|
+
/>
|
|
257
|
+
<Field
|
|
258
|
+
label="Description"
|
|
259
|
+
value={metadata.twitterDescription || metadata.ogDescription}
|
|
260
|
+
characterCount
|
|
261
|
+
limit={200}
|
|
262
|
+
/>
|
|
263
|
+
<Field label="Image" value={metadata.twitterImage} isImage missing />
|
|
264
|
+
{(metadata.twitterImageAll?.length ?? 0) > 1 && (
|
|
265
|
+
<Field label="All Images" value={metadata.twitterImageAll?.join("\n")} />
|
|
266
|
+
)}
|
|
267
|
+
<Field label="Site" value={metadata.twitterSite} />
|
|
268
|
+
<Field label="Creator" value={metadata.twitterCreator} />
|
|
269
|
+
</Card>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const recommended = {
|
|
274
|
+
og: { width: 1200, height: 630 },
|
|
275
|
+
twitter: { width: 1200, height: 600 },
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
function PreviewImage({ title, url }: { title: string; url: string }) {
|
|
279
|
+
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
|
|
280
|
+
const [failed, setFailed] = useState(false);
|
|
281
|
+
const isFavicon = /favicon/i.test(title);
|
|
282
|
+
const isTwitter = /^x$/i.test(title);
|
|
283
|
+
|
|
284
|
+
return (
|
|
285
|
+
<div className="preview-image-card">
|
|
286
|
+
<div className="section-label">{title}</div>
|
|
287
|
+
{failed ? (
|
|
288
|
+
<span className="dim">unavailable</span>
|
|
289
|
+
) : (
|
|
290
|
+
<>
|
|
291
|
+
<a href={url} target="_blank" rel="noopener noreferrer">
|
|
292
|
+
<img
|
|
293
|
+
src={url}
|
|
294
|
+
alt={title}
|
|
295
|
+
className={isFavicon ? "favicon-preview" : ""}
|
|
296
|
+
loading="lazy"
|
|
297
|
+
onLoad={(event) =>
|
|
298
|
+
setDimensions({
|
|
299
|
+
width: event.currentTarget.naturalWidth,
|
|
300
|
+
height: event.currentTarget.naturalHeight,
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
onError={() => setFailed(true)}
|
|
304
|
+
/>
|
|
305
|
+
</a>
|
|
306
|
+
{dimensions && !isFavicon && (
|
|
307
|
+
<div className="dimension-line">
|
|
308
|
+
<span
|
|
309
|
+
className={`dot ${
|
|
310
|
+
dimensions.width >= recommended.og.width &&
|
|
311
|
+
dimensions.height >=
|
|
312
|
+
(isTwitter ? recommended.twitter.height : recommended.og.height)
|
|
313
|
+
? "ok"
|
|
314
|
+
: "warn"
|
|
315
|
+
}`}
|
|
316
|
+
/>
|
|
317
|
+
{dimensions.width} × {dimensions.height}
|
|
318
|
+
{!isFavicon &&
|
|
319
|
+
dimensions.width < recommended.og.width &&
|
|
320
|
+
` (recommended ${recommended.og.width}×${recommended.og.height})`}
|
|
321
|
+
</div>
|
|
322
|
+
)}
|
|
323
|
+
</>
|
|
324
|
+
)}
|
|
325
|
+
</div>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function ImagesTab({ metadata }: { metadata: MetaData }) {
|
|
330
|
+
const hasImages = metadata.ogImage || metadata.twitterImage || metadata.favicon;
|
|
331
|
+
return (
|
|
332
|
+
<div className="stack">
|
|
333
|
+
{metadata.ogImage && <PreviewImage title="Open Graph" url={metadata.ogImage} />}
|
|
334
|
+
{metadata.twitterImage && metadata.twitterImage !== metadata.ogImage && (
|
|
335
|
+
<PreviewImage title="X" url={metadata.twitterImage} />
|
|
336
|
+
)}
|
|
337
|
+
{metadata.favicon && <PreviewImage title="Favicon" url={metadata.favicon} />}
|
|
338
|
+
{!hasImages && <div className="empty-note">no images found</div>}
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function RawTab({ metadata }: { metadata: MetaData }) {
|
|
344
|
+
const [format, setFormat] = useState<"json" | "html">("json");
|
|
345
|
+
const [copied, setCopied] = useState(false);
|
|
346
|
+
const json = JSON.stringify({ ...metadata, htmlTags: undefined }, null, 2);
|
|
347
|
+
const html = metadata.htmlTags ?? "";
|
|
348
|
+
|
|
349
|
+
const copy = () => {
|
|
350
|
+
navigator.clipboard.writeText(format === "json" ? json : html);
|
|
351
|
+
setCopied(true);
|
|
352
|
+
setTimeout(() => setCopied(false), 1500);
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<div>
|
|
357
|
+
<div className="raw-toolbar">
|
|
358
|
+
<Tabs
|
|
359
|
+
tabs={[
|
|
360
|
+
{ id: "json", label: "JSON" },
|
|
361
|
+
{ id: "html", label: "HTML" },
|
|
362
|
+
]}
|
|
363
|
+
active={format}
|
|
364
|
+
onChange={(id) => setFormat(id as "json" | "html")}
|
|
365
|
+
/>
|
|
366
|
+
<button type="button" className="copy-button" onClick={copy}>
|
|
367
|
+
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
368
|
+
{copied ? "Copied" : "Copy"}
|
|
369
|
+
</button>
|
|
370
|
+
</div>
|
|
371
|
+
<pre className="code-block">{format === "json" ? json : html}</pre>
|
|
372
|
+
</div>
|
|
373
|
+
);
|
|
374
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
6
|
+
"allowJs": false,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"esModuleInterop": true,
|
|
9
|
+
"allowSyntheticDefaultImports": true,
|
|
10
|
+
"strict": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"module": "ESNext",
|
|
13
|
+
"moduleResolution": "Bundler",
|
|
14
|
+
"resolveJsonModule": true,
|
|
15
|
+
"isolatedModules": true,
|
|
16
|
+
"noEmit": true,
|
|
17
|
+
"jsx": "react-jsx",
|
|
18
|
+
"types": ["node", "bun-types"]
|
|
19
|
+
},
|
|
20
|
+
"include": ["src", "server", "vite.config.ts", "tests"]
|
|
21
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import react from "@vitejs/plugin-react";
|
|
2
|
+
import { defineConfig } from "vite";
|
|
3
|
+
import { ogthingApi } from "./server/ogthing-api";
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [react(), ogthingApi()],
|
|
7
|
+
server: {
|
|
8
|
+
host: "127.0.0.1",
|
|
9
|
+
allowedHosts: true,
|
|
10
|
+
strictPort: true,
|
|
11
|
+
watch:
|
|
12
|
+
process.env.CODEX_SANDBOX === "seatbelt"
|
|
13
|
+
? { useFsEvents: false, usePolling: true }
|
|
14
|
+
: undefined,
|
|
15
|
+
},
|
|
16
|
+
});
|