@mailoo/blog 0.0.0-stage → 1.32.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/README.md +142 -2
- package/dist/client/article-body.cjs +115 -0
- package/dist/client/article-body.cjs.map +1 -0
- package/dist/client/article-body.d.cts +15 -0
- package/dist/client/article-body.d.ts +15 -0
- package/dist/client/article-body.js +80 -0
- package/dist/client/article-body.js.map +1 -0
- package/dist/client/index.cjs +776 -0
- package/dist/client/index.cjs.map +1 -0
- package/dist/client/index.d.cts +6 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +731 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/linked-links.cjs +125 -0
- package/dist/client/linked-links.cjs.map +1 -0
- package/dist/client/linked-links.d.cts +31 -0
- package/dist/client/linked-links.d.ts +31 -0
- package/dist/client/linked-links.js +100 -0
- package/dist/client/linked-links.js.map +1 -0
- package/dist/client/marks.cjs +300 -0
- package/dist/client/marks.cjs.map +1 -0
- package/dist/client/marks.d.cts +30 -0
- package/dist/client/marks.d.ts +30 -0
- package/dist/client/marks.js +276 -0
- package/dist/client/marks.js.map +1 -0
- package/dist/client/printable.cjs +106 -0
- package/dist/client/printable.cjs.map +1 -0
- package/dist/client/printable.d.cts +12 -0
- package/dist/client/printable.d.ts +12 -0
- package/dist/client/printable.js +82 -0
- package/dist/client/printable.js.map +1 -0
- package/dist/client/share.cjs +234 -0
- package/dist/client/share.cjs.map +1 -0
- package/dist/client/share.d.cts +41 -0
- package/dist/client/share.d.ts +41 -0
- package/dist/client/share.js +205 -0
- package/dist/client/share.js.map +1 -0
- package/dist/index.cjs +89 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +60 -0
- package/dist/index.js.map +1 -0
- package/dist/routes/index.cjs +266 -0
- package/dist/routes/index.cjs.map +1 -0
- package/dist/routes/index.d.cts +87 -0
- package/dist/routes/index.d.ts +87 -0
- package/dist/routes/index.js +240 -0
- package/dist/routes/index.js.map +1 -0
- package/dist/server/index.cjs +368 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.cts +132 -0
- package/dist/server/index.d.ts +132 -0
- package/dist/server/index.js +339 -0
- package/dist/server/index.js.map +1 -0
- package/dist/utils-DXbN9MTi.d.cts +120 -0
- package/dist/utils-DXbN9MTi.d.ts +120 -0
- 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
|
-
#
|
|
1
|
+
# `@mailoo/blog`
|
|
2
2
|
|
|
3
|
-
|
|
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":[]}
|