@blogmcp/mdx-render-react 0.3.0 → 0.5.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.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/dist/index.js +32 -27
- package/dist/registry.js +9 -4
- package/dist/render/index.d.ts +3 -0
- package/dist/render/index.js +17 -12
- package/dist/render/tripmap/TripMap.d.ts +2 -0
- package/dist/render/tripmap/TripMap.js +111 -0
- package/dist/render/tripmap/article-context.d.ts +24 -0
- package/dist/render/tripmap/article-context.js +13 -0
- package/dist/server-render-map.js +19 -17
- package/package.json +7 -4
- package/src/registry.ts +5 -0
- package/src/render/index.ts +3 -0
- package/src/render/tripmap/TripMap.tsx +209 -0
- package/src/render/tripmap/article-context.ts +35 -0
- package/src/server-render-map.ts +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lukas Zubal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -30,3 +30,12 @@ Tailwind classes verbatim.
|
|
|
30
30
|
|
|
31
31
|
When the first React consumer appears (blog-react or editor-react). Until then, keep the package
|
|
32
32
|
empty so the schema contract proves framework-neutral by construction.
|
|
33
|
+
|
|
34
|
+
## Real-browser tests
|
|
35
|
+
|
|
36
|
+
`npm run test:e2e` — Playwright against a Vite harness (`test/e2e/harness`, port 5097; JSX via
|
|
37
|
+
esbuild's automatic runtime, no plugin-react) mounting the real `TripMap` renderer: hand-made nodes,
|
|
38
|
+
the tenant-config path, AUTO mode through `MdxArticleProvider`, exclude, explicit slugs, an unknown
|
|
39
|
+
article, clusters, the detail panel, export, the viewer — on a desktop and a phone project. The
|
|
40
|
+
`/api/media/geo` mock is shared with the Vue harness (`packages/mdx-schema/test/harness/geo-mock.ts`).
|
|
41
|
+
`npm run harness` serves the pages for manual work.
|
package/dist/index.js
CHANGED
|
@@ -1,43 +1,48 @@
|
|
|
1
1
|
import { default as o } from "./MdxContent.js";
|
|
2
|
-
import { serverRenderMap as
|
|
3
|
-
import { getComponentVariants as l, getRenderComponentAsync as
|
|
4
|
-
import { createMarked as u, marked as
|
|
2
|
+
import { serverRenderMap as a } from "./server-render-map.js";
|
|
3
|
+
import { getComponentVariants as l, getRenderComponentAsync as d, reactRegistry as m } from "./registry.js";
|
|
4
|
+
import { createMarked as u, marked as x } from "./marked.js";
|
|
5
5
|
import { default as s } from "./render/callout/CalloutDefault.js";
|
|
6
|
-
import { default as
|
|
6
|
+
import { default as C } from "./render/callout/CalloutMinimal.js";
|
|
7
7
|
import { default as c } from "./render/youtube/YouTubeDefault.js";
|
|
8
|
-
import { default as
|
|
9
|
-
import { default as
|
|
8
|
+
import { default as A } from "./render/gallery/GalleryGrid.js";
|
|
9
|
+
import { default as D } from "./render/codeblock/CodeBlockDefault.js";
|
|
10
10
|
import { default as B } from "./render/audioplayer/AudioPlayerDefault.js";
|
|
11
|
-
import { default as
|
|
12
|
-
import { default as
|
|
13
|
-
import { default as
|
|
14
|
-
import { default as
|
|
11
|
+
import { default as T } from "./render/button/Button.js";
|
|
12
|
+
import { default as G } from "./render/card/Card.js";
|
|
13
|
+
import { default as P } from "./render/alert/Alert.js";
|
|
14
|
+
import { default as H } from "./render/badge/Badge.js";
|
|
15
15
|
import { default as V } from "./render/icon/Icon.js";
|
|
16
16
|
import { default as h } from "./render/tooltip/Tooltip.js";
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
17
|
+
import { default as q } from "./render/tripmap/TripMap.js";
|
|
18
|
+
import { MdxArticleContext as z, MdxArticleProvider as E } from "./render/tripmap/article-context.js";
|
|
19
|
+
import { componentMetadata as J, getComponentMetadata as K, getComponentNames as L } from "@blogmcp/mdx-schema/metadata";
|
|
20
|
+
import { defaultImageHtml as Q } from "@blogmcp/sdk";
|
|
19
21
|
export {
|
|
20
|
-
|
|
22
|
+
P as Alert,
|
|
21
23
|
B as AudioPlayerDefault,
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
H as Badge,
|
|
25
|
+
T as Button,
|
|
24
26
|
s as CalloutDefault,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
C as CalloutMinimal,
|
|
28
|
+
G as Card,
|
|
29
|
+
D as CodeBlockDefault,
|
|
30
|
+
A as GalleryGrid,
|
|
29
31
|
V as Icon,
|
|
32
|
+
z as MdxArticleContext,
|
|
33
|
+
E as MdxArticleProvider,
|
|
30
34
|
o as MdxContent,
|
|
31
35
|
h as Tooltip,
|
|
36
|
+
q as TripMap,
|
|
32
37
|
c as YouTubeDefault,
|
|
33
|
-
|
|
38
|
+
J as componentMetadata,
|
|
34
39
|
u as createMarked,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
40
|
+
Q as defaultImageHtml,
|
|
41
|
+
K as getComponentMetadata,
|
|
42
|
+
L as getComponentNames,
|
|
38
43
|
l as getComponentVariants,
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
d as getRenderComponentAsync,
|
|
45
|
+
x as marked,
|
|
46
|
+
m as reactRegistry,
|
|
47
|
+
a as serverRenderMap
|
|
43
48
|
};
|
package/dist/registry.js
CHANGED
|
@@ -57,6 +57,11 @@ const i = {
|
|
|
57
57
|
render: {
|
|
58
58
|
default: () => import("./render/tooltip/Tooltip.js")
|
|
59
59
|
}
|
|
60
|
+
},
|
|
61
|
+
TripMap: {
|
|
62
|
+
render: {
|
|
63
|
+
default: () => import("./render/tripmap/TripMap.js")
|
|
64
|
+
}
|
|
60
65
|
}
|
|
61
66
|
}, o = Object.fromEntries(
|
|
62
67
|
d.map((e) => {
|
|
@@ -68,11 +73,11 @@ const i = {
|
|
|
68
73
|
return [e.name, { metadata: e, ...r }];
|
|
69
74
|
})
|
|
70
75
|
);
|
|
71
|
-
function
|
|
76
|
+
function p(e) {
|
|
72
77
|
const r = o[e];
|
|
73
78
|
return r ? Object.keys(r.render) : [];
|
|
74
79
|
}
|
|
75
|
-
async function
|
|
80
|
+
async function u(e, r = "default") {
|
|
76
81
|
const t = o[e];
|
|
77
82
|
if (!t) return;
|
|
78
83
|
const n = t.render[r] || t.render.default;
|
|
@@ -82,7 +87,7 @@ export {
|
|
|
82
87
|
f as componentMetadata,
|
|
83
88
|
s as getComponentMetadata,
|
|
84
89
|
g as getComponentNames,
|
|
85
|
-
|
|
86
|
-
|
|
90
|
+
p as getComponentVariants,
|
|
91
|
+
u as getRenderComponentAsync,
|
|
87
92
|
o as reactRegistry
|
|
88
93
|
};
|
package/dist/render/index.d.ts
CHANGED
|
@@ -16,6 +16,9 @@ export { default as Alert } from './alert/Alert';
|
|
|
16
16
|
export { default as Badge } from './badge/Badge';
|
|
17
17
|
export { default as Icon } from './icon/Icon';
|
|
18
18
|
export { default as Tooltip } from './tooltip/Tooltip';
|
|
19
|
+
export { default as TripMap } from './tripmap/TripMap';
|
|
20
|
+
export { MdxArticleContext, MdxArticleProvider } from './tripmap/article-context';
|
|
21
|
+
export type { MdxArticleContextValue } from './tripmap/article-context';
|
|
19
22
|
export type { ButtonProps } from './button/Button';
|
|
20
23
|
export type { BadgeProps } from './badge/Badge';
|
|
21
24
|
export type { AlertProps } from './alert/Alert';
|
package/dist/render/index.js
CHANGED
|
@@ -2,25 +2,30 @@ import { default as o } from "./callout/CalloutDefault.js";
|
|
|
2
2
|
import { default as r } from "./callout/CalloutMinimal.js";
|
|
3
3
|
import { default as l } from "./youtube/YouTubeDefault.js";
|
|
4
4
|
import { default as d } from "./gallery/GalleryGrid.js";
|
|
5
|
-
import { default as
|
|
6
|
-
import { default as
|
|
5
|
+
import { default as x } from "./codeblock/CodeBlockDefault.js";
|
|
6
|
+
import { default as s } from "./audioplayer/AudioPlayerDefault.js";
|
|
7
7
|
import { default as C } from "./button/Button.js";
|
|
8
8
|
import { default as n } from "./card/Card.js";
|
|
9
|
-
import { default as
|
|
10
|
-
import { default as
|
|
11
|
-
import { default as
|
|
12
|
-
import { default as
|
|
9
|
+
import { default as D } from "./alert/Alert.js";
|
|
10
|
+
import { default as B } from "./badge/Badge.js";
|
|
11
|
+
import { default as y } from "./icon/Icon.js";
|
|
12
|
+
import { default as P } from "./tooltip/Tooltip.js";
|
|
13
|
+
import { default as g } from "./tripmap/TripMap.js";
|
|
14
|
+
import { MdxArticleContext as v, MdxArticleProvider as I } from "./tripmap/article-context.js";
|
|
13
15
|
export {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
D as Alert,
|
|
17
|
+
s as AudioPlayerDefault,
|
|
18
|
+
B as Badge,
|
|
17
19
|
C as Button,
|
|
18
20
|
o as CalloutDefault,
|
|
19
21
|
r as CalloutMinimal,
|
|
20
22
|
n as Card,
|
|
21
|
-
|
|
23
|
+
x as CodeBlockDefault,
|
|
22
24
|
d as GalleryGrid,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
y as Icon,
|
|
26
|
+
v as MdxArticleContext,
|
|
27
|
+
I as MdxArticleProvider,
|
|
28
|
+
P as Tooltip,
|
|
29
|
+
g as TripMap,
|
|
25
30
|
l as YouTubeDefault
|
|
26
31
|
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs as M, jsx as a } from "react/jsx-runtime";
|
|
3
|
+
import { useContext as q, useRef as d, useState as J, useMemo as n, useCallback as K, useEffect as _ } from "react";
|
|
4
|
+
import { tripMapStyles as r } from "@blogmcp/mdx-schema/styles";
|
|
5
|
+
import { splitTripMapList as Q, parseTripMapHandNodes as X, buildTripMapGeoUrl as Y, tripMapUiTexts as Z, tripMapPlaybackLabels as ee, tripMapFacade as te, TRIPMAP_DEFAULT_HEIGHT as ae, tripMapFormatTime as re } from "@blogmcp/mdx-schema/tripmap";
|
|
6
|
+
import { MdxArticleContext as ie } from "./article-context.js";
|
|
7
|
+
function de(o) {
|
|
8
|
+
const { slugs: T, nodes: x, height: B, line: U, filmstrip: k, playback: D, cluster: A, theme: F, styleUrl: I, center: f, zoom: R, title: c, detail: h } = o, i = q(ie), L = d(null), g = d(null), V = d(null), y = d(null), H = d(!1), [P, u] = J("facade"), W = n(() => Q(T), [T]), S = n(() => X(x), [x]), j = n(() => Y(o, i), [o, i]), E = W.length === 0 && !i?.filename, b = n(() => Z(i?.language), [i]), O = n(() => ee(i?.language), [i]), p = n(() => te(o, i), [o, i]), v = K(async () => {
|
|
9
|
+
if (!H.current) {
|
|
10
|
+
H.current = !0, u("loading");
|
|
11
|
+
try {
|
|
12
|
+
const t = async () => {
|
|
13
|
+
try {
|
|
14
|
+
const e = await fetch(j, { headers: { accept: "application/json" } });
|
|
15
|
+
if (!e.ok) throw new Error(`media/geo ${e.status}`);
|
|
16
|
+
return await e.json();
|
|
17
|
+
} catch (e) {
|
|
18
|
+
if (E) return { nodes: [] };
|
|
19
|
+
throw e;
|
|
20
|
+
}
|
|
21
|
+
}, [l, s] = await Promise.all([import("@blogmcp/tripmap-core"), t()]), N = (s.nodes ?? []).filter((e) => typeof e.latitude == "number" && typeof e.longitude == "number").map((e) => ({
|
|
22
|
+
id: e.slug,
|
|
23
|
+
lat: e.latitude,
|
|
24
|
+
lng: e.longitude,
|
|
25
|
+
capturedAt: e.capturedAt ?? void 0,
|
|
26
|
+
thumbnailUrl: e.thumbnailUrl ?? void 0,
|
|
27
|
+
imageUrl: e.url ?? void 0,
|
|
28
|
+
largeImageUrl: e.largeUrl ?? void 0,
|
|
29
|
+
altText: e.altText ?? void 0,
|
|
30
|
+
title: e.placeName ?? void 0
|
|
31
|
+
})), C = l.buildTripNodes(N, S);
|
|
32
|
+
if (C.length === 0 || !g.current) {
|
|
33
|
+
u("empty");
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
let G;
|
|
37
|
+
if (f) {
|
|
38
|
+
const [e, z] = f.split(",").map(($) => Number.parseFloat($.trim()));
|
|
39
|
+
Number.isFinite(e) && Number.isFinite(z) && (G = { center: [z, e], zoom: R });
|
|
40
|
+
}
|
|
41
|
+
const w = new l.TripMap(g.current, {
|
|
42
|
+
styleUrl: I || s.config?.styleUrl || "",
|
|
43
|
+
darkStyleUrl: s.config?.darkStyleUrl,
|
|
44
|
+
theme: F ?? "auto",
|
|
45
|
+
cluster: A ?? !0,
|
|
46
|
+
showLine: U ?? !0,
|
|
47
|
+
showFilmstrip: k ?? !0,
|
|
48
|
+
showPlayback: D ?? !0,
|
|
49
|
+
title: c,
|
|
50
|
+
detail: h ?? "popup",
|
|
51
|
+
detailHost: h === "panel" ? V.current ?? void 0 : void 0,
|
|
52
|
+
extraAttribution: s.config?.attribution,
|
|
53
|
+
labels: O,
|
|
54
|
+
initialView: G,
|
|
55
|
+
events: {}
|
|
56
|
+
});
|
|
57
|
+
w.setNodes(C), await w.init(), y.current = () => w.destroy(), u("live");
|
|
58
|
+
} catch {
|
|
59
|
+
u((t) => t === "loading" ? "facade" : t);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}, [j, E, S, I, F, A, U, k, O, f, R, c, h]);
|
|
63
|
+
_(() => {
|
|
64
|
+
const t = L.current;
|
|
65
|
+
if (!t) return;
|
|
66
|
+
if (typeof IntersectionObserver > "u") {
|
|
67
|
+
v();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const l = new IntersectionObserver(
|
|
71
|
+
(s) => {
|
|
72
|
+
s.some((N) => N.isIntersecting) && (l.disconnect(), v());
|
|
73
|
+
},
|
|
74
|
+
{ rootMargin: "300px" }
|
|
75
|
+
);
|
|
76
|
+
return l.observe(t), () => l.disconnect();
|
|
77
|
+
}, [v]), _(
|
|
78
|
+
() => () => {
|
|
79
|
+
y.current?.(), y.current = null;
|
|
80
|
+
},
|
|
81
|
+
[]
|
|
82
|
+
);
|
|
83
|
+
const m = P !== "live";
|
|
84
|
+
return /* @__PURE__ */ M("div", { ref: L, className: r.container, "data-mdx-component": "TripMap", "aria-label": c, children: [
|
|
85
|
+
c ? /* @__PURE__ */ a("p", { className: r.head, children: c }) : null,
|
|
86
|
+
m && p.thumbs.length > 0 ? /* @__PURE__ */ a("ol", { className: r.strip, children: p.thumbs.map((t) => /* @__PURE__ */ a("li", { className: r.stripItem, children: /* @__PURE__ */ a(
|
|
87
|
+
"img",
|
|
88
|
+
{
|
|
89
|
+
src: t.src,
|
|
90
|
+
alt: t.alt,
|
|
91
|
+
width: 80,
|
|
92
|
+
height: 80,
|
|
93
|
+
loading: "lazy",
|
|
94
|
+
decoding: "async",
|
|
95
|
+
className: r.stripImg
|
|
96
|
+
}
|
|
97
|
+
) }, t.slug)) }) : null,
|
|
98
|
+
/* @__PURE__ */ M("div", { className: r.mapWrap, style: { height: B || ae }, children: [
|
|
99
|
+
/* @__PURE__ */ a("div", { ref: g, className: "absolute inset-0" }),
|
|
100
|
+
m ? /* @__PURE__ */ a("div", { className: r.mapOverlay, children: /* @__PURE__ */ a("div", { className: r.mapBox, children: /* @__PURE__ */ a("span", { className: r.mapHint, children: P === "empty" ? b.empty : b.hint }) }) }) : null
|
|
101
|
+
] }),
|
|
102
|
+
m && p.stops.length > 0 ? /* @__PURE__ */ a("ol", { className: r.stopList, children: p.stops.map((t) => /* @__PURE__ */ M("li", { children: [
|
|
103
|
+
t.capturedAt ? /* @__PURE__ */ a("span", { className: r.stopTime, children: re(t.capturedAt) }) : null,
|
|
104
|
+
/* @__PURE__ */ a("span", { children: t.title })
|
|
105
|
+
] }, t.key)) }) : null,
|
|
106
|
+
m ? /* @__PURE__ */ a("p", { className: r.note, children: b.unavailable }) : null
|
|
107
|
+
] });
|
|
108
|
+
}
|
|
109
|
+
export {
|
|
110
|
+
de as default
|
|
111
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Article identity for TripMap AUTO mode (map every geotagged media of the
|
|
3
|
+
* article). The host app wraps its rendered article body in the provider —
|
|
4
|
+
* the Next blog does in `app/articles/[slug]/page.tsx` AND the draft preview
|
|
5
|
+
* `app/preview/[token]/page.tsx`; a consumer that doesn't simply has no auto
|
|
6
|
+
* mode and TripMap needs explicit `slugs` or `nodes`.
|
|
7
|
+
*/
|
|
8
|
+
import { type ReactNode } from 'react';
|
|
9
|
+
import type { TripMapFacadeMedia } from '@blogmcp/mdx-schema/tripmap';
|
|
10
|
+
export interface MdxArticleContextValue {
|
|
11
|
+
filename: string;
|
|
12
|
+
language?: string;
|
|
13
|
+
/**
|
|
14
|
+
* The article's geotagged media (`contentImagesGeo`), when the host has
|
|
15
|
+
* them: the SERVER-RENDERED facade then carries real thumbnails with alt
|
|
16
|
+
* text and named stops in AUTO mode — what crawlers read. Optional.
|
|
17
|
+
*/
|
|
18
|
+
media?: readonly TripMapFacadeMedia[] | null;
|
|
19
|
+
}
|
|
20
|
+
export declare const MdxArticleContext: import("react").Context<MdxArticleContextValue | null>;
|
|
21
|
+
export declare function MdxArticleProvider({ value, children, }: {
|
|
22
|
+
value: MdxArticleContextValue | null;
|
|
23
|
+
children: ReactNode;
|
|
24
|
+
}): import("react").FunctionComponentElement<import("react").ProviderProps<MdxArticleContextValue | null>>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext as r, createElement as o } from "react";
|
|
3
|
+
const n = r(null);
|
|
4
|
+
function i({
|
|
5
|
+
value: e,
|
|
6
|
+
children: t
|
|
7
|
+
}) {
|
|
8
|
+
return o(n.Provider, { value: e }, t);
|
|
9
|
+
}
|
|
10
|
+
export {
|
|
11
|
+
n as MdxArticleContext,
|
|
12
|
+
i as MdxArticleProvider
|
|
13
|
+
};
|
|
@@ -11,7 +11,8 @@ import P from "./render/alert/Alert.js";
|
|
|
11
11
|
import x from "./render/badge/Badge.js";
|
|
12
12
|
import y from "./render/icon/Icon.js";
|
|
13
13
|
import w from "./render/tooltip/Tooltip.js";
|
|
14
|
-
|
|
14
|
+
import E from "./render/tripmap/TripMap.js";
|
|
15
|
+
const R = {
|
|
15
16
|
Callout: c,
|
|
16
17
|
YouTube: f,
|
|
17
18
|
Gallery: l,
|
|
@@ -22,35 +23,36 @@ const E = {
|
|
|
22
23
|
Alert: P,
|
|
23
24
|
Badge: x,
|
|
24
25
|
Icon: y,
|
|
25
|
-
Tooltip: w
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
Tooltip: w,
|
|
27
|
+
TripMap: E
|
|
28
|
+
}, A = "__mdxPreparePropsAppliedFor";
|
|
29
|
+
function C(r, e) {
|
|
28
30
|
const o = function(t) {
|
|
29
|
-
const { children: a, ...
|
|
30
|
-
return p(r,
|
|
31
|
+
const { children: a, ...m } = t, n = i(m, e);
|
|
32
|
+
return p(r, n, a);
|
|
31
33
|
};
|
|
32
|
-
return o[
|
|
34
|
+
return o[A] = e.name, o.displayName = `MdxPrepared(${e.name})`, o;
|
|
33
35
|
}
|
|
34
|
-
const
|
|
36
|
+
const D = new Map(
|
|
35
37
|
d.map((r) => [r.name, r])
|
|
36
38
|
);
|
|
37
|
-
function
|
|
38
|
-
const e =
|
|
39
|
+
function T(r) {
|
|
40
|
+
const e = D.get(r);
|
|
39
41
|
if (!e)
|
|
40
42
|
throw new Error(
|
|
41
43
|
`[mdx-render-react] No componentMetadata for "${r}" — the React render map has a component the schema does not know about. Add an entry in packages/mdx-schema/src/metadata/index.ts or remove the entry from serverRenderMap.`
|
|
42
44
|
);
|
|
43
45
|
return e;
|
|
44
46
|
}
|
|
45
|
-
const
|
|
46
|
-
Object.entries(
|
|
47
|
+
const z = Object.fromEntries(
|
|
48
|
+
Object.entries(R).map(([r, e]) => [
|
|
47
49
|
r,
|
|
48
|
-
|
|
50
|
+
C(e, T(r))
|
|
49
51
|
])
|
|
50
52
|
);
|
|
51
53
|
export {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
A as MDX_PREPARED_SENTINEL,
|
|
55
|
+
R as serverRenderMap,
|
|
56
|
+
C as wrapReactComponentForMdx,
|
|
57
|
+
z as wrappedServerRenderMap
|
|
56
58
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@blogmcp/mdx-render-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "React renderer for MDX components — implements the @blogmcp/mdx-schema contract alongside @blogmcp/mdx-render-vue. Server-Component-first with two client islands (Tooltip, AudioPlayer).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -32,13 +32,16 @@
|
|
|
32
32
|
"dev": "vite build --watch",
|
|
33
33
|
"test": "vitest run",
|
|
34
34
|
"test:watch": "vitest",
|
|
35
|
-
"typecheck": "tsc --noEmit"
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"test:e2e": "playwright test --config test/e2e/playwright.config.ts",
|
|
37
|
+
"harness": "vite --config test/e2e/harness/vite.config.ts --port 5097"
|
|
36
38
|
},
|
|
37
39
|
"dependencies": {
|
|
38
40
|
"@ark-ui/react": "^5.0.0",
|
|
39
|
-
"@blogmcp/mdx-schema": "^0.
|
|
41
|
+
"@blogmcp/mdx-schema": "^0.5.0",
|
|
42
|
+
"@blogmcp/tripmap-core": "^0.1.2",
|
|
40
43
|
"@blogmcp/sanitize": "^0.3.0",
|
|
41
|
-
"@blogmcp/sdk": "^0.3.
|
|
44
|
+
"@blogmcp/sdk": "^0.3.2",
|
|
42
45
|
"@iconify/react": "^6.0.0",
|
|
43
46
|
"marked": "^15.0.7"
|
|
44
47
|
},
|
package/src/registry.ts
CHANGED
|
@@ -95,6 +95,11 @@ const reactWiring: Record<string, ReactWiring> = {
|
|
|
95
95
|
default: () => import('./render/tooltip/Tooltip'),
|
|
96
96
|
},
|
|
97
97
|
},
|
|
98
|
+
TripMap: {
|
|
99
|
+
render: {
|
|
100
|
+
default: () => import('./render/tripmap/TripMap'),
|
|
101
|
+
},
|
|
102
|
+
},
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
/** Merged registry: schema metadata + React wiring, keyed by component name. */
|
package/src/render/index.ts
CHANGED
|
@@ -16,6 +16,9 @@ export { default as Alert } from './alert/Alert'
|
|
|
16
16
|
export { default as Badge } from './badge/Badge'
|
|
17
17
|
export { default as Icon } from './icon/Icon'
|
|
18
18
|
export { default as Tooltip } from './tooltip/Tooltip'
|
|
19
|
+
export { default as TripMap } from './tripmap/TripMap'
|
|
20
|
+
export { MdxArticleContext, MdxArticleProvider } from './tripmap/article-context'
|
|
21
|
+
export type { MdxArticleContextValue } from './tripmap/article-context'
|
|
19
22
|
|
|
20
23
|
export type { ButtonProps } from './button/Button'
|
|
21
24
|
export type { BadgeProps } from './badge/Badge'
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TripMap — client island (mirrors Vue TripMapDefault.vue).
|
|
5
|
+
*
|
|
6
|
+
* SSR-safe facade (thumb strip + waypoint list + placeholder box) that
|
|
7
|
+
* upgrades to the MapLibre map when scrolled near. The map engine is
|
|
8
|
+
* reached ONLY via dynamic `import('@blogmcp/tripmap-core')` — never a
|
|
9
|
+
* static import — so bundlers emit maplibre as a lazy chunk and the
|
|
10
|
+
* single-file article-review bundle (which externalizes the core) simply
|
|
11
|
+
* keeps the facade when the import rejects.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
|
15
|
+
import type { TripMapProps } from '@blogmcp/mdx-schema'
|
|
16
|
+
import { tripMapStyles } from '@blogmcp/mdx-schema/styles'
|
|
17
|
+
import {
|
|
18
|
+
buildTripMapGeoUrl,
|
|
19
|
+
parseTripMapHandNodes,
|
|
20
|
+
splitTripMapList,
|
|
21
|
+
TRIPMAP_DEFAULT_HEIGHT,
|
|
22
|
+
tripMapFormatTime,
|
|
23
|
+
tripMapPlaybackLabels,
|
|
24
|
+
tripMapUiTexts,
|
|
25
|
+
type TripMapGeoResponse,
|
|
26
|
+
tripMapFacade,
|
|
27
|
+
} from '@blogmcp/mdx-schema/tripmap'
|
|
28
|
+
|
|
29
|
+
import { MdxArticleContext } from './article-context'
|
|
30
|
+
|
|
31
|
+
type Status = 'facade' | 'loading' | 'live' | 'empty'
|
|
32
|
+
|
|
33
|
+
export default function TripMap(props: TripMapProps) {
|
|
34
|
+
const { slugs, nodes, height, line, filmstrip, playback, cluster, theme, styleUrl, center, zoom, title, detail } = props
|
|
35
|
+
const article = useContext(MdxArticleContext)
|
|
36
|
+
|
|
37
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
38
|
+
const mapRef = useRef<HTMLDivElement | null>(null)
|
|
39
|
+
// detail="panel": the engine mounts the photo panel here, below the sized map box.
|
|
40
|
+
const detailRef = useRef<HTMLDivElement | null>(null)
|
|
41
|
+
const destroyRef = useRef<(() => void) | null>(null)
|
|
42
|
+
const startedRef = useRef(false)
|
|
43
|
+
const [status, setStatus] = useState<Status>('facade')
|
|
44
|
+
|
|
45
|
+
const slugList = useMemo(() => splitTripMapList(slugs), [slugs])
|
|
46
|
+
const handNodes = useMemo(() => parseTripMapHandNodes(nodes), [nodes])
|
|
47
|
+
const geoUrl = useMemo(() => buildTripMapGeoUrl(props, article), [props, article])
|
|
48
|
+
// Nothing selects media: the geo call only carries the tenant basemap config.
|
|
49
|
+
const configOnly = slugList.length === 0 && !article?.filename
|
|
50
|
+
const texts = useMemo(() => tripMapUiTexts(article?.language), [article])
|
|
51
|
+
const labels = useMemo(() => tripMapPlaybackLabels(article?.language), [article])
|
|
52
|
+
const facade = useMemo(() => tripMapFacade(props, article), [props, article])
|
|
53
|
+
|
|
54
|
+
const start = useCallback(async () => {
|
|
55
|
+
if (startedRef.current) return
|
|
56
|
+
startedRef.current = true
|
|
57
|
+
setStatus('loading')
|
|
58
|
+
try {
|
|
59
|
+
const fetchGeo = async (): Promise<TripMapGeoResponse> => {
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(geoUrl, { headers: { accept: 'application/json' } })
|
|
62
|
+
if (!res.ok) throw new Error(`media/geo ${res.status}`)
|
|
63
|
+
return (await res.json()) as TripMapGeoResponse
|
|
64
|
+
} catch (err) {
|
|
65
|
+
// Config-only (hand-made nodes only): keep the nodes, run on the
|
|
66
|
+
// core default style. With a selection the nodes ARE the response,
|
|
67
|
+
// so the failure propagates and the static facade stays.
|
|
68
|
+
if (configOnly) return { nodes: [] }
|
|
69
|
+
throw err
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const [core, geo] = await Promise.all([import('@blogmcp/tripmap-core'), fetchGeo()])
|
|
73
|
+
const mediaNodes = (geo.nodes ?? [])
|
|
74
|
+
.filter((n) => typeof n.latitude === 'number' && typeof n.longitude === 'number')
|
|
75
|
+
.map((n) => ({
|
|
76
|
+
id: n.slug,
|
|
77
|
+
lat: n.latitude,
|
|
78
|
+
lng: n.longitude,
|
|
79
|
+
capturedAt: n.capturedAt ?? undefined,
|
|
80
|
+
thumbnailUrl: n.thumbnailUrl ?? undefined,
|
|
81
|
+
imageUrl: n.url ?? undefined,
|
|
82
|
+
largeImageUrl: n.largeUrl ?? undefined,
|
|
83
|
+
altText: n.altText ?? undefined,
|
|
84
|
+
title: n.placeName ?? undefined,
|
|
85
|
+
}))
|
|
86
|
+
const tripNodes = core.buildTripNodes(mediaNodes, handNodes)
|
|
87
|
+
if (tripNodes.length === 0 || !mapRef.current) {
|
|
88
|
+
setStatus('empty')
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
let initialView: { center?: [number, number]; zoom?: number } | undefined
|
|
92
|
+
if (center) {
|
|
93
|
+
const [lat, lng] = center.split(',').map((v) => Number.parseFloat(v.trim()))
|
|
94
|
+
if (Number.isFinite(lat) && Number.isFinite(lng)) initialView = { center: [lng, lat], zoom }
|
|
95
|
+
}
|
|
96
|
+
const map = new core.TripMap(mapRef.current, {
|
|
97
|
+
styleUrl: styleUrl || geo.config?.styleUrl || '',
|
|
98
|
+
darkStyleUrl: geo.config?.darkStyleUrl,
|
|
99
|
+
theme: theme ?? 'auto',
|
|
100
|
+
cluster: cluster ?? true,
|
|
101
|
+
showLine: line ?? true,
|
|
102
|
+
showFilmstrip: filmstrip ?? true,
|
|
103
|
+
showPlayback: playback ?? true,
|
|
104
|
+
title,
|
|
105
|
+
detail: detail ?? 'popup',
|
|
106
|
+
detailHost: detail === 'panel' ? (detailRef.current ?? undefined) : undefined,
|
|
107
|
+
extraAttribution: geo.config?.attribution,
|
|
108
|
+
labels,
|
|
109
|
+
initialView,
|
|
110
|
+
events: {},
|
|
111
|
+
})
|
|
112
|
+
map.setNodes(tripNodes)
|
|
113
|
+
await map.init()
|
|
114
|
+
destroyRef.current = () => map.destroy()
|
|
115
|
+
setStatus('live')
|
|
116
|
+
} catch {
|
|
117
|
+
// Core unavailable (review-app iframe, offline) or fetch failed —
|
|
118
|
+
// the static facade stays, which is the designed degradation.
|
|
119
|
+
setStatus((s) => (s === 'loading' ? 'facade' : s))
|
|
120
|
+
}
|
|
121
|
+
}, [geoUrl, configOnly, handNodes, styleUrl, theme, cluster, line, filmstrip, labels, center, zoom, title, detail])
|
|
122
|
+
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
const el = rootRef.current
|
|
125
|
+
if (!el) return
|
|
126
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
127
|
+
void start()
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
const observer = new IntersectionObserver(
|
|
131
|
+
(entries) => {
|
|
132
|
+
if (entries.some((e) => e.isIntersecting)) {
|
|
133
|
+
observer.disconnect()
|
|
134
|
+
void start()
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
{ rootMargin: '300px' },
|
|
138
|
+
)
|
|
139
|
+
observer.observe(el)
|
|
140
|
+
return () => observer.disconnect()
|
|
141
|
+
}, [start])
|
|
142
|
+
|
|
143
|
+
useEffect(
|
|
144
|
+
() => () => {
|
|
145
|
+
destroyRef.current?.()
|
|
146
|
+
destroyRef.current = null
|
|
147
|
+
},
|
|
148
|
+
[],
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
const showFacade = status !== 'live'
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div ref={rootRef} className={tripMapStyles.container} data-mdx-component="TripMap" aria-label={title}>
|
|
155
|
+
{title ? <p className={tripMapStyles.head}>{title}</p> : null}
|
|
156
|
+
|
|
157
|
+
{/* Server-rendered strip with alt text — what crawlers read; replaced by the live filmstrip. */}
|
|
158
|
+
{showFacade && facade.thumbs.length > 0 ? (
|
|
159
|
+
<ol className={tripMapStyles.strip}>
|
|
160
|
+
{facade.thumbs.map((thumb) => (
|
|
161
|
+
<li key={thumb.slug} className={tripMapStyles.stripItem}>
|
|
162
|
+
<img
|
|
163
|
+
src={thumb.src}
|
|
164
|
+
alt={thumb.alt}
|
|
165
|
+
width={80}
|
|
166
|
+
height={80}
|
|
167
|
+
loading="lazy"
|
|
168
|
+
decoding="async"
|
|
169
|
+
className={tripMapStyles.stripImg}
|
|
170
|
+
/>
|
|
171
|
+
</li>
|
|
172
|
+
))}
|
|
173
|
+
</ol>
|
|
174
|
+
) : null}
|
|
175
|
+
|
|
176
|
+
{/*
|
|
177
|
+
Sized wrapper: the engine host is a static absolutely-positioned child
|
|
178
|
+
that React never re-patches (reactive props on the engine's container
|
|
179
|
+
would clobber the classes/children the engine adds); the facade box is
|
|
180
|
+
an overlay that simply unmounts when the map goes live.
|
|
181
|
+
*/}
|
|
182
|
+
<div className={tripMapStyles.mapWrap} style={{ height: height || TRIPMAP_DEFAULT_HEIGHT }}>
|
|
183
|
+
<div ref={mapRef} className="absolute inset-0" />
|
|
184
|
+
{showFacade ? (
|
|
185
|
+
<div className={tripMapStyles.mapOverlay}>
|
|
186
|
+
<div className={tripMapStyles.mapBox}>
|
|
187
|
+
<span className={tripMapStyles.mapHint}>{status === 'empty' ? texts.empty : texts.hint}</span>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
) : null}
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{showFacade && facade.stops.length > 0 ? (
|
|
194
|
+
<ol className={tripMapStyles.stopList}>
|
|
195
|
+
{facade.stops.map((stop) => (
|
|
196
|
+
<li key={stop.key}>
|
|
197
|
+
{stop.capturedAt ? (
|
|
198
|
+
<span className={tripMapStyles.stopTime}>{tripMapFormatTime(stop.capturedAt)}</span>
|
|
199
|
+
) : null}
|
|
200
|
+
<span>{stop.title}</span>
|
|
201
|
+
</li>
|
|
202
|
+
))}
|
|
203
|
+
</ol>
|
|
204
|
+
) : null}
|
|
205
|
+
|
|
206
|
+
{showFacade ? <p className={tripMapStyles.note}>{texts.unavailable}</p> : null}
|
|
207
|
+
</div>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Article identity for TripMap AUTO mode (map every geotagged media of the
|
|
5
|
+
* article). The host app wraps its rendered article body in the provider —
|
|
6
|
+
* the Next blog does in `app/articles/[slug]/page.tsx` AND the draft preview
|
|
7
|
+
* `app/preview/[token]/page.tsx`; a consumer that doesn't simply has no auto
|
|
8
|
+
* mode and TripMap needs explicit `slugs` or `nodes`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createContext, createElement, type ReactNode } from 'react'
|
|
12
|
+
import type { TripMapFacadeMedia } from '@blogmcp/mdx-schema/tripmap'
|
|
13
|
+
|
|
14
|
+
export interface MdxArticleContextValue {
|
|
15
|
+
filename: string
|
|
16
|
+
language?: string
|
|
17
|
+
/**
|
|
18
|
+
* The article's geotagged media (`contentImagesGeo`), when the host has
|
|
19
|
+
* them: the SERVER-RENDERED facade then carries real thumbnails with alt
|
|
20
|
+
* text and named stops in AUTO mode — what crawlers read. Optional.
|
|
21
|
+
*/
|
|
22
|
+
media?: readonly TripMapFacadeMedia[] | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const MdxArticleContext = createContext<MdxArticleContextValue | null>(null)
|
|
26
|
+
|
|
27
|
+
export function MdxArticleProvider({
|
|
28
|
+
value,
|
|
29
|
+
children,
|
|
30
|
+
}: {
|
|
31
|
+
value: MdxArticleContextValue | null
|
|
32
|
+
children: ReactNode
|
|
33
|
+
}) {
|
|
34
|
+
return createElement(MdxArticleContext.Provider, { value }, children)
|
|
35
|
+
}
|
package/src/server-render-map.ts
CHANGED
|
@@ -41,6 +41,7 @@ import Alert from './render/alert/Alert'
|
|
|
41
41
|
import Badge from './render/badge/Badge'
|
|
42
42
|
import Icon from './render/icon/Icon'
|
|
43
43
|
import Tooltip from './render/tooltip/Tooltip'
|
|
44
|
+
import TripMap from './render/tripmap/TripMap'
|
|
44
45
|
|
|
45
46
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
47
|
export const serverRenderMap: Record<string, ComponentType<any>> = {
|
|
@@ -55,6 +56,7 @@ export const serverRenderMap: Record<string, ComponentType<any>> = {
|
|
|
55
56
|
Badge,
|
|
56
57
|
Icon,
|
|
57
58
|
Tooltip,
|
|
59
|
+
TripMap,
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
/**
|