@rimelight/seo 0.0.6 → 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 (66) 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 +6 -2
  6. package/dist/index.mjs +5 -1
  7. package/dist/integration.mjs +182 -0
  8. package/dist/manifest.d.mts +7 -0
  9. package/dist/manifest.mjs +44 -0
  10. package/dist/og.mjs +1 -1
  11. package/dist/routes/api-catalog.d.mts +5 -0
  12. package/dist/routes/api-catalog.mjs +38 -0
  13. package/dist/routes/apple-touch-icon.png.d.mts +5 -0
  14. package/dist/routes/apple-touch-icon.png.mjs +17 -0
  15. package/dist/routes/favicon.ico.d.mts +5 -0
  16. package/dist/routes/favicon.ico.mjs +14 -0
  17. package/dist/routes/favicon.svg.d.mts +5 -0
  18. package/dist/routes/favicon.svg.mjs +12 -0
  19. package/dist/routes/icon-192.png.d.mts +5 -0
  20. package/dist/routes/icon-192.png.mjs +17 -0
  21. package/dist/routes/icon-512.png.d.mts +5 -0
  22. package/dist/routes/icon-512.png.mjs +17 -0
  23. package/dist/routes/icon-maskable-512.png.d.mts +5 -0
  24. package/dist/routes/icon-maskable-512.png.mjs +18 -0
  25. package/dist/routes/llms.txt.mjs +1 -1
  26. package/dist/routes/robots.txt.mjs +1 -1
  27. package/dist/routes/rss.xml.mjs +48 -20
  28. package/dist/routes/security.txt.d.mts +5 -0
  29. package/dist/routes/security.txt.mjs +24 -0
  30. package/dist/routes/site.webmanifest.d.mts +5 -0
  31. package/dist/routes/site.webmanifest.mjs +23 -0
  32. package/dist/routes/sitemap.xml.mjs +1 -1
  33. package/dist/security.d.mts +7 -0
  34. package/dist/security.mjs +34 -0
  35. package/dist/types.d.mts +191 -8
  36. package/package.json +23 -2
  37. package/src/assets.test.ts +70 -0
  38. package/src/assets.ts +137 -0
  39. package/src/catalog.test.ts +26 -0
  40. package/src/catalog.ts +11 -0
  41. package/src/components/SEOHead.astro +1 -1
  42. package/src/env.d.ts +56 -33
  43. package/src/index.ts +18 -1
  44. package/src/integration.ts +353 -172
  45. package/src/manifest.test.ts +41 -0
  46. package/src/manifest.ts +59 -0
  47. package/src/og.ts +1 -9
  48. package/src/routes/api-catalog.ts +55 -0
  49. package/src/routes/apple-touch-icon.png.ts +19 -0
  50. package/src/routes/favicon.ico.ts +19 -0
  51. package/src/routes/favicon.svg.ts +17 -0
  52. package/src/routes/icon-192.png.ts +19 -0
  53. package/src/routes/icon-512.png.ts +19 -0
  54. package/src/routes/icon-maskable-512.png.ts +24 -0
  55. package/src/routes/llms-full.txt.ts +77 -77
  56. package/src/routes/llms.txt.ts +93 -90
  57. package/src/routes/robots.txt.ts +63 -63
  58. package/src/routes/rss.xml.ts +129 -87
  59. package/src/routes/security.txt.ts +30 -0
  60. package/src/routes/site.webmanifest.ts +28 -0
  61. package/src/routes/sitemap.xml.ts +43 -43
  62. package/src/rss.test.ts +28 -28
  63. package/src/rss.ts +63 -63
  64. package/src/security.test.ts +46 -0
  65. package/src/security.ts +78 -0
  66. package/src/types.ts +564 -367
