@rimelight/seo 0.0.5 → 0.0.7
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/dist/assets.d.mts +39 -0
- package/dist/assets.mjs +96 -0
- package/dist/catalog.d.mts +7 -0
- package/dist/catalog.mjs +10 -0
- package/dist/index.d.mts +9 -3
- package/dist/index.mjs +10 -2
- package/dist/integration.d.mts +3 -20
- package/dist/integration.mjs +257 -7
- package/dist/manifest.d.mts +7 -0
- package/dist/manifest.mjs +44 -0
- package/dist/og.d.mts +117 -0
- package/dist/og.mjs +302 -0
- package/dist/routes/api-catalog.d.mts +5 -0
- package/dist/routes/api-catalog.mjs +38 -0
- package/dist/routes/apple-touch-icon.png.d.mts +5 -0
- package/dist/routes/apple-touch-icon.png.mjs +17 -0
- package/dist/routes/favicon.ico.d.mts +5 -0
- package/dist/routes/favicon.ico.mjs +14 -0
- package/dist/routes/favicon.svg.d.mts +5 -0
- package/dist/routes/favicon.svg.mjs +12 -0
- package/dist/routes/icon-192.png.d.mts +5 -0
- package/dist/routes/icon-192.png.mjs +17 -0
- package/dist/routes/icon-512.png.d.mts +5 -0
- package/dist/routes/icon-512.png.mjs +17 -0
- package/dist/routes/icon-maskable-512.png.d.mts +5 -0
- package/dist/routes/icon-maskable-512.png.mjs +18 -0
- package/dist/routes/llms-full.txt.d.mts +5 -0
- package/dist/routes/llms-full.txt.mjs +57 -0
- package/dist/routes/llms.txt.d.mts +1 -0
- package/dist/routes/llms.txt.mjs +60 -12
- package/dist/routes/robots.txt.mjs +32 -4
- package/dist/routes/rss.xml.d.mts +4 -0
- package/dist/routes/rss.xml.mjs +79 -0
- package/dist/routes/security.txt.d.mts +5 -0
- package/dist/routes/security.txt.mjs +24 -0
- package/dist/routes/site.webmanifest.d.mts +5 -0
- package/dist/routes/site.webmanifest.mjs +23 -0
- package/dist/routes/sitemap.xml.mjs +18 -4
- package/dist/rss.d.mts +8 -0
- package/dist/rss.mjs +44 -0
- package/dist/security.d.mts +7 -0
- package/dist/security.mjs +34 -0
- package/dist/types.d.mts +263 -3
- package/package.json +38 -3
- package/src/assets.test.ts +70 -0
- package/src/assets.ts +137 -0
- package/src/catalog.test.ts +26 -0
- package/src/catalog.ts +11 -0
- package/src/components/SEOHead.astro +1 -1
- package/src/env.d.ts +51 -3
- package/src/index.ts +33 -2
- package/src/integration.ts +307 -28
- package/src/manifest.test.ts +41 -0
- package/src/manifest.ts +59 -0
- package/src/og.ts +436 -0
- package/src/routes/api-catalog.ts +55 -0
- package/src/routes/apple-touch-icon.png.ts +19 -0
- package/src/routes/favicon.ico.ts +19 -0
- package/src/routes/favicon.svg.ts +17 -0
- package/src/routes/icon-192.png.ts +19 -0
- package/src/routes/icon-512.png.ts +19 -0
- package/src/routes/icon-maskable-512.png.ts +24 -0
- package/src/routes/llms-full.txt.ts +77 -0
- package/src/routes/llms.txt.ts +84 -13
- package/src/routes/robots.txt.ts +47 -4
- package/src/routes/rss.xml.ts +129 -0
- package/src/routes/security.txt.ts +30 -0
- package/src/routes/site.webmanifest.ts +28 -0
- package/src/routes/sitemap.xml.ts +32 -5
- package/src/rss.test.ts +28 -0
- package/src/rss.ts +63 -0
- package/src/security.test.ts +46 -0
- package/src/security.ts +78 -0
- package/src/types.ts +286 -3
package/src/og.ts
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Font configuration for OG image rendering. Pass to `loadOgFonts()` or `renderOgResponse()`.
|
|
3
|
+
*/
|
|
4
|
+
export interface OgFontConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Font-family name referenced in layout styles
|
|
7
|
+
*/
|
|
8
|
+
name: string
|
|
9
|
+
/**
|
|
10
|
+
* URL to the regular-weight TTF
|
|
11
|
+
*/
|
|
12
|
+
regularUrl: string
|
|
13
|
+
/**
|
|
14
|
+
* URL to the bold-weight TTF
|
|
15
|
+
*/
|
|
16
|
+
boldUrl: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Default font: Noto Sans from jsDelivr Fontsource CDN.
|
|
21
|
+
*/
|
|
22
|
+
export const NOTO_SANS: OgFontConfig = {
|
|
23
|
+
name: "Noto Sans",
|
|
24
|
+
regularUrl: "https://cdn.jsdelivr.net/fontsource/fonts/noto-sans@latest/latin-400-normal.ttf",
|
|
25
|
+
boldUrl: "https://cdn.jsdelivr.net/fontsource/fonts/noto-sans@latest/latin-700-normal.ttf"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Options for the default OG image layout. Build your own layout instead via `renderOgResponse()`
|
|
30
|
+
* directly.
|
|
31
|
+
*/
|
|
32
|
+
export interface OgLayoutOptions {
|
|
33
|
+
/**
|
|
34
|
+
* Main heading -- required
|
|
35
|
+
*/
|
|
36
|
+
title: string
|
|
37
|
+
/**
|
|
38
|
+
* Subtitle / excerpt beneath the title
|
|
39
|
+
*/
|
|
40
|
+
description?: string
|
|
41
|
+
/**
|
|
42
|
+
* Branding shown top-left. Pass a plain string (site name) or a pre-built takumi-js VNode for a
|
|
43
|
+
* custom logo treatment.
|
|
44
|
+
*/
|
|
45
|
+
brand?: string | object
|
|
46
|
+
/**
|
|
47
|
+
* Pill badge shown bottom-left. e.g. "Blog Post", "Documentation", "Legal"
|
|
48
|
+
*/
|
|
49
|
+
badge?: string
|
|
50
|
+
/**
|
|
51
|
+
* Date string shown bottom-right. e.g. new Date(postedAt).toLocaleDateString()
|
|
52
|
+
*/
|
|
53
|
+
date?: string
|
|
54
|
+
/**
|
|
55
|
+
* Background style. "dark" -> solid #0a0a0a (default) "gradient" -> diagonal dark-to-navy
|
|
56
|
+
* gradient any string -> treated as a CSS `background` value
|
|
57
|
+
*/
|
|
58
|
+
background?: "dark" | "gradient" | (string & {})
|
|
59
|
+
/**
|
|
60
|
+
* When true, overlays a dashed red PREVIEW border. Useful during development.
|
|
61
|
+
*/
|
|
62
|
+
preview?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Rendering config shared by `renderOgResponse()` and `renderDefaultOg()`.
|
|
67
|
+
*/
|
|
68
|
+
export interface OgRenderConfig {
|
|
69
|
+
/**
|
|
70
|
+
* Font to embed -- defaults to `NOTO_SANS`
|
|
71
|
+
*/
|
|
72
|
+
font?: OgFontConfig
|
|
73
|
+
/**
|
|
74
|
+
* Image width in px -- defaults to 1200
|
|
75
|
+
*/
|
|
76
|
+
width?: number
|
|
77
|
+
/**
|
|
78
|
+
* Image height in px -- defaults to 630
|
|
79
|
+
*/
|
|
80
|
+
height?: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Font loading
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
const fontCaches = new Map<string, { regular: ArrayBuffer; bold: ArrayBuffer }>()
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Fetches an ArrayBuffer with a timeout.
|
|
91
|
+
*/
|
|
92
|
+
async function fetchArrayBuffer(url: string, timeoutMs = 5000): Promise<ArrayBuffer> {
|
|
93
|
+
const controller = new AbortController()
|
|
94
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetch(url, { signal: controller.signal })
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
throw new Error(`Failed to fetch font from ${url}: status ${res.status}`)
|
|
99
|
+
}
|
|
100
|
+
return await res.arrayBuffer()
|
|
101
|
+
} finally {
|
|
102
|
+
clearTimeout(timeoutId)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Fetches and caches the regular + bold TTF buffers for the given font config. Uses a module-level
|
|
108
|
+
* cache keyed on `regularUrl`, so repeated calls within the same Worker lifetime are free after the
|
|
109
|
+
* first fetch.
|
|
110
|
+
*/
|
|
111
|
+
export async function loadOgFonts(
|
|
112
|
+
config: OgFontConfig = NOTO_SANS,
|
|
113
|
+
timeoutMs = 5000
|
|
114
|
+
): Promise<{ regular: ArrayBuffer; bold: ArrayBuffer }> {
|
|
115
|
+
const cached = fontCaches.get(config.regularUrl)
|
|
116
|
+
if (cached) return cached
|
|
117
|
+
|
|
118
|
+
const [regular, bold] = await Promise.all([
|
|
119
|
+
fetchArrayBuffer(config.regularUrl, timeoutMs),
|
|
120
|
+
fetchArrayBuffer(config.boldUrl, timeoutMs)
|
|
121
|
+
])
|
|
122
|
+
const result = { regular, bold }
|
|
123
|
+
fontCaches.set(config.regularUrl, result)
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Default layout builder
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Builds a takumi-js-compatible VNode for the default Rimelight OG layout.
|
|
133
|
+
*
|
|
134
|
+
* Consumers that need a fully custom look should build their own VNode and call
|
|
135
|
+
* `renderOgResponse()` directly -- this function is just the house style.
|
|
136
|
+
*/
|
|
137
|
+
export function defaultOgLayout(options: OgLayoutOptions, font: OgFontConfig = NOTO_SANS): object {
|
|
138
|
+
const { title, description, brand, badge, date, background = "dark", preview = false } = options
|
|
139
|
+
|
|
140
|
+
const MAX_TITLE_LEN = 160
|
|
141
|
+
const MAX_DESC_LEN = 240
|
|
142
|
+
const safeTitle = title.length > MAX_TITLE_LEN ? title.slice(0, MAX_TITLE_LEN - 1) + "…" : title
|
|
143
|
+
const safeDescription =
|
|
144
|
+
description && description.length > MAX_DESC_LEN
|
|
145
|
+
? description.slice(0, MAX_DESC_LEN - 1) + "…"
|
|
146
|
+
: description
|
|
147
|
+
|
|
148
|
+
const bg =
|
|
149
|
+
background === "dark"
|
|
150
|
+
? { backgroundColor: "#0a0a0a" }
|
|
151
|
+
: background === "gradient"
|
|
152
|
+
? { background: "linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 100%)" }
|
|
153
|
+
: { background }
|
|
154
|
+
|
|
155
|
+
const isGradient = background !== "dark"
|
|
156
|
+
|
|
157
|
+
const brandNode: object =
|
|
158
|
+
typeof brand === "object" && brand !== null
|
|
159
|
+
? brand
|
|
160
|
+
: {
|
|
161
|
+
type: "span",
|
|
162
|
+
props: {
|
|
163
|
+
style: {
|
|
164
|
+
fontSize: "28px",
|
|
165
|
+
fontWeight: 700,
|
|
166
|
+
color: "#ffffff",
|
|
167
|
+
letterSpacing: "-0.02em"
|
|
168
|
+
},
|
|
169
|
+
children: brand ?? ""
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const accentBar: object | null = isGradient
|
|
174
|
+
? {
|
|
175
|
+
type: "div",
|
|
176
|
+
props: {
|
|
177
|
+
style: {
|
|
178
|
+
width: "60px",
|
|
179
|
+
height: "4px",
|
|
180
|
+
backgroundColor: "#60a5fa",
|
|
181
|
+
borderRadius: "2px",
|
|
182
|
+
marginBottom: "16px"
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
: null
|
|
187
|
+
|
|
188
|
+
const titleFontSize = isGradient ? "52px" : "56px"
|
|
189
|
+
const descColor = isGradient ? "#94a3b8" : "#a0a0a0"
|
|
190
|
+
const descFontSize = isGradient ? "22px" : "24px"
|
|
191
|
+
|
|
192
|
+
const badgeNode: object | null = badge
|
|
193
|
+
? isGradient
|
|
194
|
+
? {
|
|
195
|
+
type: "div",
|
|
196
|
+
props: {
|
|
197
|
+
style: {
|
|
198
|
+
padding: "6px 16px",
|
|
199
|
+
borderRadius: "9999px",
|
|
200
|
+
backgroundColor: "rgba(96,165,250,0.15)",
|
|
201
|
+
border: "1px solid rgba(96,165,250,0.3)",
|
|
202
|
+
fontSize: "14px",
|
|
203
|
+
fontWeight: 600,
|
|
204
|
+
color: "#93c5fd"
|
|
205
|
+
},
|
|
206
|
+
children: badge
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
: {
|
|
210
|
+
type: "div",
|
|
211
|
+
props: {
|
|
212
|
+
style: {
|
|
213
|
+
padding: "8px 20px",
|
|
214
|
+
borderRadius: "9999px",
|
|
215
|
+
border: "1px solid #333333",
|
|
216
|
+
fontSize: "16px",
|
|
217
|
+
fontWeight: 600,
|
|
218
|
+
color: "#e5e5e5"
|
|
219
|
+
},
|
|
220
|
+
children: badge
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
: null
|
|
224
|
+
|
|
225
|
+
const dateNode: object | null = date
|
|
226
|
+
? {
|
|
227
|
+
type: "div",
|
|
228
|
+
props: {
|
|
229
|
+
style: { fontSize: "16px", color: "#666666" },
|
|
230
|
+
children: date
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
: null
|
|
234
|
+
|
|
235
|
+
const previewOverlay: object | null = preview
|
|
236
|
+
? {
|
|
237
|
+
type: "div",
|
|
238
|
+
props: {
|
|
239
|
+
style: {
|
|
240
|
+
position: "absolute",
|
|
241
|
+
inset: 0,
|
|
242
|
+
border: "6px dashed #ff4444",
|
|
243
|
+
pointerEvents: "none"
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
: null
|
|
248
|
+
|
|
249
|
+
const previewLabel: object | null = preview
|
|
250
|
+
? {
|
|
251
|
+
type: "div",
|
|
252
|
+
props: {
|
|
253
|
+
style: {
|
|
254
|
+
position: "absolute",
|
|
255
|
+
top: "12px",
|
|
256
|
+
right: "12px",
|
|
257
|
+
backgroundColor: "#ff4444",
|
|
258
|
+
color: "#ffffff",
|
|
259
|
+
fontSize: "14px",
|
|
260
|
+
fontWeight: 700,
|
|
261
|
+
padding: "6px 16px",
|
|
262
|
+
borderRadius: "4px",
|
|
263
|
+
letterSpacing: "0.05em"
|
|
264
|
+
},
|
|
265
|
+
children: "PREVIEW"
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
: null
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
type: "div",
|
|
272
|
+
props: {
|
|
273
|
+
style: {
|
|
274
|
+
width: "100%",
|
|
275
|
+
height: "100%",
|
|
276
|
+
display: "flex",
|
|
277
|
+
flexDirection: "column",
|
|
278
|
+
padding: "56px",
|
|
279
|
+
color: "#e5e5e5",
|
|
280
|
+
fontFamily: font.name,
|
|
281
|
+
position: "relative",
|
|
282
|
+
overflow: "hidden",
|
|
283
|
+
...bg
|
|
284
|
+
},
|
|
285
|
+
children: [
|
|
286
|
+
{
|
|
287
|
+
type: "div",
|
|
288
|
+
props: {
|
|
289
|
+
style: { display: "flex", alignItems: "center" },
|
|
290
|
+
children: [brandNode]
|
|
291
|
+
}
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
type: "div",
|
|
295
|
+
props: {
|
|
296
|
+
style: {
|
|
297
|
+
display: "flex",
|
|
298
|
+
flexDirection: "column",
|
|
299
|
+
justifyContent: "center",
|
|
300
|
+
flexGrow: 1,
|
|
301
|
+
paddingTop: "24px"
|
|
302
|
+
},
|
|
303
|
+
children: [
|
|
304
|
+
accentBar,
|
|
305
|
+
{
|
|
306
|
+
type: "div",
|
|
307
|
+
props: {
|
|
308
|
+
style: {
|
|
309
|
+
fontSize: titleFontSize,
|
|
310
|
+
fontWeight: 700,
|
|
311
|
+
color: "#ffffff",
|
|
312
|
+
lineHeight: 1.15,
|
|
313
|
+
maxWidth: "950px"
|
|
314
|
+
},
|
|
315
|
+
children: safeTitle
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
safeDescription
|
|
319
|
+
? {
|
|
320
|
+
type: "div",
|
|
321
|
+
props: {
|
|
322
|
+
style: {
|
|
323
|
+
fontSize: descFontSize,
|
|
324
|
+
fontWeight: 400,
|
|
325
|
+
color: descColor,
|
|
326
|
+
marginTop: isGradient ? "12px" : "16px",
|
|
327
|
+
lineHeight: 1.4,
|
|
328
|
+
maxWidth: isGradient ? "800px" : "850px"
|
|
329
|
+
},
|
|
330
|
+
children: safeDescription
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
: null
|
|
334
|
+
].filter(Boolean)
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
type: "div",
|
|
339
|
+
props: {
|
|
340
|
+
style: {
|
|
341
|
+
display: "flex",
|
|
342
|
+
alignItems: "center",
|
|
343
|
+
justifyContent: isGradient ? "flex-start" : "space-between",
|
|
344
|
+
gap: "12px",
|
|
345
|
+
marginTop: "auto"
|
|
346
|
+
},
|
|
347
|
+
children: [badgeNode, dateNode].filter(Boolean)
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
previewOverlay,
|
|
351
|
+
previewLabel
|
|
352
|
+
].filter(Boolean)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Render helpers
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Renders any takumi-js VNode to a PNG `Response` with standard OG headers. Use this when you
|
|
363
|
+
* supply your own layout entirely.
|
|
364
|
+
*
|
|
365
|
+
* @example
|
|
366
|
+
* const vnode = myBrandedLayout({ title, description })
|
|
367
|
+
* return renderOgResponse(vnode, { font: MY_FONT })
|
|
368
|
+
*/
|
|
369
|
+
export async function renderOgResponse(
|
|
370
|
+
vnode: object,
|
|
371
|
+
config: OgRenderConfig = {}
|
|
372
|
+
): Promise<Response> {
|
|
373
|
+
const { font = NOTO_SANS, width = 1200, height = 630 } = config
|
|
374
|
+
|
|
375
|
+
try {
|
|
376
|
+
const { render } = await import("takumi-js")
|
|
377
|
+
const { regular, bold } = await loadOgFonts(font)
|
|
378
|
+
|
|
379
|
+
const pngBuffer = await render(vnode, {
|
|
380
|
+
width,
|
|
381
|
+
height,
|
|
382
|
+
fonts: [
|
|
383
|
+
{ name: font.name, data: regular, weight: 400, style: "normal" },
|
|
384
|
+
{ name: font.name, data: bold, weight: 700, style: "normal" }
|
|
385
|
+
]
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
return new Response(new Blob([new Uint8Array(pngBuffer)], { type: "image/png" }), {
|
|
389
|
+
status: 200,
|
|
390
|
+
headers: {
|
|
391
|
+
"Content-Type": "image/png",
|
|
392
|
+
"Cache-Control": "public, max-age=31536000, immutable",
|
|
393
|
+
"CDN-Cache-Control": "public, max-age=31536000"
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
} catch (error) {
|
|
397
|
+
console.error("[OG Render Error]:", error)
|
|
398
|
+
return new Response(
|
|
399
|
+
JSON.stringify({
|
|
400
|
+
error: "Failed to render Open Graph image",
|
|
401
|
+
message: error instanceof Error ? error.message : String(error)
|
|
402
|
+
}),
|
|
403
|
+
{
|
|
404
|
+
status: 500,
|
|
405
|
+
headers: {
|
|
406
|
+
"Content-Type": "application/json",
|
|
407
|
+
"Cache-Control": "no-store"
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Renders the default OG layout to a PNG `Response`. The 90% case -- use `renderOgResponse()` for
|
|
416
|
+
* full layout control.
|
|
417
|
+
*
|
|
418
|
+
* @example
|
|
419
|
+
* export const GET: APIRoute = async ({ request }) => {
|
|
420
|
+
* const url = new URL(request.url)
|
|
421
|
+
* return renderDefaultOg({
|
|
422
|
+
* title: url.searchParams.get("title") ?? "Untitled",
|
|
423
|
+
* description: url.searchParams.get("description") ?? "",
|
|
424
|
+
* brand: "My Site",
|
|
425
|
+
* badge: url.searchParams.get("type") ?? ""
|
|
426
|
+
* })
|
|
427
|
+
* }
|
|
428
|
+
*/
|
|
429
|
+
export async function renderDefaultOg(
|
|
430
|
+
options: OgLayoutOptions,
|
|
431
|
+
config: OgRenderConfig = {}
|
|
432
|
+
): Promise<Response> {
|
|
433
|
+
const { font = NOTO_SANS } = config
|
|
434
|
+
const vnode = defaultOgLayout(options, font)
|
|
435
|
+
return renderOgResponse(vnode, config)
|
|
436
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { buildApiCatalog } from "../catalog.js"
|
|
3
|
+
import type { ApiCatalogItem } from "../types.js"
|
|
4
|
+
import seoOptions from "virtual:rimelight-seo/options"
|
|
5
|
+
|
|
6
|
+
export const prerender = false
|
|
7
|
+
|
|
8
|
+
export const GET: APIRoute = () => {
|
|
9
|
+
const catalogOpts = typeof seoOptions?.apiCatalog === "object" ? seoOptions.apiCatalog : {}
|
|
10
|
+
|
|
11
|
+
const defaultItems: ApiCatalogItem[] = [
|
|
12
|
+
{
|
|
13
|
+
rel: "self",
|
|
14
|
+
href: "/.well-known/api-catalog",
|
|
15
|
+
type: "application/linkset+json"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
if (seoOptions?.sitemap !== false) {
|
|
20
|
+
defaultItems.push({
|
|
21
|
+
rel: "alternate",
|
|
22
|
+
href: "/sitemap.xml",
|
|
23
|
+
type: "application/xml",
|
|
24
|
+
title: "Sitemap"
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (seoOptions?.rss !== false) {
|
|
29
|
+
defaultItems.push({
|
|
30
|
+
rel: "alternate",
|
|
31
|
+
href: "/rss.xml",
|
|
32
|
+
type: "application/rss+xml",
|
|
33
|
+
title: "RSS Feed"
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (seoOptions?.llms !== false) {
|
|
38
|
+
defaultItems.push({
|
|
39
|
+
rel: "llms",
|
|
40
|
+
href: "/llms.txt",
|
|
41
|
+
type: "text/plain",
|
|
42
|
+
title: "LLMs.txt"
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const items = catalogOpts.items || defaultItems
|
|
47
|
+
const catalog = buildApiCatalog({ items })
|
|
48
|
+
|
|
49
|
+
return new Response(JSON.stringify(catalog, null, 2), {
|
|
50
|
+
headers: {
|
|
51
|
+
"Content-Type": "application/linkset+json; charset=utf-8",
|
|
52
|
+
"Cache-Control": "public, max-age=86400, stale-while-revalidate=604800"
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { generatePng } from "../assets.js"
|
|
3
|
+
import { masterIconBuffer } from "virtual:rimelight-seo/assets"
|
|
4
|
+
|
|
5
|
+
export const prerender = true
|
|
6
|
+
|
|
7
|
+
export const GET: APIRoute = async () => {
|
|
8
|
+
if (!masterIconBuffer) {
|
|
9
|
+
return new Response(null, { status: 404 })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const png = await generatePng(masterIconBuffer, { width: 180, height: 180 })
|
|
13
|
+
return new Response(new Uint8Array(png), {
|
|
14
|
+
headers: {
|
|
15
|
+
"Content-Type": "image/png",
|
|
16
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { generateIco } from "../assets.js"
|
|
3
|
+
import { masterIconBuffer } from "virtual:rimelight-seo/assets"
|
|
4
|
+
|
|
5
|
+
export const prerender = true
|
|
6
|
+
|
|
7
|
+
export const GET: APIRoute = async () => {
|
|
8
|
+
if (!masterIconBuffer) {
|
|
9
|
+
return new Response(null, { status: 404 })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const ico = await generateIco(masterIconBuffer, 32)
|
|
13
|
+
return new Response(new Uint8Array(ico), {
|
|
14
|
+
headers: {
|
|
15
|
+
"Content-Type": "image/x-icon",
|
|
16
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { masterIconBuffer, isSvg } from "virtual:rimelight-seo/assets"
|
|
3
|
+
|
|
4
|
+
export const prerender = true
|
|
5
|
+
|
|
6
|
+
export const GET: APIRoute = async () => {
|
|
7
|
+
if (!masterIconBuffer || !isSvg) {
|
|
8
|
+
return new Response(null, { status: 404 })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return new Response(new Uint8Array(masterIconBuffer), {
|
|
12
|
+
headers: {
|
|
13
|
+
"Content-Type": "image/svg+xml",
|
|
14
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { generatePng } from "../assets.js"
|
|
3
|
+
import { masterIconBuffer } from "virtual:rimelight-seo/assets"
|
|
4
|
+
|
|
5
|
+
export const prerender = true
|
|
6
|
+
|
|
7
|
+
export const GET: APIRoute = async () => {
|
|
8
|
+
if (!masterIconBuffer) {
|
|
9
|
+
return new Response(null, { status: 404 })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const png = await generatePng(masterIconBuffer, { width: 192, height: 192 })
|
|
13
|
+
return new Response(new Uint8Array(png), {
|
|
14
|
+
headers: {
|
|
15
|
+
"Content-Type": "image/png",
|
|
16
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { generatePng } from "../assets.js"
|
|
3
|
+
import { masterIconBuffer } from "virtual:rimelight-seo/assets"
|
|
4
|
+
|
|
5
|
+
export const prerender = true
|
|
6
|
+
|
|
7
|
+
export const GET: APIRoute = async () => {
|
|
8
|
+
if (!masterIconBuffer) {
|
|
9
|
+
return new Response(null, { status: 404 })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const png = await generatePng(masterIconBuffer, { width: 512, height: 512 })
|
|
13
|
+
return new Response(new Uint8Array(png), {
|
|
14
|
+
headers: {
|
|
15
|
+
"Content-Type": "image/png",
|
|
16
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import { generateMaskablePng } from "../assets.js"
|
|
3
|
+
import { masterIconBuffer, backgroundColor } from "virtual:rimelight-seo/assets"
|
|
4
|
+
|
|
5
|
+
export const prerender = true
|
|
6
|
+
|
|
7
|
+
export const GET: APIRoute = async () => {
|
|
8
|
+
if (!masterIconBuffer) {
|
|
9
|
+
return new Response(null, { status: 404 })
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const png = await generateMaskablePng(masterIconBuffer, {
|
|
13
|
+
size: 512,
|
|
14
|
+
background: backgroundColor || "#ffffff",
|
|
15
|
+
paddingRatio: 0.15
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
return new Response(new Uint8Array(png), {
|
|
19
|
+
headers: {
|
|
20
|
+
"Content-Type": "image/png",
|
|
21
|
+
"Cache-Control": "public, max-age=31536000, immutable"
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { APIRoute } from "astro"
|
|
2
|
+
import * as siteConfigMod from "virtual:rimelight-seo/site-config"
|
|
3
|
+
import * as dbMod from "virtual:rimelight-seo/db"
|
|
4
|
+
import * as corpusMod from "virtual:rimelight-seo/corpus"
|
|
5
|
+
|
|
6
|
+
export const prerender = false
|
|
7
|
+
|
|
8
|
+
export const GET: APIRoute = async ({ site, url }) => {
|
|
9
|
+
const siteConfig = siteConfigMod.siteConfig || {}
|
|
10
|
+
const siteUrl = site ? site.toString() : siteConfig.url || `${url.protocol}//${url.host}`
|
|
11
|
+
const title = siteConfig.name
|
|
12
|
+
? `${siteConfig.name} Full Documentation Corpus`
|
|
13
|
+
: "Full Documentation Corpus"
|
|
14
|
+
const description = "Complete single-corpus documentation for AI agents and LLM ingest."
|
|
15
|
+
|
|
16
|
+
// 1. Try corpusPages if available
|
|
17
|
+
if (Array.isArray(corpusMod.corpusPages) && corpusMod.corpusPages.length > 0) {
|
|
18
|
+
const sections = new Map<string, string[]>()
|
|
19
|
+
for (const p of corpusMod.corpusPages) {
|
|
20
|
+
const key = (p.section || "GENERAL").toUpperCase()
|
|
21
|
+
if (!sections.has(key)) sections.set(key, [])
|
|
22
|
+
const slug = p.slug === "index" ? "" : p.slug
|
|
23
|
+
const pageUrl = `${siteUrl.replace(/\/$/, "")}/${slug}`.replace(/\/+$/, "")
|
|
24
|
+
sections.get(key)!.push(`### ${p.title}\nURL: ${pageUrl}\n${p.description || ""}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const body = [
|
|
28
|
+
`# ${title}`,
|
|
29
|
+
"",
|
|
30
|
+
description,
|
|
31
|
+
"",
|
|
32
|
+
...Array.from(sections.entries()).flatMap(([section, pages]) => [
|
|
33
|
+
`## ${section}`,
|
|
34
|
+
"",
|
|
35
|
+
...pages,
|
|
36
|
+
""
|
|
37
|
+
])
|
|
38
|
+
].join("\n")
|
|
39
|
+
|
|
40
|
+
return new Response(body, {
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
43
|
+
"Cache-Control": "public, max-age=3600, s-maxage=86400"
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 2. Try CMS renderCorpusMarkdown if db exists
|
|
49
|
+
if (dbMod.db) {
|
|
50
|
+
try {
|
|
51
|
+
const { renderCorpusMarkdown } = await import("@rimelight/cms")
|
|
52
|
+
const body = await renderCorpusMarkdown(dbMod.db, {
|
|
53
|
+
type: "doc",
|
|
54
|
+
locale: "en",
|
|
55
|
+
siteUrl,
|
|
56
|
+
title,
|
|
57
|
+
description
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
return new Response(body, {
|
|
61
|
+
headers: {
|
|
62
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
63
|
+
"Cache-Control": "public, max-age=3600, s-maxage=86400"
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
} catch {
|
|
67
|
+
// Fallback
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return new Response(`# ${title}\n\n${description}\n`, {
|
|
72
|
+
headers: {
|
|
73
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
74
|
+
"Cache-Control": "public, max-age=3600, s-maxage=86400"
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
}
|