@mailoo/blog 0.0.0-stage → 1.33.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 (59) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +142 -2
  3. package/dist/client/article-body.cjs +115 -0
  4. package/dist/client/article-body.cjs.map +1 -0
  5. package/dist/client/article-body.d.cts +15 -0
  6. package/dist/client/article-body.d.ts +15 -0
  7. package/dist/client/article-body.js +80 -0
  8. package/dist/client/article-body.js.map +1 -0
  9. package/dist/client/index.cjs +776 -0
  10. package/dist/client/index.cjs.map +1 -0
  11. package/dist/client/index.d.cts +6 -0
  12. package/dist/client/index.d.ts +6 -0
  13. package/dist/client/index.js +731 -0
  14. package/dist/client/index.js.map +1 -0
  15. package/dist/client/linked-links.cjs +125 -0
  16. package/dist/client/linked-links.cjs.map +1 -0
  17. package/dist/client/linked-links.d.cts +31 -0
  18. package/dist/client/linked-links.d.ts +31 -0
  19. package/dist/client/linked-links.js +100 -0
  20. package/dist/client/linked-links.js.map +1 -0
  21. package/dist/client/marks.cjs +300 -0
  22. package/dist/client/marks.cjs.map +1 -0
  23. package/dist/client/marks.d.cts +30 -0
  24. package/dist/client/marks.d.ts +30 -0
  25. package/dist/client/marks.js +276 -0
  26. package/dist/client/marks.js.map +1 -0
  27. package/dist/client/printable.cjs +106 -0
  28. package/dist/client/printable.cjs.map +1 -0
  29. package/dist/client/printable.d.cts +12 -0
  30. package/dist/client/printable.d.ts +12 -0
  31. package/dist/client/printable.js +82 -0
  32. package/dist/client/printable.js.map +1 -0
  33. package/dist/client/share.cjs +234 -0
  34. package/dist/client/share.cjs.map +1 -0
  35. package/dist/client/share.d.cts +41 -0
  36. package/dist/client/share.d.ts +41 -0
  37. package/dist/client/share.js +205 -0
  38. package/dist/client/share.js.map +1 -0
  39. package/dist/index.cjs +89 -0
  40. package/dist/index.cjs.map +1 -0
  41. package/dist/index.d.cts +5 -0
  42. package/dist/index.d.ts +5 -0
  43. package/dist/index.js +60 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/routes/index.cjs +266 -0
  46. package/dist/routes/index.cjs.map +1 -0
  47. package/dist/routes/index.d.cts +87 -0
  48. package/dist/routes/index.d.ts +87 -0
  49. package/dist/routes/index.js +240 -0
  50. package/dist/routes/index.js.map +1 -0
  51. package/dist/server/index.cjs +368 -0
  52. package/dist/server/index.cjs.map +1 -0
  53. package/dist/server/index.d.cts +132 -0
  54. package/dist/server/index.d.ts +132 -0
  55. package/dist/server/index.js +339 -0
  56. package/dist/server/index.js.map +1 -0
  57. package/dist/utils-DXbN9MTi.d.cts +120 -0
  58. package/dist/utils-DXbN9MTi.d.ts +120 -0
  59. package/package.json +110 -3
