+ extensions?: GpxPointExtensions;
}
interface GpxTrack {
name: string;
- createdAt?: Date; // defaults to first point time
- points: GpxPoint[];
-}
-
-interface GpxOptions {
- creator?: string; // defaults to "gpx-export"
+ createdAt?: Date;
+ points?: GpxPoint[];
+ segments?: { points: GpxPoint[] }[];
+ cmt?: string;
+ desc?: string;
+ extensions?: GpxPointExtensions;
}
```
+Why speed appears in two places:
+- `GpxPoint.speed` exists for backward compatibility with older callers.
+- `GpxPoint.extensions.speed` is the canonical field for new code.
+- When both are provided, `extensions.speed` wins and only one GPX speed tag is emitted.
+
+All exported type definitions are available from the package root.
+
+---
+
+## **Notes**
+
+- elevation is formatted to 2 decimal places
+- speed is formatted to 4 decimal places
+- the Garmin `gpxtpx` namespace is included when Garmin metrics are present on track points
+- Garmin `gpxtpx` metric tags are emitted on track points (`trkpt`) only
+- `rawXml` values are inserted as trusted XML and are not escaped
+- element output order is deterministic for identical inputs
+
---
-## Notes
+## **Testing**
-- Adds Garmin `gpxtpx` namespace only when speed values are present.
-- If `createdAt` is omitted, metadata time uses the first point time; if there are no points, it falls back to the current time.
-- Elevation is formatted to 2 decimal places; speed is formatted to 4 decimal places.
-- Saving/downloading the GPX is left to the caller.
+Run tests:
+
+```bash
+npm test
+```
---
-## License
+Pull requests are welcome.
+
+## **License**
-MIT
\ No newline at end of file
+MIT
diff --git a/docs/api.html b/docs/api.html
index 76a6053..3c1140a 100644
--- a/docs/api.html
+++ b/docs/api.html
@@ -14,26 +14,29 @@ API Reference
gpx-export exposes a focused, typed API for building GPX 1.1 documents.
- The main export is generateGpx(track, options?).
+ The main export is generateGpx(...).
- generateGpx(track, options?)
+ generateGpx(...)
- Creates GPX 1.1 XML with metadata, track name, segment, and points.
- Garmin TrackPointExtension output is automatically added when speed
- values are present.
+ Generates GPX 1.1 XML from either:
+ generateGpx(track: GpxTrack, metadata?: GpxMetadata) or
+ generateGpx(document: GpxDocument, metadata?: GpxMetadata).
import { generateGpx } from "gpx-export";
+const now = new Date("2026-01-01T12:00:00.000Z");
+
const xml = generateGpx(
{
- name: "Morning Ride",
- points: [
- { lat: 54.57, lon: -1.31, time: new Date(), speed: 5.2 }
- ]
+ name: "Session",
+ points: [{ lat: 54.57, lon: -1.31, time: now }]
},
- { creator: "my-app" }
+ {
+ name: "Morning Ride",
+ time: now
+ }
);
@@ -42,45 +45,114 @@ Returns
string - GPX 1.1 XML document
- GpxPoint
-
- Defines each point in the generated track.
-
+ GpxDocument
+ interface GpxDocument {
+ metadata?: GpxMetadata;
+ waypoints?: GpxWaypoint[];
+ routes?: GpxRoute[];
+ tracks?: GpxTrack[];
+}
+
+
+ GpxMetadata
+ interface GpxMetadata {
+ name?: string;
+ desc?: string;
+ author?: GpxAuthor;
+ link?: GpxLink;
+ time?: Date;
+ keywords?: string;
+ copyright?: string | GpxCopyright;
+ bounds?: GpxBounds;
+}
+interface GpxAuthor {
+ name: string;
+ link?: GpxLink;
+}
+
+interface GpxCopyright {
+ author: string;
+ year?: number;
+ license?: string;
+}
+
+interface GpxLink {
+ href: string;
+ text?: string;
+ type?: string;
+}
+
+
+ GpxPointExtensions
+ interface GpxPointExtensions {
+ speed?: number; // gpxtpx:speed (m/s)
+ heartRate?: number; // gpxtpx:hr (bpm)
+ cadence?: number; // gpxtpx:cad (rpm)
+ rawXml?: string; // trusted XML in <extensions>
+}
+
+
+ GpxPoint
interface GpxPoint {
lat: number;
lon: number;
time: Date;
- speed?: number; // m/s
- elevation?: number; // metres
+ speed?: number; // legacy alias for extensions.speed
+ elevation?: number; // metres
+ extensions?: GpxPointExtensions;
}
- GpxTrack
- Defines metadata and ordered points.
+ Why speed appears in both places: GpxPoint.speed is a legacy compatibility alias,
+ while GpxPoint.extensions.speed is the canonical field for new code.
+ If both are provided, extension speed takes precedence and only one GPX speed tag is emitted.
+ GpxTrack
interface GpxTrack {
name: string;
createdAt?: Date;
- points: GpxPoint[];
+ points?: GpxPoint[];
+ segments?: { points: GpxPoint[] }[];
+ cmt?: string;
+ desc?: string;
+ extensions?: GpxPointExtensions;
}
- GpxOptions
- interface GpxOptions {
- creator?: string;
+ GpxRoute and GpxWaypoint
+ interface GpxRoute {
+ name?: string;
+ cmt?: string;
+ desc?: string;
+ points: GpxRoutePoint[];
+ extensions?: GpxPointExtensions;
+}
+
+interface GpxWaypoint {
+ lat: number;
+ lon: number;
+ name?: string;
+ cmt?: string;
+ desc?: string;
+ elevation?: number;
+ time?: Date;
+ extensions?: GpxPointExtensions;
}
Behavior notes
Creator defaults to gpx-export.
- Track name and creator are XML-escaped before output.
- Metadata time defaults to first point time, then current time.
+ generateGpx(track, metadata?) wraps track input into a one-track document.
+ metadata shallow-merges onto document.metadata.
Elevation is formatted to 2 decimal places.
Speed is formatted to 4 decimal places as gpxtpx:speed.
+ Garmin gpxtpx namespace is emitted when Garmin metrics are present on track points.
+ Garmin metric tags are emitted under track-point <extensions> only.
+ rawXml extension values are inserted as trusted XML and are not escaped.
diff --git a/docs/data-rules.html b/docs/data-rules.html
index c077bef..72ddbd4 100644
--- a/docs/data-rules.html
+++ b/docs/data-rules.html
@@ -13,29 +13,51 @@
Data Rules
- gpx-export converts track input into a consistent GPX 1.1 structure.
- This page explains how values are mapped and written.
+ gpx-export converts typed input into a consistent GPX 1.1 document.
+ This page describes how values are merged and serialized.
Serialization rules
Required fields are explicit in TypeScript types.
Dates are emitted as ISO-8601 with toISOString().
- Track names and creator values are XML-escaped.
+ String element values and selected attributes are XML-escaped.
Optional fields appear only when provided.
+ Elevation is formatted to 2 decimal places.
+ Garmin speed is formatted to 4 decimal places.
- Input handling
+ Document handling
- Coordinates are written as provided by your application.
- Only known GPX fields are emitted by the library.
- Application-level validation remains your responsibility.
+ generateGpx(track, metadata?) accepts tracks-only input.
+ generateGpx(document, metadata?) accepts full GpxDocument input.
+ Track input is normalized to a one-track document internally.
+ Provided metadata shallow-merges into document.metadata.
+
+
+ Track and segment behavior
+
+ If track.segments is provided and non-empty, those segments are emitted.
+ Otherwise, a single segment is created from track.points (or empty points).
+ Track points require time; route points and waypoints use optional time.
+
+
+ Extensions behavior
+
+ Garmin namespace/schema declarations are added when Garmin metrics are present on track points.
+ Garmin point values map to gpxtpx:speed, gpxtpx:hr, and
+ gpxtpx:cad on track points.
+
+ GpxPoint.speed exists as a legacy compatibility alias; extensions.speed is
+ the canonical field for new code.
+ When both are provided on a track point, extensions.speed takes precedence.
+ Extension rawXml values are trusted XML and are not escaped.
Recommended workflow
- Normalize and validate source points first, then call
- generateGpx() and save or upload the returned XML string.
+ Validate your source data before generation, then call
+ generateGpx() and persist or upload the resulting GPX XML.
diff --git a/docs/examples.html b/docs/examples.html
index 237b838..f5ef9f4 100644
--- a/docs/examples.html
+++ b/docs/examples.html
@@ -13,58 +13,117 @@
Examples
- gpx-export can be used anywhere you need portable GPS track exports.
- Below are small, focused examples for common app flows.
+ gpx-export can be used anywhere you need portable GPX 1.1 exports.
+ These examples show common generation flows with the current API.
- Basic one-point export
+ Basic one-point track export
- Useful for smoke tests and validating export flow.
+ Useful for smoke tests and validating end-to-end export wiring.
import { generateGpx } from "gpx-export";
-const gpx = generateGpx({
- name: "Quick Point",
- points: [
+const now = new Date("2026-03-23T09:00:00Z");
+
+const gpx = generateGpx(
+ {
+ name: "Quick Point",
+ points: [
+ {
+ lat: 54.57,
+ lon: -1.31,
+ time: now
+ }
+ ]
+ },
+ { name: "Quick Point", time: now }
+);
+
+
+ Multi-point run with Garmin metrics
+
+ Garmin extension output is included when Garmin metrics are present on track points.
+
+
+ const gpx = generateGpx({
+ name: "Interval Session",
+ segments: [
{
- lat: 54.57,
- lon: -1.31,
- time: new Date("2026-03-23T09:00:00Z")
+ points: [
+ {
+ lat: 54.5741,
+ lon: -1.3180,
+ time: new Date("2026-03-23T07:15:00Z"),
+ elevation: 32.4,
+ extensions: {
+ speed: 5.2,
+ heartRate: 151,
+ cadence: 86
+ }
+ },
+ {
+ lat: 54.5751,
+ lon: -1.3194,
+ time: new Date("2026-03-23T07:16:00Z"),
+ elevation: 34.0,
+ speed: 5.6
+ }
+ ]
}
]
});
- Multi-point run with elevation and speed
+ Waypoints and routes
- Includes Garmin extension output because speed is present.
+ Build complete GPX documents with waypoint and route data.
const gpx = generateGpx({
- name: "Interval Session",
- points: [
- {
- lat: 54.5741,
- lon: -1.3180,
- time: new Date("2026-03-23T07:15:00Z"),
- elevation: 32.4,
- speed: 5.2
- },
+ waypoints: [
+ { lat: 54.57, lon: -1.31, name: "Start", time: new Date() }
+ ],
+ routes: [
{
- lat: 54.5751,
- lon: -1.3194,
- time: new Date("2026-03-23T07:16:00Z"),
- elevation: 34.0,
- speed: 5.6
+ name: "Scenic Route",
+ points: [
+ { lat: 54.57, lon: -1.31, time: new Date() },
+ { lat: 54.59, lon: -1.28, time: new Date() }
+ ]
}
]
});
+
+
+ Using optional metadata merge
+
+ generateGpx(document, metadata?) accepts optional
+ metadata that shallow-merges into document.metadata.
+
+
+ import { generateGpx } from "gpx-export";
+
+const gpx = generateGpx(
+ {
+ tracks: [
+ {
+ name: "Session",
+ points: [{ lat: 54.57, lon: -1.31, time: new Date() }]
+ }
+ ]
+ },
+ {
+ name: "Morning Ride",
+ time: new Date(),
+ keywords: "cycling,training"
+ }
+);
Mapping app sensor samples
- Convert your own schema into gpx-export points.
+ Convert your own schema into typed track points.
const points = samples.map((sample) => ({
@@ -72,13 +131,14 @@ Mapping app sensor samples
lon: sample.longitude,
time: new Date(sample.timestamp),
elevation: sample.altitudeMeters,
- speed: sample.speedMetersPerSecond
+ extensions: {
+ speed: sample.speedMetersPerSecond,
+ heartRate: sample.heartRate,
+ cadence: sample.cadence
+ }
}));
-const gpx = generateGpx({
- name: "Mapped Sensor Track",
- points
-});
+const gpx = generateGpx({ name: "Mapped Sensor Track", points });
Node.js file export
diff --git a/docs/formats.html b/docs/formats.html
index b93db17..59319cc 100644
--- a/docs/formats.html
+++ b/docs/formats.html
@@ -2,83 +2,76 @@
-
-
- gpx-export - Formats
-
+
+
+ gpx-export - Formats
+
-
- GPX Output Format
+
+ GPX Output Format
-
- gpx-export produces a GPX 1.1 XML document containing metadata and one
- track segment with ordered track points.
-
+
+ gpx-export produces a GPX 1.1 XML document with Topografix GPX 1.1 schema
+ declarations, plus Garmin TrackPointExtension v2 declarations when needed.
+
- Structure
- The generated document has this shape:
+ Base structure
+ The generated document can include metadata, waypoints, routes, and tracks:
- <gpx version="1.1" creator="gpx-export" ...>
- <metadata>
- <name>Morning Ride</name>
- <time>2026-03-23T07:10:00.000Z</time>
- </metadata>
- <trk>
- <name>Morning Ride</name>
- <trkseg>
- <trkpt lat="54.5741" lon="-1.318">
- <ele>32.40</ele>
- <time>2026-03-23T07:15:00.000Z</time>
- </trkpt>
- </trkseg>
- </trk>
+ <gpx version="1.1" creator="gpx-export"
+ xmlns="http://www.topografix.com/GPX/1/1"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd">
+ <metadata>...</metadata>
+ <wpt lat="..." lon="...">...</wpt>
+ <rte>...</rte>
+ <trk>...</trk>
</gpx>
- Fields
-
- metadata/name and trk/name
-
- Track name is written in both metadata and track sections for compatibility.
-
+ Metadata mapping
+
+ metadata.name -> <metadata><name>
+ metadata.desc -> <metadata><desc>
+ metadata.author -> <metadata><author>
+ metadata.link -> <metadata><link href="...">
+ metadata.time -> <metadata><time>
+ metadata.keywords -> <metadata><keywords>
+ metadata.bounds -> <metadata><bounds minlat="..." ... />
+ metadata.copyright supports shorthand string or full object form
+
- metadata/time
-
- Written as ISO-8601. Uses createdAt, then first point time,
- then current time if no points exist.
-
+ Track and segment mapping
+
+ Each track is emitted as <trk> with required <name>.
+ If segments exist, they are emitted as provided.
+ Otherwise a single <trkseg> is built from points.
+ Track points include required lat, lon, and time.
+ Point elevation writes to <ele> with 2 decimal places.
+
- trkpt lat/lon
-
- Latitude and longitude are written as attributes on each point.
-
+ Garmin extensions
+
+ When Garmin extension fields are present, xmlns:gpxtpx and Garmin schema location are
+ added.
+ speed, heartRate, and cadence map to gpxtpx:speed,
+ gpxtpx:hr, and gpxtpx:cad on track points.
+
+ speed on track points is treated as a legacy alias for extension speed.
+ Garmin extension values are emitted under
+ <extensions><gpxtpx:TrackPointExtension> in <trkpt>.
+
+
- ele and time
-
- Elevation is optional and formatted to two decimals.
- Time is required and written as ISO-8601.
-
-
- Garmin extension
-
- When any point has speed, gpxtpx namespace is added.
- Speed appears under gpxtpx:TrackPointExtension.
- If no speed is present, no Garmin extension tags are emitted.
-
-
- Example with Garmin speed data
- <gpx version="1.1" creator="gpx-export"
+ Example with Garmin data
+ <gpx version="1.1" creator="gpx-export"
xmlns="http://www.topografix.com/GPX/1/1"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v2"
xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd
http://www.garmin.com/xmlschemas/TrackPointExtension/v2 https://www8.garmin.com/xmlschemas/TrackPointExtensionv2.xsd">
- <metadata>
- <name>Morning Ride</name>
- <time>2026-03-23T07:10:00.000Z</time>
- </metadata>
<trk>
<name>Morning Ride</name>
<trkseg>
@@ -88,6 +81,8 @@ Example with Garmin speed data
<extensions>
<gpxtpx:TrackPointExtension>
<gpxtpx:speed>5.2000</gpxtpx:speed>
+ <gpxtpx:hr>151</gpxtpx:hr>
+ <gpxtpx:cad>86</gpxtpx:cad>
</gpxtpx:TrackPointExtension>
</extensions>
</trkpt>
@@ -96,20 +91,26 @@ Example with Garmin speed data
</gpx>
-
- Overview
- Install
- Data Rules
- API
- Formats
- Examples
-
+ Trusted raw XML
+
+ Per-point/route/track/waypoint extensions.rawXml values are inserted as-is and are not escaped.
+ Use only trusted XML strings.
+
+
+
+ Overview
+ Install
+ Data Rules
+ API
+ Formats
+ Examples
+
-
- GitHub ·
- npm
-
-
+
+ GitHub ·
+ npm
+
+
\ No newline at end of file
diff --git a/docs/index.html b/docs/index.html
index 114ce7e..a5b7056 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -15,33 +15,41 @@ gpx-export
Lightweight, portable GPX 1.1 generation without dependencies.
- gpx-export converts track points into standards-compliant GPX 1.1 XML.
- It is a focused generator for GPS exports in Node.js, browsers, and mobile
- runtimes, with optional Garmin TrackPointExtension speed data.
+ gpx-export generates GPX 1.1 XML from JavaScript and TypeScript
+ objects. It supports full-document output with metadata, waypoints, routes,
+ and tracks, and runs in Node.js, web, and mobile runtimes.
import { generateGpx } from "gpx-export";
-const gpx = generateGpx({
- name: "Morning Ride",
- points: [
- {
- lat: 54.5741,
- lon: -1.3180,
- time: new Date(),
- elevation: 32.4,
- speed: 5.2
- }
- ]
-});
+const now = new Date();
+
+const gpx = generateGpx(
+ {
+ name: "Morning Ride",
+ points: [
+ {
+ lat: 54.5741,
+ lon: -1.3180,
+ time: now,
+ elevation: 32.4,
+ speed: 5.2
+ }
+ ]
+ },
+ {
+ name: "Morning Ride",
+ time: now
+ }
+);
console.log(gpx);
- The output includes metadata, track segments, and point timestamps.
- When any point includes speed, gpx-export automatically emits Garmin
- TrackPointExtension v2 data and namespace declarations.
+ The output always uses GPX 1.1 root/schema declarations.
+ Garmin TrackPointExtension v2 declarations are added automatically when
+ Garmin metrics are present on track points.
diff --git a/docs/install.html b/docs/install.html
index 974bd62..fa07d09 100644
--- a/docs/install.html
+++ b/docs/install.html
@@ -14,7 +14,7 @@ Install
gpx-export is a small, dependency-free package that outputs GPX 1.1 XML
- from timestamped coordinate points.
+ from typed JavaScript objects.
It works in modern Node runtimes, all evergreen browsers, and hybrid apps.
diff --git a/package.json b/package.json
index 16036c8..d113cf4 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "gpx-export",
- "version": "0.0.1",
+ "version": "0.1.0",
"author": "Chris Myers (https://www.npmjs.com/~cmyers-dev)",
"description": "Export to GPX format from GPS data",
"type": "module",
diff --git a/src/GpxExport.ts b/src/GpxExport.ts
index 8103b2d..a9f9dfc 100644
--- a/src/GpxExport.ts
+++ b/src/GpxExport.ts
@@ -1,26 +1,18 @@
// GpxExport — zero-dependency GPX 1.1 generator with Garmin TrackPointExtension v2 support
-export interface GpxPoint {
- lat: number;
- lon: number;
- time: Date;
- /** Speed in m/s — written as gpxtpx:speed (Garmin TrackPointExtension v2) */
- speed?: number;
- /** Elevation in metres — written as standard GPX */
- elevation?: number;
-}
-
-export interface GpxTrack {
- name: string;
- /** Defaults to the time of the first point when omitted */
- createdAt?: Date;
- points: GpxPoint[];
-}
-
-export interface GpxOptions {
- /** Value written to the GPX creator attribute. Defaults to 'gpx-export' */
- creator?: string;
-}
+import type {
+ GpxBounds,
+ GpxDocument,
+ GpxLink,
+ GpxMetadata,
+ GpxPoint,
+ GpxPointExtensions,
+ GpxRoute,
+ GpxRoutePoint,
+ GpxTrack,
+ GpxTrackSegment,
+ GpxWaypoint,
+} from './types';
function escapeXml(value: string): string {
return value
@@ -30,61 +22,329 @@ function escapeXml(value: string): string {
.replace(/"/g, '"');
}
-export function generateGpx(track: GpxTrack, options: GpxOptions = {}): string {
- const creator = escapeXml(options.creator ?? 'gpx-export');
- const name = escapeXml(track.name);
- const createdAt = (track.createdAt ?? track.points[0]?.time ?? new Date()).toISOString();
-
- const useGarminExtension = track.points.some((p) => p.speed !== undefined);
-
- const trkpts = track.points
- .map((p) => {
- const lines = [
- ` `,
- ...(p.elevation !== undefined ? [` ${p.elevation.toFixed(2)} `] : []),
- ` ${p.time.toISOString()} `,
- ];
- if (p.speed !== undefined) {
- lines.push(
- ` `,
- ` `,
- ` ${p.speed.toFixed(4)} `,
- ` `,
- ` `,
- );
+function indentLines(lines: string[], level: number): string[] {
+ const indent = ' '.repeat(level);
+ return lines.map((line) => `${indent}${line}`);
+}
+
+function xmlTag(name: string, value: string): string {
+ return `<${name}>${escapeXml(value)}${name}>`;
+}
+
+function xmlTagNumber(name: string, value: number, precision?: number): string {
+ const rendered = precision !== undefined ? value.toFixed(precision) : String(value);
+ return `<${name}>${rendered}${name}>`;
+}
+
+function toIso(value?: Date): string | undefined {
+ return value ? value.toISOString() : undefined;
+}
+
+function hasValue(value: string | undefined): value is string {
+ return Boolean(value && value.length > 0);
+}
+
+function resolveTrackSegments(track: GpxTrack): GpxTrackSegment[] {
+ if (track.segments && track.segments.length > 0) {
+ return track.segments;
+ }
+ return [{ points: track.points ?? [] }];
+}
+
+function normalizePointExtensions(legacySpeed: number | undefined, extensions: GpxPointExtensions | undefined): GpxPointExtensions | undefined {
+ const normalized: GpxPointExtensions = {
+ speed: extensions?.speed ?? legacySpeed,
+ heartRate: extensions?.heartRate,
+ cadence: extensions?.cadence,
+ rawXml: extensions?.rawXml,
+ };
+
+ if (
+ normalized.speed === undefined
+ && normalized.heartRate === undefined
+ && normalized.cadence === undefined
+ && !hasValue(normalized.rawXml)
+ ) {
+ return undefined;
+ }
+
+ return normalized;
+}
+
+function hasGarminPointExtensions(ext: GpxPointExtensions | undefined): boolean {
+ return Boolean(ext?.speed !== undefined || ext?.heartRate !== undefined || ext?.cadence !== undefined);
+}
+
+function renderBounds(bounds: GpxBounds): string {
+ return ` `;
+}
+
+function renderLink(link: GpxLink): string[] {
+ const lines: string[] = [` `];
+ if (link.text) {
+ lines.push(` ${xmlTag('text', link.text)}`);
+ }
+ if (link.type) {
+ lines.push(` ${xmlTag('type', link.type)}`);
+ }
+ lines.push(``);
+ return lines;
+}
+
+function renderMetadata(metadata: GpxMetadata | undefined): string[] {
+ if (!metadata) {
+ return [];
+ }
+
+ const lines: string[] = [''];
+ if (metadata.name) {
+ lines.push(` ${xmlTag('name', metadata.name)}`);
+ }
+ if (metadata.desc) {
+ lines.push(` ${xmlTag('desc', metadata.desc)}`);
+ }
+ if (metadata.author) {
+ lines.push(` `);
+ lines.push(` ${xmlTag('name', metadata.author.name)}`);
+ if (metadata.author.link) {
+ lines.push(...indentLines(renderLink(metadata.author.link), 2));
+ }
+ lines.push(` `);
+ }
+ if (metadata.copyright) {
+ if (typeof metadata.copyright === 'string') {
+ lines.push(` `);
+ } else {
+ lines.push(` `);
+ if (metadata.copyright.year !== undefined) {
+ lines.push(` ${xmlTagNumber('year', metadata.copyright.year)}`);
+ }
+ if (metadata.copyright.license) {
+ lines.push(` ${xmlTag('license', metadata.copyright.license)}`);
}
- lines.push(` `);
- return lines.join('\n');
- })
- .join('\n');
+ lines.push(` `);
+ }
+ }
+ if (metadata.link) {
+ lines.push(...indentLines(renderLink(metadata.link), 1));
+ }
+ if (metadata.time) {
+ lines.push(` ${xmlTag('time', metadata.time.toISOString())}`);
+ }
+ if (metadata.keywords) {
+ lines.push(` ${xmlTag('keywords', metadata.keywords)}`);
+ }
+ if (metadata.bounds) {
+ lines.push(` ${renderBounds(metadata.bounds)}`);
+ }
+ lines.push('');
+ return lines;
+}
- const garminAttrs = useGarminExtension
- ? [
- ` xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v2"`,
- ` xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd`,
- ` http://www.garmin.com/xmlschemas/TrackPointExtension/v2 https://www8.garmin.com/xmlschemas/TrackPointExtensionv2.xsd"`,
- ]
- : [
- ` xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd"`,
- ];
+function renderPointExtensions(extensions: GpxPointExtensions | undefined, level: number, includeGarminMetrics = false): string[] {
+ if (!extensions) {
+ return [];
+ }
+
+ const lines: string[] = [''];
+ if (includeGarminMetrics && hasGarminPointExtensions(extensions)) {
+ lines.push(` `);
+ if (extensions.speed !== undefined) {
+ lines.push(` ${xmlTagNumber('gpxtpx:speed', extensions.speed, 4)}`);
+ }
+ if (extensions.heartRate !== undefined) {
+ lines.push(` ${xmlTagNumber('gpxtpx:hr', extensions.heartRate)}`);
+ }
+ if (extensions.cadence !== undefined) {
+ lines.push(` ${xmlTagNumber('gpxtpx:cad', extensions.cadence)}`);
+ }
+ lines.push(` `);
+ }
+
+ if (hasValue(extensions.rawXml)) {
+ lines.push(extensions.rawXml);
+ }
+
+ if (lines.length === 1) {
+ return [];
+ }
- return [
+ lines.push(' ');
+ return indentLines(lines, level);
+}
+
+function renderTrackPoint(point: GpxPoint, level: number): string[] {
+ const lines: string[] = [``];
+ if (point.elevation !== undefined) {
+ lines.push(` ${xmlTagNumber('ele', point.elevation, 2)}`);
+ }
+ lines.push(` ${xmlTag('time', point.time.toISOString())}`);
+
+ const extensions = normalizePointExtensions(point.speed, point.extensions);
+ lines.push(...renderPointExtensions(extensions, 1, true));
+ lines.push(' ');
+ return indentLines(lines, level);
+}
+
+function renderRoutePoint(point: GpxRoutePoint, level: number): string[] {
+ const lines: string[] = [``];
+ if (point.elevation !== undefined) {
+ lines.push(` ${xmlTagNumber('ele', point.elevation, 2)}`);
+ }
+ const iso = toIso(point.time);
+ if (iso) {
+ lines.push(` ${xmlTag('time', iso)}`);
+ }
+ lines.push(...renderPointExtensions(point.extensions, 1));
+ lines.push(' ');
+ return indentLines(lines, level);
+}
+
+function renderWaypoint(waypoint: GpxWaypoint, level: number): string[] {
+ const lines: string[] = [``];
+ if (waypoint.elevation !== undefined) {
+ lines.push(` ${xmlTagNumber('ele', waypoint.elevation, 2)}`);
+ }
+ const iso = toIso(waypoint.time);
+ if (iso) {
+ lines.push(` ${xmlTag('time', iso)}`);
+ }
+ if (waypoint.name) {
+ lines.push(` ${xmlTag('name', waypoint.name)}`);
+ }
+ if (waypoint.cmt) {
+ lines.push(` ${xmlTag('cmt', waypoint.cmt)}`);
+ }
+ if (waypoint.desc) {
+ lines.push(` ${xmlTag('desc', waypoint.desc)}`);
+ }
+ lines.push(...renderPointExtensions(waypoint.extensions, 1));
+ lines.push(' ');
+ return indentLines(lines, level);
+}
+
+function renderRoute(route: GpxRoute, level: number): string[] {
+ const lines: string[] = [''];
+ if (route.name) {
+ lines.push(` ${xmlTag('name', route.name)}`);
+ }
+ if (route.cmt) {
+ lines.push(` ${xmlTag('cmt', route.cmt)}`);
+ }
+ if (route.desc) {
+ lines.push(` ${xmlTag('desc', route.desc)}`);
+ }
+ lines.push(...renderPointExtensions(route.extensions, 1));
+ for (const point of route.points) {
+ lines.push(...renderRoutePoint(point, 1));
+ }
+ lines.push(' ');
+ return indentLines(lines, level);
+}
+
+function renderTrack(track: GpxTrack, level: number): string[] {
+ const lines: string[] = [''];
+ lines.push(` ${xmlTag('name', track.name)}`);
+ if (track.cmt) {
+ lines.push(` ${xmlTag('cmt', track.cmt)}`);
+ }
+ if (track.desc) {
+ lines.push(` ${xmlTag('desc', track.desc)}`);
+ }
+ lines.push(...renderPointExtensions(track.extensions, 1));
+
+ for (const segment of resolveTrackSegments(track)) {
+ lines.push(' ');
+ for (const point of segment.points) {
+ lines.push(...renderTrackPoint(point, 2));
+ }
+ lines.push(' ');
+ }
+ lines.push(' ');
+ return indentLines(lines, level);
+}
+
+function shouldUseGarminExtension(document: GpxDocument): boolean {
+ for (const track of document.tracks ?? []) {
+ for (const segment of resolveTrackSegments(track)) {
+ for (const point of segment.points) {
+ const normalized = normalizePointExtensions(point.speed, point.extensions);
+ if (hasGarminPointExtensions(normalized)) {
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+}
+
+function mergeMetadata(base: GpxMetadata | undefined, incoming: GpxMetadata | undefined): GpxMetadata | undefined {
+ if (!base && !incoming) {
+ return undefined;
+ }
+ return {
+ ...(base ?? {}),
+ ...(incoming ?? {}),
+ };
+}
+
+function normalizeDocument(document: GpxDocument, metadata: GpxMetadata | undefined): GpxDocument {
+ return {
+ metadata: mergeMetadata(document.metadata, metadata),
+ waypoints: document.waypoints ?? [],
+ routes: document.routes ?? [],
+ tracks: document.tracks ?? [],
+ };
+}
+
+function generateGpxDocument(document: GpxDocument, metadata?: GpxMetadata): string {
+ const normalized = normalizeDocument(document, metadata);
+
+ const creator = escapeXml('gpx-export');
+ const useGarminExtension = shouldUseGarminExtension(normalized);
+
+ const lines: string[] = [
``,
``,
- ` `,
- ` ${name} `,
- ` ${createdAt} `,
- ` `,
- ` `,
- ` ${name} `,
- ` `,
- trkpts,
- ` `,
- ` `,
- ` `,
- ].join('\n');
+ ];
+
+ if (useGarminExtension) {
+ lines.push(
+ ` xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v2"`,
+ ` xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd`,
+ ` http://www.garmin.com/xmlschemas/TrackPointExtension/v2 https://www8.garmin.com/xmlschemas/TrackPointExtensionv2.xsd"`,
+ );
+ } else {
+ lines.push(` xsi:schemaLocation="http://www.topografix.com/GPX/1/1 https://www.topografix.com/GPX/1/1/gpx.xsd"`);
+ }
+ lines.push('>');
+
+ lines.push(...indentLines(renderMetadata(normalized.metadata), 1));
+ for (const waypoint of normalized.waypoints ?? []) {
+ lines.push(...renderWaypoint(waypoint, 1));
+ }
+ for (const route of normalized.routes ?? []) {
+ lines.push(...renderRoute(route, 1));
+ }
+ for (const track of normalized.tracks ?? []) {
+ lines.push(...renderTrack(track, 1));
+ }
+
+ lines.push(``);
+ return lines.join('\n');
+}
+
+function isTrackInput(input: GpxTrack | GpxDocument): input is GpxTrack {
+ return 'name' in input;
+}
+
+export function generateGpx(input: GpxTrack | GpxDocument, metadata?: GpxMetadata): string {
+ if (isTrackInput(input)) {
+ return generateGpxDocument({ tracks: [input] }, metadata);
+ }
+ return generateGpxDocument(input, metadata);
}
+
diff --git a/src/index.ts b/src/index.ts
index eee55da..a692d7a 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1 +1,2 @@
-export * from "./GpxExport.js";
\ No newline at end of file
+export * from "./GpxExport.js";
+export type * from "./types/index";
diff --git a/src/types/GpxAuthor.ts b/src/types/GpxAuthor.ts
new file mode 100644
index 0000000..ae922d8
--- /dev/null
+++ b/src/types/GpxAuthor.ts
@@ -0,0 +1,6 @@
+import type { GpxLink } from './GpxLink';
+
+export interface GpxAuthor {
+ name: string;
+ link?: GpxLink;
+}
diff --git a/src/types/GpxBounds.ts b/src/types/GpxBounds.ts
new file mode 100644
index 0000000..2094824
--- /dev/null
+++ b/src/types/GpxBounds.ts
@@ -0,0 +1,6 @@
+export interface GpxBounds {
+ minLat: number;
+ minLon: number;
+ maxLat: number;
+ maxLon: number;
+}
diff --git a/src/types/GpxCopyright.ts b/src/types/GpxCopyright.ts
new file mode 100644
index 0000000..119aa10
--- /dev/null
+++ b/src/types/GpxCopyright.ts
@@ -0,0 +1,5 @@
+export interface GpxCopyright {
+ author: string;
+ year?: number;
+ license?: string;
+}
diff --git a/src/types/GpxDocument.ts b/src/types/GpxDocument.ts
new file mode 100644
index 0000000..7836278
--- /dev/null
+++ b/src/types/GpxDocument.ts
@@ -0,0 +1,12 @@
+import type { GpxMetadata } from './GpxMetadata';
+import type { GpxRoute } from './GpxRoute';
+import type { GpxTrack } from './GpxTrack';
+import type { GpxWaypoint } from './GpxWaypoint';
+
+export interface GpxDocument {
+ metadata?: GpxMetadata;
+ waypoints?: GpxWaypoint[];
+ routes?: GpxRoute[];
+ tracks?: GpxTrack[];
+}
+
diff --git a/src/types/GpxLink.ts b/src/types/GpxLink.ts
new file mode 100644
index 0000000..e1e8823
--- /dev/null
+++ b/src/types/GpxLink.ts
@@ -0,0 +1,5 @@
+export interface GpxLink {
+ href: string;
+ text?: string;
+ type?: string;
+}
diff --git a/src/types/GpxMetadata.ts b/src/types/GpxMetadata.ts
new file mode 100644
index 0000000..86ec3ec
--- /dev/null
+++ b/src/types/GpxMetadata.ts
@@ -0,0 +1,21 @@
+import type { GpxAuthor } from './GpxAuthor';
+import type { GpxBounds } from './GpxBounds';
+import type { GpxCopyright } from './GpxCopyright';
+import type { GpxLink } from './GpxLink';
+
+export interface GpxMetadata {
+ name?: string;
+ desc?: string;
+ author?: GpxAuthor;
+ link?: GpxLink;
+ time?: Date;
+ keywords?: string;
+ /**
+ * Backward compatible shorthand:
+ * - string => rendered as
+ * - object => full GPX 1.1 copyright structure
+ */
+ copyright?: string | GpxCopyright;
+ bounds?: GpxBounds;
+}
+
diff --git a/src/types/GpxPoint.ts b/src/types/GpxPoint.ts
new file mode 100644
index 0000000..ffb09f2
--- /dev/null
+++ b/src/types/GpxPoint.ts
@@ -0,0 +1,12 @@
+import type { GpxPointExtensions } from './GpxPointExtensions';
+
+export interface GpxPoint {
+ lat: number;
+ lon: number;
+ time: Date;
+ /** Speed in m/s — written as gpxtpx:speed (Garmin TrackPointExtension v2) */
+ speed?: number;
+ /** Elevation in metres — written as standard GPX */
+ elevation?: number;
+ extensions?: GpxPointExtensions;
+}
diff --git a/src/types/GpxPointExtensions.ts b/src/types/GpxPointExtensions.ts
new file mode 100644
index 0000000..d72edac
--- /dev/null
+++ b/src/types/GpxPointExtensions.ts
@@ -0,0 +1,10 @@
+export interface GpxPointExtensions {
+ /** Speed in m/s — written as gpxtpx:speed */
+ speed?: number;
+ /** Heart rate in bpm — written as gpxtpx:hr */
+ heartRate?: number;
+ /** Cadence in rpm — written as gpxtpx:cad */
+ cadence?: number;
+ /** Trusted raw XML inserted into without escaping */
+ rawXml?: string;
+}
diff --git a/src/types/GpxRoute.ts b/src/types/GpxRoute.ts
new file mode 100644
index 0000000..6dceb21
--- /dev/null
+++ b/src/types/GpxRoute.ts
@@ -0,0 +1,11 @@
+import type { GpxPointExtensions } from './GpxPointExtensions';
+import type { GpxRoutePoint } from './GpxRoutePoint';
+
+export interface GpxRoute {
+ name?: string;
+ cmt?: string;
+ desc?: string;
+ points: GpxRoutePoint[];
+ extensions?: GpxPointExtensions;
+}
+
diff --git a/src/types/GpxRoutePoint.ts b/src/types/GpxRoutePoint.ts
new file mode 100644
index 0000000..11356db
--- /dev/null
+++ b/src/types/GpxRoutePoint.ts
@@ -0,0 +1,10 @@
+import type { GpxPointExtensions } from './GpxPointExtensions';
+
+export interface GpxRoutePoint {
+ lat: number;
+ lon: number;
+ elevation?: number;
+ time?: Date;
+ extensions?: GpxPointExtensions;
+}
+
diff --git a/src/types/GpxTrack.ts b/src/types/GpxTrack.ts
new file mode 100644
index 0000000..e1097cf
--- /dev/null
+++ b/src/types/GpxTrack.ts
@@ -0,0 +1,15 @@
+import type { GpxPoint } from './GpxPoint';
+import type { GpxPointExtensions } from './GpxPointExtensions';
+import type { GpxTrackSegment } from './GpxTrackSegment';
+
+export interface GpxTrack {
+ name: string;
+ /** Defaults to the time of the first point when omitted */
+ createdAt?: Date;
+ points?: GpxPoint[];
+ segments?: GpxTrackSegment[];
+ cmt?: string;
+ desc?: string;
+ extensions?: GpxPointExtensions;
+}
+
diff --git a/src/types/GpxTrackSegment.ts b/src/types/GpxTrackSegment.ts
new file mode 100644
index 0000000..badf329
--- /dev/null
+++ b/src/types/GpxTrackSegment.ts
@@ -0,0 +1,6 @@
+import type { GpxPoint } from './GpxPoint';
+
+export interface GpxTrackSegment {
+ points: GpxPoint[];
+}
+
diff --git a/src/types/GpxWaypoint.ts b/src/types/GpxWaypoint.ts
new file mode 100644
index 0000000..3273ff6
--- /dev/null
+++ b/src/types/GpxWaypoint.ts
@@ -0,0 +1,13 @@
+import type { GpxPointExtensions } from './GpxPointExtensions';
+
+export interface GpxWaypoint {
+ lat: number;
+ lon: number;
+ name?: string;
+ cmt?: string;
+ desc?: string;
+ elevation?: number;
+ time?: Date;
+ extensions?: GpxPointExtensions;
+}
+
diff --git a/src/types/index.ts b/src/types/index.ts
new file mode 100644
index 0000000..d8c04f8
--- /dev/null
+++ b/src/types/index.ts
@@ -0,0 +1,14 @@
+export type { GpxAuthor } from './GpxAuthor';
+export type { GpxBounds } from './GpxBounds';
+export type { GpxCopyright } from './GpxCopyright';
+export type { GpxDocument } from './GpxDocument';
+export type { GpxLink } from './GpxLink';
+export type { GpxMetadata } from './GpxMetadata';
+export type { GpxPoint } from './GpxPoint';
+export type { GpxPointExtensions } from './GpxPointExtensions';
+export type { GpxRoute } from './GpxRoute';
+export type { GpxRoutePoint } from './GpxRoutePoint';
+export type { GpxTrack } from './GpxTrack';
+export type { GpxTrackSegment } from './GpxTrackSegment';
+export type { GpxWaypoint } from './GpxWaypoint';
+
diff --git a/tests/generateGpx.test.ts b/tests/generateGpx.test.ts
index 45c301a..94d1db4 100644
--- a/tests/generateGpx.test.ts
+++ b/tests/generateGpx.test.ts
@@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
import { generateGpx } from '../src/GpxExport';
describe('generateGpx', () => {
- it('generates a basic GPX document', () => {
- const now = new Date('2024-01-01T12:00:00Z');
+ it('generates a basic GPX document from track input', () => {
+ const now = new Date('2026-01-01T12:00:00.000Z');
const gpx = generateGpx({
name: 'Test Track',
@@ -20,11 +20,11 @@ describe('generateGpx', () => {
expect(gpx).toContain('');
expect(gpx).toContain('Test Track ');
expect(gpx).toContain('');
- expect(gpx).toContain('2024-01-01T12:00:00.000Z ');
+ expect(gpx).toContain('2026-01-01T12:00:00.000Z ');
});
it('includes Garmin speed extension when speed is present', () => {
- const now = new Date('2024-01-01T12:00:00Z');
+ const now = new Date('2026-01-01T12:00:00.000Z');
const gpx = generateGpx({
name: 'Speed Test',
@@ -41,4 +41,576 @@ describe('generateGpx', () => {
expect(gpx).toContain('gpxtpx:TrackPointExtension');
expect(gpx).toContain('3.5000 ');
});
+
+ it('escapes xml in legacy track name', () => {
+ const now = new Date('2026-01-01T12:00:00.000Z');
+
+ const gpx = generateGpx({
+ name: 'Track ',
+ points: [
+ {
+ lat: 10,
+ lon: 20,
+ time: now,
+ },
+ ],
+ });
+
+ expect(gpx).toContain('Track <A&B> ');
+ });
+
+ it('wraps track input as a tracks-only document', () => {
+ const now = new Date('2026-01-01T12:00:00.000Z');
+
+ const gpx = generateGpx({
+ name: 'Legacy Track',
+ points: [
+ {
+ lat: 12.34,
+ lon: 56.78,
+ time: now,
+ },
+ ],
+ });
+
+ expect(gpx).toContain('');
+ expect(gpx).toContain('Legacy Track ');
+ expect(gpx).toContain('');
+ expect(gpx).not.toContain('');
+ });
+
+ it('allows optional metadata for track input', () => {
+ const now = new Date('2026-01-01T12:00:00.000Z');
+
+ const gpx = generateGpx(
+ {
+ name: 'Legacy Track',
+ points: [
+ {
+ lat: 1,
+ lon: 2,
+ time: now,
+ },
+ ],
+ },
+ {
+ name: 'Metadata Name',
+ desc: 'Metadata Description',
+ time: now,
+ },
+ );
+
+ expect(gpx).toContain('');
+ expect(gpx).toContain('Metadata Name ');
+ expect(gpx).toContain('Metadata Description ');
+ expect(gpx).toContain('2026-01-01T12:00:00.000Z ');
+ expect(gpx).toContain('Legacy Track ');
+ });
+});
+
+describe('generateGpx (document input)', () => {
+ it('accepts full document input with a single API', () => {
+ const now = new Date('2026-01-01T12:00:00.000Z');
+
+ const gpx = generateGpx({
+ metadata: {
+ name: 'Document Input',
+ time: now,
+ },
+ waypoints: [
+ {
+ lat: 1,
+ lon: 2,
+ name: 'W',
+ },
+ ],
+ routes: [
+ {
+ name: 'R',
+ points: [
+ {
+ lat: 1,
+ lon: 2,
+ },
+ ],
+ },
+ ],
+ tracks: [
+ {
+ name: 'T',
+ points: [
+ {
+ lat: 1,
+ lon: 2,
+ time: now,
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(gpx).toContain('');
+ expect(gpx).toContain('Document Input ');
+ expect(gpx).toContain('');
+ expect(gpx).toContain('');
+ expect(gpx).toContain('');
+ });
+
+ it('renders a minimal Strava-compatible track activity shape', () => {
+ const now = new Date('2026-01-01T12:00:00.000Z');
+
+ const gpx = generateGpx({
+ tracks: [
+ {
+ name: 'Morning Run',
+ points: [
+ {
+ lat: 51.5007,
+ lon: -0.1246,
+ time: now,
+ elevation: 12.3,
+ },
+ {
+ lat: 51.5008,
+ lon: -0.1242,
+ time: new Date('2026-01-01T12:00:10.000Z'),
+ elevation: 12.8,
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(gpx).toContain('');
+ expect(gpx).toContain('Morning Run ');
+ expect(gpx).toContain('');
+ expect(gpx).toContain('');
+ expect(gpx).toContain('2026-01-01T12:00:00.000Z ');
+ expect(gpx).toContain('2026-01-01T12:00:10.000Z ');
+
+ // Keep the minimal track shape free of unrelated sections.
+ expect(gpx).not.toContain('');
+ expect(gpx).not.toContain('xmlns:gpxtpx=');
+ });
+
+ it('generates waypoints, routes, and tracks with deterministic order', () => {
+ const now = new Date('2026-01-01T12:00:00.000Z');
+
+ const gpx = generateGpx({
+ metadata: {
+ name: 'Document Name',
+ desc: 'Document Description',
+ keywords: 'test,document',
+ time: now,
+ },
+ waypoints: [
+ {
+ lat: 40,
+ lon: -74,
+ name: 'Start',
+ time: now,
+ },
+ ],
+ routes: [
+ {
+ name: 'Route One',
+ points: [
+ {
+ lat: 40,
+ lon: -74,
+ time: now,
+ },
+ {
+ lat: 41,
+ lon: -73,
+ time: now,
+ },
+ ],
+ },
+ ],
+ tracks: [
+ {
+ name: 'Track One',
+ segments: [
+ {
+ points: [
+ {
+ lat: 40,
+ lon: -74,
+ time: now,
+ },
+ ],
+ },
+ {
+ points: [
+ {
+ lat: 41,
+ lon: -73,
+ time: now,
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ const metadataIndex = gpx.indexOf('');
+ const waypointIndex = gpx.indexOf('');
+ const trackIndex = gpx.indexOf('');
+
+ expect(metadataIndex).toBeGreaterThan(-1);
+ expect(waypointIndex).toBeGreaterThan(metadataIndex);
+ expect(routeIndex).toBeGreaterThan(waypointIndex);
+ expect(trackIndex).toBeGreaterThan(routeIndex);
+ expect(gpx.match(//g)?.length ?? 0).toBe(2);
+ });
+
+ it('uses metadata bounds from the document', () => {
+ const gpx = generateGpx(
+ {
+ metadata: {
+ bounds: {
+ minLat: 1,
+ minLon: 2,
+ maxLat: 3,
+ maxLon: 4,
+ },
+ },
+ tracks: [
+ {
+ name: 'Track',
+ points: [],
+ },
+ ],
+ },
+ );
+
+ expect(gpx).toContain(' ');
+ });
+
+ it('uses default creator', () => {
+ const gpx = generateGpx({
+ tracks: [
+ {
+ name: 'Track',
+ points: [],
+ },
+ ],
+ });
+
+ expect(gpx).toContain(' {
+ const now = new Date('2026-01-01T12:00:00.000Z');
+
+ const gpx = generateGpx({
+ tracks: [
+ {
+ name: 'Fitness',
+ points: [
+ {
+ lat: 1,
+ lon: 2,
+ time: now,
+ extensions: {
+ heartRate: 150,
+ cadence: 85,
+ },
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(gpx).toContain('xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v2"');
+ expect(gpx).toContain('150 ');
+ expect(gpx).toContain('85 ');
+ });
+
+ it('does not emit Garmin TrackPointExtension for non-track points', () => {
+ const gpx = generateGpx({
+ waypoints: [
+ {
+ lat: 1,
+ lon: 2,
+ extensions: {
+ heartRate: 150,
+ cadence: 85,
+ speed: 3.5,
+ },
+ },
+ ],
+ routes: [
+ {
+ name: 'R',
+ points: [
+ {
+ lat: 1,
+ lon: 2,
+ extensions: {
+ heartRate: 140,
+ },
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(gpx).not.toContain('xmlns:gpxtpx="http://www.garmin.com/xmlschemas/TrackPointExtension/v2"');
+ expect(gpx).not.toContain('gpxtpx:TrackPointExtension');
+ expect(gpx).not.toContain('');
+ expect(gpx).not.toContain('');
+ expect(gpx).not.toContain('');
+ });
+
+ it('is deterministic for identical input', () => {
+ const now = new Date('2026-01-01T12:00:00.000Z');
+ const input = {
+ metadata: {
+ name: 'Deterministic',
+ time: now,
+ },
+ tracks: [
+ {
+ name: 'Track',
+ points: [
+ {
+ lat: 1,
+ lon: 2,
+ time: now,
+ },
+ ],
+ },
+ ],
+ };
+
+ const a = generateGpx(input);
+ const b = generateGpx(input);
+ expect(a).toBe(b);
+ });
+
+ it('renders metadata in GPX 1.1 field order with structured copyright', () => {
+ const now = new Date('2026-01-01T12:00:00.000Z');
+
+ const gpx = generateGpx({
+ metadata: {
+ name: 'N',
+ desc: 'D',
+ author: {
+ name: 'Chris',
+ },
+ copyright: {
+ author: 'Chris',
+ year: 2024,
+ license: 'https://github.com/cmyers/gpx-export/blob/main/LICENSE',
+ },
+ link: {
+ href: 'https://github.com/cmyers/gpx-export',
+ },
+ time: now,
+ keywords: 'k',
+ },
+ });
+
+ const authorIdx = gpx.indexOf('');
+ const copyrightIdx = gpx.indexOf('');
+ const linkIdx = gpx.indexOf(' ');
+ const timeIdx = gpx.indexOf('2026-01-01T12:00:00.000Z ');
+ const keywordsIdx = gpx.indexOf('k ');
+
+ expect(copyrightIdx).toBeGreaterThan(authorIdx);
+ expect(linkIdx).toBeGreaterThan(copyrightIdx);
+ expect(timeIdx).toBeGreaterThan(linkIdx);
+ expect(keywordsIdx).toBeGreaterThan(timeIdx);
+ expect(gpx).toContain('2024 ');
+ expect(gpx).toContain('https://github.com/cmyers/gpx-export/blob/main/LICENSE ');
+ });
+
+ it('renders waypoint ele and time before name/cmt/desc', () => {
+ const now = new Date('2026-01-01T12:00:00.000Z');
+
+ const gpx = generateGpx({
+ waypoints: [
+ {
+ lat: 1,
+ lon: 2,
+ elevation: 99.1,
+ time: now,
+ name: 'Name',
+ cmt: 'Comment',
+ desc: 'Description',
+ },
+ ],
+ });
+
+ const eleIdx = gpx.indexOf('99.10 ');
+ const timeIdx = gpx.indexOf('2026-01-01T12:00:00.000Z ');
+ const nameIdx = gpx.indexOf('Name ');
+ const cmtIdx = gpx.indexOf('Comment ');
+ const descIdx = gpx.indexOf('Description ');
+
+ expect(eleIdx).toBeGreaterThan(-1);
+ expect(timeIdx).toBeGreaterThan(eleIdx);
+ expect(nameIdx).toBeGreaterThan(timeIdx);
+ expect(cmtIdx).toBeGreaterThan(nameIdx);
+ expect(descIdx).toBeGreaterThan(cmtIdx);
+ });
+
+ it('renders backward-compatible string copyright as author attribute', () => {
+ const gpx = generateGpx({
+ metadata: {
+ copyright: 'Legacy Owner',
+ },
+ });
+
+ expect(gpx).toContain(' ');
+ });
+
+ it('renders a maximal supported document', () => {
+ const now = new Date('2026-01-01T12:00:00.000Z');
+
+ const gpx = generateGpx({
+ metadata: {
+ name: 'Doc Name',
+ desc: 'Doc Desc',
+ author: {
+ name: 'Chris',
+ link: {
+ href: 'https://github.com/cmyers/gpx-export',
+ text: 'Project',
+ type: 'text/html',
+ },
+ },
+ copyright: {
+ author: 'Chris',
+ year: 2024,
+ license: 'https://github.com/cmyers/gpx-export/blob/main/LICENSE',
+ },
+ link: {
+ href: 'https://github.com/cmyers/gpx-export',
+ text: 'Project',
+ type: 'text/html',
+ },
+ time: now,
+ keywords: 'a,b,c',
+ bounds: {
+ minLat: 1,
+ minLon: 2,
+ maxLat: 3,
+ maxLon: 4,
+ },
+ },
+ waypoints: [
+ {
+ lat: 50.1,
+ lon: -1.2,
+ elevation: 12.34,
+ time: now,
+ name: 'Waypoint A',
+ cmt: 'Waypoint Comment',
+ desc: 'Waypoint Description',
+ extensions: {
+ speed: 1.2345,
+ heartRate: 140,
+ cadence: 88,
+ rawXml: 'w ',
+ },
+ },
+ ],
+ routes: [
+ {
+ name: 'Route A',
+ cmt: 'Route Comment',
+ desc: 'Route Description',
+ extensions: {
+ rawXml: 'r ',
+ },
+ points: [
+ {
+ lat: 50.2,
+ lon: -1.3,
+ elevation: 13.45,
+ time: now,
+ extensions: {
+ speed: 2.3456,
+ heartRate: 150,
+ cadence: 90,
+ rawXml: 'rp ',
+ },
+ },
+ ],
+ },
+ ],
+ tracks: [
+ {
+ name: 'Track A',
+ cmt: 'Track Comment',
+ desc: 'Track Description',
+ extensions: {
+ rawXml: 't ',
+ },
+ segments: [
+ {
+ points: [
+ {
+ lat: 50.3,
+ lon: -1.4,
+ elevation: 14.56,
+ time: now,
+ speed: 3.4567,
+ extensions: {
+ heartRate: 160,
+ cadence: 95,
+ rawXml: 'tp ',
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ });
+
+ expect(gpx).toContain('');
+ expect(gpx).toContain('Doc Name ');
+ expect(gpx).toContain('Doc Desc ');
+ expect(gpx).toContain('');
+ expect(gpx).toContain(' ');
+ expect(gpx).toContain('Project ');
+ expect(gpx).toContain('text/html ');
+ expect(gpx).toContain('');
+ expect(gpx).toContain('2024 ');
+ expect(gpx).toContain('https://github.com/cmyers/gpx-export/blob/main/LICENSE ');
+ expect(gpx).toContain(' ');
+ expect(gpx).toContain('2026-01-01T12:00:00.000Z ');
+ expect(gpx).toContain('a,b,c ');
+ expect(gpx).toContain(' ');
+
+ expect(gpx).toContain('');
+ expect(gpx).toContain('Waypoint A ');
+ expect(gpx).toContain('w ');
+ expect(gpx).toContain('');
+ expect(gpx).toContain('Route A ');
+ expect(gpx).toContain('r ');
+ expect(gpx).toContain('rp ');
+
+ expect(gpx).toContain('');
+ expect(gpx).toContain('Track A ');
+ expect(gpx).toContain('t ');
+ expect(gpx).toContain('');
+ expect(gpx).toContain('tp ');
+
+ expect(gpx).toContain('3.4567 ');
+ expect(gpx).toContain('160 ');
+ expect(gpx).toContain('95 ');
+ });
});