@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.
Files changed (74) hide show
  1. package/dist/assets.d.mts +39 -0
  2. package/dist/assets.mjs +96 -0
  3. package/dist/catalog.d.mts +7 -0
  4. package/dist/catalog.mjs +10 -0
  5. package/dist/index.d.mts +9 -3
  6. package/dist/index.mjs +10 -2
  7. package/dist/integration.d.mts +3 -20
  8. package/dist/integration.mjs +257 -7
  9. package/dist/manifest.d.mts +7 -0
  10. package/dist/manifest.mjs +44 -0
  11. package/dist/og.d.mts +117 -0
  12. package/dist/og.mjs +302 -0
  13. package/dist/routes/api-catalog.d.mts +5 -0
  14. package/dist/routes/api-catalog.mjs +38 -0
  15. package/dist/routes/apple-touch-icon.png.d.mts +5 -0
  16. package/dist/routes/apple-touch-icon.png.mjs +17 -0
  17. package/dist/routes/favicon.ico.d.mts +5 -0
  18. package/dist/routes/favicon.ico.mjs +14 -0
  19. package/dist/routes/favicon.svg.d.mts +5 -0
  20. package/dist/routes/favicon.svg.mjs +12 -0
  21. package/dist/routes/icon-192.png.d.mts +5 -0
  22. package/dist/routes/icon-192.png.mjs +17 -0
  23. package/dist/routes/icon-512.png.d.mts +5 -0
  24. package/dist/routes/icon-512.png.mjs +17 -0
  25. package/dist/routes/icon-maskable-512.png.d.mts +5 -0
  26. package/dist/routes/icon-maskable-512.png.mjs +18 -0
  27. package/dist/routes/llms-full.txt.d.mts +5 -0
  28. package/dist/routes/llms-full.txt.mjs +57 -0
  29. package/dist/routes/llms.txt.d.mts +1 -0
  30. package/dist/routes/llms.txt.mjs +60 -12
  31. package/dist/routes/robots.txt.mjs +32 -4
  32. package/dist/routes/rss.xml.d.mts +4 -0
  33. package/dist/routes/rss.xml.mjs +79 -0
  34. package/dist/routes/security.txt.d.mts +5 -0
  35. package/dist/routes/security.txt.mjs +24 -0
  36. package/dist/routes/site.webmanifest.d.mts +5 -0
  37. package/dist/routes/site.webmanifest.mjs +23 -0
  38. package/dist/routes/sitemap.xml.mjs +18 -4
  39. package/dist/rss.d.mts +8 -0
  40. package/dist/rss.mjs +44 -0
  41. package/dist/security.d.mts +7 -0
  42. package/dist/security.mjs +34 -0
  43. package/dist/types.d.mts +263 -3
  44. package/package.json +38 -3
  45. package/src/assets.test.ts +70 -0
  46. package/src/assets.ts +137 -0
  47. package/src/catalog.test.ts +26 -0
  48. package/src/catalog.ts +11 -0
  49. package/src/components/SEOHead.astro +1 -1
  50. package/src/env.d.ts +51 -3
  51. package/src/index.ts +33 -2
  52. package/src/integration.ts +307 -28
  53. package/src/manifest.test.ts +41 -0
  54. package/src/manifest.ts +59 -0
  55. package/src/og.ts +436 -0
  56. package/src/routes/api-catalog.ts +55 -0
  57. package/src/routes/apple-touch-icon.png.ts +19 -0
  58. package/src/routes/favicon.ico.ts +19 -0
  59. package/src/routes/favicon.svg.ts +17 -0
  60. package/src/routes/icon-192.png.ts +19 -0
  61. package/src/routes/icon-512.png.ts +19 -0
  62. package/src/routes/icon-maskable-512.png.ts +24 -0
  63. package/src/routes/llms-full.txt.ts +77 -0
  64. package/src/routes/llms.txt.ts +84 -13
  65. package/src/routes/robots.txt.ts +47 -4
  66. package/src/routes/rss.xml.ts +129 -0
  67. package/src/routes/security.txt.ts +30 -0
  68. package/src/routes/site.webmanifest.ts +28 -0
  69. package/src/routes/sitemap.xml.ts +32 -5
  70. package/src/rss.test.ts +28 -0
  71. package/src/rss.ts +63 -0
  72. package/src/security.test.ts +46 -0
  73. package/src/security.ts +78 -0
  74. 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
+ }