@rimelight/seo 0.0.6 → 0.0.8

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 (67) 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 +194 -3
  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 +30 -7
  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 +55 -33
  43. package/src/index.ts +18 -1
  44. package/src/integration.ts +365 -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
  67. package/LICENSE +0 -21
@@ -1,172 +1,365 @@
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 hasConfigExport = (name: string): boolean => {
33
+ if (!seoConfigPath) return false
34
+ const content = fs.readFileSync(seoConfigPath, "utf8")
35
+ return (
36
+ new RegExp(
37
+ `export\\s+(?:const|let|var|async\\s+function|function|class)\\s+${name}\\b`
38
+ ).test(content) || new RegExp(`export\\s*\\{[^}]*\\b${name}\\b}`).test(content)
39
+ )
40
+ }
41
+
42
+ const siteConfigPath = findFile([
43
+ "src/config/site.config.ts",
44
+ "src/config/site.config.js",
45
+ "src/config/site.config.mjs"
46
+ ])
47
+
48
+ const dbPath = findFile(["src/db/index.ts", "src/db/index.js", "src/db/index.mjs"])
49
+
50
+ const corpusPath = findFile([
51
+ "src/config/llms.corpus.ts",
52
+ "src/config/llms.corpus.js",
53
+ "src/config/llms.corpus.mjs"
54
+ ])
55
+
56
+ // Master Icon Discovery for dynamic public asset generation
57
+ const assetOpts = typeof options.assets === "object" ? options.assets : {}
58
+ const explicitIcon = assetOpts.icon ? path.resolve(rootDir, assetOpts.icon) : null
59
+ const masterIconPath =
60
+ explicitIcon && fs.existsSync(explicitIcon)
61
+ ? explicitIcon
62
+ : findFile([
63
+ "src/assets/logos/logomark_color.svg",
64
+ "src/assets/logos/logo_color.svg",
65
+ "src/assets/logos/logomark.svg",
66
+ "src/assets/logos/logo.svg",
67
+ "src/assets/icon.svg",
68
+ "src/assets/logo.svg",
69
+ "src/assets/icon.png",
70
+ "public/favicon.svg"
71
+ ])
72
+
73
+ let masterIconBuffer: Buffer | null = null
74
+ let isSvg = false
75
+ if (masterIconPath && fs.existsSync(masterIconPath)) {
76
+ masterIconBuffer = fs.readFileSync(masterIconPath)
77
+ isSvg = masterIconPath.endsWith(".svg")
78
+ }
79
+
80
+ const bgColor = assetOpts.backgroundColor || "#ffffff"
81
+
82
+ // Inject standard routes
83
+ if (options.robots !== false) {
84
+ const pattern =
85
+ typeof options.robots === "object" && options.robots.path
86
+ ? options.robots.path
87
+ : "/robots.txt"
88
+ injectRoute({
89
+ pattern,
90
+ entrypoint: "@rimelight/seo/routes/robots.txt.ts"
91
+ })
92
+ }
93
+
94
+ if (options.sitemap !== false) {
95
+ const pattern =
96
+ typeof options.sitemap === "object" && options.sitemap.path
97
+ ? options.sitemap.path
98
+ : "/sitemap.xml"
99
+ injectRoute({
100
+ pattern,
101
+ entrypoint: "@rimelight/seo/routes/sitemap.xml.ts"
102
+ })
103
+ }
104
+
105
+ if (options.rss !== false) {
106
+ const pattern =
107
+ typeof options.rss === "object" && options.rss.path ? options.rss.path : "/rss.xml"
108
+ injectRoute({
109
+ pattern,
110
+ entrypoint: "@rimelight/seo/routes/rss.xml.ts"
111
+ })
112
+ }
113
+
114
+ if (options.llms !== false) {
115
+ const pattern =
116
+ typeof options.llms === "object" && options.llms.path ? options.llms.path : "/llms.txt"
117
+ injectRoute({
118
+ pattern,
119
+ entrypoint: "@rimelight/seo/routes/llms.txt.ts"
120
+ })
121
+ }
122
+
123
+ if (options.llmsFull !== false) {
124
+ const pattern =
125
+ typeof options.llmsFull === "object" && options.llmsFull.path
126
+ ? options.llmsFull.path
127
+ : "/llms-full.txt"
128
+ injectRoute({
129
+ pattern,
130
+ entrypoint: "@rimelight/seo/routes/llms-full.txt.ts"
131
+ })
132
+ }
133
+
134
+ if (options.security !== false) {
135
+ const pattern =
136
+ typeof options.security === "object" && options.security.path
137
+ ? options.security.path
138
+ : "/.well-known/security.txt"
139
+ injectRoute({
140
+ pattern,
141
+ entrypoint: "@rimelight/seo/routes/security.txt.ts"
142
+ })
143
+
144
+ const legacyRoute =
145
+ typeof options.security === "object" ? options.security.legacyRoute !== false : true
146
+ if (legacyRoute && pattern === "/.well-known/security.txt") {
147
+ injectRoute({
148
+ pattern: "/security.txt",
149
+ entrypoint: "@rimelight/seo/routes/security.txt.ts"
150
+ })
151
+ }
152
+ }
153
+
154
+ if (options.apiCatalog !== false) {
155
+ const pattern =
156
+ typeof options.apiCatalog === "object" && options.apiCatalog.path
157
+ ? options.apiCatalog.path
158
+ : "/.well-known/api-catalog"
159
+ injectRoute({
160
+ pattern,
161
+ entrypoint: "@rimelight/seo/routes/api-catalog.ts"
162
+ })
163
+ }
164
+
165
+ if (options.manifest !== false) {
166
+ const pattern =
167
+ typeof options.manifest === "object" && options.manifest.path
168
+ ? options.manifest.path
169
+ : "/site.webmanifest"
170
+ injectRoute({
171
+ pattern,
172
+ entrypoint: "@rimelight/seo/routes/site.webmanifest.ts"
173
+ })
174
+ }
175
+
176
+ updateConfig({
177
+ vite: {
178
+ plugins: [
179
+ {
180
+ name: "vite-plugin-rimelight-seo-assets",
181
+ configureServer(server) {
182
+ if (!masterIconBuffer) return
183
+ server.middlewares.use(async (req, res, next) => {
184
+ const url = req.url?.split("?")[0]
185
+ if (url === "/favicon.ico" && assetOpts.faviconIco !== false) {
186
+ const ico = await generateIco(masterIconBuffer, 32)
187
+ res.setHeader("Content-Type", "image/x-icon")
188
+ res.setHeader("Cache-Control", "no-cache")
189
+ res.end(ico)
190
+ return
191
+ }
192
+ if (url === "/apple-touch-icon.png" && assetOpts.appleTouchIcon !== false) {
193
+ const png = await generatePng(masterIconBuffer, { width: 180, height: 180 })
194
+ res.setHeader("Content-Type", "image/png")
195
+ res.setHeader("Cache-Control", "no-cache")
196
+ res.end(png)
197
+ return
198
+ }
199
+ if (url === "/icon-192.png" && assetOpts.icon192 !== false) {
200
+ const png = await generatePng(masterIconBuffer, { width: 192, height: 192 })
201
+ res.setHeader("Content-Type", "image/png")
202
+ res.setHeader("Cache-Control", "no-cache")
203
+ res.end(png)
204
+ return
205
+ }
206
+ if (url === "/icon-512.png" && assetOpts.icon512 !== false) {
207
+ const png = await generatePng(masterIconBuffer, { width: 512, height: 512 })
208
+ res.setHeader("Content-Type", "image/png")
209
+ res.setHeader("Cache-Control", "no-cache")
210
+ res.end(png)
211
+ return
212
+ }
213
+ if (url === "/icon-maskable-512.png" && assetOpts.iconMaskable !== false) {
214
+ const png = await generateMaskablePng(masterIconBuffer, {
215
+ size: 512,
216
+ background: bgColor
217
+ })
218
+ res.setHeader("Content-Type", "image/png")
219
+ res.setHeader("Cache-Control", "no-cache")
220
+ res.end(png)
221
+ return
222
+ }
223
+ if (url === "/favicon.svg" && isSvg) {
224
+ res.setHeader("Content-Type", "image/svg+xml")
225
+ res.setHeader("Cache-Control", "no-cache")
226
+ res.end(masterIconBuffer)
227
+ return
228
+ }
229
+ if (url === "/og.png" && assetOpts.ogImage !== false) {
230
+ const og = await generateOgCard({
231
+ logoBuffer: masterIconBuffer,
232
+ background: bgColor
233
+ })
234
+ res.setHeader("Content-Type", "image/png")
235
+ res.setHeader("Cache-Control", "no-cache")
236
+ res.end(og)
237
+ return
238
+ }
239
+ next()
240
+ })
241
+ },
242
+ async generateBundle() {
243
+ if (options.assets === false || !masterIconBuffer) return
244
+
245
+ if (assetOpts.faviconIco !== false) {
246
+ const ico = await generateIco(masterIconBuffer, 32)
247
+ this.emitFile({ type: "asset", fileName: "favicon.ico", source: ico })
248
+ }
249
+ if (assetOpts.appleTouchIcon !== false) {
250
+ const png = await generatePng(masterIconBuffer, { width: 180, height: 180 })
251
+ this.emitFile({ type: "asset", fileName: "apple-touch-icon.png", source: png })
252
+ }
253
+ if (assetOpts.icon192 !== false) {
254
+ const png = await generatePng(masterIconBuffer, { width: 192, height: 192 })
255
+ this.emitFile({ type: "asset", fileName: "icon-192.png", source: png })
256
+ }
257
+ if (assetOpts.icon512 !== false) {
258
+ const png = await generatePng(masterIconBuffer, { width: 512, height: 512 })
259
+ this.emitFile({ type: "asset", fileName: "icon-512.png", source: png })
260
+ }
261
+ if (assetOpts.iconMaskable !== false) {
262
+ const png = await generateMaskablePng(masterIconBuffer, {
263
+ size: 512,
264
+ background: bgColor
265
+ })
266
+ this.emitFile({
267
+ type: "asset",
268
+ fileName: "icon-maskable-512.png",
269
+ source: png
270
+ })
271
+ }
272
+ if (assetOpts.ogImage !== false) {
273
+ const og = await generateOgCard({
274
+ logoBuffer: masterIconBuffer,
275
+ background: bgColor
276
+ })
277
+ this.emitFile({
278
+ type: "asset",
279
+ fileName: "og.png",
280
+ source: og
281
+ })
282
+ }
283
+ if (isSvg) {
284
+ this.emitFile({
285
+ type: "asset",
286
+ fileName: "favicon.svg",
287
+ source: masterIconBuffer
288
+ })
289
+ }
290
+ }
291
+ },
292
+ {
293
+ name: "vite-plugin-rimelight-seo",
294
+ resolveId(id) {
295
+ if (
296
+ id === "virtual:rimelight-seo-config" ||
297
+ id === "virtual:rimelight-seo/options"
298
+ ) {
299
+ return "\0virtual:rimelight-seo/options"
300
+ }
301
+ if (id === "virtual:rimelight-seo/config") {
302
+ return "\0virtual:rimelight-seo/config"
303
+ }
304
+ if (id === "virtual:rimelight-seo/site-config") {
305
+ return "\0virtual:rimelight-seo/site-config"
306
+ }
307
+ if (id === "virtual:rimelight-seo/db") {
308
+ return "\0virtual:rimelight-seo/db"
309
+ }
310
+ if (id === "virtual:rimelight-seo/corpus") {
311
+ return "\0virtual:rimelight-seo/corpus"
312
+ }
313
+ return null
314
+ },
315
+ load(id) {
316
+ if (id === "\0virtual:rimelight-seo/options") {
317
+ return `export default ${JSON.stringify(options)};`
318
+ }
319
+ if (id === "\0virtual:rimelight-seo/config") {
320
+ if (seoConfigPath) {
321
+ const seoEntriesExport = hasConfigExport("seoEntries")
322
+ const rssEntriesExport = hasConfigExport("rssEntries")
323
+ return `import * as customConfig from ${JSON.stringify(seoConfigPath)};
324
+ export const SEO_LOCALES = customConfig.SEO_LOCALES;
325
+ export const PRIVATE_PATH_PREFIXES = customConfig.PRIVATE_PATH_PREFIXES || [];
326
+ export const seoEntries = ${seoEntriesExport ? "customConfig.seoEntries" : "undefined"};
327
+ export const rssEntries = ${rssEntriesExport ? "customConfig.rssEntries" : "undefined"};
328
+ export default customConfig;`
329
+ }
330
+ return `export const SEO_LOCALES = undefined;
331
+ export const PRIVATE_PATH_PREFIXES = [];
332
+ export const seoEntries = async () => [];
333
+ export const rssEntries = undefined;`
334
+ }
335
+ if (id === "\0virtual:rimelight-seo/site-config") {
336
+ if (siteConfigPath) {
337
+ return `export * from ${JSON.stringify(siteConfigPath)};`
338
+ }
339
+ return `export const siteConfig = { name: "Site", description: "", url: "" };`
340
+ }
341
+ if (id === "\0virtual:rimelight-seo/db") {
342
+ if (dbPath) {
343
+ return `export * from ${JSON.stringify(dbPath)};`
344
+ }
345
+ return `export const db = null;
346
+ export const pages = null;`
347
+ }
348
+ if (id === "\0virtual:rimelight-seo/corpus") {
349
+ if (corpusPath) {
350
+ return `export * from ${JSON.stringify(corpusPath)};`
351
+ }
352
+ return `export const corpusPages = [];`
353
+ }
354
+ return null
355
+ }
356
+ }
357
+ ]
358
+ }
359
+ })
360
+ }
361
+ }
362
+ }
363
+ }
364
+
365
+ 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