@@ -1,172 +1,353 @@
1
- import type { AstroIntegration } from "astro"
2
- import path from "node:path"
3
- import fs from "node:fs"
4
- import { fileURLToPath } from "node:url"
5
- import type { RimelightSeoOptions } from "./types.js"
6
-
7
- export { type RimelightSeoOptions } from "./types.js"
8
-
9
- export function rimelightSeo(options: RimelightSeoOptions = {}): AstroIntegration {
10
- return {
11
- name: "@rimelight/seo",
12
- hooks: {
13
- "astro:config:setup": ({ injectRoute, updateConfig, config }) => {
14
- const rootDir = fileURLToPath(config.root)
15
-
16
- // Resolve site files
17
- const findFile = (candidates: string[]): string | null => {
18
- for (const c of candidates) {
19
- const resolved = path.resolve(rootDir, c)
20
- if (fs.existsSync(resolved)) return resolved
21
- }
22
- return null
23
- }
24
-
25
- const seoConfigPath = findFile([
26
- "src/config/seo.config.ts",
27
- "src/config/seo.config.js",
28
- "src/config/seo.config.mjs"
29
- ])
30
-
31
- const siteConfigPath = findFile([
32
- "src/config/site.config.ts",
33
- "src/config/site.config.js",
34
- "src/config/site.config.mjs"
35
- ])
36
-
37
- const dbPath = findFile(["src/db/index.ts", "src/db/index.js", "src/db/index.mjs"])
38
-
39
- const corpusPath = findFile([
40
- "src/config/llms.corpus.ts",
41
- "src/config/llms.corpus.js",
42
- "src/config/llms.corpus.mjs"
43
- ])
44
-
45
- // Inject routes
46
- if (options.robots !== false) {
47
- const pattern =
48
- typeof options.robots === "object" && options.robots.path
49
- ? options.robots.path
50
- : "/robots.txt"
51
- injectRoute({
52
- pattern,
53
- entrypoint: "@rimelight/seo/routes/robots.txt.ts"
54
- })
55
- }
56
-
57
- if (options.sitemap !== false) {
58
- const pattern =
59
- typeof options.sitemap === "object" && options.sitemap.path
60
- ? options.sitemap.path
61
- : "/sitemap.xml"
62
- injectRoute({
63
- pattern,
64
- entrypoint: "@rimelight/seo/routes/sitemap.xml.ts"
65
- })
66
- }
67
-
68
- if (options.rss !== false) {
69
- const pattern =
70
- typeof options.rss === "object" && options.rss.path ? options.rss.path : "/rss.xml"
71
- injectRoute({
72
- pattern,
73
- entrypoint: "@rimelight/seo/routes/rss.xml.ts"
74
- })
75
- }
76
-
77
- if (options.llms !== false) {
78
- const pattern =
79
- typeof options.llms === "object" && options.llms.path ? options.llms.path : "/llms.txt"
80
- injectRoute({
81
- pattern,
82
- entrypoint: "@rimelight/seo/routes/llms.txt.ts"
83
- })
84
- }
85
-
86
- if (options.llmsFull !== false) {
87
- const pattern =
88
- typeof options.llmsFull === "object" && options.llmsFull.path
89
- ? options.llmsFull.path
90
- : "/llms-full.txt"
91
- injectRoute({
92
- pattern,
93
- entrypoint: "@rimelight/seo/routes/llms-full.txt.ts"
94
- })
95
- }
96
-
97
- updateConfig({
98
- vite: {
99
- plugins: [
100
- {
101
- name: "vite-plugin-rimelight-seo",
102
- resolveId(id) {
103
- if (
104
- id === "virtual:rimelight-seo-config" ||
105
- id === "virtual:rimelight-seo/options"
106
- ) {
107
- return "\0virtual:rimelight-seo/options"
108
- }
109
- if (id === "virtual:rimelight-seo/config") {
110
- return "\0virtual:rimelight-seo/config"
111
- }
112
- if (id === "virtual:rimelight-seo/site-config") {
113
- return "\0virtual:rimelight-seo/site-config"
114
- }
115
- if (id === "virtual:rimelight-seo/db") {
116
- return "\0virtual:rimelight-seo/db"
117
- }
118
- if (id === "virtual:rimelight-seo/corpus") {
119
- return "\0virtual:rimelight-seo/corpus"
120
- }
121
- return null
122
- },
123
- load(id) {
124
- if (id === "\0virtual:rimelight-seo/options") {
125
- return `export default ${JSON.stringify(options)};`
126
- }
127
- if (id === "\0virtual:rimelight-seo/config") {
128
- if (seoConfigPath) {
129
- return `import * as customConfig from ${JSON.stringify(seoConfigPath)};
130
- export const SEO_LOCALES = customConfig.SEO_LOCALES;
131
- export const PRIVATE_PATH_PREFIXES = customConfig.PRIVATE_PATH_PREFIXES || [];
132
- export const seoEntries = customConfig.seoEntries;
133
- export const rssEntries = customConfig.rssEntries;
134
- export default customConfig;`
135
- }
136
- return `export const SEO_LOCALES = undefined;
137
- export const PRIVATE_PATH_PREFIXES = [];
138
- export const seoEntries = async () => [];
139
- export const rssEntries = undefined;`
140
- }
141
- if (id === "\0virtual:rimelight-seo/site-config") {
142
- if (siteConfigPath) {
143
- return `export * from ${JSON.stringify(siteConfigPath)};`
144
- }
145
- return `export const siteConfig = { name: "Site", description: "", url: "" };`
146
- }
147
- if (id === "\0virtual:rimelight-seo/db") {
148
- if (dbPath) {
149
- return `export * from ${JSON.stringify(dbPath)};`
150
- }
151
- return `export const db = null;
152
- export const pages = null;`
153
- }
154
- if (id === "\0virtual:rimelight-seo/corpus") {
155
- if (corpusPath) {
156
- return `export * from ${JSON.stringify(corpusPath)};`
157
- }
158
- return `export const corpusPages = [];`
159
- }
160
- return null
161
- }
162
- }
163
- ]
164
- }
165
- })
166
- }
167
- }
168
- }
169
- }
170
-
171
- export default rimelightSeo
172
-
1
+ import type { AstroIntegration } from "astro"
2
+ import path from "node:path"
3
+ import fs from "node:fs"
4
+ import { fileURLToPath } from "node:url"
5
+ import type { RimelightSeoOptions } from "./types.js"
6
+ import { generateIco, generatePng, generateMaskablePng, generateOgCard } from "./assets.js"
7
+
8
+ export { type RimelightSeoOptions } from "./types.js"
9
+
10
+ export function rimelightSeo(options: RimelightSeoOptions = {}): AstroIntegration {
11
+ return {
12
+ name: "@rimelight/seo",
13
+ hooks: {
14
+ "astro:config:setup": ({ injectRoute, updateConfig, config }) => {
15
+ const rootDir = fileURLToPath(config.root)
16
+
17
+ // Resolve site files
18
+ const findFile = (candidates: string[]): string | null => {
19
+ for (const c of candidates) {
20
+ const resolved = path.resolve(rootDir, c)
21
+ if (fs.existsSync(resolved)) return resolved
22
+ }
23
+ return null
24
+ }
25
+
26
+ const seoConfigPath = findFile([
27
+ "src/config/seo.config.ts",
28
+ "src/config/seo.config.js",
29
+ "src/config/seo.config.mjs"
30
+ ])
31
+
32
+ const siteConfigPath = findFile([
33
+ "src/config/site.config.ts",
34
+ "src/config/site.config.js",
35
+ "src/config/site.config.mjs"
36
+ ])
37
+
38
+ const dbPath = findFile(["src/db/index.ts", "src/db/index.js", "src/db/index.mjs"])
39
+
40
+ const corpusPath = findFile([
41
+ "src/config/llms.corpus.ts",
42
+ "src/config/llms.corpus.js",
43
+ "src/config/llms.corpus.mjs"
44
+ ])
45
+
46
+ // Master Icon Discovery for dynamic public asset generation
47
+ const assetOpts = typeof options.assets === "object" ? options.assets : {}
48
+ const explicitIcon = assetOpts.icon ? path.resolve(rootDir, assetOpts.icon) : null
49
+ const masterIconPath =
50
+ explicitIcon && fs.existsSync(explicitIcon)
51
+ ? explicitIcon
52
+ : findFile([
53
+ "src/assets/logos/logomark_color.svg",
54
+ "src/assets/logos/logo_color.svg",
55
+ "src/assets/logos/logomark.svg",
56
+ "src/assets/logos/logo.svg",
57
+ "src/assets/icon.svg",
58
+ "src/assets/logo.svg",
59
+ "src/assets/icon.png",
60
+ "public/favicon.svg"
61
+ ])
62
+
63
+ let masterIconBuffer: Buffer | null = null
64
+ let isSvg = false
65
+ if (masterIconPath && fs.existsSync(masterIconPath)) {
66
+ masterIconBuffer = fs.readFileSync(masterIconPath)
67
+ isSvg = masterIconPath.endsWith(".svg")
68
+ }
69
+
70
+ const bgColor = assetOpts.backgroundColor || "#ffffff"
71
+
72
+ // Inject standard routes
73
+ if (options.robots !== false) {
74
+ const pattern =
75
+ typeof options.robots === "object" && options.robots.path
76
+ ? options.robots.path
77
+ : "/robots.txt"
78
+ injectRoute({
79
+ pattern,
80
+ entrypoint: "@rimelight/seo/routes/robots.txt.ts"
81
+ })
82
+ }
83
+
84
+ if (options.sitemap !== false) {
85
+ const pattern =
86
+ typeof options.sitemap === "object" && options.sitemap.path
87
+ ? options.sitemap.path
88
+ : "/sitemap.xml"
89
+ injectRoute({
90
+ pattern,
91
+ entrypoint: "@rimelight/seo/routes/sitemap.xml.ts"
92
+ })
93
+ }
94
+
95
+ if (options.rss !== false) {
96
+ const pattern =
97
+ typeof options.rss === "object" && options.rss.path ? options.rss.path : "/rss.xml"
98
+ injectRoute({
99
+ pattern,
100
+ entrypoint: "@rimelight/seo/routes/rss.xml.ts"
101
+ })
102
+ }
103
+
104
+ if (options.llms !== false) {
105
+ const pattern =
106
+ typeof options.llms === "object" && options.llms.path ? options.llms.path : "/llms.txt"
107
+ injectRoute({
108
+ pattern,
109
+ entrypoint: "@rimelight/seo/routes/llms.txt.ts"
110
+ })
111
+ }
112
+
113
+ if (options.llmsFull !== false) {
114
+ const pattern =
115
+ typeof options.llmsFull === "object" && options.llmsFull.path
116
+ ? options.llmsFull.path
117
+ : "/llms-full.txt"
118
+ injectRoute({
119
+ pattern,
120
+ entrypoint: "@rimelight/seo/routes/llms-full.txt.ts"
121
+ })
122
+ }
123
+
124
+ if (options.security !== false) {
125
+ const pattern =
126
+ typeof options.security === "object" && options.security.path
127
+ ? options.security.path
128
+ : "/.well-known/security.txt"
129
+ injectRoute({
130
+ pattern,
131
+ entrypoint: "@rimelight/seo/routes/security.txt.ts"
132
+ })
133
+
134
+ const legacyRoute =
135
+ typeof options.security === "object" ? options.security.legacyRoute !== false : true
136
+ if (legacyRoute && pattern === "/.well-known/security.txt") {
137
+ injectRoute({
138
+ pattern: "/security.txt",
139
+ entrypoint: "@rimelight/seo/routes/security.txt.ts"
140
+ })
141
+ }
142
+ }
143
+
144
+ if (options.apiCatalog !== false) {
145
+ const pattern =
146
+ typeof options.apiCatalog === "object" && options.apiCatalog.path
147
+ ? options.apiCatalog.path
148
+ : "/.well-known/api-catalog"
149
+ injectRoute({
150
+ pattern,
151
+ entrypoint: "@rimelight/seo/routes/api-catalog.ts"
152
+ })
153
+ }
154
+
155
+ if (options.manifest !== false) {
156
+ const pattern =
157
+ typeof options.manifest === "object" && options.manifest.path
158
+ ? options.manifest.path
159
+ : "/site.webmanifest"
160
+ injectRoute({
161
+ pattern,
162
+ entrypoint: "@rimelight/seo/routes/site.webmanifest.ts"
163
+ })
164
+ }
165
+
166
+ updateConfig({
167
+ vite: {
168
+ plugins: [
169
+ {
170
+ name: "vite-plugin-rimelight-seo-assets",
171
+ configureServer(server) {
172
+ if (!masterIconBuffer) return
173
+ server.middlewares.use(async (req, res, next) => {
174
+ const url = req.url?.split("?")[0]
175
+ if (url === "/favicon.ico" && assetOpts.faviconIco !== false) {
176
+ const ico = await generateIco(masterIconBuffer, 32)
177
+ res.setHeader("Content-Type", "image/x-icon")
178
+ res.setHeader("Cache-Control", "no-cache")
179
+ res.end(ico)
180
+ return
181
+ }
182
+ if (url === "/apple-touch-icon.png" && assetOpts.appleTouchIcon !== false) {
183
+ const png = await generatePng(masterIconBuffer, { width: 180, height: 180 })
184
+ res.setHeader("Content-Type", "image/png")
185
+ res.setHeader("Cache-Control", "no-cache")
186
+ res.end(png)
187
+ return
188
+ }
189
+ if (url === "/icon-192.png" && assetOpts.icon192 !== false) {
190
+ const png = await generatePng(masterIconBuffer, { width: 192, height: 192 })
191
+ res.setHeader("Content-Type", "image/png")
192
+ res.setHeader("Cache-Control", "no-cache")
193
+ res.end(png)
194
+ return
195
+ }
196
+ if (url === "/icon-512.png" && assetOpts.icon512 !== false) {
197
+ const png = await generatePng(masterIconBuffer, { width: 512, height: 512 })
198
+ res.setHeader("Content-Type", "image/png")
199
+ res.setHeader("Cache-Control", "no-cache")
200
+ res.end(png)
201
+ return
202
+ }
203
+ if (url === "/icon-maskable-512.png" && assetOpts.iconMaskable !== false) {
204
+ const png = await generateMaskablePng(masterIconBuffer, {
205
+ size: 512,
206
+ background: bgColor
207
+ })
208
+ res.setHeader("Content-Type", "image/png")
209
+ res.setHeader("Cache-Control", "no-cache")
210
+ res.end(png)
211
+ return
212
+ }
213
+ if (url === "/favicon.svg" && isSvg) {
214
+ res.setHeader("Content-Type", "image/svg+xml")
215
+ res.setHeader("Cache-Control", "no-cache")
216
+ res.end(masterIconBuffer)
217
+ return
218
+ }
219
+ if (url === "/og.png" && assetOpts.ogImage !== false) {
220
+ const og = await generateOgCard({
221
+ logoBuffer: masterIconBuffer,
222
+ background: bgColor
223
+ })
224
+ res.setHeader("Content-Type", "image/png")
225
+ res.setHeader("Cache-Control", "no-cache")
226
+ res.end(og)
227
+ return
228
+ }
229
+ next()
230
+ })
231
+ },
232
+ async generateBundle() {
233
+ if (options.assets === false || !masterIconBuffer) return
234
+
235
+ if (assetOpts.faviconIco !== false) {
236
+ const ico = await generateIco(masterIconBuffer, 32)
237
+ this.emitFile({ type: "asset", fileName: "favicon.ico", source: ico })
238
+ }
239
+ if (assetOpts.appleTouchIcon !== false) {
240
+ const png = await generatePng(masterIconBuffer, { width: 180, height: 180 })
241
+ this.emitFile({ type: "asset", fileName: "apple-touch-icon.png", source: png })
242
+ }
243
+ if (assetOpts.icon192 !== false) {
244
+ const png = await generatePng(masterIconBuffer, { width: 192, height: 192 })
245
+ this.emitFile({ type: "asset", fileName: "icon-192.png", source: png })
246
+ }
247
+ if (assetOpts.icon512 !== false) {
248
+ const png = await generatePng(masterIconBuffer, { width: 512, height: 512 })
249
+ this.emitFile({ type: "asset", fileName: "icon-512.png", source: png })
250
+ }
251
+ if (assetOpts.iconMaskable !== false) {
252
+ const png = await generateMaskablePng(masterIconBuffer, {
253
+ size: 512,
254
+ background: bgColor
255
+ })
256
+ this.emitFile({
257
+ type: "asset",
258
+ fileName: "icon-maskable-512.png",
259
+ source: png
260
+ })
261
+ }
262
+ if (assetOpts.ogImage !== false) {
263
+ const og = await generateOgCard({
264
+ logoBuffer: masterIconBuffer,
265
+ background: bgColor
266
+ })
267
+ this.emitFile({
268
+ type: "asset",
269
+ fileName: "og.png",
270
+ source: og
271
+ })
272
+ }
273
+ if (isSvg) {
274
+ this.emitFile({
275
+ type: "asset",
276
+ fileName: "favicon.svg",
277
+ source: masterIconBuffer
278
+ })
279
+ }
280
+ }
281
+ },
282
+ {
283
+ name: "vite-plugin-rimelight-seo",
284
+ resolveId(id) {
285
+ if (
286
+ id === "virtual:rimelight-seo-config" ||
287
+ id === "virtual:rimelight-seo/options"
288
+ ) {
289
+ return "\0virtual:rimelight-seo/options"
290
+ }
291
+ if (id === "virtual:rimelight-seo/config") {
292
+ return "\0virtual:rimelight-seo/config"
293
+ }
294
+ if (id === "virtual:rimelight-seo/site-config") {
295
+ return "\0virtual:rimelight-seo/site-config"
296
+ }
297
+ if (id === "virtual:rimelight-seo/db") {
298
+ return "\0virtual:rimelight-seo/db"
299
+ }
300
+ if (id === "virtual:rimelight-seo/corpus") {
301
+ return "\0virtual:rimelight-seo/corpus"
302
+ }
303
+ return null
304
+ },
305
+ load(id) {
306
+ if (id === "\0virtual:rimelight-seo/options") {
307
+ return `export default ${JSON.stringify(options)};`
308
+ }
309
+ if (id === "\0virtual:rimelight-seo/config") {
310
+ if (seoConfigPath) {
311
+ return `import * as customConfig from ${JSON.stringify(seoConfigPath)};
312
+ export const SEO_LOCALES = customConfig.SEO_LOCALES;
313
+ export const PRIVATE_PATH_PREFIXES = customConfig.PRIVATE_PATH_PREFIXES || [];
314
+ export const seoEntries = customConfig.seoEntries;
315
+ export const rssEntries = customConfig.rssEntries;
316
+ export default customConfig;`
317
+ }
318
+ return `export const SEO_LOCALES = undefined;
319
+ export const PRIVATE_PATH_PREFIXES = [];
320
+ export const seoEntries = async () => [];
321
+ export const rssEntries = undefined;`
322
+ }
323
+ if (id === "\0virtual:rimelight-seo/site-config") {
324
+ if (siteConfigPath) {
325
+ return `export * from ${JSON.stringify(siteConfigPath)};`
326
+ }
327
+ return `export const siteConfig = { name: "Site", description: "", url: "" };`
328
+ }
329
+ if (id === "\0virtual:rimelight-seo/db") {
330
+ if (dbPath) {
331
+ return `export * from ${JSON.stringify(dbPath)};`
332
+ }
333
+ return `export const db = null;
334
+ export const pages = null;`
335
+ }
336
+ if (id === "\0virtual:rimelight-seo/corpus") {
337
+ if (corpusPath) {
338
+ return `export * from ${JSON.stringify(corpusPath)};`
339
+ }
340
+ return `export const corpusPages = [];`
341
+ }
342
+ return null
343
+ }
344
+ }
345
+ ]
346
+ }
347
+ })
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ export default rimelightSeo
@@ -0,0 +1,41 @@
1
+ import { describe, it, expect } from "vite-plus/test"
2
+ import { buildWebManifest } from "./manifest.js"
3
+
4
+ describe("buildWebManifest", () => {
5
+ it("generates standard web manifest with sensible defaults", () => {
6
+ const manifest = buildWebManifest({
7
+ name: "My Application"
8
+ })
9
+
10
+ expect(manifest.name).toBe("My Application")
11
+ expect(manifest.short_name).toBe("My Application")
12
+ expect(manifest.start_url).toBe("/")
13
+ expect(manifest.display).toBe("standalone")
14
+ expect(manifest.background_color).toBe("#ffffff")
15
+ expect(manifest.theme_color).toBe("#ffffff")
16
+ expect(manifest.icons).toHaveLength(3)
17
+ })
18
+
19
+ it("customizes theme, icons and shortcuts", () => {
20
+ const manifest = buildWebManifest({
21
+ name: "Custom App",
22
+ shortName: "App",
23
+ description: "A custom web application",
24
+ themeColor: "#000000",
25
+ backgroundColor: "#111111",
26
+ shortcuts: [
27
+ {
28
+ name: "Dashboard",
29
+ url: "/dashboard"
30
+ }
31
+ ]
32
+ })
33
+
34
+ expect(manifest.name).toBe("Custom App")
35
+ expect(manifest.short_name).toBe("App")
36
+ expect(manifest.description).toBe("A custom web application")
37
+ expect(manifest.theme_color).toBe("#000000")
38
+ expect(manifest.background_color).toBe("#111111")
39
+ expect(manifest.shortcuts).toHaveLength(1)
40
+ })
41
+ })
@@ -0,0 +1,59 @@
1
+ import type { WebManifest, WebManifestOptions } from "./types.js"
2
+
3
+ /**
4
+ * Build web app manifest (site.webmanifest) compliant with W3C Web App Manifest.
5
+ */
6
+ export function buildWebManifest(options: WebManifestOptions): WebManifest {
7
+ const {
8
+ name,
9
+ shortName = name,
10
+ description = "",
11
+ startUrl = "/",
12
+ display = "standalone",
13
+ backgroundColor = "#ffffff",
14
+ themeColor = "#ffffff",
15
+ icons = [
16
+ {
17
+ src: "/icon-192.png",
18
+ sizes: "192x192",
19
+ type: "image/png",
20
+ purpose: "any"
21
+ },
22
+ {
23
+ src: "/icon-512.png",
24
+ sizes: "512x512",
25
+ type: "image/png",
26
+ purpose: "any"
27
+ },
28
+ {
29
+ src: "/icon-maskable-512.png",
30
+ sizes: "512x512",
31
+ type: "image/png",
32
+ purpose: "maskable"
33
+ }
34
+ ],
35
+ shortcuts,
36
+ categories,
37
+ lang,
38
+ dir,
39
+ orientation,
40
+ scope
41
+ } = options
42
+
43
+ return {
44
+ name,
45
+ short_name: shortName,
46
+ ...(description ? { description } : {}),
47
+ start_url: startUrl,
48
+ display,
49
+ background_color: backgroundColor,
50
+ theme_color: themeColor,
51
+ icons,
52
+ ...(shortcuts ? { shortcuts } : {}),
53
+ ...(categories ? { categories } : {}),
54
+ ...(lang ? { lang } : {}),
55
+ ...(dir ? { dir } : {}),
56
+ ...(orientation ? { orientation } : {}),
57
+ ...(scope ? { scope } : {})
58
+ }
59
+ }
package/src/og.ts CHANGED
@@ -135,15 +135,7 @@ export async function loadOgFonts(
135
135
  * `renderOgResponse()` directly -- this function is just the house style.
136
136
  */
137
137
  export function defaultOgLayout(options: OgLayoutOptions, font: OgFontConfig = NOTO_SANS): object {
138
- const {
139
- title = "",
140
- description,
141
- brand,
142
- badge,
143
- date,
144
- background = "dark",
145
- preview = false
146
- } = options
138
+ const { title, description, brand, badge, date, background = "dark", preview = false } = options
147
139
 
148
140
  const MAX_TITLE_LEN = 160
149
141
  const MAX_DESC_LEN = 240