next-blogpanel 1.0.0

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 (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +211 -0
  3. package/dist/admin/index.cjs +3062 -0
  4. package/dist/admin/index.cjs.map +1 -0
  5. package/dist/admin/index.d.cts +51 -0
  6. package/dist/admin/index.d.ts +51 -0
  7. package/dist/admin/index.js +3029 -0
  8. package/dist/admin/index.js.map +1 -0
  9. package/dist/api/categories.cjs +82 -0
  10. package/dist/api/categories.cjs.map +1 -0
  11. package/dist/api/categories.d.cts +27 -0
  12. package/dist/api/categories.d.ts +27 -0
  13. package/dist/api/categories.js +77 -0
  14. package/dist/api/categories.js.map +1 -0
  15. package/dist/api/media-file.cjs +46 -0
  16. package/dist/api/media-file.cjs.map +1 -0
  17. package/dist/api/media-file.d.cts +7 -0
  18. package/dist/api/media-file.d.ts +7 -0
  19. package/dist/api/media-file.js +44 -0
  20. package/dist/api/media-file.js.map +1 -0
  21. package/dist/api/media.cjs +180 -0
  22. package/dist/api/media.cjs.map +1 -0
  23. package/dist/api/media.d.cts +22 -0
  24. package/dist/api/media.d.ts +22 -0
  25. package/dist/api/media.js +176 -0
  26. package/dist/api/media.js.map +1 -0
  27. package/dist/api/posts.cjs +103 -0
  28. package/dist/api/posts.cjs.map +1 -0
  29. package/dist/api/posts.d.cts +27 -0
  30. package/dist/api/posts.d.ts +27 -0
  31. package/dist/api/posts.js +98 -0
  32. package/dist/api/posts.js.map +1 -0
  33. package/dist/api/rss.cjs +25 -0
  34. package/dist/api/rss.cjs.map +1 -0
  35. package/dist/api/rss.d.cts +5 -0
  36. package/dist/api/rss.d.ts +5 -0
  37. package/dist/api/rss.js +23 -0
  38. package/dist/api/rss.js.map +1 -0
  39. package/dist/api/settings.cjs +40 -0
  40. package/dist/api/settings.cjs.map +1 -0
  41. package/dist/api/settings.d.cts +17 -0
  42. package/dist/api/settings.d.ts +17 -0
  43. package/dist/api/settings.js +37 -0
  44. package/dist/api/settings.js.map +1 -0
  45. package/dist/api/sitemap.cjs +25 -0
  46. package/dist/api/sitemap.cjs.map +1 -0
  47. package/dist/api/sitemap.d.cts +5 -0
  48. package/dist/api/sitemap.d.ts +5 -0
  49. package/dist/api/sitemap.js +23 -0
  50. package/dist/api/sitemap.js.map +1 -0
  51. package/dist/api/tokens.cjs +56 -0
  52. package/dist/api/tokens.cjs.map +1 -0
  53. package/dist/api/tokens.d.cts +22 -0
  54. package/dist/api/tokens.d.ts +22 -0
  55. package/dist/api/tokens.js +52 -0
  56. package/dist/api/tokens.js.map +1 -0
  57. package/dist/chunk-2M46MMWN.cjs +76 -0
  58. package/dist/chunk-2M46MMWN.cjs.map +1 -0
  59. package/dist/chunk-4GK22Q3R.cjs +229 -0
  60. package/dist/chunk-4GK22Q3R.cjs.map +1 -0
  61. package/dist/chunk-6PZ772NQ.js +595 -0
  62. package/dist/chunk-6PZ772NQ.js.map +1 -0
  63. package/dist/chunk-BZXPRGLN.cjs +351 -0
  64. package/dist/chunk-BZXPRGLN.cjs.map +1 -0
  65. package/dist/chunk-DAX5C6SC.cjs +86 -0
  66. package/dist/chunk-DAX5C6SC.cjs.map +1 -0
  67. package/dist/chunk-DWNMHQ4I.cjs +57 -0
  68. package/dist/chunk-DWNMHQ4I.cjs.map +1 -0
  69. package/dist/chunk-FPGVR2BX.cjs +121 -0
  70. package/dist/chunk-FPGVR2BX.cjs.map +1 -0
  71. package/dist/chunk-JBP3G4QJ.js +50 -0
  72. package/dist/chunk-JBP3G4QJ.js.map +1 -0
  73. package/dist/chunk-JDP4DIHI.js +215 -0
  74. package/dist/chunk-JDP4DIHI.js.map +1 -0
  75. package/dist/chunk-LIZOVA4W.js +118 -0
  76. package/dist/chunk-LIZOVA4W.js.map +1 -0
  77. package/dist/chunk-QLBTOCT3.js +118 -0
  78. package/dist/chunk-QLBTOCT3.js.map +1 -0
  79. package/dist/chunk-UI2KDZ5H.js +344 -0
  80. package/dist/chunk-UI2KDZ5H.js.map +1 -0
  81. package/dist/chunk-V7QPRX4A.cjs +125 -0
  82. package/dist/chunk-V7QPRX4A.cjs.map +1 -0
  83. package/dist/chunk-VEJFN65Y.js +82 -0
  84. package/dist/chunk-VEJFN65Y.js.map +1 -0
  85. package/dist/chunk-WYZFAI6O.js +74 -0
  86. package/dist/chunk-WYZFAI6O.js.map +1 -0
  87. package/dist/chunk-XLYTATRQ.cjs +637 -0
  88. package/dist/chunk-XLYTATRQ.cjs.map +1 -0
  89. package/dist/cli/index.cjs +2036 -0
  90. package/dist/components/index.cjs +647 -0
  91. package/dist/components/index.cjs.map +1 -0
  92. package/dist/components/index.d.cts +187 -0
  93. package/dist/components/index.d.ts +187 -0
  94. package/dist/components/index.js +633 -0
  95. package/dist/components/index.js.map +1 -0
  96. package/dist/db-M3KXLBUP.js +3 -0
  97. package/dist/db-M3KXLBUP.js.map +1 -0
  98. package/dist/db-TDCZNY3Q.cjs +112 -0
  99. package/dist/db-TDCZNY3Q.cjs.map +1 -0
  100. package/dist/editor/index.cjs +1126 -0
  101. package/dist/editor/index.cjs.map +1 -0
  102. package/dist/editor/index.d.cts +137 -0
  103. package/dist/editor/index.d.ts +137 -0
  104. package/dist/editor/index.js +1093 -0
  105. package/dist/editor/index.js.map +1 -0
  106. package/dist/index-BcNka7gK.d.cts +475 -0
  107. package/dist/index-DPRgaNEE.d.ts +475 -0
  108. package/dist/index.cjs +433 -0
  109. package/dist/index.cjs.map +1 -0
  110. package/dist/index.d.cts +27 -0
  111. package/dist/index.d.ts +27 -0
  112. package/dist/index.js +209 -0
  113. package/dist/index.js.map +1 -0
  114. package/dist/lib/index.cjs +193 -0
  115. package/dist/lib/index.cjs.map +1 -0
  116. package/dist/lib/index.d.cts +4 -0
  117. package/dist/lib/index.d.ts +4 -0
  118. package/dist/lib/index.js +8 -0
  119. package/dist/lib/index.js.map +1 -0
  120. package/dist/styles/admin.css +667 -0
  121. package/dist/styles/blog.css +851 -0
  122. package/dist/styles/editor.css +452 -0
  123. package/dist/styles/globals.css +270 -0
  124. package/dist/styles/prose.css +299 -0
  125. package/dist/types-CgrBuig-.d.cts +778 -0
  126. package/dist/types-CgrBuig-.d.ts +778 -0
  127. package/package.json +140 -0
@@ -0,0 +1,118 @@
1
+ import { rollbackUploads } from './chunk-JDP4DIHI.js';
2
+ import { getConfig } from './chunk-6PZ772NQ.js';
3
+
4
+ // src/lib/image.ts
5
+ var RESPONSIVE_SIZES = [640, 768, 1024, 1280, 1920];
6
+ var QUALITY_STEPS = [85, 75, 65, 55];
7
+ var DIMENSION_CAPS = [null, 2560, 1920];
8
+ var ImageTooLargeError = class extends Error {
9
+ constructor(achievedBytes, limitBytes) {
10
+ super(
11
+ `Could not compress image below ${limitBytes} bytes (best achieved: ${achievedBytes})`
12
+ );
13
+ this.achievedBytes = achievedBytes;
14
+ this.limitBytes = limitBytes;
15
+ this.name = "ImageTooLargeError";
16
+ }
17
+ };
18
+ async function compressToLimit(file, limitBytes) {
19
+ const sharp = (await import('sharp')).default;
20
+ let best = Number.POSITIVE_INFINITY;
21
+ for (const cap of DIMENSION_CAPS) {
22
+ for (const quality of QUALITY_STEPS) {
23
+ let pipe = sharp(file);
24
+ if (cap !== null) {
25
+ pipe = pipe.resize(cap, null, { withoutEnlargement: true });
26
+ }
27
+ const out = await pipe.webp({ quality }).toBuffer();
28
+ if (out.length <= limitBytes) return out;
29
+ best = Math.min(best, out.length);
30
+ }
31
+ }
32
+ throw new ImageTooLargeError(best, limitBytes);
33
+ }
34
+ async function processImage(file, filename, storage) {
35
+ let sharp;
36
+ try {
37
+ sharp = (await import('sharp')).default;
38
+ } catch {
39
+ const result = await storage.upload(file, filename, getMimeType(filename));
40
+ return {
41
+ original: result,
42
+ width: 0,
43
+ height: 0,
44
+ format: getExtension(filename),
45
+ uploadedKeys: [result.key]
46
+ };
47
+ }
48
+ const image = sharp(file);
49
+ const metadata = await image.metadata();
50
+ const { maxImageSize } = getConfig().editor;
51
+ const webpBuffer = await compressToLimit(file, maxImageSize);
52
+ const webpFilename = filename.replace(/\.[^.]+$/, ".webp");
53
+ const original = await storage.upload(webpBuffer, webpFilename, "image/webp");
54
+ let derivativeKeys;
55
+ try {
56
+ derivativeKeys = await generateResponsiveSizes(file, webpFilename, storage, metadata.width || 0);
57
+ } catch (error) {
58
+ await rollbackUploads(storage, [original.key], "derivative generation failed");
59
+ throw error;
60
+ }
61
+ return {
62
+ original,
63
+ width: metadata.width || 0,
64
+ height: metadata.height || 0,
65
+ format: "webp",
66
+ uploadedKeys: [original.key, ...derivativeKeys]
67
+ };
68
+ }
69
+ async function generateResponsiveSizes(file, filename, storage, originalWidth) {
70
+ const sharp = (await import('sharp')).default;
71
+ const sizes = RESPONSIVE_SIZES.filter((s) => s < originalWidth);
72
+ const uploadSize = async (width) => {
73
+ const resized = await sharp(file).resize(width).webp({ quality: 80 }).toBuffer();
74
+ const sizedFilename = filename.replace(".webp", `-${width}w.webp`);
75
+ return storage.upload(resized, sizedFilename, "image/webp");
76
+ };
77
+ const uploadThumbnail = async () => {
78
+ const thumb = await sharp(file).resize(200, 200, { fit: "cover" }).webp({ quality: 70 }).toBuffer();
79
+ const thumbFilename = filename.replace(".webp", "-thumb.webp");
80
+ return storage.upload(thumb, thumbFilename, "image/webp");
81
+ };
82
+ const results = await Promise.allSettled([...sizes.map(uploadSize), uploadThumbnail()]);
83
+ const failures = results.filter(
84
+ (r) => r.status === "rejected"
85
+ );
86
+ if (failures.length > 0) {
87
+ const succeeded = results.filter(
88
+ (r) => r.status === "fulfilled"
89
+ );
90
+ await rollbackUploads(
91
+ storage,
92
+ succeeded.map((r) => r.value.key),
93
+ "derivative generation failed"
94
+ );
95
+ throw failures[0].reason;
96
+ }
97
+ return results.map((r) => r.value.key);
98
+ }
99
+ function getMimeType(filename) {
100
+ const ext = getExtension(filename);
101
+ const mimeTypes = {
102
+ jpg: "image/jpeg",
103
+ jpeg: "image/jpeg",
104
+ png: "image/png",
105
+ gif: "image/gif",
106
+ webp: "image/webp",
107
+ svg: "image/svg+xml",
108
+ avif: "image/avif"
109
+ };
110
+ return mimeTypes[ext] || "application/octet-stream";
111
+ }
112
+ function getExtension(filename) {
113
+ return filename.split(".").pop()?.toLowerCase() || "";
114
+ }
115
+
116
+ export { ImageTooLargeError, processImage };
117
+ //# sourceMappingURL=chunk-LIZOVA4W.js.map
118
+ //# sourceMappingURL=chunk-LIZOVA4W.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/image.ts"],"names":[],"mappings":"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","file":"chunk-LIZOVA4W.js","sourcesContent":["import type { StorageProvider, MediaUploadResult } from './storage';\nimport { rollbackUploads } from './storage';\nimport { getConfig } from './config';\n\nexport interface ProcessedImage {\n original: MediaUploadResult;\n width: number;\n height: number;\n format: string;\n /**\n * Every storage key this call wrote — the original plus every responsive\n * derivative and the thumbnail. A caller that persists a reference\n * afterward (e.g. createMedia) and then fails must roll all of these back,\n * since none of them would otherwise be reachable through the UI.\n */\n uploadedKeys: string[];\n}\n\nconst RESPONSIVE_SIZES = [640, 768, 1024, 1280, 1920];\n\nconst QUALITY_STEPS = [85, 75, 65, 55];\nconst DIMENSION_CAPS: (number | null)[] = [null, 2560, 1920];\n\nexport class ImageTooLargeError extends Error {\n constructor(\n public readonly achievedBytes: number,\n public readonly limitBytes: number\n ) {\n super(\n `Could not compress image below ${limitBytes} bytes (best achieved: ${achievedBytes})`\n );\n this.name = 'ImageTooLargeError';\n }\n}\n\n/**\n * Encodes to WebP, degrading quality then dimensions until the result fits\n * limitBytes. Returns on the first success, so a normal image costs one encode.\n * Worst case is 12 encodes before ImageTooLargeError.\n */\nexport async function compressToLimit(file: Buffer, limitBytes: number): Promise<Buffer> {\n const sharp = (await import('sharp')).default;\n let best = Number.POSITIVE_INFINITY;\n\n for (const cap of DIMENSION_CAPS) {\n for (const quality of QUALITY_STEPS) {\n let pipe = sharp(file);\n if (cap !== null) {\n pipe = pipe.resize(cap, null, { withoutEnlargement: true });\n }\n const out = await pipe.webp({ quality }).toBuffer();\n if (out.length <= limitBytes) return out;\n best = Math.min(best, out.length);\n }\n }\n\n throw new ImageTooLargeError(best, limitBytes);\n}\n\nexport async function processImage(\n file: Buffer,\n filename: string,\n storage: StorageProvider\n): Promise<ProcessedImage> {\n let sharp: typeof import('sharp');\n try {\n sharp = (await import('sharp')).default;\n } catch {\n // sharp not available — upload raw file\n const result = await storage.upload(file, filename, getMimeType(filename));\n return {\n original: result,\n width: 0,\n height: 0,\n format: getExtension(filename),\n uploadedKeys: [result.key],\n };\n }\n\n const image = sharp(file);\n const metadata = await image.metadata();\n\n const { maxImageSize } = getConfig().editor;\n const webpBuffer = await compressToLimit(file, maxImageSize);\n const webpFilename = filename.replace(/\\.[^.]+$/, '.webp');\n\n const original = await storage.upload(webpBuffer, webpFilename, 'image/webp');\n\n // Awaited, not fire-and-forget: serverless runtimes may freeze the process\n // once the response is returned, which would silently drop these writes and\n // leave the media library showing broken thumbnails.\n //\n // A consequence of awaiting: a derivative failure now surfaces here, after\n // `original` has already landed in storage but before the caller has had a\n // chance to record it anywhere. Left alone, that upload becomes an orphan —\n // it counts against quota, and nothing (not even this module) has a\n // reference to it once the exception unwinds past this function.\n //\n // We choose atomicity over partial success: on any derivative failure, undo\n // the original upload (best-effort) and rethrow, so a failed processImage()\n // call never leaves storage holding an object nothing points to. The\n // alternative — record the DB row before generating derivatives — was\n // rejected because it trades an invisible-but-harmless storage orphan for a\n // *visible*, permanently broken media row (missing responsive sizes/\n // thumbnail), which is worse: it ships silently to the media library and to\n // any post that references it, whereas atomic failure just asks the user to\n // retry the upload.\n let derivativeKeys: string[];\n try {\n derivativeKeys = await generateResponsiveSizes(file, webpFilename, storage, metadata.width || 0);\n } catch (error) {\n await rollbackUploads(storage, [original.key], 'derivative generation failed');\n throw error;\n }\n\n return {\n original,\n width: metadata.width || 0,\n height: metadata.height || 0,\n format: 'webp',\n uploadedKeys: [original.key, ...derivativeKeys],\n };\n}\n\nasync function generateResponsiveSizes(\n file: Buffer,\n filename: string,\n storage: StorageProvider,\n originalWidth: number\n): Promise<string[]> {\n const sharp = (await import('sharp')).default;\n\n const sizes = RESPONSIVE_SIZES.filter((s) => s < originalWidth);\n\n const uploadSize = async (width: number): Promise<MediaUploadResult> => {\n const resized = await sharp(file).resize(width).webp({ quality: 80 }).toBuffer();\n const sizedFilename = filename.replace('.webp', `-${width}w.webp`);\n return storage.upload(resized, sizedFilename, 'image/webp');\n };\n\n const uploadThumbnail = async (): Promise<MediaUploadResult> => {\n const thumb = await sharp(file)\n .resize(200, 200, { fit: 'cover' })\n .webp({ quality: 70 })\n .toBuffer();\n const thumbFilename = filename.replace('.webp', '-thumb.webp');\n return storage.upload(thumb, thumbFilename, 'image/webp');\n };\n\n // allSettled (not all) so every derivative attempt runs to completion before\n // we decide pass/fail — otherwise a fast rejection could resolve this\n // function's promise while sibling uploads are still in flight, and any\n // that land afterward would themselves become untracked orphans.\n const results = await Promise.allSettled([...sizes.map(uploadSize), uploadThumbnail()]);\n\n const failures = results.filter(\n (r): r is PromiseRejectedResult => r.status === 'rejected'\n );\n\n if (failures.length > 0) {\n const succeeded = results.filter(\n (r): r is PromiseFulfilledResult<MediaUploadResult> => r.status === 'fulfilled'\n );\n // Best-effort: undo whichever derivatives did land before the failure.\n await rollbackUploads(\n storage,\n succeeded.map((r) => r.value.key),\n 'derivative generation failed'\n );\n throw failures[0].reason;\n }\n\n return (results as PromiseFulfilledResult<MediaUploadResult>[]).map((r) => r.value.key);\n}\n\nfunction getMimeType(filename: string): string {\n const ext = getExtension(filename);\n const mimeTypes: Record<string, string> = {\n jpg: 'image/jpeg',\n jpeg: 'image/jpeg',\n png: 'image/png',\n gif: 'image/gif',\n webp: 'image/webp',\n svg: 'image/svg+xml',\n avif: 'image/avif',\n };\n return mimeTypes[ext] || 'application/octet-stream';\n}\n\nfunction getExtension(filename: string): string {\n return filename.split('.').pop()?.toLowerCase() || '';\n}\n"]}
@@ -0,0 +1,118 @@
1
+ import { z } from 'zod';
2
+
3
+ // src/lib/types.ts
4
+ var mediaUrlSchema = z.string().min(1).refine(
5
+ (value) => /^https?:\/\//i.test(value) || value.startsWith("/") && !value.startsWith("//"),
6
+ {
7
+ message: 'must be an http:// or https:// URL, or a root-relative path beginning with a single "/"'
8
+ }
9
+ );
10
+ z.enum([
11
+ "paragraph",
12
+ "heading",
13
+ "image",
14
+ "codeBlock",
15
+ "blockquote",
16
+ "bulletList",
17
+ "orderedList",
18
+ "taskList",
19
+ "table",
20
+ "embed",
21
+ "horizontalRule",
22
+ "callout",
23
+ "tableOfContents",
24
+ "faq",
25
+ "html"
26
+ ]);
27
+ z.object({
28
+ filename: z.string(),
29
+ originalName: z.string(),
30
+ mimeType: z.string(),
31
+ size: z.number(),
32
+ width: z.number().optional(),
33
+ height: z.number().optional(),
34
+ storageKey: z.string(),
35
+ // Widened from z.enum(['gridfs','r2']) so a registered custom backend can
36
+ // record itself here. Purely additive: 'gridfs' and 'r2' still validate,
37
+ // and no stored document changes shape, so there is nothing to migrate.
38
+ // Non-empty is still enforced — an empty provider is unresolvable, and
39
+ // DELETE would have to fall back to the configured backend to act on it.
40
+ provider: z.string().min(1),
41
+ url: mediaUrlSchema,
42
+ alt: z.string().optional(),
43
+ caption: z.string().optional(),
44
+ createdAt: z.date()
45
+ });
46
+ var AuthorSchema = z.object({
47
+ name: z.string().min(1),
48
+ avatar: mediaUrlSchema.optional(),
49
+ bio: z.string().optional(),
50
+ url: z.string().url().optional()
51
+ });
52
+ var PostSEOSchema = z.object({
53
+ metaTitle: z.string().optional(),
54
+ metaDescription: z.string().optional(),
55
+ canonicalUrl: z.string().url().optional(),
56
+ ogImage: mediaUrlSchema.optional(),
57
+ ogType: z.string().default("article"),
58
+ noIndex: z.boolean().default(false),
59
+ structuredData: z.record(z.unknown()).optional(),
60
+ focusKeyword: z.string().optional()
61
+ });
62
+ var PostStatusSchema = z.enum(["draft", "published", "scheduled", "archived"]);
63
+ var CreatePostSchema = z.object({
64
+ title: z.string().min(1, "Title is required"),
65
+ slug: z.string().optional(),
66
+ excerpt: z.string().optional(),
67
+ content: z.any(),
68
+ // BlockContent[] — validated structurally
69
+ contentHTML: z.string().optional(),
70
+ contentText: z.string().optional(),
71
+ coverImage: z.object({
72
+ _id: z.string(),
73
+ url: mediaUrlSchema,
74
+ alt: z.string().optional(),
75
+ caption: z.string().optional(),
76
+ width: z.number().optional(),
77
+ height: z.number().optional()
78
+ }).optional(),
79
+ categories: z.array(z.string()).default([]),
80
+ tags: z.array(z.string()).default([]),
81
+ author: AuthorSchema.optional(),
82
+ seo: PostSEOSchema.optional(),
83
+ status: PostStatusSchema.default("draft"),
84
+ publishedAt: z.coerce.date().optional(),
85
+ scheduledAt: z.coerce.date().optional()
86
+ });
87
+ var UpdatePostSchema = CreatePostSchema.partial();
88
+ var CreateCategorySchema = z.object({
89
+ name: z.string().min(1, "Category name is required"),
90
+ slug: z.string().optional(),
91
+ description: z.string().optional(),
92
+ seo: z.object({
93
+ metaTitle: z.string().optional(),
94
+ metaDescription: z.string().optional()
95
+ }).optional(),
96
+ order: z.number().default(0),
97
+ parentId: z.string().optional()
98
+ });
99
+ var UpdateCategorySchema = CreateCategorySchema.partial();
100
+ var BlogSettingsSchema = z.object({
101
+ postsPerPage: z.number().min(1).max(100).default(10),
102
+ defaultAuthor: AuthorSchema.optional(),
103
+ defaultOgImage: mediaUrlSchema.optional(),
104
+ commentSystem: z.enum(["none", "giscus", "disqus"]).default("none"),
105
+ commentConfig: z.record(z.unknown()).optional(),
106
+ customCSS: z.string().optional(),
107
+ analytics: z.object({
108
+ gaId: z.string().optional(),
109
+ plausibleDomain: z.string().optional()
110
+ }).optional()
111
+ });
112
+ var CreateApiTokenSchema = z.object({
113
+ name: z.string().min(1, "Token name is required").max(100)
114
+ });
115
+
116
+ export { BlogSettingsSchema, CreateApiTokenSchema, CreateCategorySchema, CreatePostSchema, UpdateCategorySchema, UpdatePostSchema };
117
+ //# sourceMappingURL=chunk-QLBTOCT3.js.map
118
+ //# sourceMappingURL=chunk-QLBTOCT3.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/lib/types.ts"],"names":[],"mappings":";;;AA6BA,IAAM,iBAAiB,CAAA,CACpB,MAAA,EAAO,CACP,GAAA,CAAI,CAAC,CAAA,CACL,MAAA;AAAA,EACC,CAAC,KAAA,KAAU,eAAA,CAAgB,IAAA,CAAK,KAAK,CAAA,IAAM,KAAA,CAAM,UAAA,CAAW,GAAG,CAAA,IAAK,CAAC,KAAA,CAAM,WAAW,IAAI,CAAA;AAAA,EAC1F;AAAA,IACE,OAAA,EAAS;AAAA;AAEb,CAAA;AAM6B,EAAE,IAAA,CAAK;AAAA,EACpC,WAAA;AAAA,EACA,SAAA;AAAA,EACA,OAAA;AAAA,EACA,WAAA;AAAA,EACA,YAAA;AAAA,EACA,YAAA;AAAA,EACA,aAAA;AAAA,EACA,UAAA;AAAA,EACA,OAAA;AAAA,EACA,OAAA;AAAA,EACA,gBAAA;AAAA,EACA,SAAA;AAAA,EACA,iBAAA;AAAA,EACA,KAAA;AAAA,EACA;AACF,CAAC;AAyB0B,EAAE,MAAA,CAAO;AAAA,EAClC,QAAA,EAAU,EAAE,MAAA,EAAO;AAAA,EACnB,YAAA,EAAc,EAAE,MAAA,EAAO;AAAA,EACvB,QAAA,EAAU,EAAE,MAAA,EAAO;AAAA,EACnB,IAAA,EAAM,EAAE,MAAA,EAAO;AAAA,EACf,KAAA,EAAO,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EAC3B,MAAA,EAAQ,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EAC5B,UAAA,EAAY,EAAE,MAAA,EAAO;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMrB,QAAA,EAAU,CAAA,CAAE,MAAA,EAAO,CAAE,IAAI,CAAC,CAAA;AAAA,EAC1B,GAAA,EAAK,cAAA;AAAA,EACL,GAAA,EAAK,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EACzB,OAAA,EAAS,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EAC7B,SAAA,EAAW,EAAE,IAAA;AACf,CAAC;AA6BM,IAAM,YAAA,GAAe,EAAE,MAAA,CAAO;AAAA,EACnC,IAAA,EAAM,CAAA,CAAE,MAAA,EAAO,CAAE,IAAI,CAAC,CAAA;AAAA,EACtB,MAAA,EAAQ,eAAe,QAAA,EAAS;AAAA,EAChC,GAAA,EAAK,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EACzB,KAAK,CAAA,CAAE,MAAA,EAAO,CAAE,GAAA,GAAM,QAAA;AACxB,CAAC,CAAA;AAaM,IAAM,aAAA,GAAgB,EAAE,MAAA,CAAO;AAAA,EACpC,SAAA,EAAW,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EAC/B,eAAA,EAAiB,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EACrC,cAAc,CAAA,CAAE,MAAA,EAAO,CAAE,GAAA,GAAM,QAAA,EAAS;AAAA,EACxC,OAAA,EAAS,eAAe,QAAA,EAAS;AAAA,EACjC,MAAA,EAAQ,CAAA,CAAE,MAAA,EAAO,CAAE,QAAQ,SAAS,CAAA;AAAA,EACpC,OAAA,EAAS,CAAA,CAAE,OAAA,EAAQ,CAAE,QAAQ,KAAK,CAAA;AAAA,EAClC,gBAAgB,CAAA,CAAE,MAAA,CAAO,EAAE,OAAA,EAAS,EAAE,QAAA,EAAS;AAAA,EAC/C,YAAA,EAAc,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA;AAC3B,CAAC,CAAA;AA6BM,IAAM,gBAAA,GAAmB,EAAE,IAAA,CAAK,CAAC,SAAS,WAAA,EAAa,WAAA,EAAa,UAAU,CAAC,CAAA;AAG/E,IAAM,gBAAA,GAAmB,EAAE,MAAA,CAAO;AAAA,EACvC,OAAO,CAAA,CAAE,MAAA,EAAO,CAAE,GAAA,CAAI,GAAG,mBAAmB,CAAA;AAAA,EAC5C,IAAA,EAAM,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EAC1B,OAAA,EAAS,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EAC7B,OAAA,EAAS,EAAE,GAAA,EAAI;AAAA;AAAA,EACf,WAAA,EAAa,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EACjC,WAAA,EAAa,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EACjC,UAAA,EAAY,EACT,MAAA,CAAO;AAAA,IACN,GAAA,EAAK,EAAE,MAAA,EAAO;AAAA,IACd,GAAA,EAAK,cAAA;AAAA,IACL,GAAA,EAAK,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,IACzB,OAAA,EAAS,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,IAC7B,KAAA,EAAO,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,IAC3B,MAAA,EAAQ,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA;AAAS,GAC7B,EACA,QAAA,EAAS;AAAA,EACZ,UAAA,EAAY,EAAE,KAAA,CAAM,CAAA,CAAE,QAAQ,CAAA,CAAE,OAAA,CAAQ,EAAE,CAAA;AAAA,EAC1C,IAAA,EAAM,EAAE,KAAA,CAAM,CAAA,CAAE,QAAQ,CAAA,CAAE,OAAA,CAAQ,EAAE,CAAA;AAAA,EACpC,MAAA,EAAQ,aAAa,QAAA,EAAS;AAAA,EAC9B,GAAA,EAAK,cAAc,QAAA,EAAS;AAAA,EAC5B,MAAA,EAAQ,gBAAA,CAAiB,OAAA,CAAQ,OAAO,CAAA;AAAA,EACxC,WAAA,EAAa,CAAA,CAAE,MAAA,CAAO,IAAA,GAAO,QAAA,EAAS;AAAA,EACtC,WAAA,EAAa,CAAA,CAAE,MAAA,CAAO,IAAA,GAAO,QAAA;AAC/B,CAAC;AAEM,IAAM,gBAAA,GAAmB,iBAAiB,OAAA;AAiC1C,IAAM,oBAAA,GAAuB,EAAE,MAAA,CAAO;AAAA,EAC3C,MAAM,CAAA,CAAE,MAAA,EAAO,CAAE,GAAA,CAAI,GAAG,2BAA2B,CAAA;AAAA,EACnD,IAAA,EAAM,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EAC1B,WAAA,EAAa,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EACjC,GAAA,EAAK,EACF,MAAA,CAAO;AAAA,IACN,SAAA,EAAW,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,IAC/B,eAAA,EAAiB,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA;AAAS,GACtC,EACA,QAAA,EAAS;AAAA,EACZ,KAAA,EAAO,CAAA,CAAE,MAAA,EAAO,CAAE,QAAQ,CAAC,CAAA;AAAA,EAC3B,QAAA,EAAU,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA;AACvB,CAAC;AAEM,IAAM,oBAAA,GAAuB,qBAAqB,OAAA;AAuBlD,IAAM,kBAAA,GAAqB,EAAE,MAAA,CAAO;AAAA,EACzC,YAAA,EAAc,CAAA,CAAE,MAAA,EAAO,CAAE,GAAA,CAAI,CAAC,CAAA,CAAE,GAAA,CAAI,GAAG,CAAA,CAAE,OAAA,CAAQ,EAAE,CAAA;AAAA,EACnD,aAAA,EAAe,aAAa,QAAA,EAAS;AAAA,EACrC,cAAA,EAAgB,eAAe,QAAA,EAAS;AAAA,EACxC,aAAA,EAAe,CAAA,CAAE,IAAA,CAAK,CAAC,MAAA,EAAQ,UAAU,QAAQ,CAAC,CAAA,CAAE,OAAA,CAAQ,MAAM,CAAA;AAAA,EAClE,eAAe,CAAA,CAAE,MAAA,CAAO,EAAE,OAAA,EAAS,EAAE,QAAA,EAAS;AAAA,EAC9C,SAAA,EAAW,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,EAC/B,SAAA,EAAW,EACR,MAAA,CAAO;AAAA,IACN,IAAA,EAAM,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA,EAAS;AAAA,IAC1B,eAAA,EAAiB,CAAA,CAAE,MAAA,EAAO,CAAE,QAAA;AAAS,GACtC,EACA,QAAA;AACL,CAAC;AAoBM,IAAM,oBAAA,GAAuB,EAAE,MAAA,CAAO;AAAA,EAC3C,IAAA,EAAM,EAAE,MAAA,EAAO,CAAE,IAAI,CAAA,EAAG,wBAAwB,CAAA,CAAE,GAAA,CAAI,GAAG;AAC3D,CAAC","file":"chunk-QLBTOCT3.js","sourcesContent":["import { z } from 'zod';\nimport type { ObjectId } from 'mongodb';\n\n/**\n * A URL that points at an image this install stores.\n *\n * Not `z.string().url()`: GridFS is the default backend and it returns\n * root-relative URLs (`/api/blog/media/file/…`), which `.url()` rejects. With\n * the strict validator, an image uploaded through the media library could not\n * be attached as a cover image, an `ogImage`, or an avatar on a default\n * install — only R2 URLs got through. Both shapes are accepted here and the\n * consumers that require an absolute URL (Open Graph, JSON-LD, RSS) prefix\n * `BLOGPANEL_SITE_URL` at the point of use.\n *\n * Deliberately narrower than `isAbsoluteUrl()` from `./url`, which is right\n * for its own job (recognising a value that is *already* resolvable, whatever\n * its scheme, so it can be passed through untouched) but wrong for this one:\n * this schema validates a value a user is submitting to be *stored* as a\n * cover image, `ogImage`, or avatar and later rendered into `<img src>`,\n * `og:image`, and JSON-LD. `isAbsoluteUrl()` also treats protocol-relative\n * `//host/path` as already-absolute, and any `scheme:` — including\n * `javascript:` and `data:`, which `z.string().url()` accepted before this\n * release too, via `new URL()` — as acceptable. Accepting `//evil.example.com/x`\n * here would let an off-origin image be set as `og:image` or a post's cover\n * image with no scheme ever pinned down: the browser or crawler fills in\n * whichever scheme the current page happens to be on. So this schema\n * allowlists `http:`/`https:` plus a genuinely root-relative path — a single\n * leading `/`, not `//` — instead.\n */\nconst mediaUrlSchema = z\n .string()\n .min(1)\n .refine(\n (value) => /^https?:\\/\\//i.test(value) || (value.startsWith('/') && !value.startsWith('//')),\n {\n message: 'must be an http:// or https:// URL, or a root-relative path beginning with a single \"/\"',\n }\n );\n\n// ============================================================\n// Block Editor Types\n// ============================================================\n\nexport const BlockTypeSchema = z.enum([\n 'paragraph',\n 'heading',\n 'image',\n 'codeBlock',\n 'blockquote',\n 'bulletList',\n 'orderedList',\n 'taskList',\n 'table',\n 'embed',\n 'horizontalRule',\n 'callout',\n 'tableOfContents',\n 'faq',\n 'html',\n]);\n\nexport type BlockType = z.infer<typeof BlockTypeSchema>;\n\nexport interface BlockContent {\n type: string;\n attrs?: Record<string, unknown>;\n content?: BlockContent[];\n text?: string;\n marks?: { type: string; attrs?: Record<string, unknown> }[];\n}\n\n// ============================================================\n// Media\n// ============================================================\n\nexport interface MediaReference {\n _id: string;\n url: string;\n alt?: string;\n caption?: string;\n width?: number;\n height?: number;\n}\n\nexport const MediaSchema = z.object({\n filename: z.string(),\n originalName: z.string(),\n mimeType: z.string(),\n size: z.number(),\n width: z.number().optional(),\n height: z.number().optional(),\n storageKey: z.string(),\n // Widened from z.enum(['gridfs','r2']) so a registered custom backend can\n // record itself here. Purely additive: 'gridfs' and 'r2' still validate,\n // and no stored document changes shape, so there is nothing to migrate.\n // Non-empty is still enforced — an empty provider is unresolvable, and\n // DELETE would have to fall back to the configured backend to act on it.\n provider: z.string().min(1),\n url: mediaUrlSchema,\n alt: z.string().optional(),\n caption: z.string().optional(),\n createdAt: z.date(),\n});\n\nexport interface Media {\n _id: ObjectId;\n filename: string;\n originalName: string;\n mimeType: string;\n size: number;\n width?: number;\n height?: number;\n storageKey: string;\n /**\n * Name of the backend that actually holds this object — `'gridfs'`, `'r2'`,\n * or any name passed to `registerStorageProvider()`. Read back by DELETE to\n * route the removal to the right backend rather than to whichever one is\n * configured today. A `string`, not a union of the built-ins: a third-party\n * backend that cannot be recorded here cannot have its objects deleted.\n */\n provider: string;\n url: string;\n alt?: string;\n caption?: string;\n createdAt: Date;\n}\n\n// ============================================================\n// Author\n// ============================================================\n\nexport const AuthorSchema = z.object({\n name: z.string().min(1),\n avatar: mediaUrlSchema.optional(),\n bio: z.string().optional(),\n url: z.string().url().optional(),\n});\n\nexport interface Author {\n name: string;\n avatar?: string;\n bio?: string;\n url?: string;\n}\n\n// ============================================================\n// SEO\n// ============================================================\n\nexport const PostSEOSchema = z.object({\n metaTitle: z.string().optional(),\n metaDescription: z.string().optional(),\n canonicalUrl: z.string().url().optional(),\n ogImage: mediaUrlSchema.optional(),\n ogType: z.string().default('article'),\n noIndex: z.boolean().default(false),\n structuredData: z.record(z.unknown()).optional(),\n focusKeyword: z.string().optional(),\n});\n\nexport interface PostSEO {\n metaTitle?: string;\n metaDescription?: string;\n canonicalUrl?: string;\n ogImage?: string;\n ogType: string;\n noIndex: boolean;\n structuredData?: Record<string, unknown>;\n focusKeyword?: string;\n}\n\n// ============================================================\n// Revision\n// ============================================================\n\nexport interface Revision {\n version: number;\n title: string;\n content: BlockContent[];\n contentHTML: string;\n savedAt: Date;\n}\n\n// ============================================================\n// Post\n// ============================================================\n\nexport const PostStatusSchema = z.enum(['draft', 'published', 'scheduled', 'archived']);\nexport type PostStatus = z.infer<typeof PostStatusSchema>;\n\nexport const CreatePostSchema = z.object({\n title: z.string().min(1, 'Title is required'),\n slug: z.string().optional(),\n excerpt: z.string().optional(),\n content: z.any(), // BlockContent[] — validated structurally\n contentHTML: z.string().optional(),\n contentText: z.string().optional(),\n coverImage: z\n .object({\n _id: z.string(),\n url: mediaUrlSchema,\n alt: z.string().optional(),\n caption: z.string().optional(),\n width: z.number().optional(),\n height: z.number().optional(),\n })\n .optional(),\n categories: z.array(z.string()).default([]),\n tags: z.array(z.string()).default([]),\n author: AuthorSchema.optional(),\n seo: PostSEOSchema.optional(),\n status: PostStatusSchema.default('draft'),\n publishedAt: z.coerce.date().optional(),\n scheduledAt: z.coerce.date().optional(),\n});\n\nexport const UpdatePostSchema = CreatePostSchema.partial();\n\nexport type CreatePostInput = z.infer<typeof CreatePostSchema>;\nexport type UpdatePostInput = z.infer<typeof UpdatePostSchema>;\n\nexport interface BlogPost {\n _id: ObjectId;\n title: string;\n slug: string;\n excerpt: string;\n content: BlockContent[];\n contentHTML: string;\n contentText: string;\n coverImage?: MediaReference;\n categories: string[];\n tags: string[];\n author: Author;\n seo: PostSEO;\n status: PostStatus;\n publishedAt?: Date;\n scheduledAt?: Date;\n readingTime: number;\n wordCount: number;\n version: number;\n revisions: Revision[];\n createdAt: Date;\n updatedAt: Date;\n}\n\n// ============================================================\n// Category\n// ============================================================\n\nexport const CreateCategorySchema = z.object({\n name: z.string().min(1, 'Category name is required'),\n slug: z.string().optional(),\n description: z.string().optional(),\n seo: z\n .object({\n metaTitle: z.string().optional(),\n metaDescription: z.string().optional(),\n })\n .optional(),\n order: z.number().default(0),\n parentId: z.string().optional(),\n});\n\nexport const UpdateCategorySchema = CreateCategorySchema.partial();\n\nexport type CreateCategoryInput = z.infer<typeof CreateCategorySchema>;\nexport type UpdateCategoryInput = z.infer<typeof UpdateCategorySchema>;\n\nexport interface Category {\n _id: ObjectId;\n name: string;\n slug: string;\n description?: string;\n seo?: {\n metaTitle?: string;\n metaDescription?: string;\n };\n order: number;\n parentId?: ObjectId;\n postCount: number;\n}\n\n// ============================================================\n// Settings\n// ============================================================\n\nexport const BlogSettingsSchema = z.object({\n postsPerPage: z.number().min(1).max(100).default(10),\n defaultAuthor: AuthorSchema.optional(),\n defaultOgImage: mediaUrlSchema.optional(),\n commentSystem: z.enum(['none', 'giscus', 'disqus']).default('none'),\n commentConfig: z.record(z.unknown()).optional(),\n customCSS: z.string().optional(),\n analytics: z\n .object({\n gaId: z.string().optional(),\n plausibleDomain: z.string().optional(),\n })\n .optional(),\n});\n\nexport interface BlogSettings {\n _id: string;\n postsPerPage: number;\n defaultAuthor?: Author;\n defaultOgImage?: string;\n commentSystem: 'none' | 'giscus' | 'disqus';\n commentConfig?: Record<string, unknown>;\n customCSS?: string;\n analytics?: {\n gaId?: string;\n plausibleDomain?: string;\n };\n}\n\n// ============================================================\n// API Tokens\n// ============================================================\n\nexport const CreateApiTokenSchema = z.object({\n name: z.string().min(1, 'Token name is required').max(100),\n});\n\nexport interface ApiToken {\n _id: ObjectId;\n name: string;\n tokenHash: string;\n prefix: string;\n lastUsedAt?: Date;\n createdAt: Date;\n}\n\n// ============================================================\n// API Response Types\n// ============================================================\n\nexport interface ApiSuccessResponse<T = unknown> {\n success: true;\n data: T;\n meta?: {\n page: number;\n limit: number;\n total: number;\n totalPages: number;\n };\n}\n\nexport interface ApiErrorResponse {\n success: false;\n error: {\n code: string;\n message: string;\n };\n}\n\nexport type ApiResponse<T = unknown> = ApiSuccessResponse<T> | ApiErrorResponse;\n\n// ============================================================\n// SEO Score Types\n// ============================================================\n\nexport type SEOCheckStatus = 'pass' | 'warn' | 'fail';\nexport type SEOOverallScore = 'good' | 'ok' | 'poor';\n\nexport interface SEOCheck {\n id: string;\n status: SEOCheckStatus;\n message: string;\n}\n\nexport interface SEOScore {\n overall: SEOOverallScore;\n checks: SEOCheck[];\n}\n\n// ============================================================\n// Config Types\n// ============================================================\n\nexport interface BlogPanelConfig {\n basePath: string;\n adminPath: string;\n apiPath: string;\n postsPerPage: number;\n excerptLength: number;\n codeHighlighter: 'shiki' | 'prism';\n editor: {\n blocks: BlockType[];\n /** Ceiling on the STORED result. Enforced by compression, never by rejection. */\n maxImageSize: number;\n /** Ceiling on the RAW upload. Enforced by rejection, before buffering. */\n maxUploadSize: number;\n imageFormats: string[];\n autosaveInterval: number;\n };\n seo: {\n titleTemplate: string;\n defaultOgImage?: string;\n generateRSS: boolean;\n generateSitemap: boolean;\n structuredData: boolean;\n minContentLength: number;\n };\n auth: {\n strategy: 'api-key' | 'custom' | 'credentials';\n verify?: (request: Request) => Promise<boolean>;\n admins?: string[];\n };\n features: {\n search: boolean;\n relatedPosts: boolean;\n readingProgress: boolean;\n tableOfContents: boolean;\n shareButtons: boolean;\n darkMode: boolean;\n scheduling: boolean;\n revisionHistory: boolean;\n imageOptimization: boolean;\n };\n theme: {\n variables?: Record<string, string>;\n darkMode?: boolean;\n components?: Record<string, React.ComponentType<unknown>>;\n };\n hooks: {\n beforePublish?: (post: BlogPost) => Promise<void>;\n afterPublish?: (post: BlogPost) => Promise<void>;\n beforeDelete?: (post: BlogPost) => Promise<void>;\n onMediaUpload?: (media: Media) => Promise<void>;\n };\n}\n\n/**\n * What a user's `next-blogpanel.config.ts` is allowed to contain.\n *\n * A plain `Partial<BlogPanelConfig>` only makes the top-level keys optional:\n * it would demand all five `editor` keys from anyone who wanted to change\n * `maxImageSize` alone. The merge in `getConfig()`/`setConfig()` has always\n * been a per-key merge of the nested groups, so the type is what was wrong,\n * not the behaviour.\n */\nexport type BlogPanelUserConfig = Partial<\n Omit<BlogPanelConfig, 'editor' | 'seo' | 'auth' | 'features' | 'theme' | 'hooks'>\n> & {\n editor?: Partial<BlogPanelConfig['editor']>;\n seo?: Partial<BlogPanelConfig['seo']>;\n auth?: Partial<BlogPanelConfig['auth']>;\n features?: Partial<BlogPanelConfig['features']>;\n theme?: Partial<BlogPanelConfig['theme']>;\n hooks?: Partial<BlogPanelConfig['hooks']>;\n};\n\n// ============================================================\n// Query Types\n// ============================================================\n\nexport interface PostListQuery {\n page?: number;\n limit?: number;\n category?: string;\n tag?: string;\n status?: PostStatus;\n search?: string;\n sortBy?: 'publishedAt' | 'createdAt' | 'title';\n sortOrder?: 'asc' | 'desc';\n}\n\nexport interface MediaListQuery {\n page?: number;\n limit?: number;\n mimeType?: string;\n}\n"]}
@@ -0,0 +1,344 @@
1
+ import { absoluteUrl } from './chunk-VEJFN65Y.js';
2
+ import { getEnvConfig, getConfig } from './chunk-6PZ772NQ.js';
3
+
4
+ // src/lib/search.ts
5
+ async function searchPosts(collection, query, limit = 10) {
6
+ if (!query.trim()) return [];
7
+ const results = await collection.find(
8
+ {
9
+ $text: { $search: query },
10
+ status: "published"
11
+ },
12
+ {
13
+ projection: {
14
+ slug: 1,
15
+ title: 1,
16
+ excerpt: 1,
17
+ score: { $meta: "textScore" }
18
+ }
19
+ }
20
+ ).sort({ score: { $meta: "textScore" } }).limit(limit).toArray();
21
+ return results.map((doc) => ({
22
+ slug: doc.slug,
23
+ title: doc.title,
24
+ excerpt: doc.excerpt,
25
+ score: doc.score
26
+ }));
27
+ }
28
+
29
+ // src/lib/seo.ts
30
+ function generateMetaTags(post) {
31
+ const env = getEnvConfig();
32
+ const config = getConfig();
33
+ const postUrl = `${env.BLOGPANEL_SITE_URL}${config.basePath}/${post.slug}`;
34
+ const title = post.seo?.metaTitle || post.title;
35
+ const description = post.seo?.metaDescription || post.excerpt;
36
+ const canonical = post.seo?.canonicalUrl || postUrl;
37
+ const ogImage = absoluteUrl(
38
+ post.seo?.ogImage || post.coverImage?.url,
39
+ env.BLOGPANEL_SITE_URL,
40
+ "the og:image/twitter:image for a post"
41
+ );
42
+ return {
43
+ title: `${title} | ${env.BLOGPANEL_SITE_NAME}`,
44
+ description,
45
+ canonical,
46
+ openGraph: {
47
+ title,
48
+ description,
49
+ url: postUrl,
50
+ siteName: env.BLOGPANEL_SITE_NAME,
51
+ type: post.seo?.ogType || "article",
52
+ images: ogImage ? [
53
+ {
54
+ url: ogImage,
55
+ width: 1200,
56
+ height: 630,
57
+ alt: title
58
+ }
59
+ ] : [],
60
+ article: {
61
+ publishedTime: post.publishedAt?.toISOString() || "",
62
+ modifiedTime: post.updatedAt.toISOString(),
63
+ section: post.categories[0],
64
+ tags: post.tags
65
+ }
66
+ },
67
+ twitter: {
68
+ card: "summary_large_image",
69
+ title,
70
+ description,
71
+ images: ogImage ? [ogImage] : []
72
+ },
73
+ robots: post.seo?.noIndex ? "noindex, nofollow" : void 0
74
+ };
75
+ }
76
+ function generateStructuredData(post) {
77
+ const env = getEnvConfig();
78
+ const config = getConfig();
79
+ const postUrl = `${env.BLOGPANEL_SITE_URL}${config.basePath}/${post.slug}`;
80
+ return {
81
+ "@context": "https://schema.org",
82
+ "@type": "BlogPosting",
83
+ headline: post.title,
84
+ description: post.excerpt,
85
+ // Same requirement as og:image: schema.org consumers fetch the image
86
+ // directly, so a relative path is not usable.
87
+ image: absoluteUrl(
88
+ post.coverImage?.url || post.seo?.ogImage,
89
+ env.BLOGPANEL_SITE_URL,
90
+ "the JSON-LD image for a post"
91
+ ),
92
+ datePublished: post.publishedAt?.toISOString(),
93
+ dateModified: post.updatedAt.toISOString(),
94
+ author: {
95
+ "@type": "Person",
96
+ name: post.author.name,
97
+ url: post.author.url
98
+ },
99
+ publisher: {
100
+ "@type": "Organization",
101
+ name: env.BLOGPANEL_SITE_NAME
102
+ },
103
+ mainEntityOfPage: {
104
+ "@type": "WebPage",
105
+ "@id": postUrl
106
+ },
107
+ wordCount: post.wordCount,
108
+ articleSection: post.categories[0]
109
+ };
110
+ }
111
+ function generateFAQStructuredData(faqItems) {
112
+ if (!faqItems.length) return null;
113
+ return {
114
+ "@context": "https://schema.org",
115
+ "@type": "FAQPage",
116
+ mainEntity: faqItems.map((item) => ({
117
+ "@type": "Question",
118
+ name: item.question,
119
+ acceptedAnswer: {
120
+ "@type": "Answer",
121
+ text: item.answer
122
+ }
123
+ }))
124
+ };
125
+ }
126
+ function generateBreadcrumbs(post, categoryName) {
127
+ const env = getEnvConfig();
128
+ const config = getConfig();
129
+ const items = [
130
+ {
131
+ "@type": "ListItem",
132
+ position: 1,
133
+ name: "Home",
134
+ item: env.BLOGPANEL_SITE_URL
135
+ },
136
+ {
137
+ "@type": "ListItem",
138
+ position: 2,
139
+ name: "Blog",
140
+ item: `${env.BLOGPANEL_SITE_URL}${config.basePath}`
141
+ }
142
+ ];
143
+ if (categoryName && post.categories[0]) {
144
+ items.push({
145
+ "@type": "ListItem",
146
+ position: 3,
147
+ name: categoryName,
148
+ item: `${env.BLOGPANEL_SITE_URL}${config.basePath}/category/${post.categories[0]}`
149
+ });
150
+ items.push({
151
+ "@type": "ListItem",
152
+ position: 4,
153
+ name: post.title
154
+ });
155
+ } else {
156
+ items.push({
157
+ "@type": "ListItem",
158
+ position: 3,
159
+ name: post.title
160
+ });
161
+ }
162
+ return {
163
+ "@context": "https://schema.org",
164
+ "@type": "BreadcrumbList",
165
+ itemListElement: items
166
+ };
167
+ }
168
+
169
+ // src/lib/seo-scorer.ts
170
+ function check(id, status, message) {
171
+ return { id, status, message };
172
+ }
173
+ function calculateSEOScore(post) {
174
+ const checks = [];
175
+ const keyword = post.seo?.focusKeyword?.toLowerCase() || "";
176
+ const title = post.title.toLowerCase();
177
+ const slug = post.slug.toLowerCase();
178
+ const excerpt = (post.seo?.metaDescription || post.excerpt || "").toLowerCase();
179
+ const contentText = post.contentText?.toLowerCase() || "";
180
+ const contentHTML = post.contentHTML || "";
181
+ if (!keyword) {
182
+ checks.push(check("focus-keyword-in-title", "warn", "No focus keyword set"));
183
+ } else if (title.includes(keyword)) {
184
+ checks.push(check("focus-keyword-in-title", "pass", "Focus keyword appears in title"));
185
+ } else {
186
+ checks.push(check("focus-keyword-in-title", "fail", "Focus keyword not found in title"));
187
+ }
188
+ if (!keyword) {
189
+ checks.push(check("focus-keyword-in-slug", "warn", "No focus keyword set"));
190
+ } else if (slug.includes(keyword.replace(/\s+/g, "-"))) {
191
+ checks.push(check("focus-keyword-in-slug", "pass", "Focus keyword appears in URL slug"));
192
+ } else {
193
+ checks.push(check("focus-keyword-in-slug", "fail", "Focus keyword not found in URL slug"));
194
+ }
195
+ if (!keyword) {
196
+ checks.push(check("focus-keyword-in-excerpt", "warn", "No focus keyword set"));
197
+ } else if (excerpt.includes(keyword)) {
198
+ checks.push(check("focus-keyword-in-excerpt", "pass", "Focus keyword appears in meta description"));
199
+ } else {
200
+ checks.push(check("focus-keyword-in-excerpt", "fail", "Focus keyword not found in meta description"));
201
+ }
202
+ if (keyword) {
203
+ const h2Regex = /<h2[^>]*>(.*?)<\/h2>/gi;
204
+ const h2s = contentHTML.match(h2Regex) || [];
205
+ const keywordInH2 = h2s.some((h2) => h2.toLowerCase().includes(keyword));
206
+ if (keywordInH2) {
207
+ checks.push(check("focus-keyword-in-h2", "pass", "Focus keyword found in a subheading"));
208
+ } else {
209
+ checks.push(check("focus-keyword-in-h2", "warn", "Focus keyword not found in any H2 subheading"));
210
+ }
211
+ }
212
+ if (keyword && contentText) {
213
+ const first150Words = contentText.split(/\s+/).slice(0, 150).join(" ");
214
+ if (first150Words.includes(keyword)) {
215
+ checks.push(check("focus-keyword-in-first-paragraph", "pass", "Focus keyword appears early in content"));
216
+ } else {
217
+ checks.push(check("focus-keyword-in-first-paragraph", "warn", "Focus keyword not found in the first paragraph"));
218
+ }
219
+ }
220
+ if (keyword && contentText) {
221
+ const words = contentText.split(/\s+/).length;
222
+ const keywordCount = (contentText.match(new RegExp(keyword, "g")) || []).length;
223
+ const density = keywordCount / words * 100;
224
+ if (density >= 0.5 && density <= 2.5) {
225
+ checks.push(check("focus-keyword-density", "pass", `Keyword density is ${density.toFixed(1)}% (ideal)`));
226
+ } else if (density < 0.5) {
227
+ checks.push(check("focus-keyword-density", "warn", `Keyword density is ${density.toFixed(1)}% (too low, aim for 0.5-2.5%)`));
228
+ } else {
229
+ checks.push(check("focus-keyword-density", "warn", `Keyword density is ${density.toFixed(1)}% (too high, aim for 0.5-2.5%)`));
230
+ }
231
+ }
232
+ const metaTitle = post.seo?.metaTitle || post.title;
233
+ if (metaTitle.length >= 50 && metaTitle.length <= 60) {
234
+ checks.push(check("title-length", "pass", `Title is ${metaTitle.length} characters (ideal)`));
235
+ } else if (metaTitle.length < 30) {
236
+ checks.push(check("title-length", "fail", `Title is ${metaTitle.length} characters (too short, aim for 50-60)`));
237
+ } else if (metaTitle.length > 70) {
238
+ checks.push(check("title-length", "warn", `Title is ${metaTitle.length} characters (too long, aim for 50-60)`));
239
+ } else {
240
+ checks.push(check("title-length", "warn", `Title is ${metaTitle.length} characters (aim for 50-60)`));
241
+ }
242
+ const metaDesc = post.seo?.metaDescription || post.excerpt || "";
243
+ if (metaDesc.length >= 150 && metaDesc.length <= 160) {
244
+ checks.push(check("meta-description-length", "pass", `Meta description is ${metaDesc.length} characters (ideal)`));
245
+ } else if (metaDesc.length < 120) {
246
+ checks.push(check("meta-description-length", "warn", `Meta description is ${metaDesc.length} characters (too short, aim for 150-160)`));
247
+ } else if (metaDesc.length > 170) {
248
+ checks.push(check("meta-description-length", "warn", `Meta description is ${metaDesc.length} characters (too long, may be truncated)`));
249
+ } else {
250
+ checks.push(check("meta-description-length", "pass", `Meta description is ${metaDesc.length} characters`));
251
+ }
252
+ if (post.slug.length <= 75) {
253
+ checks.push(check("slug-length", "pass", `URL slug is ${post.slug.length} characters`));
254
+ } else {
255
+ checks.push(check("slug-length", "warn", `URL slug is ${post.slug.length} characters (should be under 75)`));
256
+ }
257
+ if (post.wordCount >= 300) {
258
+ checks.push(check("content-length", "pass", `Content is ${post.wordCount} words`));
259
+ } else {
260
+ checks.push(check("content-length", "fail", `Content is only ${post.wordCount} words (aim for 300+)`));
261
+ }
262
+ const headingRegex = /<(h[2-6])[^>]*>/gi;
263
+ const headings = [...contentHTML.matchAll(headingRegex)].map((m) => parseInt(m[1][1]));
264
+ let hierarchyOk = true;
265
+ for (let i = 1; i < headings.length; i++) {
266
+ if (headings[i] > headings[i - 1] + 1) {
267
+ hierarchyOk = false;
268
+ break;
269
+ }
270
+ }
271
+ if (headings.length === 0) {
272
+ checks.push(check("heading-hierarchy", "warn", "No subheadings found \u2014 add H2s to structure content"));
273
+ } else if (hierarchyOk) {
274
+ checks.push(check("heading-hierarchy", "pass", "Heading hierarchy is correct"));
275
+ } else {
276
+ checks.push(check("heading-hierarchy", "warn", "Heading levels are skipped (e.g., H2 \u2192 H4)"));
277
+ }
278
+ const imgRegex = /<img[^>]*>/gi;
279
+ const images = contentHTML.match(imgRegex) || [];
280
+ const missingAlt = images.filter((img) => !img.includes("alt=") || img.includes('alt=""'));
281
+ if (images.length === 0) {
282
+ checks.push(check("image-alt-text", "warn", "No images found in content"));
283
+ } else if (missingAlt.length === 0) {
284
+ checks.push(check("image-alt-text", "pass", "All images have alt text"));
285
+ } else {
286
+ checks.push(check("image-alt-text", "fail", `${missingAlt.length} image(s) missing alt text`));
287
+ }
288
+ const internalLinkRegex = /href=["']\/[^"']*["']/gi;
289
+ const internalLinks = contentHTML.match(internalLinkRegex) || [];
290
+ if (internalLinks.length > 0) {
291
+ checks.push(check("internal-links", "pass", `${internalLinks.length} internal link(s) found`));
292
+ } else {
293
+ checks.push(check("internal-links", "warn", "No internal links found \u2014 add links to related content"));
294
+ }
295
+ const externalLinkRegex = /href=["']https?:\/\/[^"']*["']/gi;
296
+ const externalLinks = contentHTML.match(externalLinkRegex) || [];
297
+ if (externalLinks.length > 0) {
298
+ checks.push(check("external-links", "pass", `${externalLinks.length} external link(s) found`));
299
+ } else {
300
+ checks.push(check("external-links", "warn", "No external links found"));
301
+ }
302
+ const paragraphs = contentHTML.split(/<\/p>/i).filter((p) => p.trim());
303
+ const longParagraphs = paragraphs.filter((p) => {
304
+ const text = p.replace(/<[^>]+>/g, "");
305
+ return text.split(/\s+/).length > 300;
306
+ });
307
+ if (longParagraphs.length === 0) {
308
+ checks.push(check("paragraph-length", "pass", "All paragraphs are a reasonable length"));
309
+ } else {
310
+ checks.push(check("paragraph-length", "warn", `${longParagraphs.length} paragraph(s) exceed 300 words`));
311
+ }
312
+ if (post.coverImage?.url) {
313
+ checks.push(check("cover-image", "pass", "Post has a cover image"));
314
+ } else {
315
+ checks.push(check("cover-image", "warn", "No cover image set \u2014 social shares may look plain"));
316
+ }
317
+ if (contentText) {
318
+ const sentences = contentText.split(/[.!?]+/).filter((s) => s.trim().length > 0);
319
+ const words = contentText.split(/\s+/).length;
320
+ const avgWordsPerSentence = sentences.length > 0 ? words / sentences.length : 0;
321
+ if (avgWordsPerSentence <= 20) {
322
+ checks.push(check("readability-score", "pass", `Average sentence length: ${avgWordsPerSentence.toFixed(0)} words`));
323
+ } else if (avgWordsPerSentence <= 25) {
324
+ checks.push(check("readability-score", "warn", `Average sentence length: ${avgWordsPerSentence.toFixed(0)} words (try to keep under 20)`));
325
+ } else {
326
+ checks.push(check("readability-score", "fail", `Average sentence length: ${avgWordsPerSentence.toFixed(0)} words (too long, aim for under 20)`));
327
+ }
328
+ }
329
+ const fails = checks.filter((c) => c.status === "fail").length;
330
+ const warns = checks.filter((c) => c.status === "warn").length;
331
+ let overall;
332
+ if (fails >= 3) {
333
+ overall = "poor";
334
+ } else if (fails >= 1 || warns >= 5) {
335
+ overall = "ok";
336
+ } else {
337
+ overall = "good";
338
+ }
339
+ return { overall, checks };
340
+ }
341
+
342
+ export { calculateSEOScore, generateBreadcrumbs, generateFAQStructuredData, generateMetaTags, generateStructuredData, searchPosts };
343
+ //# sourceMappingURL=chunk-UI2KDZ5H.js.map
344
+ //# sourceMappingURL=chunk-UI2KDZ5H.js.map