{
+ return new SignJWT({ route_id: routeId, permissions })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuer(ISSUER)
+ .setExpirationTime("7d")
+ .setIssuedAt()
+ .sign(JWT_SECRET);
+}
+
+export async function verifyRouteToken(token: string): Promise<{ routeId: string; permissions: string[] }> {
+ const { payload } = await jwtVerify(token, JWT_SECRET, {
+ issuer: ISSUER,
+ });
+
+ return {
+ routeId: payload.route_id as string,
+ permissions: payload.permissions as string[],
+ };
+}
diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts
index 8f69788..66acaab 100644
--- a/apps/journal/app/routes.ts
+++ b/apps/journal/app/routes.ts
@@ -12,5 +12,8 @@ export default [
route("routes/new", "routes/routes.new.tsx"),
route("routes/:id", "routes/routes.$id.tsx"),
route("routes/:id/edit", "routes/routes.$id.edit.tsx"),
+ route("api/routes/:id/callback", "routes/api.routes.$id.callback.ts"),
+ route("api/routes/:id/edit-in-planner", "routes/api.routes.$id.edit-in-planner.ts"),
+ route("api/routes/:id/gpx", "routes/api.routes.$id.gpx.ts"),
route("users/:username", "routes/users.$username.tsx"),
] satisfies RouteConfig;
diff --git a/apps/journal/app/routes/api.routes.$id.callback.ts b/apps/journal/app/routes/api.routes.$id.callback.ts
new file mode 100644
index 0000000..e656e38
--- /dev/null
+++ b/apps/journal/app/routes/api.routes.$id.callback.ts
@@ -0,0 +1,52 @@
+import { data } from "react-router";
+import type { Route } from "./+types/api.routes.$id.callback";
+import { verifyRouteToken } from "~/lib/jwt.server";
+import { updateRoute, getRoute } from "~/lib/routes.server";
+
+export async function action({ params, request }: Route.ActionArgs) {
+ if (request.method !== "POST") {
+ return data({ error: "Method not allowed" }, { status: 405 });
+ }
+
+ // Verify JWT token from Authorization header or body
+ const authHeader = request.headers.get("Authorization");
+ const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null;
+
+ if (!token) {
+ return data({ error: "Missing authorization token" }, { status: 401 });
+ }
+
+ try {
+ const { routeId, permissions } = await verifyRouteToken(token);
+
+ // Verify token is for this route
+ if (routeId !== params.id) {
+ return data({ error: "Token not valid for this route" }, { status: 403 });
+ }
+
+ if (!permissions.includes("write")) {
+ return data({ error: "Token does not have write permission" }, { status: 403 });
+ }
+
+ // Get route to verify it exists
+ const route = await getRoute(params.id);
+ if (!route) {
+ return data({ error: "Route not found" }, { status: 404 });
+ }
+
+ // Parse GPX from request body
+ const body = await request.json();
+ const { gpx } = body as { gpx: string };
+
+ if (!gpx) {
+ return data({ error: "Missing GPX data" }, { status: 400 });
+ }
+
+ // Update route with new GPX (creates new version)
+ await updateRoute(params.id, route.ownerId, { gpx });
+
+ return data({ success: true, routeId: params.id });
+ } catch (e) {
+ return data({ error: (e as Error).message }, { status: 401 });
+ }
+}
diff --git a/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts
new file mode 100644
index 0000000..5bd38cf
--- /dev/null
+++ b/apps/journal/app/routes/api.routes.$id.edit-in-planner.ts
@@ -0,0 +1,50 @@
+import { data } from "react-router";
+import type { Route } from "./+types/api.routes.$id.edit-in-planner";
+import { getSessionUser } from "~/lib/auth.server";
+import { getRouteWithVersions } from "~/lib/routes.server";
+import { createRouteToken } from "~/lib/jwt.server";
+
+export async function action({ params, request }: Route.ActionArgs) {
+ const user = await getSessionUser(request);
+ if (!user) return data({ error: "Not authenticated" }, { status: 401 });
+
+ const route = await getRouteWithVersions(params.id);
+ if (!route) return data({ error: "Route not found" }, { status: 404 });
+ if (route.ownerId !== user.id) return data({ error: "Not authorized" }, { status: 403 });
+
+ const token = await createRouteToken(params.id);
+ const origin = process.env.ORIGIN ?? "http://localhost:3000";
+ const callbackUrl = `${origin}/api/routes/${params.id}/callback`;
+ const plannerUrl = process.env.PLANNER_URL ?? "http://localhost:3001";
+ const returnUrl = `${origin}/routes/${params.id}`;
+
+ // Create Planner session via API (POST body, not URL params)
+ const sessionResp = await fetch(`${plannerUrl}/api/sessions`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ callbackUrl,
+ callbackToken: token,
+ gpx: route.gpx ?? undefined,
+ }),
+ });
+
+ if (!sessionResp.ok) {
+ return data({ error: "Failed to create Planner session" }, { status: 502 });
+ }
+
+ const session = (await sessionResp.json()) as {
+ url: string;
+ initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>;
+ };
+
+ // Encode waypoints in URL params (small — just coordinates, not full GPX)
+ const urlParams = new URLSearchParams({ returnUrl });
+ if (session.initialWaypoints?.length) {
+ urlParams.set("waypoints", JSON.stringify(session.initialWaypoints));
+ }
+
+ return data({
+ url: `${plannerUrl}${session.url}?${urlParams}`,
+ });
+}
diff --git a/apps/journal/app/routes/api.routes.$id.gpx.ts b/apps/journal/app/routes/api.routes.$id.gpx.ts
new file mode 100644
index 0000000..f3b721d
--- /dev/null
+++ b/apps/journal/app/routes/api.routes.$id.gpx.ts
@@ -0,0 +1,16 @@
+import type { Route } from "./+types/api.routes.$id.gpx";
+import { getRouteWithVersions } from "~/lib/routes.server";
+
+export async function loader({ params }: Route.LoaderArgs) {
+ const route = await getRouteWithVersions(params.id);
+ if (!route?.gpx) {
+ return new Response("No GPX data", { status: 404 });
+ }
+
+ return new Response(route.gpx, {
+ headers: {
+ "Content-Type": "application/gpx+xml",
+ "Content-Disposition": `attachment; filename="${route.name.replace(/[^a-z0-9]/gi, "_")}.gpx"`,
+ },
+ });
+}
diff --git a/apps/journal/app/routes/routes.$id.tsx b/apps/journal/app/routes/routes.$id.tsx
index 75a4ee9..6e90df0 100644
--- a/apps/journal/app/routes/routes.$id.tsx
+++ b/apps/journal/app/routes/routes.$id.tsx
@@ -1,7 +1,9 @@
+import { useState, useCallback } from "react";
import { data, redirect } from "react-router";
import type { Route } from "./+types/routes.$id";
import { getSessionUser } from "~/lib/auth.server";
import { getRouteWithVersions, deleteRoute, updateRoute } from "~/lib/routes.server";
+import { ClientDate } from "~/components/ClientDate";
export async function loader({ params, request }: Route.LoaderArgs) {
@@ -61,18 +63,6 @@ export async function action({ params, request }: Route.ActionArgs) {
return redirect(`/routes/${params.id}`);
}
- if (intent === "export-gpx") {
- const route = await getRouteWithVersions(params.id);
- if (!route?.gpx) return data({ error: "No GPX data" }, { status: 400 });
-
- return new Response(route.gpx, {
- headers: {
- "Content-Type": "application/gpx+xml",
- "Content-Disposition": `attachment; filename="${route.name.replace(/[^a-z0-9]/gi, "_")}.gpx"`,
- },
- });
- }
-
return data({ error: "Unknown action" }, { status: 400 });
}
@@ -83,6 +73,20 @@ export function meta({ data: loaderData }: Route.MetaArgs) {
export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
const { route, versions, isOwner } = loaderData;
+ const [editLoading, setEditLoading] = useState(false);
+
+ const handleEditInPlanner = useCallback(async () => {
+ setEditLoading(true);
+ try {
+ const resp = await fetch(`/api/routes/${route.id}/edit-in-planner`, { method: "POST" });
+ const result = await resp.json();
+ if (result.url) {
+ window.location.href = result.url;
+ }
+ } finally {
+ setEditLoading(false);
+ }
+ }, [route.id]);
return (
@@ -95,22 +99,28 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
{isOwner && (
+
Edit
-
+
+ )}
)}
@@ -147,7 +157,7 @@ export default function RouteDetailPage({ loaderData }: Route.ComponentProps) {
v{v.version}
- {new Date(v.createdAt).toLocaleDateString()}
+
{v.changeDescription && (
diff --git a/apps/journal/app/routes/routes._index.tsx b/apps/journal/app/routes/routes._index.tsx
index 59a30c5..506f07f 100644
--- a/apps/journal/app/routes/routes._index.tsx
+++ b/apps/journal/app/routes/routes._index.tsx
@@ -2,6 +2,7 @@ import { data, redirect } from "react-router";
import type { Route } from "./+types/routes._index";
import { getSessionUser } from "~/lib/auth.server";
import { listRoutes } from "~/lib/routes.server";
+import { ClientDate } from "~/components/ClientDate";
export async function loader({ request }: Route.LoaderArgs) {
const user = await getSessionUser(request);
@@ -53,7 +54,7 @@ export default function RoutesListPage({ loaderData }: Route.ComponentProps) {
{route.name}
- {new Date(route.updatedAt).toLocaleDateString()}
+
diff --git a/apps/journal/package.json b/apps/journal/package.json
index ecb6454..63feaf4 100644
--- a/apps/journal/package.json
+++ b/apps/journal/package.json
@@ -23,6 +23,7 @@
"@trails-cool/ui": "workspace:*",
"drizzle-orm": "catalog:",
"isbot": "^5.1.0",
+ "jose": "^6.2.2",
"react": "catalog:",
"react-dom": "catalog:",
"react-router": "catalog:"
diff --git a/apps/planner/app/components/SaveToJournalButton.tsx b/apps/planner/app/components/SaveToJournalButton.tsx
new file mode 100644
index 0000000..113bb4e
--- /dev/null
+++ b/apps/planner/app/components/SaveToJournalButton.tsx
@@ -0,0 +1,86 @@
+import { useState, useCallback } from "react";
+import * as Y from "yjs";
+import type { YjsState } from "~/lib/use-yjs";
+import { generateGpx } from "@trails-cool/gpx";
+import type { TrackPoint } from "@trails-cool/gpx";
+
+interface SaveToJournalButtonProps {
+ yjs: YjsState;
+ callbackUrl: string;
+ callbackToken: string;
+ returnUrl?: string;
+}
+
+export function SaveToJournalButton({ yjs, callbackUrl, callbackToken, returnUrl }: SaveToJournalButtonProps) {
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState
(null);
+
+ const handleSave = useCallback(async () => {
+ setSaving(true);
+ setError(null);
+
+ try {
+ // Build GPX from current Yjs state
+ const waypoints = yjs.waypoints.toArray().map((yMap: Y.Map) => ({
+ lat: yMap.get("lat") as number,
+ lon: yMap.get("lon") as number,
+ name: yMap.get("name") as string | undefined,
+ }));
+
+ let tracks: TrackPoint[][] = [];
+ const geojsonStr = yjs.routeData.get("geojson") as string | undefined;
+ if (geojsonStr) {
+ try {
+ const geojson = JSON.parse(geojsonStr);
+ const coords: number[][] = geojson.features?.[0]?.geometry?.coordinates ?? [];
+ if (coords.length > 0) {
+ tracks = [coords.map((c) => ({ lat: c[1]!, lon: c[0]!, ele: c[2] }))];
+ }
+ } catch { /* invalid geojson */ }
+ }
+
+ const gpx = generateGpx({ name: "trails.cool route", waypoints, tracks });
+
+ // POST to Journal callback
+ const response = await fetch(callbackUrl, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${callbackToken}`,
+ },
+ body: JSON.stringify({ gpx }),
+ });
+
+ if (!response.ok) {
+ const result = await response.json();
+ throw new Error(result.error ?? "Save failed");
+ }
+
+ setSaved(true);
+ } catch (err) {
+ setError((err as Error).message);
+ } finally {
+ setSaving(false);
+ }
+ }, [yjs, callbackUrl, callbackToken]);
+
+ return (
+
+
+ {saved &&
Saved!}
+ {error &&
{error}}
+ {saved && returnUrl && (
+
+ Return to Journal
+
+ )}
+
+ );
+}
diff --git a/apps/planner/app/components/SessionView.tsx b/apps/planner/app/components/SessionView.tsx
index 17bee15..779e4f1 100644
--- a/apps/planner/app/components/SessionView.tsx
+++ b/apps/planner/app/components/SessionView.tsx
@@ -3,6 +3,7 @@ import { useYjs } from "~/lib/use-yjs";
import { useRouting } from "~/lib/use-routing";
import { ProfileSelector } from "~/components/ProfileSelector";
import { ExportButton } from "~/components/ExportButton";
+import { SaveToJournalButton } from "~/components/SaveToJournalButton";
import { YjsDebugPanel } from "~/components/YjsDebugPanel";
const PlannerMap = lazy(() =>
@@ -15,8 +16,16 @@ const ElevationChart = lazy(() =>
import("~/components/ElevationChart").then((m) => ({ default: m.ElevationChart })),
);
-export function SessionView({ sessionId }: { sessionId: string }) {
- const yjs = useYjs(sessionId);
+interface SessionViewProps {
+ sessionId: string;
+ callbackUrl?: string;
+ callbackToken?: string;
+ returnUrl?: string;
+ initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>;
+}
+
+export function SessionView({ sessionId, callbackUrl, callbackToken, returnUrl, initialWaypoints }: SessionViewProps) {
+ const yjs = useYjs(sessionId, initialWaypoints);
const { isHost, computing, routeStats, requestRoute } = useRouting(yjs);
const [highlightPosition, setHighlightPosition] = useState<[number, number] | null>(null);
@@ -40,6 +49,14 @@ export function SessionView({ sessionId }: { sessionId: string }) {
+ {callbackUrl && callbackToken && (
+
+ )}
{computing && (
Computing route...
diff --git a/apps/planner/app/lib/use-yjs.ts b/apps/planner/app/lib/use-yjs.ts
index 066c7fb..e7f4d67 100644
--- a/apps/planner/app/lib/use-yjs.ts
+++ b/apps/planner/app/lib/use-yjs.ts
@@ -36,10 +36,14 @@ export interface YjsState {
connected: boolean;
}
-export function useYjs(sessionId: string): YjsState | null {
+export function useYjs(
+ sessionId: string,
+ initialWaypoints?: Array<{ lat: number; lon: number; name?: string }>,
+): YjsState | null {
const [state, setState] = useState
(null);
const providerRef = useRef(null);
const docRef = useRef(null);
+ const initializedWaypoints = useRef(false);
useEffect(() => {
const doc = new Y.Doc();
@@ -52,10 +56,28 @@ export function useYjs(sessionId: string): YjsState | null {
const waypoints = doc.getArray>("waypoints");
const routeData = doc.getMap("routeData");
- // Use persistent identity
const { color, name } = getOrCreateUserIdentity();
provider.awareness.setLocalStateField("user", { color, name });
+ // Initialize waypoints once after first sync
+ if (initialWaypoints?.length && !initializedWaypoints.current) {
+ (provider as unknown as { on(event: string, cb: () => void): void }).on("synced", () => {
+ // Only add if the doc is empty (avoid duplicating on reconnect)
+ if (waypoints.length === 0 && !initializedWaypoints.current) {
+ initializedWaypoints.current = true;
+ doc.transact(() => {
+ for (const wp of initialWaypoints) {
+ const yMap = new Y.Map();
+ yMap.set("lat", wp.lat);
+ yMap.set("lon", wp.lon);
+ if (wp.name) yMap.set("name", wp.name);
+ waypoints.push([yMap]);
+ }
+ });
+ }
+ });
+ }
+
const updateState = (connected: boolean) => {
setState({
doc,
diff --git a/apps/planner/app/routes.ts b/apps/planner/app/routes.ts
index 584332b..1abbf77 100644
--- a/apps/planner/app/routes.ts
+++ b/apps/planner/app/routes.ts
@@ -2,6 +2,7 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
+ route("new", "routes/new.tsx"),
route("api/sessions", "routes/api.sessions.ts"),
route("api/route", "routes/api.route.ts"),
route("session/:id", "routes/session.$id.tsx"),
diff --git a/apps/planner/app/routes/api.sessions.ts b/apps/planner/app/routes/api.sessions.ts
index 8fc4eb9..66c437b 100644
--- a/apps/planner/app/routes/api.sessions.ts
+++ b/apps/planner/app/routes/api.sessions.ts
@@ -1,7 +1,7 @@
import { data } from "react-router";
import type { Route } from "./+types/api.sessions";
-import { createSession, initializeSessionWithWaypoints, listSessions } from "~/lib/sessions";
-import { parseGpx } from "@trails-cool/gpx";
+import { createSession, listSessions } from "~/lib/sessions";
+import { parseGpxAsync } from "@trails-cool/gpx";
export async function action({ request }: Route.ActionArgs) {
if (request.method !== "POST") {
@@ -17,10 +17,11 @@ export async function action({ request }: Route.ActionArgs) {
const session = await createSession({ callbackUrl, callbackToken });
+ let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined;
if (gpx) {
try {
- const gpxData = parseGpx(gpx);
- initializeSessionWithWaypoints(session.id, gpxData.waypoints);
+ const gpxData = await parseGpxAsync(gpx);
+ initialWaypoints = gpxData.waypoints;
} catch (_e) {
// Continue with empty session if GPX is invalid
}
@@ -30,6 +31,7 @@ export async function action({ request }: Route.ActionArgs) {
{
sessionId: session.id,
url: `/session/${session.id}`,
+ initialWaypoints,
},
{ status: 201 },
);
diff --git a/apps/planner/app/routes/new.tsx b/apps/planner/app/routes/new.tsx
new file mode 100644
index 0000000..7b11d2c
--- /dev/null
+++ b/apps/planner/app/routes/new.tsx
@@ -0,0 +1,36 @@
+import { redirect } from "react-router";
+import type { Route } from "./+types/new";
+import { createSession, initializeSessionWithWaypoints } from "~/lib/sessions";
+import { parseGpx } from "@trails-cool/gpx";
+
+export async function loader({ request }: Route.LoaderArgs) {
+ const url = new URL(request.url);
+ const callbackUrl = url.searchParams.get("callback");
+ const token = url.searchParams.get("token");
+ const returnUrl = url.searchParams.get("returnUrl");
+ const gpxEncoded = url.searchParams.get("gpx");
+
+ // Create a session with callback info
+ const session = await createSession({
+ callbackUrl: callbackUrl ?? undefined,
+ callbackToken: token ?? undefined,
+ });
+
+ // Initialize with GPX waypoints if provided
+ if (gpxEncoded) {
+ try {
+ const gpx = decodeURIComponent(gpxEncoded);
+ const gpxData = parseGpx(gpx);
+ initializeSessionWithWaypoints(session.id, gpxData.waypoints);
+ } catch {
+ // Continue with empty session if GPX is invalid
+ }
+ }
+
+ // Store returnUrl in the session URL for later
+ const sessionUrl = returnUrl
+ ? `/session/${session.id}?returnUrl=${encodeURIComponent(returnUrl)}`
+ : `/session/${session.id}`;
+
+ return redirect(sessionUrl);
+}
diff --git a/apps/planner/app/routes/session.$id.tsx b/apps/planner/app/routes/session.$id.tsx
index e99de06..101c881 100644
--- a/apps/planner/app/routes/session.$id.tsx
+++ b/apps/planner/app/routes/session.$id.tsx
@@ -1,4 +1,4 @@
-import { useParams } from "react-router";
+import { useParams, useSearchParams } from "react-router";
import type { Route } from "./+types/session.$id";
import { getSession } from "~/lib/sessions";
import { data } from "react-router";
@@ -20,12 +20,20 @@ export async function loader({ params }: Route.LoaderArgs) {
}
return data({
sessionId: session.id,
- hasCallback: !!session.callbackUrl,
+ callbackUrl: session.callbackUrl ?? null,
+ callbackToken: session.callbackToken ?? null,
});
}
-export default function SessionPage() {
+export default function SessionPage({ loaderData }: Route.ComponentProps) {
const { id } = useParams();
+ const [searchParams] = useSearchParams();
+ const returnUrl = searchParams.get("returnUrl") ?? undefined;
+ const waypointsParam = searchParams.get("waypoints");
+ let initialWaypoints: Array<{ lat: number; lon: number; name?: string }> | undefined;
+ if (waypointsParam) {
+ try { initialWaypoints = JSON.parse(waypointsParam); } catch { /* ignore */ }
+ }
return (
@@ -44,7 +52,13 @@ export default function SessionPage() {
}
>
-
+
)}
diff --git a/openspec/changes/phase-1-mvp/tasks.md b/openspec/changes/phase-1-mvp/tasks.md
index 5411fb1..d86ccec 100644
--- a/openspec/changes/phase-1-mvp/tasks.md
+++ b/openspec/changes/phase-1-mvp/tasks.md
@@ -89,12 +89,12 @@
## 9. Planner-Journal Handoff
-- [ ] 9.1 Implement JWT token generation in Journal (scoped to route_id, with expiry)
-- [ ] 9.2 Implement "Edit in Planner" button on Journal route detail page (redirect with callback + token + GPX)
-- [ ] 9.3 Implement callback URL handling in Planner (store callback URL in session metadata)
-- [ ] 9.4 Implement "Save to Journal" button in Planner (POST GPX + JWT to callback URL)
-- [ ] 9.5 Implement callback endpoint in Journal (POST /api/routes/:id/callback — validate JWT, save new version)
-- [ ] 9.6 Implement "Return to Journal" link after successful save
+- [x] 9.1 Implement JWT token generation in Journal (scoped to route_id, with expiry)
+- [x] 9.2 Implement "Edit in Planner" button on Journal route detail page (redirect with callback + token + GPX)
+- [x] 9.3 Implement callback URL handling in Planner (store callback URL in session metadata)
+- [x] 9.4 Implement "Save to Journal" button in Planner (POST GPX + JWT to callback URL)
+- [x] 9.5 Implement callback endpoint in Journal (POST /api/routes/:id/callback — validate JWT, save new version)
+- [x] 9.6 Implement "Return to Journal" link after successful save
## 10. Journal — Activity Feed
diff --git a/package.json b/package.json
index 142ae2d..4f7f882 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"i18next-browser-languagedetector": "^8.2.1",
"jsdom": "^29.0.1",
"leaflet": "^1.9.4",
+ "linkedom": "^0.18.12",
"playwright": "^1.58.2",
"postgres": "^3.4.8",
"prettier": "^3.8.1",
diff --git a/packages/gpx/src/index.ts b/packages/gpx/src/index.ts
index 6dc0a8d..46ec02a 100644
--- a/packages/gpx/src/index.ts
+++ b/packages/gpx/src/index.ts
@@ -1,3 +1,3 @@
-export { parseGpx } from "./parse";
+export { parseGpx, parseGpxAsync } from "./parse";
export { generateGpx } from "./generate";
export type { GpxData, TrackPoint, ElevationProfile } from "./types";
diff --git a/packages/gpx/src/parse.ts b/packages/gpx/src/parse.ts
index d1069ea..b9cb5dc 100644
--- a/packages/gpx/src/parse.ts
+++ b/packages/gpx/src/parse.ts
@@ -4,9 +4,39 @@ import type { GpxData, TrackPoint, ElevationProfile } from "./types";
/**
* Parse a GPX XML string into structured data.
*/
+let _LinkedDOMParser: typeof DOMParser | null = null;
+
+async function getDOMParser(): Promise {
+ if (typeof DOMParser !== "undefined") return DOMParser;
+ if (!_LinkedDOMParser) {
+ const linkedom = await import("linkedom");
+ _LinkedDOMParser = linkedom.DOMParser as unknown as typeof DOMParser;
+ }
+ return _LinkedDOMParser;
+}
+
export function parseGpx(xml: string): GpxData {
- const parser = new DOMParser();
- const doc = parser.parseFromString(xml, "application/xml");
+ // Synchronous path for browser
+ if (typeof DOMParser !== "undefined") {
+ return parseGpxWithParser(new DOMParser(), xml);
+ }
+ // Fallback: try linkedom synchronously (works in Node with top-level await or CJS)
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { DOMParser: LP } = require("linkedom");
+ return parseGpxWithParser(new LP() as unknown as DOMParser, xml);
+ } catch {
+ throw new Error("DOMParser not available — install linkedom for Node.js");
+ }
+}
+
+export async function parseGpxAsync(xml: string): Promise {
+ const Parser = await getDOMParser();
+ return parseGpxWithParser(new Parser(), xml);
+}
+
+function parseGpxWithParser(parser: DOMParser, xml: string): GpxData {
+ const doc = parser.parseFromString(xml, "application/xml") as unknown as Document;
const parserError = doc.querySelector("parsererror");
if (parserError) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index cfd3763..7423955 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -119,6 +119,9 @@ importers:
leaflet:
specifier: ^1.9.4
version: 1.9.4
+ linkedom:
+ specifier: ^0.18.12
+ version: 0.18.12
playwright:
specifier: ^1.58.2
version: 1.58.2
@@ -197,6 +200,9 @@ importers:
isbot:
specifier: ^5.1.0
version: 5.1.36
+ jose:
+ specifier: ^6.2.2
+ version: 6.2.2
react:
specifier: 'catalog:'
version: 19.2.4
@@ -1542,6 +1548,9 @@ packages:
resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==}
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+ boolbase@1.0.0:
+ resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
+
brace-expansion@5.0.4:
resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==}
engines: {node: 18 || 20 || >=22}
@@ -1618,13 +1627,23 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ css-select@5.2.2:
+ resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
+
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+ css-what@6.2.2:
+ resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
+ engines: {node: '>= 6'}
+
css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+ cssom@0.5.0:
+ resolution: {integrity: sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -1685,6 +1704,19 @@ packages:
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+ dom-serializer@2.0.0:
+ resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
+ domelementtype@2.3.0:
+ resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+ domhandler@5.0.3:
+ resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+ engines: {node: '>= 4'}
+
+ domutils@3.2.2:
+ resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
drizzle-kit@0.31.10:
resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==}
hasBin: true
@@ -1894,10 +1926,18 @@ packages:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
entities@6.0.1:
resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
engines: {node: '>=0.12'}
+ entities@7.0.1:
+ resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
+ engines: {node: '>=0.12'}
+
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@@ -2107,9 +2147,15 @@ packages:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ html-escaper@3.0.3:
+ resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
+
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
+ htmlparser2@10.1.0:
+ resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
+
http-errors@2.0.1:
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
engines: {node: '>= 0.8'}
@@ -2177,6 +2223,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ jose@6.2.2:
+ resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2293,6 +2342,15 @@ packages:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
+ linkedom@0.18.12:
+ resolution: {integrity: sha512-jalJsOwIKuQJSeTvsgzPe9iJzyfVaEJiEXl+25EkKevsULHvMJzpNqwvj1jOESWdmgKDiXObyjOYwlUqG7wo1Q==}
+ engines: {node: '>=16'}
+ peerDependencies:
+ canvas: '>= 2'
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -2386,6 +2444,9 @@ packages:
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
+ nth-check@2.1.1:
+ resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
+
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@@ -2765,6 +2826,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ uhyphen@0.2.0:
+ resolution: {integrity: sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==}
+
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
@@ -4126,6 +4190,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ boolbase@1.0.0: {}
+
brace-expansion@5.0.4:
dependencies:
balanced-match: 4.0.4
@@ -4200,13 +4266,25 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ css-select@5.2.2:
+ dependencies:
+ boolbase: 1.0.0
+ css-what: 6.2.2
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ nth-check: 2.1.1
+
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
+ css-what@6.2.2: {}
+
css.escape@1.5.1: {}
+ cssom@0.5.0: {}
+
csstype@3.2.3: {}
data-urls@7.0.0:
@@ -4242,6 +4320,24 @@ snapshots:
dom-accessibility-api@0.6.3: {}
+ dom-serializer@2.0.0:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ entities: 4.5.0
+
+ domelementtype@2.3.0: {}
+
+ domhandler@5.0.3:
+ dependencies:
+ domelementtype: 2.3.0
+
+ domutils@3.2.2:
+ dependencies:
+ dom-serializer: 2.0.0
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+
drizzle-kit@0.31.10:
dependencies:
'@drizzle-team/brocli': 0.10.2
@@ -4278,8 +4374,12 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
+ entities@4.5.0: {}
+
entities@6.0.1: {}
+ entities@7.0.1: {}
+
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@@ -4570,10 +4670,19 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
+ html-escaper@3.0.3: {}
+
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
+ htmlparser2@10.1.0:
+ dependencies:
+ domelementtype: 2.3.0
+ domhandler: 5.0.3
+ domutils: 3.2.2
+ entities: 7.0.1
+
http-errors@2.0.1:
dependencies:
depd: 2.0.0
@@ -4624,6 +4733,8 @@ snapshots:
jiti@2.6.1: {}
+ jose@6.2.2: {}
+
js-tokens@4.0.0: {}
jsdom@29.0.1:
@@ -4726,6 +4837,14 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
+ linkedom@0.18.12:
+ dependencies:
+ css-select: 5.2.2
+ cssom: 0.5.0
+ html-escaper: 3.0.3
+ htmlparser2: 10.1.0
+ uhyphen: 0.2.0
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -4794,6 +4913,10 @@ snapshots:
node-releases@2.0.36: {}
+ nth-check@2.1.1:
+ dependencies:
+ boolbase: 1.0.0
+
object-inspect@1.13.4: {}
obug@2.1.1: {}
@@ -5171,6 +5294,8 @@ snapshots:
typescript@5.9.3: {}
+ uhyphen@0.2.0: {}
+
undici-types@7.18.2: {}
undici@7.24.5: {}