@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/dist/types.d.mts
CHANGED
|
@@ -115,16 +115,17 @@ export interface SiteConfig {
|
|
|
115
115
|
name: string;
|
|
116
116
|
description: string;
|
|
117
117
|
url: string;
|
|
118
|
-
ogImage
|
|
118
|
+
ogImage?: string;
|
|
119
119
|
author: string;
|
|
120
120
|
email: string;
|
|
121
121
|
branding: {
|
|
122
122
|
logo: {
|
|
123
123
|
alt: string;
|
|
124
124
|
};
|
|
125
|
-
favicon
|
|
125
|
+
favicon?: {
|
|
126
126
|
svg: string;
|
|
127
127
|
};
|
|
128
|
+
icon?: string;
|
|
128
129
|
colors: {
|
|
129
130
|
themeColor: string;
|
|
130
131
|
backgroundColor: string;
|
|
@@ -132,7 +133,7 @@ export interface SiteConfig {
|
|
|
132
133
|
};
|
|
133
134
|
seo: {
|
|
134
135
|
titleTemplate: string;
|
|
135
|
-
ogImageFallback
|
|
136
|
+
ogImageFallback?: string;
|
|
136
137
|
maxDescriptionLength: number;
|
|
137
138
|
authorHandle?: string;
|
|
138
139
|
};
|
|
@@ -273,5 +274,264 @@ export interface HeadProps {
|
|
|
273
274
|
*/
|
|
274
275
|
twitter?: TwitterMeta;
|
|
275
276
|
}
|
|
277
|
+
export interface RssItem {
|
|
278
|
+
title: string;
|
|
279
|
+
link: string;
|
|
280
|
+
pubDate: Date | string | number;
|
|
281
|
+
description?: string;
|
|
282
|
+
content?: string;
|
|
283
|
+
guid?: string;
|
|
284
|
+
author?: string;
|
|
285
|
+
}
|
|
286
|
+
export interface RssFeedOptions {
|
|
287
|
+
title: string;
|
|
288
|
+
description: string;
|
|
289
|
+
site: string;
|
|
290
|
+
feedUrl?: string;
|
|
291
|
+
language?: string;
|
|
292
|
+
items: RssItem[];
|
|
293
|
+
}
|
|
294
|
+
export interface RssCollectionConfig {
|
|
295
|
+
name: string;
|
|
296
|
+
pathPrefix?: string;
|
|
297
|
+
}
|
|
298
|
+
export interface RssOptions extends Omit<Partial<RssFeedOptions>, "items"> {
|
|
299
|
+
/**
|
|
300
|
+
* Custom RSS route pattern (default: "/rss.xml")
|
|
301
|
+
*/
|
|
302
|
+
path?: string;
|
|
303
|
+
/**
|
|
304
|
+
* Custom items loader or static items array
|
|
305
|
+
*/
|
|
306
|
+
items?: RssItem[] | (() => Promise<RssItem[]> | RssItem[]);
|
|
307
|
+
/**
|
|
308
|
+
* Collections to include in RSS feed (defaults to ["blog", "posts", "news", "articles"])
|
|
309
|
+
*/
|
|
310
|
+
collections?: (string | RssCollectionConfig)[];
|
|
311
|
+
}
|
|
312
|
+
export interface SitemapOptions {
|
|
313
|
+
/**
|
|
314
|
+
* Sitemap route pattern (default: "/sitemap.xml")
|
|
315
|
+
*/
|
|
316
|
+
path?: string;
|
|
317
|
+
urls?: SitemapUrl[];
|
|
318
|
+
entries?: () => Promise<SeoEntry[]> | SeoEntry[];
|
|
319
|
+
locales?: LocaleConfig;
|
|
320
|
+
[key: string]: unknown;
|
|
321
|
+
}
|
|
322
|
+
export interface SecurityTxtOptions {
|
|
323
|
+
/**
|
|
324
|
+
* Contact URI or list of contact URIs (e.g. "mailto:security@example.com") - REQUIRED by RFC 9116
|
|
325
|
+
*/
|
|
326
|
+
contact: string | string[];
|
|
327
|
+
/**
|
|
328
|
+
* Expiration date/time (ISO 8601 string or Date) - REQUIRED by RFC 9116
|
|
329
|
+
*/
|
|
330
|
+
expires?: Date | string;
|
|
331
|
+
/**
|
|
332
|
+
* URI to PGP/GPG public key
|
|
333
|
+
*/
|
|
334
|
+
encryption?: string | string[];
|
|
335
|
+
/**
|
|
336
|
+
* URI to security acknowledgments / hall of fame
|
|
337
|
+
*/
|
|
338
|
+
acknowledgments?: string | string[];
|
|
339
|
+
/**
|
|
340
|
+
* Comma-separated list of preferred languages (e.g. "en, pt")
|
|
341
|
+
*/
|
|
342
|
+
preferredLanguages?: string;
|
|
343
|
+
/**
|
|
344
|
+
* Canonical URI of the security.txt file
|
|
345
|
+
*/
|
|
346
|
+
canonical?: string;
|
|
347
|
+
/**
|
|
348
|
+
* URI to security policy
|
|
349
|
+
*/
|
|
350
|
+
policy?: string;
|
|
351
|
+
/**
|
|
352
|
+
* URI to security hiring page
|
|
353
|
+
*/
|
|
354
|
+
hiring?: string;
|
|
355
|
+
/**
|
|
356
|
+
* URI to Common Security Advisory Framework (CSAF) provider
|
|
357
|
+
*/
|
|
358
|
+
csaf?: string;
|
|
359
|
+
}
|
|
360
|
+
export interface SecurityRouteOptions extends Partial<SecurityTxtOptions> {
|
|
361
|
+
/**
|
|
362
|
+
* Route pattern (default: "/.well-known/security.txt")
|
|
363
|
+
*/
|
|
364
|
+
path?: string;
|
|
365
|
+
/**
|
|
366
|
+
* Whether to also inject legacy fallback route at "/security.txt" (default: true)
|
|
367
|
+
*/
|
|
368
|
+
legacyRoute?: boolean;
|
|
369
|
+
}
|
|
370
|
+
export interface WebManifestIcon {
|
|
371
|
+
src: string;
|
|
372
|
+
sizes: string;
|
|
373
|
+
type: string;
|
|
374
|
+
purpose?: "any" | "maskable" | "monochrome";
|
|
375
|
+
}
|
|
376
|
+
export interface WebManifestShortcut {
|
|
377
|
+
name: string;
|
|
378
|
+
url: string;
|
|
379
|
+
short_name?: string;
|
|
380
|
+
description?: string;
|
|
381
|
+
icons?: WebManifestIcon[];
|
|
382
|
+
}
|
|
383
|
+
export interface WebManifest {
|
|
384
|
+
name: string;
|
|
385
|
+
short_name?: string;
|
|
386
|
+
description?: string;
|
|
387
|
+
start_url: string;
|
|
388
|
+
display: "fullscreen" | "standalone" | "minimal-ui" | "browser";
|
|
389
|
+
background_color: string;
|
|
390
|
+
theme_color: string;
|
|
391
|
+
icons: WebManifestIcon[];
|
|
392
|
+
shortcuts?: WebManifestShortcut[];
|
|
393
|
+
categories?: string[];
|
|
394
|
+
lang?: string;
|
|
395
|
+
dir?: "auto" | "ltr" | "rtl";
|
|
396
|
+
orientation?: "any" | "natural" | "landscape" | "portrait";
|
|
397
|
+
scope?: string;
|
|
398
|
+
[key: string]: unknown;
|
|
399
|
+
}
|
|
400
|
+
export interface WebManifestOptions {
|
|
401
|
+
name: string;
|
|
402
|
+
shortName?: string;
|
|
403
|
+
description?: string;
|
|
404
|
+
startUrl?: string;
|
|
405
|
+
display?: "fullscreen" | "standalone" | "minimal-ui" | "browser";
|
|
406
|
+
backgroundColor?: string;
|
|
407
|
+
themeColor?: string;
|
|
408
|
+
icons?: WebManifestIcon[];
|
|
409
|
+
shortcuts?: WebManifestShortcut[];
|
|
410
|
+
categories?: string[];
|
|
411
|
+
lang?: string;
|
|
412
|
+
dir?: "auto" | "ltr" | "rtl";
|
|
413
|
+
orientation?: "any" | "natural" | "landscape" | "portrait";
|
|
414
|
+
scope?: string;
|
|
415
|
+
}
|
|
416
|
+
export interface ManifestRouteOptions extends Partial<WebManifestOptions> {
|
|
417
|
+
/**
|
|
418
|
+
* Route pattern (default: "/site.webmanifest")
|
|
419
|
+
*/
|
|
420
|
+
path?: string;
|
|
421
|
+
}
|
|
422
|
+
export interface ApiCatalogItem {
|
|
423
|
+
rel: string;
|
|
424
|
+
href: string;
|
|
425
|
+
type?: string;
|
|
426
|
+
title?: string;
|
|
427
|
+
[key: string]: unknown;
|
|
428
|
+
}
|
|
429
|
+
export interface ApiCatalog {
|
|
430
|
+
"api-catalog": ApiCatalogItem[];
|
|
431
|
+
}
|
|
432
|
+
export interface ApiCatalogOptions {
|
|
433
|
+
items: ApiCatalogItem[];
|
|
434
|
+
}
|
|
435
|
+
export interface ApiCatalogRouteOptions extends Partial<ApiCatalogOptions> {
|
|
436
|
+
/**
|
|
437
|
+
* Route pattern (default: "/.well-known/api-catalog")
|
|
438
|
+
*/
|
|
439
|
+
path?: string;
|
|
440
|
+
}
|
|
441
|
+
export interface RobotsRouteOptions extends Partial<RobotsOptions> {
|
|
442
|
+
/**
|
|
443
|
+
* Route pattern (default: "/robots.txt")
|
|
444
|
+
*/
|
|
445
|
+
path?: string;
|
|
446
|
+
}
|
|
447
|
+
export interface LlmsRouteOptions extends Partial<LlmsTxtOptions> {
|
|
448
|
+
/**
|
|
449
|
+
* Route pattern (default: "/llms.txt")
|
|
450
|
+
*/
|
|
451
|
+
path?: string;
|
|
452
|
+
}
|
|
453
|
+
export interface LlmsFullRouteOptions extends Partial<LlmsFullTxtOptions> {
|
|
454
|
+
/**
|
|
455
|
+
* Route pattern (default: "/llms-full.txt")
|
|
456
|
+
*/
|
|
457
|
+
path?: string;
|
|
458
|
+
}
|
|
459
|
+
export interface AssetRouteOptions {
|
|
460
|
+
/**
|
|
461
|
+
* Path to the master SVG/PNG icon file (relative to project root or absolute). If omitted, will
|
|
462
|
+
* search in src/assets/logos/ or siteConfig.branding.icon.
|
|
463
|
+
*/
|
|
464
|
+
icon?: string;
|
|
465
|
+
/**
|
|
466
|
+
* Background color for maskable icons (defaults to siteConfig.branding.colors.backgroundColor or
|
|
467
|
+
* #ffffff)
|
|
468
|
+
*/
|
|
469
|
+
backgroundColor?: string;
|
|
470
|
+
/**
|
|
471
|
+
* Whether to inject /favicon.ico (default: true)
|
|
472
|
+
*/
|
|
473
|
+
faviconIco?: boolean;
|
|
474
|
+
/**
|
|
475
|
+
* Whether to inject /apple-touch-icon.png (default: true)
|
|
476
|
+
*/
|
|
477
|
+
appleTouchIcon?: boolean;
|
|
478
|
+
/**
|
|
479
|
+
* Whether to inject /icon-192.png (default: true)
|
|
480
|
+
*/
|
|
481
|
+
icon192?: boolean;
|
|
482
|
+
/**
|
|
483
|
+
* Whether to inject /icon-512.png (default: true)
|
|
484
|
+
*/
|
|
485
|
+
icon512?: boolean;
|
|
486
|
+
/**
|
|
487
|
+
* Whether to inject /icon-maskable-512.png (default: true)
|
|
488
|
+
*/
|
|
489
|
+
iconMaskable?: boolean;
|
|
490
|
+
/**
|
|
491
|
+
* Whether to generate and emit /og.png branded card (default: true)
|
|
492
|
+
*/
|
|
493
|
+
ogImage?: boolean;
|
|
494
|
+
}
|
|
495
|
+
export interface RimelightSeoOptions {
|
|
496
|
+
/**
|
|
497
|
+
* Sitemap configuration or false to disable automatic /sitemap.xml (default: true)
|
|
498
|
+
*/
|
|
499
|
+
sitemap?: boolean | SitemapOptions;
|
|
500
|
+
/**
|
|
501
|
+
* Robots.txt configuration or false to disable automatic /robots.txt (default: true)
|
|
502
|
+
*/
|
|
503
|
+
robots?: boolean | RobotsRouteOptions;
|
|
504
|
+
/**
|
|
505
|
+
* RSS feed configuration or false to disable automatic /rss.xml (default: true)
|
|
506
|
+
*/
|
|
507
|
+
rss?: boolean | RssOptions;
|
|
508
|
+
/**
|
|
509
|
+
* LLMs.txt configuration or false to disable automatic /llms.txt (default: true)
|
|
510
|
+
*/
|
|
511
|
+
llms?: boolean | LlmsRouteOptions;
|
|
512
|
+
/**
|
|
513
|
+
* LLMs-full.txt configuration or false to disable automatic /llms-full.txt (default: true)
|
|
514
|
+
*/
|
|
515
|
+
llmsFull?: boolean | LlmsFullRouteOptions;
|
|
516
|
+
/**
|
|
517
|
+
* Security.txt configuration or false to disable automatic /.well-known/security.txt (default:
|
|
518
|
+
* true)
|
|
519
|
+
*/
|
|
520
|
+
security?: boolean | SecurityRouteOptions;
|
|
521
|
+
/**
|
|
522
|
+
* RFC 9652 API Catalog configuration or false to disable automatic /.well-known/api-catalog
|
|
523
|
+
* (default: true)
|
|
524
|
+
*/
|
|
525
|
+
apiCatalog?: boolean | ApiCatalogRouteOptions;
|
|
526
|
+
/**
|
|
527
|
+
* Web App Manifest configuration or false to disable automatic /site.webmanifest (default: true)
|
|
528
|
+
*/
|
|
529
|
+
manifest?: boolean | ManifestRouteOptions;
|
|
530
|
+
/**
|
|
531
|
+
* Automatic generation of compliant public image assets (favicon.ico, apple-touch-icon.png, PWA
|
|
532
|
+
* icons) from a single master icon (default: true)
|
|
533
|
+
*/
|
|
534
|
+
assets?: boolean | AssetRouteOptions;
|
|
535
|
+
}
|
|
276
536
|
//#endregion
|
|
277
537
|
export type { ArticleSchemaOptions, BreadcrumbItem, LlmsFullTxtOptions, LlmsPage, LlmsTxtOptions };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rimelight/seo",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.7",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Rimelight Entertainment's SEO Package",
|
|
6
6
|
"homepage": "https://rimelight.com/docs",
|
|
@@ -41,6 +41,30 @@
|
|
|
41
41
|
"types": "./dist/integration.d.mts",
|
|
42
42
|
"import": "./dist/integration.mjs"
|
|
43
43
|
},
|
|
44
|
+
"./rss": {
|
|
45
|
+
"types": "./dist/rss.d.mts",
|
|
46
|
+
"import": "./dist/rss.mjs"
|
|
47
|
+
},
|
|
48
|
+
"./security": {
|
|
49
|
+
"types": "./dist/security.d.mts",
|
|
50
|
+
"import": "./dist/security.mjs"
|
|
51
|
+
},
|
|
52
|
+
"./manifest": {
|
|
53
|
+
"types": "./dist/manifest.d.mts",
|
|
54
|
+
"import": "./dist/manifest.mjs"
|
|
55
|
+
},
|
|
56
|
+
"./catalog": {
|
|
57
|
+
"types": "./dist/catalog.d.mts",
|
|
58
|
+
"import": "./dist/catalog.mjs"
|
|
59
|
+
},
|
|
60
|
+
"./og": {
|
|
61
|
+
"types": "./dist/og.d.mts",
|
|
62
|
+
"import": "./dist/og.mjs"
|
|
63
|
+
},
|
|
64
|
+
"./assets": {
|
|
65
|
+
"types": "./dist/assets.d.mts",
|
|
66
|
+
"import": "./dist/assets.mjs"
|
|
67
|
+
},
|
|
44
68
|
"./components/*": "./src/components/*",
|
|
45
69
|
"./routes/*": "./src/routes/*",
|
|
46
70
|
"./*": "./src/*"
|
|
@@ -49,12 +73,23 @@
|
|
|
49
73
|
"access": "public"
|
|
50
74
|
},
|
|
51
75
|
"devDependencies": {
|
|
52
|
-
"@rimelight/config": "0.0.
|
|
76
|
+
"@rimelight/config": "0.0.9",
|
|
53
77
|
"astro": "7.3.2",
|
|
78
|
+
"sharp": "^0.35.4",
|
|
54
79
|
"typescript": "6.0.3"
|
|
55
80
|
},
|
|
56
81
|
"peerDependencies": {
|
|
57
|
-
"astro": ">=7.0.0"
|
|
82
|
+
"astro": ">=7.0.0",
|
|
83
|
+
"sharp": ">=0.33.0",
|
|
84
|
+
"takumi-js": ">=2.0.0"
|
|
85
|
+
},
|
|
86
|
+
"peerDependenciesMeta": {
|
|
87
|
+
"sharp": {
|
|
88
|
+
"optional": true
|
|
89
|
+
},
|
|
90
|
+
"takumi-js": {
|
|
91
|
+
"optional": true
|
|
92
|
+
}
|
|
58
93
|
},
|
|
59
94
|
"engines": {
|
|
60
95
|
"node": ">=26.8.2"
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from "vite-plus/test"
|
|
2
|
+
import { generatePng, generateMaskablePng, generateIco, generateOgCard } from "./assets.js"
|
|
3
|
+
import sharp from "sharp"
|
|
4
|
+
|
|
5
|
+
const sampleSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
|
6
|
+
<circle cx="50" cy="50" r="40" fill="#ff0000" />
|
|
7
|
+
</svg>`
|
|
8
|
+
|
|
9
|
+
describe("Asset Generation", () => {
|
|
10
|
+
it("generates PNG buffer with correct dimensions", async () => {
|
|
11
|
+
const pngBuffer = await generatePng(Buffer.from(sampleSvg), { width: 192, height: 192 })
|
|
12
|
+
expect(pngBuffer).toBeInstanceOf(Buffer)
|
|
13
|
+
|
|
14
|
+
const meta = await sharp(pngBuffer).metadata()
|
|
15
|
+
expect(meta.format).toBe("png")
|
|
16
|
+
expect(meta.width).toBe(192)
|
|
17
|
+
expect(meta.height).toBe(192)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it("generates maskable PNG buffer with padding and background", async () => {
|
|
21
|
+
const maskableBuffer = await generateMaskablePng(Buffer.from(sampleSvg), {
|
|
22
|
+
size: 512,
|
|
23
|
+
background: "#123456"
|
|
24
|
+
})
|
|
25
|
+
expect(maskableBuffer).toBeInstanceOf(Buffer)
|
|
26
|
+
|
|
27
|
+
const meta = await sharp(maskableBuffer).metadata()
|
|
28
|
+
expect(meta.format).toBe("png")
|
|
29
|
+
expect(meta.width).toBe(512)
|
|
30
|
+
expect(meta.height).toBe(512)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("generates valid .ico buffer containing PNG data", async () => {
|
|
34
|
+
const icoBuffer = await generateIco(Buffer.from(sampleSvg), 32)
|
|
35
|
+
expect(icoBuffer).toBeInstanceOf(Buffer)
|
|
36
|
+
expect(icoBuffer.length).toBeGreaterThan(22)
|
|
37
|
+
|
|
38
|
+
// Verify ICO header
|
|
39
|
+
expect(icoBuffer.readUInt16LE(0)).toBe(0) // Reserved
|
|
40
|
+
expect(icoBuffer.readUInt16LE(2)).toBe(1) // Type = 1 (ICO)
|
|
41
|
+
expect(icoBuffer.readUInt16LE(4)).toBe(1) // 1 image
|
|
42
|
+
|
|
43
|
+
// Verify directory entry
|
|
44
|
+
expect(icoBuffer.readUInt8(6)).toBe(32) // Width
|
|
45
|
+
expect(icoBuffer.readUInt8(7)).toBe(32) // Height
|
|
46
|
+
expect(icoBuffer.readUInt16LE(10)).toBe(1) // Planes
|
|
47
|
+
expect(icoBuffer.readUInt16LE(12)).toBe(32) // 32 bpp
|
|
48
|
+
expect(icoBuffer.readUInt32LE(18)).toBe(22) // Offset = 22
|
|
49
|
+
|
|
50
|
+
// Verify PNG payload inside ICO
|
|
51
|
+
const pngSlice = icoBuffer.subarray(22)
|
|
52
|
+
const meta = await sharp(pngSlice).metadata()
|
|
53
|
+
expect(meta.format).toBe("png")
|
|
54
|
+
expect(meta.width).toBe(32)
|
|
55
|
+
expect(meta.height).toBe(32)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("generates standard 1200x630 OG card buffer", async () => {
|
|
59
|
+
const ogBuffer = await generateOgCard({
|
|
60
|
+
logoBuffer: Buffer.from(sampleSvg),
|
|
61
|
+
background: "#0a0a0a"
|
|
62
|
+
})
|
|
63
|
+
expect(ogBuffer).toBeInstanceOf(Buffer)
|
|
64
|
+
|
|
65
|
+
const meta = await sharp(ogBuffer).metadata()
|
|
66
|
+
expect(meta.format).toBe("png")
|
|
67
|
+
expect(meta.width).toBe(1200)
|
|
68
|
+
expect(meta.height).toBe(630)
|
|
69
|
+
})
|
|
70
|
+
})
|
package/src/assets.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import sharp from "sharp"
|
|
2
|
+
|
|
3
|
+
export interface GeneratePngOptions {
|
|
4
|
+
width: number
|
|
5
|
+
height: number
|
|
6
|
+
background?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface GenerateMaskableOptions {
|
|
10
|
+
size?: number
|
|
11
|
+
background?: string
|
|
12
|
+
paddingRatio?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GenerateOgCardOptions {
|
|
16
|
+
width?: number
|
|
17
|
+
height?: number
|
|
18
|
+
background?: string
|
|
19
|
+
logoBuffer?: Buffer | Uint8Array | string | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generate a PNG buffer resized to specific dimensions from an SVG or image buffer.
|
|
24
|
+
*/
|
|
25
|
+
export async function generatePng(
|
|
26
|
+
source: Buffer | Uint8Array | string,
|
|
27
|
+
options: { width: number; height: number }
|
|
28
|
+
): Promise<Buffer> {
|
|
29
|
+
return sharp(source)
|
|
30
|
+
.resize(options.width, options.height, {
|
|
31
|
+
fit: "contain",
|
|
32
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
33
|
+
})
|
|
34
|
+
.png()
|
|
35
|
+
.toBuffer()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generate a maskable PWA PNG icon with safe-zone padding and solid background. The standard
|
|
40
|
+
* recommendation is to place the master icon in the inner 80% safe zone.
|
|
41
|
+
*/
|
|
42
|
+
export async function generateMaskablePng(
|
|
43
|
+
source: Buffer | Uint8Array | string,
|
|
44
|
+
options: GenerateMaskableOptions = {}
|
|
45
|
+
): Promise<Buffer> {
|
|
46
|
+
const { size = 512, background = "#ffffff", paddingRatio = 0.15 } = options
|
|
47
|
+
const innerSize = Math.round(size * (1 - paddingRatio * 2))
|
|
48
|
+
const pad = Math.floor((size - innerSize) / 2)
|
|
49
|
+
|
|
50
|
+
const innerPng = await sharp(source)
|
|
51
|
+
.resize(innerSize, innerSize, {
|
|
52
|
+
fit: "contain",
|
|
53
|
+
background: { r: 0, g: 0, b: 0, alpha: 0 }
|
|
54
|
+
})
|
|
55
|
+
.png()
|
|
56
|
+
.toBuffer()
|
|
57
|
+
|
|
58
|
+
return sharp(innerPng)
|
|
59
|
+
.extend({
|
|
60
|
+
top: pad,
|
|
61
|
+
bottom: size - innerSize - pad,
|
|
62
|
+
left: pad,
|
|
63
|
+
right: size - innerSize - pad,
|
|
64
|
+
background
|
|
65
|
+
})
|
|
66
|
+
.png()
|
|
67
|
+
.toBuffer()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Generate a valid .ico buffer containing a 32x32 PNG image according to the Windows ICO
|
|
72
|
+
* specification.
|
|
73
|
+
*/
|
|
74
|
+
export async function generateIco(
|
|
75
|
+
source: Buffer | Uint8Array | string,
|
|
76
|
+
size = 32
|
|
77
|
+
): Promise<Buffer> {
|
|
78
|
+
const pngBuffer = await generatePng(source, { width: size, height: size })
|
|
79
|
+
|
|
80
|
+
const header = Buffer.alloc(22)
|
|
81
|
+
// Reserved (2 bytes)
|
|
82
|
+
header.writeUInt16LE(0, 0)
|
|
83
|
+
// Image type (1 = ICO) (2 bytes)
|
|
84
|
+
header.writeUInt16LE(1, 2)
|
|
85
|
+
// Number of images (2 bytes)
|
|
86
|
+
header.writeUInt16LE(1, 4)
|
|
87
|
+
|
|
88
|
+
// Directory entry (16 bytes)
|
|
89
|
+
header.writeUInt8(size === 256 ? 0 : size, 6) // Width
|
|
90
|
+
header.writeUInt8(size === 256 ? 0 : size, 7) // Height
|
|
91
|
+
header.writeUInt8(0, 8) // Palette size (0 = no palette)
|
|
92
|
+
header.writeUInt8(0, 9) // Reserved
|
|
93
|
+
header.writeUInt16LE(1, 10) // Color planes
|
|
94
|
+
header.writeUInt16LE(32, 12) // Bits per pixel (32bpp)
|
|
95
|
+
header.writeUInt32LE(pngBuffer.length, 14) // Image size in bytes
|
|
96
|
+
header.writeUInt32LE(22, 18) // Offset of image data (header size = 22)
|
|
97
|
+
|
|
98
|
+
return Buffer.concat([header, pngBuffer])
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Generate a standard 1200x630 branded Open Graph card from a master logo and background color.
|
|
103
|
+
*/
|
|
104
|
+
export async function generateOgCard(options: GenerateOgCardOptions = {}): Promise<Buffer> {
|
|
105
|
+
const { width = 1200, height = 630, background = "#0a0a0a", logoBuffer } = options
|
|
106
|
+
|
|
107
|
+
if (logoBuffer) {
|
|
108
|
+
const logoSize = Math.min(Math.round(height * 0.45), 280)
|
|
109
|
+
const logoPng = await generatePng(logoBuffer, { width: logoSize, height: logoSize })
|
|
110
|
+
const topPad = Math.floor((height - logoSize) / 2)
|
|
111
|
+
const bottomPad = height - logoSize - topPad
|
|
112
|
+
const leftPad = Math.floor((width - logoSize) / 2)
|
|
113
|
+
const rightPad = width - logoSize - leftPad
|
|
114
|
+
|
|
115
|
+
return sharp(logoPng)
|
|
116
|
+
.extend({
|
|
117
|
+
top: topPad,
|
|
118
|
+
bottom: bottomPad,
|
|
119
|
+
left: leftPad,
|
|
120
|
+
right: rightPad,
|
|
121
|
+
background
|
|
122
|
+
})
|
|
123
|
+
.png()
|
|
124
|
+
.toBuffer()
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return sharp({
|
|
128
|
+
create: {
|
|
129
|
+
width,
|
|
130
|
+
height,
|
|
131
|
+
channels: 4,
|
|
132
|
+
background
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
.png()
|
|
136
|
+
.toBuffer()
|
|
137
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expect } from "vite-plus/test"
|
|
2
|
+
import { buildApiCatalog } from "./catalog.js"
|
|
3
|
+
|
|
4
|
+
describe("buildApiCatalog", () => {
|
|
5
|
+
it("builds linkset api-catalog object", () => {
|
|
6
|
+
const catalog = buildApiCatalog({
|
|
7
|
+
items: [
|
|
8
|
+
{
|
|
9
|
+
rel: "self",
|
|
10
|
+
href: "/.well-known/api-catalog",
|
|
11
|
+
type: "application/linkset+json"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
rel: "alternate",
|
|
15
|
+
href: "/sitemap.xml",
|
|
16
|
+
type: "application/xml"
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const items = catalog["api-catalog"]
|
|
22
|
+
expect(items).toHaveLength(2)
|
|
23
|
+
expect(items[0]?.rel).toBe("self")
|
|
24
|
+
expect(items[1]?.href).toBe("/sitemap.xml")
|
|
25
|
+
})
|
|
26
|
+
})
|
package/src/catalog.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ApiCatalog, ApiCatalogOptions } from "./types.js"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Build RFC 9652 Application Linkset API Catalog. https://www.rfc-editor.org/rfc/rfc9652
|
|
5
|
+
*/
|
|
6
|
+
export function buildApiCatalog(options: ApiCatalogOptions): ApiCatalog {
|
|
7
|
+
const { items } = options
|
|
8
|
+
return {
|
|
9
|
+
"api-catalog": items
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -39,7 +39,7 @@ const {
|
|
|
39
39
|
// Resolve defaults from config if available
|
|
40
40
|
const siteName = config?.name || 'Rimelight'
|
|
41
41
|
const siteDescription = config?.description || ''
|
|
42
|
-
const ogImageFallback = config?.seo?.ogImageFallback || config?.ogImage || ''
|
|
42
|
+
const ogImageFallback = config?.seo?.ogImageFallback || config?.ogImage || '/og.png'
|
|
43
43
|
const themeColor = config?.branding?.colors?.themeColor
|
|
44
44
|
const favicon = customFavicon || config?.branding?.favicon?.svg || '/favicon.svg'
|
|
45
45
|
const faviconType = favicon.endsWith('.ico')
|
package/src/env.d.ts
CHANGED
|
@@ -1,8 +1,56 @@
|
|
|
1
|
-
declare module "*.astro" {
|
|
2
|
-
export default {} as import("astro/runtime/server").AstroComponentFactory
|
|
3
|
-
}
|
|
4
1
|
|
|
5
2
|
declare module "virtual:rimelight-seo-config" {
|
|
6
3
|
const config: import("./integration.js").RimelightSeoOptions
|
|
7
4
|
export default config
|
|
8
5
|
}
|
|
6
|
+
|
|
7
|
+
declare module "virtual:rimelight-seo/options" {
|
|
8
|
+
const config: import("./integration.js").RimelightSeoOptions
|
|
9
|
+
export default config
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
declare module "virtual:rimelight-seo/config" {
|
|
13
|
+
export const SEO_LOCALES: import("./types.js").LocaleConfig | undefined
|
|
14
|
+
export const PRIVATE_PATH_PREFIXES: string[] | undefined
|
|
15
|
+
export const seoEntries: (() => Promise<import("./types.js").SeoEntry[]>) | undefined
|
|
16
|
+
export const rssEntries: (() => Promise<import("./types.js").RssItem[]>) | undefined
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
declare module "virtual:rimelight-seo/site-config" {
|
|
20
|
+
export const siteConfig: import("./types.js").SiteConfig | Record<string, any>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
declare module "virtual:rimelight-seo/db" {
|
|
24
|
+
export const db: any
|
|
25
|
+
export const pages: any
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
declare module "virtual:rimelight-seo/corpus" {
|
|
29
|
+
export const corpusPages: any[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
declare module "#db/schema" {
|
|
33
|
+
export const pages: any
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
declare module "drizzle-orm" {
|
|
37
|
+
export const and: (...args: any[]) => any
|
|
38
|
+
export const isNull: (column: any) => any
|
|
39
|
+
export const isNotNull: (column: any) => any
|
|
40
|
+
export const sql: any
|
|
41
|
+
export const eq: (column: any, value: any) => any
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
declare module "@rimelight/cms" {
|
|
45
|
+
export const renderCorpusMarkdown: (db: any, options: any) => Promise<string>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
declare module "astro:content" {
|
|
49
|
+
export const getCollection: (collection: string) => Promise<any[]>
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
declare module "virtual:rimelight-seo/assets" {
|
|
53
|
+
export const masterIconBuffer: Buffer | null
|
|
54
|
+
export const isSvg: boolean
|
|
55
|
+
export const backgroundColor: string
|
|
56
|
+
}
|