package/LICENSE ADDED
@@ -0,0 +1,16 @@
1
+ Mailoo Integrator Use License
2
+
3
+ Copyright (c) 2026 Mailoo
4
+
5
+ Permission is granted to use, install, and distribute this software in
6
+ unmodified form, including as a dependency of an application.
7
+
8
+ Modification of this software is not permitted.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
11
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
12
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
13
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
14
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
15
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
16
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,143 @@
1
- # Temporary Holding Version
1
+ # `@mailoo/blog`
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Headless Mailoo Blog module for Next.js apps.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pnpm add @mailoo/blog @mailoo/next-core
9
+ ```
10
+
11
+ ## Env (server-only)
12
+
13
+ ```
14
+ MAILOO_BLOG_API=
15
+ MAILOO_BLOG_API_KEY=
16
+ MAILOO_BLOG_PROJECT_UID=
17
+ MAILOO_BLOG_INTEGRATION_ID=
18
+ ```
19
+
20
+ The API key needs `blog.external-read` for GET routes, `blog.article-mark`
21
+ for like/dislike/feedback POST routes, and `image.external-read` when the
22
+ host proxies cover/body images (or FULL).
23
+
24
+ Pass `imageProxyPrefixWithIdEquals` so list, slug, category description, and
25
+ JSON-LD image URLs point at your BFF instead of the canonical Mailoo embed
26
+ URL (that URL is **401** without `X-API-Key`):
27
+
28
+ ```ts
29
+ const client = createMailooBlogClient(config, {
30
+ imageProxyPrefixWithIdEquals: '/api/v1/images/public/content?id=',
31
+ })
32
+ ```
33
+
34
+ Mount `createPublicImageContentHandler` from `@mailoo/images/routes` on that
35
+ path. Omit the option to keep canonical URLs for server-only callers.
36
+ `rewriteMailooBlogMediaUrls` does the same rewrite for a single article.
37
+
38
+ ## Usage
39
+
40
+ ### BFF routes
41
+
42
+ ```ts
43
+ // app/api/v1/blog/route.ts
44
+ import { createBlogListHandler } from '@mailoo/blog/routes'
45
+ export const GET = createBlogListHandler()
46
+ ```
47
+
48
+ ```ts
49
+ // app/api/v1/blog/slug/[slug]/route.ts
50
+ import { createBlogSlugHandler } from '@mailoo/blog/routes'
51
+ export const GET = createBlogSlugHandler()
52
+ ```
53
+
54
+ ```ts
55
+ // app/api/v1/blog/slug/[slug]/json-ld/route.ts
56
+ import { createBlogJsonLdHandler } from '@mailoo/blog/routes'
57
+ export const GET = createBlogJsonLdHandler()
58
+ ```
59
+
60
+ ```ts
61
+ // app/api/v1/blog/slug/[slug]/reaction/route.ts
62
+ import { createBlogReactionHandler } from '@mailoo/blog/routes'
63
+ export const POST = createBlogReactionHandler()
64
+ ```
65
+
66
+ ```ts
67
+ // app/api/v1/blog/slug/[slug]/feedback/route.ts
68
+ import { createBlogFeedbackHandler } from '@mailoo/blog/routes'
69
+ export const POST = createBlogFeedbackHandler()
70
+ ```
71
+
72
+ ### Server client / RSC helpers
73
+
74
+ ```ts
75
+ import {
76
+ createMailooBlogClient,
77
+ getMailooBlogConfigFromEnv,
78
+ fetchBlogPostBySlug,
79
+ } from '@mailoo/blog/server'
80
+
81
+ const config = getMailooBlogConfigFromEnv()
82
+ if (config) {
83
+ const client = createMailooBlogClient(config)
84
+ const list = await client.listPosts({ limit: 20, locale })
85
+ if (list.ok) {
86
+ const posts = list.data
87
+ const { pagination, imagePublicEmbedBaseUrl } = list
88
+ }
89
+ const post = await client.getPostBySlug(slug, locale)
90
+ // Slug detail includes likeCount / dislikeCount / feedbackCount
91
+ const jsonLd = await client.getPostJsonLd(slug, locale)
92
+ await client.setReaction(slug, {
93
+ visitorKey,
94
+ action: 'set',
95
+ sentiment: 'LIKE',
96
+ })
97
+ await client.setFeedback(slug, {
98
+ visitorKey,
99
+ action: 'set',
100
+ comment: 'Helpful article',
101
+ })
102
+ }
103
+ ```
104
+
105
+ Use `createMailooBlogClient` (direct Mailoo API) from RSC, sitemap, and
106
+ other server jobs so they never loop back to the host BFF. Direct client
107
+ fetches use Next.js `next: { revalidate: 60 }` by default; pass
108
+ `{ revalidate: 0 }` (or another number) as the last argument to override.
109
+ `listPosts` keeps the API list envelope: `data` (posts array),
110
+ `pagination`, and `imagePublicEmbedBaseUrl`. Use
111
+ `fetchBlogPostBySlug` / `fetchBlogPostJsonLd` only when calling the
112
+ same-origin BFF from the browser or another same-origin path.
113
+ `setReaction` / `setFeedback` always POST without ISR cache.
114
+
115
+ ### Components
116
+
117
+ ```tsx
118
+ import {
119
+ BlogArticleBody,
120
+ BlogLinkedLinksDisplay,
121
+ BlogPrintableButtons,
122
+ BlogArticleMarks,
123
+ BlogArticleShare,
124
+ } from '@mailoo/blog/client'
125
+ ```
126
+
127
+ `BlogArticleMarks` POSTs to same-origin BFF (`/api/v1/blog/slug/.../reaction|feedback`),
128
+ shows public counts, and stores visitor state in `localStorage`. Pass i18n labels
129
+ from the host.
130
+
131
+ `BlogArticleShare` renders X / Facebook / LinkedIn / copy-link controls. Pass
132
+ interpolated `text`, i18n `labels`, and optional absolute `url` (when omitted,
133
+ clicks use `origin + pathname`). Subpath: `@mailoo/blog/client/share`.
134
+
135
+ Host app owns locale routing, i18n strings, theme, and page layouts.
136
+
137
+ ## Host: errors must not take down the app
138
+
139
+ `createMailooBlogClient` methods return `{ ok: false, status }` on network /
140
+ upstream failure — they **never throw**. Check `ok` before rendering. Prefer
141
+ feature-local error UI over crashing the page. Do not call the blog client
142
+ from root `layout.tsx`. Article pages: use `notFound()` only for HTTP 404;
143
+ other failures should show an in-page error (HTTP 200).
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/components/blog-article-body.tsx
31
+ var blog_article_body_exports = {};
32
+ __export(blog_article_body_exports, {
33
+ BlogArticleBody: () => BlogArticleBody
34
+ });
35
+ module.exports = __toCommonJS(blog_article_body_exports);
36
+ var import_react_markdown = __toESM(require("react-markdown"), 1);
37
+ var import_remark_gfm = __toESM(require("remark-gfm"), 1);
38
+
39
+ // src/markdown-link-target.ts
40
+ function markdownHrefOpensInNewTab(href) {
41
+ const h = href.trim();
42
+ if (h === "") return false;
43
+ if (h.startsWith("#") || h.startsWith("?")) return false;
44
+ if (h.startsWith("//")) return true;
45
+ if (h.startsWith("/")) return false;
46
+ if (h.startsWith("./") || h.startsWith("../")) return false;
47
+ const lower = h.toLowerCase();
48
+ return lower.startsWith("http://") || lower.startsWith("https://");
49
+ }
50
+
51
+ // src/server/utils.ts
52
+ var BLOG_ARTICLE_PROSE_CLASSNAME = [
53
+ "prose",
54
+ "prose-gray",
55
+ "prose-lg",
56
+ "max-w-none",
57
+ "dark:prose-invert",
58
+ "prose-a:text-blue-600",
59
+ "hover:prose-a:text-blue-700",
60
+ "dark:prose-a:text-blue-400",
61
+ "dark:hover:prose-a:text-blue-300",
62
+ "prose-blockquote:border-blue-500",
63
+ "prose-img:rounded-lg",
64
+ "prose-img:shadow-sm"
65
+ ].join(" ");
66
+
67
+ // src/components/blog-article-body.tsx
68
+ var import_jsx_runtime = require("react/jsx-runtime");
69
+ function BlogArticleBody({
70
+ htmlContent,
71
+ markdownFallback,
72
+ className = BLOG_ARTICLE_PROSE_CLASSNAME
73
+ }) {
74
+ const html = htmlContent?.trim();
75
+ if (html) {
76
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
77
+ "div",
78
+ {
79
+ className,
80
+ dangerouslySetInnerHTML: { __html: html }
81
+ }
82
+ );
83
+ }
84
+ const md = markdownFallback?.trim();
85
+ if (md) {
86
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
87
+ import_react_markdown.default,
88
+ {
89
+ remarkPlugins: [import_remark_gfm.default],
90
+ components: {
91
+ a({ children, href, ...props }) {
92
+ const h = href ?? "";
93
+ const openNew = markdownHrefOpensInNewTab(h);
94
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
95
+ "a",
96
+ {
97
+ href: h,
98
+ ...openNew ? { target: "_blank", rel: "noopener noreferrer" } : {},
99
+ ...props,
100
+ children
101
+ }
102
+ );
103
+ }
104
+ },
105
+ children: md
106
+ }
107
+ ) });
108
+ }
109
+ return null;
110
+ }
111
+ // Annotate the CommonJS export names for ESM import in node:
112
+ 0 && (module.exports = {
113
+ BlogArticleBody
114
+ });
115
+ //# sourceMappingURL=article-body.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/blog-article-body.tsx","../../src/markdown-link-target.ts","../../src/server/utils.ts"],"sourcesContent":["import ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { markdownHrefOpensInNewTab } from '../markdown-link-target.js'\nimport { BLOG_ARTICLE_PROSE_CLASSNAME } from '../server/utils.js'\n\nexport interface BlogArticleBodyProps {\n htmlContent?: string | null\n markdownFallback?: string | null\n /** Override default Tailwind Typography classes */\n className?: string\n}\n\n/**\n * Renders blog article body: prefers `htmlContent` from the headless API,\n * falls back to Markdown when `htmlContent` is empty.\n */\nexport function BlogArticleBody({\n htmlContent,\n markdownFallback,\n className = BLOG_ARTICLE_PROSE_CLASSNAME,\n}: BlogArticleBodyProps) {\n const html = htmlContent?.trim()\n if (html) {\n return (\n <div\n className={className}\n dangerouslySetInnerHTML={{ __html: html }}\n />\n )\n }\n\n const md = markdownFallback?.trim()\n if (md) {\n return (\n <div className={className}>\n <ReactMarkdown\n remarkPlugins={[remarkGfm]}\n components={{\n a({ children, href, ...props }) {\n const h = href ?? ''\n const openNew = markdownHrefOpensInNewTab(h)\n return (\n <a\n href={h}\n {...(openNew\n ? { target: '_blank', rel: 'noopener noreferrer' }\n : {})}\n {...props}\n >\n {children}\n </a>\n )\n },\n }}\n >\n {md}\n </ReactMarkdown>\n </div>\n )\n }\n\n return null\n}\n","export function markdownHrefOpensInNewTab(href: string): boolean {\n const h = href.trim()\n if (h === '') return false\n if (h.startsWith('#') || h.startsWith('?')) return false\n if (h.startsWith('//')) return true\n if (h.startsWith('/')) return false\n if (h.startsWith('./') || h.startsWith('../')) return false\n const lower = h.toLowerCase()\n return lower.startsWith('http://') || lower.startsWith('https://')\n}\n","/**\n * First absolute image URL for OG/Twitter previews: prefer HTML from the API,\n * then markdown image syntax in content.\n */\nconst HTML_IMG_SRC = /<img[^>]+src=[\"']([^\"']+)[\"']/i\nconst MD_IMAGE = /!\\[[^\\]]*]\\(([^)\\s]+)\\)/\n\nfunction normalizeUrl(raw: string): string | null {\n const u = raw.trim()\n if (!u) return null\n if (\n u.startsWith('http://') ||\n u.startsWith('https://') ||\n u.startsWith('/')\n ) {\n return u\n }\n return null\n}\n\nexport function firstEmbedImageUrlFromArticle(\n htmlContent: string | null | undefined,\n markdownContent: string | null | undefined\n): string | null {\n if (htmlContent) {\n const m = htmlContent.match(HTML_IMG_SRC)\n if (m?.[1]) {\n const url = normalizeUrl(m[1])\n if (url) return url\n }\n }\n if (markdownContent) {\n const m = markdownContent.match(MD_IMAGE)\n if (m?.[1]) {\n const url = normalizeUrl(m[1])\n if (url) return url\n }\n }\n return null\n}\n\n/**\n * Default Tailwind Typography class string for article body.\n * Hosts may pass a custom `className` instead.\n */\nexport const BLOG_ARTICLE_PROSE_CLASSNAME = [\n 'prose',\n 'prose-gray',\n 'prose-lg',\n 'max-w-none',\n 'dark:prose-invert',\n 'prose-a:text-blue-600',\n 'hover:prose-a:text-blue-700',\n 'dark:prose-a:text-blue-400',\n 'dark:hover:prose-a:text-blue-300',\n 'prose-blockquote:border-blue-500',\n 'prose-img:rounded-lg',\n 'prose-img:shadow-sm',\n].join(' ')\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,4BAA0B;AAC1B,wBAAsB;;;ACDf,SAAS,0BAA0B,MAAuB;AAC/D,QAAM,IAAI,KAAK,KAAK;AACpB,MAAI,MAAM,GAAI,QAAO;AACrB,MAAI,EAAE,WAAW,GAAG,KAAK,EAAE,WAAW,GAAG,EAAG,QAAO;AACnD,MAAI,EAAE,WAAW,IAAI,EAAG,QAAO;AAC/B,MAAI,EAAE,WAAW,GAAG,EAAG,QAAO;AAC9B,MAAI,EAAE,WAAW,IAAI,KAAK,EAAE,WAAW,KAAK,EAAG,QAAO;AACtD,QAAM,QAAQ,EAAE,YAAY;AAC5B,SAAO,MAAM,WAAW,SAAS,KAAK,MAAM,WAAW,UAAU;AACnE;;;ACoCO,IAAM,+BAA+B;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EAAE,KAAK,GAAG;;;AFlCJ;AARC,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA,YAAY;AACd,GAAyB;AACvB,QAAM,OAAO,aAAa,KAAK;AAC/B,MAAI,MAAM;AACR,WACE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,yBAAyB,EAAE,QAAQ,KAAK;AAAA;AAAA,IAC1C;AAAA,EAEJ;AAEA,QAAM,KAAK,kBAAkB,KAAK;AAClC,MAAI,IAAI;AACN,WACE,4CAAC,SAAI,WACH;AAAA,MAAC,sBAAAA;AAAA,MAAA;AAAA,QACC,eAAe,CAAC,kBAAAC,OAAS;AAAA,QACzB,YAAY;AAAA,UACV,EAAE,EAAE,UAAU,MAAM,GAAG,MAAM,GAAG;AAC9B,kBAAM,IAAI,QAAQ;AAClB,kBAAM,UAAU,0BAA0B,CAAC;AAC3C,mBACE;AAAA,cAAC;AAAA;AAAA,gBACC,MAAM;AAAA,gBACL,GAAI,UACD,EAAE,QAAQ,UAAU,KAAK,sBAAsB,IAC/C,CAAC;AAAA,gBACJ,GAAG;AAAA,gBAEH;AAAA;AAAA,YACH;AAAA,UAEJ;AAAA,QACF;AAAA,QAEC;AAAA;AAAA,IACH,GACF;AAAA,EAEJ;AAEA,SAAO;AACT;","names":["ReactMarkdown","remarkGfm"]}
@@ -0,0 +1,15 @@
1
+ import * as react from 'react';
2
+
3
+ interface BlogArticleBodyProps {
4
+ htmlContent?: string | null;
5
+ markdownFallback?: string | null;
6
+ /** Override default Tailwind Typography classes */
7
+ className?: string;
8
+ }
9
+ /**
10
+ * Renders blog article body: prefers `htmlContent` from the headless API,
11
+ * falls back to Markdown when `htmlContent` is empty.
12
+ */
13
+ declare function BlogArticleBody({ htmlContent, markdownFallback, className, }: BlogArticleBodyProps): react.JSX.Element | null;
14
+
15
+ export { BlogArticleBody, type BlogArticleBodyProps };
@@ -0,0 +1,15 @@
1
+ import * as react from 'react';
2
+
3
+ interface BlogArticleBodyProps {
4
+ htmlContent?: string | null;
5
+ markdownFallback?: string | null;
6
+ /** Override default Tailwind Typography classes */
7
+ className?: string;
8
+ }
9
+ /**
10
+ * Renders blog article body: prefers `htmlContent` from the headless API,
11
+ * falls back to Markdown when `htmlContent` is empty.
12
+ */
13
+ declare function BlogArticleBody({ htmlContent, markdownFallback, className, }: BlogArticleBodyProps): react.JSX.Element | null;
14
+
15
+ export { BlogArticleBody, type BlogArticleBodyProps };
@@ -0,0 +1,80 @@
1
+ // src/components/blog-article-body.tsx
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkGfm from "remark-gfm";
4
+
5
+ // src/markdown-link-target.ts
6
+ function markdownHrefOpensInNewTab(href) {
7
+ const h = href.trim();
8
+ if (h === "") return false;
9
+ if (h.startsWith("#") || h.startsWith("?")) return false;
10
+ if (h.startsWith("//")) return true;
11
+ if (h.startsWith("/")) return false;
12
+ if (h.startsWith("./") || h.startsWith("../")) return false;
13
+ const lower = h.toLowerCase();
14
+ return lower.startsWith("http://") || lower.startsWith("https://");
15
+ }
16
+
17
+ // src/server/utils.ts
18
+ var BLOG_ARTICLE_PROSE_CLASSNAME = [
19
+ "prose",
20
+ "prose-gray",
21
+ "prose-lg",
22
+ "max-w-none",
23
+ "dark:prose-invert",
24
+ "prose-a:text-blue-600",
25
+ "hover:prose-a:text-blue-700",
26
+ "dark:prose-a:text-blue-400",
27
+ "dark:hover:prose-a:text-blue-300",
28
+ "prose-blockquote:border-blue-500",
29
+ "prose-img:rounded-lg",
30
+ "prose-img:shadow-sm"
31
+ ].join(" ");
32
+
33
+ // src/components/blog-article-body.tsx
34
+ import { jsx } from "react/jsx-runtime";
35
+ function BlogArticleBody({
36
+ htmlContent,
37
+ markdownFallback,
38
+ className = BLOG_ARTICLE_PROSE_CLASSNAME
39
+ }) {
40
+ const html = htmlContent?.trim();
41
+ if (html) {
42
+ return /* @__PURE__ */ jsx(
43
+ "div",
44
+ {
45
+ className,
46
+ dangerouslySetInnerHTML: { __html: html }
47
+ }
48
+ );
49
+ }
50
+ const md = markdownFallback?.trim();
51
+ if (md) {
52
+ return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(
53
+ ReactMarkdown,
54
+ {
55
+ remarkPlugins: [remarkGfm],
56
+ components: {
57
+ a({ children, href, ...props }) {
58
+ const h = href ?? "";
59
+ const openNew = markdownHrefOpensInNewTab(h);
60
+ return /* @__PURE__ */ jsx(
61
+ "a",
62
+ {
63
+ href: h,
64
+ ...openNew ? { target: "_blank", rel: "noopener noreferrer" } : {},
65
+ ...props,
66
+ children
67
+ }
68
+ );
69
+ }
70
+ },
71
+ children: md
72
+ }
73
+ ) });
74
+ }
75
+ return null;
76
+ }
77
+ export {
78
+ BlogArticleBody
79
+ };
80
+ //# sourceMappingURL=article-body.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/blog-article-body.tsx","../../src/markdown-link-target.ts","../../src/server/utils.ts"],"sourcesContent":["import ReactMarkdown from 'react-markdown'\nimport remarkGfm from 'remark-gfm'\nimport { markdownHrefOpensInNewTab } from '../markdown-link-target.js'\nimport { BLOG_ARTICLE_PROSE_CLASSNAME } from '../server/utils.js'\n\nexport interface BlogArticleBodyProps {\n htmlContent?: string | null\n markdownFallback?: string | null\n /** Override default Tailwind Typography classes */\n className?: string\n}\n\n/**\n * Renders blog article body: prefers `htmlContent` from the headless API,\n * falls back to Markdown when `htmlContent` is empty.\n */\nexport function BlogArticleBody({\n htmlContent,\n markdownFallback,\n className = BLOG_ARTICLE_PROSE_CLASSNAME,\n}: BlogArticleBodyProps) {\n const html = htmlContent?.trim()\n if (html) {\n return (\n <div\n className={className}\n dangerouslySetInnerHTML={{ __html: html }}\n />\n )\n }\n\n const md = markdownFallback?.trim()\n if (md) {\n return (\n <div className={className}>\n <ReactMarkdown\n remarkPlugins={[remarkGfm]}\n components={{\n a({ children, href, ...props }) {\n const h = href ?? ''\n const openNew = markdownHrefOpensInNewTab(h)\n return (\n <a\n href={h}\n {...(openNew\n ? { target: '_blank', rel: 'noopener noreferrer' }\n : {})}\n {...props}\n >\n {children}\n </a>\n )\n },\n }}\n >\n {md}\n </ReactMarkdown>\n </div>\n )\n }\n\n return null\n}\n","export function markdownHrefOpensInNewTab(href: string): boolean {\n const h = href.trim()\n if (h === '') return false\n if (h.startsWith('#') || h.startsWith('?')) return false\n if (h.startsWith('//')) return true\n if (h.startsWith('/')) return false\n if (h.startsWith('./') || h.startsWith('../')) return false\n const lower = h.toLowerCase()\n return lower.startsWith('http://') || lower.startsWith('https://')\n}\n","/**\n * First absolute image URL for OG/Twitter previews: prefer HTML from the API,\n * then markdown image syntax in content.\n */\nconst HTML_IMG_SRC = /<img[^>]+src=[\"']([^\"']+)[\"']/i\nconst MD_IMAGE = /!\\[[^\\]]*]\\(([^)\\s]+)\\)/\n\nfunction normalizeUrl(raw: string): string | null {\n const u = raw.trim()\n if (!u) return null\n if (\n u.startsWith('http://') ||\n u.startsWith('https://') ||\n u.startsWith('/')\n ) {\n return u\n }\n return null\n}\n\nexport function firstEmbedImageUrlFromArticle(\n htmlContent: string | null | undefined,\n markdownContent: string | null | undefined\n): string | null {\n if (htmlContent) {\n const m = htmlContent.match(HTML_IMG_SRC)\n if (m?.[1]) {\n const url = normalizeUrl(m[1])\n if (url) return url\n }\n }\n if (markdownContent) {\n const m = markdownContent.match(MD_IMAGE)\n if (m?.[1]) {\n const url = normalizeUrl(m[1])\n if (url) return url\n }\n }\n return null\n}\n\n/**\n * Default Tailwind Typography class string for article body.\n * Hosts may pass a custom `className` instead.\n */\nexport const BLOG_ARTICLE_PROSE_CLASSNAME = [\n 'prose',\n 'prose-gray',\n 'prose-lg',\n 'max-w-none',\n 'dark:prose-invert',\n 'prose-a:text-blue-600',\n 'hover:prose-a:text-blue-700',\n 'dark:prose-a:text-blue-400',\n 'dark:hover:prose-a:text-blue-300',\n 'prose-blockquote:border-blue-500',\n 'prose-img:rounded-lg',\n 'prose-img:shadow-sm',\n].join(' ')\n"],"mappings":";AAAA,OAAO,mBAAmB;AAC1B,OAAO,eAAe;;;ACDf,SAAS,0BAA0B,MAAuB;AAC/D,QAAM,IAAI,KAAK,KAAK;AACpB,MAAI,MAAM,GAAI,QAAO;AACrB,MAAI,EAAE,WAAW,GAAG,KAAK,EAAE,WAAW,GAAG,EAAG,QAAO;AACnD,MAAI,EAAE,WAAW,IAAI,EAAG,QAAO;AAC/B,MAAI,EAAE,WAAW,GAAG,EAAG,QAAO;AAC9B,MAAI,EAAE,WAAW,IAAI,KAAK,EAAE,WAAW,KAAK,EAAG,QAAO;AACtD,QAAM,QAAQ,EAAE,YAAY;AAC5B,SAAO,MAAM,WAAW,SAAS,KAAK,MAAM,WAAW,UAAU;AACnE;;;ACoCO,IAAM,+BAA+B;AAAA,EAC1C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF,EAAE,KAAK,GAAG;;;AFlCJ;AARC,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA,YAAY;AACd,GAAyB;AACvB,QAAM,OAAO,aAAa,KAAK;AAC/B,MAAI,MAAM;AACR,WACE;AAAA,MAAC;AAAA;AAAA,QACC;AAAA,QACA,yBAAyB,EAAE,QAAQ,KAAK;AAAA;AAAA,IAC1C;AAAA,EAEJ;AAEA,QAAM,KAAK,kBAAkB,KAAK;AAClC,MAAI,IAAI;AACN,WACE,oBAAC,SAAI,WACH;AAAA,MAAC;AAAA;AAAA,QACC,eAAe,CAAC,SAAS;AAAA,QACzB,YAAY;AAAA,UACV,EAAE,EAAE,UAAU,MAAM,GAAG,MAAM,GAAG;AAC9B,kBAAM,IAAI,QAAQ;AAClB,kBAAM,UAAU,0BAA0B,CAAC;AAC3C,mBACE;AAAA,cAAC;AAAA;AAAA,gBACC,MAAM;AAAA,gBACL,GAAI,UACD,EAAE,QAAQ,UAAU,KAAK,sBAAsB,IAC/C,CAAC;AAAA,gBACJ,GAAG;AAAA,gBAEH;AAAA;AAAA,YACH;AAAA,UAEJ;AAAA,QACF;AAAA,QAEC;AAAA;AAAA,IACH,GACF;AAAA,EAEJ;AAEA,SAAO;AACT;","names":[]}