@blogmcp/mdx-render-react 0.3.0 → 0.4.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/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 +107 -0
- package/dist/render/tripmap/article-context.d.ts +17 -0
- package/dist/render/tripmap/article-context.js +13 -0
- package/dist/server-render-map.js +19 -17
- package/package.json +3 -2
- package/src/registry.ts +5 -0
- package/src/render/index.ts +3 -0
- package/src/render/tripmap/TripMap.tsx +200 -0
- package/src/render/tripmap/article-context.ts +28 -0
- package/src/server-render-map.ts +2 -0
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,107 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsxs as T, jsx as r } from "react/jsx-runtime";
|
|
3
|
+
import { useContext as D, useRef as d, useState as V, useMemo as n, useCallback as W, useEffect as G } from "react";
|
|
4
|
+
import { tripMapStyles as a } from "@blogmcp/mdx-schema/styles";
|
|
5
|
+
import { splitTripMapList as q, parseTripMapHandNodes as J, buildTripMapGeoUrl as K, tripMapUiTexts as Q, tripMapPlaybackLabels as X, TRIPMAP_DEFAULT_HEIGHT as Y, tripMapFormatTime as Z } from "@blogmcp/mdx-schema/tripmap";
|
|
6
|
+
import { MdxArticleContext as ee } from "./article-context.js";
|
|
7
|
+
function se(p) {
|
|
8
|
+
const { slugs: x, nodes: M, height: z, line: U, filmstrip: A, playback: _, cluster: I, theme: k, styleUrl: F, center: m, zoom: R, title: h } = p, l = D(ee), L = d(null), f = d(null), g = d(null), P = d(!1), [S, o] = V("facade"), y = n(() => q(x), [x]), c = n(() => J(M), [M]), j = n(() => K(p, l), [p, l]), C = y.length === 0 && !l?.filename, b = n(() => Q(l?.language), [l]), E = n(() => X(l?.language), [l]), N = W(async () => {
|
|
9
|
+
if (!P.current) {
|
|
10
|
+
P.current = !0, o("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 (C) return { nodes: [] };
|
|
19
|
+
throw e;
|
|
20
|
+
}
|
|
21
|
+
}, [i, s] = await Promise.all([import("@blogmcp/tripmap-core"), t()]), v = (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
|
+
altText: e.altText ?? void 0,
|
|
29
|
+
title: e.placeName ?? void 0
|
|
30
|
+
})), H = i.buildTripNodes(v, c);
|
|
31
|
+
if (H.length === 0 || !f.current) {
|
|
32
|
+
o("empty");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
let O;
|
|
36
|
+
if (m) {
|
|
37
|
+
const [e, $] = m.split(",").map((B) => Number.parseFloat(B.trim()));
|
|
38
|
+
Number.isFinite(e) && Number.isFinite($) && (O = { center: [$, e], zoom: R });
|
|
39
|
+
}
|
|
40
|
+
const w = new i.TripMap(f.current, {
|
|
41
|
+
styleUrl: F || s.config?.styleUrl || "",
|
|
42
|
+
darkStyleUrl: s.config?.darkStyleUrl,
|
|
43
|
+
theme: k ?? "auto",
|
|
44
|
+
cluster: I ?? !0,
|
|
45
|
+
showLine: U ?? !0,
|
|
46
|
+
showFilmstrip: A ?? !0,
|
|
47
|
+
showPlayback: _ ?? !0,
|
|
48
|
+
extraAttribution: s.config?.attribution,
|
|
49
|
+
labels: E,
|
|
50
|
+
initialView: O,
|
|
51
|
+
events: {}
|
|
52
|
+
});
|
|
53
|
+
w.setNodes(H), await w.init(), g.current = () => w.destroy(), o("live");
|
|
54
|
+
} catch {
|
|
55
|
+
o((t) => t === "loading" ? "facade" : t);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}, [j, C, c, F, k, I, U, A, E, m, R]);
|
|
59
|
+
G(() => {
|
|
60
|
+
const t = L.current;
|
|
61
|
+
if (!t) return;
|
|
62
|
+
if (typeof IntersectionObserver > "u") {
|
|
63
|
+
N();
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const i = new IntersectionObserver(
|
|
67
|
+
(s) => {
|
|
68
|
+
s.some((v) => v.isIntersecting) && (i.disconnect(), N());
|
|
69
|
+
},
|
|
70
|
+
{ rootMargin: "300px" }
|
|
71
|
+
);
|
|
72
|
+
return i.observe(t), () => i.disconnect();
|
|
73
|
+
}, [N]), G(
|
|
74
|
+
() => () => {
|
|
75
|
+
g.current?.(), g.current = null;
|
|
76
|
+
},
|
|
77
|
+
[]
|
|
78
|
+
);
|
|
79
|
+
const u = S !== "live";
|
|
80
|
+
return /* @__PURE__ */ T("div", { ref: L, className: a.container, "data-mdx-component": "TripMap", "aria-label": h, children: [
|
|
81
|
+
h ? /* @__PURE__ */ r("p", { className: a.head, children: h }) : null,
|
|
82
|
+
u && y.length > 0 ? /* @__PURE__ */ r("ol", { className: a.strip, children: y.map((t) => /* @__PURE__ */ r("li", { className: a.stripItem, children: /* @__PURE__ */ r(
|
|
83
|
+
"img",
|
|
84
|
+
{
|
|
85
|
+
src: `/api/media/thumb/${encodeURIComponent(t)}?width=192`,
|
|
86
|
+
alt: "",
|
|
87
|
+
width: 80,
|
|
88
|
+
height: 80,
|
|
89
|
+
loading: "lazy",
|
|
90
|
+
decoding: "async",
|
|
91
|
+
className: a.stripImg
|
|
92
|
+
}
|
|
93
|
+
) }, t)) }) : null,
|
|
94
|
+
/* @__PURE__ */ T("div", { className: a.mapWrap, style: { height: z || Y }, children: [
|
|
95
|
+
/* @__PURE__ */ r("div", { ref: f, className: "absolute inset-0" }),
|
|
96
|
+
u ? /* @__PURE__ */ r("div", { className: a.mapOverlay, children: /* @__PURE__ */ r("div", { className: a.mapBox, children: /* @__PURE__ */ r("span", { className: a.mapHint, children: S === "empty" ? b.empty : b.hint }) }) }) : null
|
|
97
|
+
] }),
|
|
98
|
+
u && c.length > 0 ? /* @__PURE__ */ r("ol", { className: a.stopList, children: c.map((t, i) => /* @__PURE__ */ T("li", { children: [
|
|
99
|
+
t.capturedAt ? /* @__PURE__ */ r("span", { className: a.stopTime, children: Z(t.capturedAt) }) : null,
|
|
100
|
+
/* @__PURE__ */ r("span", { children: t.title || `${t.lat}, ${t.lng}` })
|
|
101
|
+
] }, t.id || i)) }) : null,
|
|
102
|
+
u ? /* @__PURE__ */ r("p", { className: a.note, children: b.unavailable }) : null
|
|
103
|
+
] });
|
|
104
|
+
}
|
|
105
|
+
export {
|
|
106
|
+
se as default
|
|
107
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
export interface MdxArticleContextValue {
|
|
10
|
+
filename: string;
|
|
11
|
+
language?: string;
|
|
12
|
+
}
|
|
13
|
+
export declare const MdxArticleContext: import("react").Context<MdxArticleContextValue | null>;
|
|
14
|
+
export declare function MdxArticleProvider({ value, children, }: {
|
|
15
|
+
value: MdxArticleContextValue | null;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}): 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.4.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": {
|
|
@@ -36,7 +36,8 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@ark-ui/react": "^5.0.0",
|
|
39
|
-
"@blogmcp/mdx-schema": "^0.
|
|
39
|
+
"@blogmcp/mdx-schema": "^0.4.0",
|
|
40
|
+
"@blogmcp/tripmap-core": "^0.1.0",
|
|
40
41
|
"@blogmcp/sanitize": "^0.3.0",
|
|
41
42
|
"@blogmcp/sdk": "^0.3.0",
|
|
42
43
|
"@iconify/react": "^6.0.0",
|
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,200 @@
|
|
|
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
|
+
} from '@blogmcp/mdx-schema/tripmap'
|
|
27
|
+
|
|
28
|
+
import { MdxArticleContext } from './article-context'
|
|
29
|
+
|
|
30
|
+
type Status = 'facade' | 'loading' | 'live' | 'empty'
|
|
31
|
+
|
|
32
|
+
export default function TripMap(props: TripMapProps) {
|
|
33
|
+
const { slugs, nodes, height, line, filmstrip, playback, cluster, theme, styleUrl, center, zoom, title } = props
|
|
34
|
+
const article = useContext(MdxArticleContext)
|
|
35
|
+
|
|
36
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
37
|
+
const mapRef = useRef<HTMLDivElement | null>(null)
|
|
38
|
+
const destroyRef = useRef<(() => void) | null>(null)
|
|
39
|
+
const startedRef = useRef(false)
|
|
40
|
+
const [status, setStatus] = useState<Status>('facade')
|
|
41
|
+
|
|
42
|
+
const slugList = useMemo(() => splitTripMapList(slugs), [slugs])
|
|
43
|
+
const handNodes = useMemo(() => parseTripMapHandNodes(nodes), [nodes])
|
|
44
|
+
const geoUrl = useMemo(() => buildTripMapGeoUrl(props, article), [props, article])
|
|
45
|
+
// Nothing selects media: the geo call only carries the tenant basemap config.
|
|
46
|
+
const configOnly = slugList.length === 0 && !article?.filename
|
|
47
|
+
const texts = useMemo(() => tripMapUiTexts(article?.language), [article])
|
|
48
|
+
const labels = useMemo(() => tripMapPlaybackLabels(article?.language), [article])
|
|
49
|
+
|
|
50
|
+
const start = useCallback(async () => {
|
|
51
|
+
if (startedRef.current) return
|
|
52
|
+
startedRef.current = true
|
|
53
|
+
setStatus('loading')
|
|
54
|
+
try {
|
|
55
|
+
const fetchGeo = async (): Promise<TripMapGeoResponse> => {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(geoUrl, { headers: { accept: 'application/json' } })
|
|
58
|
+
if (!res.ok) throw new Error(`media/geo ${res.status}`)
|
|
59
|
+
return (await res.json()) as TripMapGeoResponse
|
|
60
|
+
} catch (err) {
|
|
61
|
+
// Config-only (hand-made nodes only): keep the nodes, run on the
|
|
62
|
+
// core default style. With a selection the nodes ARE the response,
|
|
63
|
+
// so the failure propagates and the static facade stays.
|
|
64
|
+
if (configOnly) return { nodes: [] }
|
|
65
|
+
throw err
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const [core, geo] = await Promise.all([import('@blogmcp/tripmap-core'), fetchGeo()])
|
|
69
|
+
const mediaNodes = (geo.nodes ?? [])
|
|
70
|
+
.filter((n) => typeof n.latitude === 'number' && typeof n.longitude === 'number')
|
|
71
|
+
.map((n) => ({
|
|
72
|
+
id: n.slug,
|
|
73
|
+
lat: n.latitude,
|
|
74
|
+
lng: n.longitude,
|
|
75
|
+
capturedAt: n.capturedAt ?? undefined,
|
|
76
|
+
thumbnailUrl: n.thumbnailUrl ?? undefined,
|
|
77
|
+
imageUrl: n.url ?? undefined,
|
|
78
|
+
altText: n.altText ?? undefined,
|
|
79
|
+
title: n.placeName ?? undefined,
|
|
80
|
+
}))
|
|
81
|
+
const tripNodes = core.buildTripNodes(mediaNodes, handNodes)
|
|
82
|
+
if (tripNodes.length === 0 || !mapRef.current) {
|
|
83
|
+
setStatus('empty')
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
let initialView: { center?: [number, number]; zoom?: number } | undefined
|
|
87
|
+
if (center) {
|
|
88
|
+
const [lat, lng] = center.split(',').map((v) => Number.parseFloat(v.trim()))
|
|
89
|
+
if (Number.isFinite(lat) && Number.isFinite(lng)) initialView = { center: [lng, lat], zoom }
|
|
90
|
+
}
|
|
91
|
+
const map = new core.TripMap(mapRef.current, {
|
|
92
|
+
styleUrl: styleUrl || geo.config?.styleUrl || '',
|
|
93
|
+
darkStyleUrl: geo.config?.darkStyleUrl,
|
|
94
|
+
theme: theme ?? 'auto',
|
|
95
|
+
cluster: cluster ?? true,
|
|
96
|
+
showLine: line ?? true,
|
|
97
|
+
showFilmstrip: filmstrip ?? true,
|
|
98
|
+
showPlayback: playback ?? true,
|
|
99
|
+
extraAttribution: geo.config?.attribution,
|
|
100
|
+
labels,
|
|
101
|
+
initialView,
|
|
102
|
+
events: {},
|
|
103
|
+
})
|
|
104
|
+
map.setNodes(tripNodes)
|
|
105
|
+
await map.init()
|
|
106
|
+
destroyRef.current = () => map.destroy()
|
|
107
|
+
setStatus('live')
|
|
108
|
+
} catch {
|
|
109
|
+
// Core unavailable (review-app iframe, offline) or fetch failed —
|
|
110
|
+
// the static facade stays, which is the designed degradation.
|
|
111
|
+
setStatus((s) => (s === 'loading' ? 'facade' : s))
|
|
112
|
+
}
|
|
113
|
+
}, [geoUrl, configOnly, handNodes, styleUrl, theme, cluster, line, filmstrip, labels, center, zoom])
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const el = rootRef.current
|
|
117
|
+
if (!el) return
|
|
118
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
119
|
+
void start()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
const observer = new IntersectionObserver(
|
|
123
|
+
(entries) => {
|
|
124
|
+
if (entries.some((e) => e.isIntersecting)) {
|
|
125
|
+
observer.disconnect()
|
|
126
|
+
void start()
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{ rootMargin: '300px' },
|
|
130
|
+
)
|
|
131
|
+
observer.observe(el)
|
|
132
|
+
return () => observer.disconnect()
|
|
133
|
+
}, [start])
|
|
134
|
+
|
|
135
|
+
useEffect(
|
|
136
|
+
() => () => {
|
|
137
|
+
destroyRef.current?.()
|
|
138
|
+
destroyRef.current = null
|
|
139
|
+
},
|
|
140
|
+
[],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const showFacade = status !== 'live'
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div ref={rootRef} className={tripMapStyles.container} data-mdx-component="TripMap" aria-label={title}>
|
|
147
|
+
{title ? <p className={tripMapStyles.head}>{title}</p> : null}
|
|
148
|
+
|
|
149
|
+
{showFacade && slugList.length > 0 ? (
|
|
150
|
+
<ol className={tripMapStyles.strip}>
|
|
151
|
+
{slugList.map((slug) => (
|
|
152
|
+
<li key={slug} className={tripMapStyles.stripItem}>
|
|
153
|
+
<img
|
|
154
|
+
src={`/api/media/thumb/${encodeURIComponent(slug)}?width=192`}
|
|
155
|
+
alt=""
|
|
156
|
+
width={80}
|
|
157
|
+
height={80}
|
|
158
|
+
loading="lazy"
|
|
159
|
+
decoding="async"
|
|
160
|
+
className={tripMapStyles.stripImg}
|
|
161
|
+
/>
|
|
162
|
+
</li>
|
|
163
|
+
))}
|
|
164
|
+
</ol>
|
|
165
|
+
) : null}
|
|
166
|
+
|
|
167
|
+
{/*
|
|
168
|
+
Sized wrapper: the engine host is a static absolutely-positioned child
|
|
169
|
+
that React never re-patches (reactive props on the engine's container
|
|
170
|
+
would clobber the classes/children the engine adds); the facade box is
|
|
171
|
+
an overlay that simply unmounts when the map goes live.
|
|
172
|
+
*/}
|
|
173
|
+
<div className={tripMapStyles.mapWrap} style={{ height: height || TRIPMAP_DEFAULT_HEIGHT }}>
|
|
174
|
+
<div ref={mapRef} className="absolute inset-0" />
|
|
175
|
+
{showFacade ? (
|
|
176
|
+
<div className={tripMapStyles.mapOverlay}>
|
|
177
|
+
<div className={tripMapStyles.mapBox}>
|
|
178
|
+
<span className={tripMapStyles.mapHint}>{status === 'empty' ? texts.empty : texts.hint}</span>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
) : null}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{showFacade && handNodes.length > 0 ? (
|
|
185
|
+
<ol className={tripMapStyles.stopList}>
|
|
186
|
+
{handNodes.map((node, i) => (
|
|
187
|
+
<li key={node.id || i}>
|
|
188
|
+
{node.capturedAt ? (
|
|
189
|
+
<span className={tripMapStyles.stopTime}>{tripMapFormatTime(node.capturedAt)}</span>
|
|
190
|
+
) : null}
|
|
191
|
+
<span>{node.title || `${node.lat}, ${node.lng}`}</span>
|
|
192
|
+
</li>
|
|
193
|
+
))}
|
|
194
|
+
</ol>
|
|
195
|
+
) : null}
|
|
196
|
+
|
|
197
|
+
{showFacade ? <p className={tripMapStyles.note}>{texts.unavailable}</p> : null}
|
|
198
|
+
</div>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
|
|
13
|
+
export interface MdxArticleContextValue {
|
|
14
|
+
filename: string
|
|
15
|
+
language?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const MdxArticleContext = createContext<MdxArticleContextValue | null>(null)
|
|
19
|
+
|
|
20
|
+
export function MdxArticleProvider({
|
|
21
|
+
value,
|
|
22
|
+
children,
|
|
23
|
+
}: {
|
|
24
|
+
value: MdxArticleContextValue | null
|
|
25
|
+
children: ReactNode
|
|
26
|
+
}) {
|
|
27
|
+
return createElement(MdxArticleContext.Provider, { value }, children)
|
|
28
|
+
}
|
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
|
/**
|