Rust utility to generate a Nuxt 4 based website from a Subsurface dive log. https://www.anthony-mills.com/projects/subsurface-site-generator.html
  • Vue 67%
  • Rust 32.5%
  • TypeScript 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Anthony Mills e0328b372a
All checks were successful
CI / lint (push) Successful in 1m26s
CI / test (push) Successful in 1m39s
Added a changelog file to the project.
2026-09-04 13:10:55 +08:00
.forgejo/workflows Add wget to AppImage build dependencies 2026-09-04 12:52:17 +08:00
.idea Added License and made README updates. 2026-06-05 23:13:54 +08:00
data Add longest dives page, fix XML attribute whitespace, npm install error, and missing DiveLocationPanel component 2026-08-18 00:02:42 +08:00
src Fix import: dive_log -> subsurface_site_generator 2026-09-04 12:19:08 +08:00
template Add per-tank gas information and SAC rate graphs with moving-average smoothing. 2026-08-31 09:27:37 +08:00
tests Fix test import: dive_log -> subsurface_site_generator 2026-09-04 12:41:30 +08:00
.gitignore Added License and made README updates. 2026-06-05 23:13:54 +08:00
Cargo.lock Make dive profile charts scrollable on mobile and span the full axis for sparse temperature readings 2026-08-30 19:24:51 +08:00
Cargo.toml Fix binary paths: use lowercase subsurface_site_generator matching cargo build output 2026-09-04 12:36:38 +08:00
CHANGELOG.md Added a changelog file to the project. 2026-09-04 13:10:55 +08:00
License Added License and made README updates. 2026-06-05 23:13:54 +08:00
README.md Add per-tank gas information and SAC rate graphs with moving-average smoothing. 2026-08-31 09:27:37 +08:00

Subsurface Dive Log Generator

CI

Subsurface is my favourite dive log app. It supports almost every dive computer I am likely to use and its open source.

That said, the HTML export functionality is not really the prettiest, and sometimes I would like to share the details of a dive with other people that I dived with.

Enter this Rust CLI tool, point it at the XML file that Subsurface stores your log information in. It will then generate a Nuxt-4-based website of your dive log, which you can then upload to a web server so that others can see it.

My own dive log site generated with the tool can be found here if you want to see how it looks.

Features

  • Parses the Subsurface XML dive log format (version 3)
  • Generates a fully static, self-contained Nuxt 4 website consisting of two-page layouts:
    • Dive listing page — searchable grid of all dives showing site name, location, depth, duration, water temperature, and tags
    • Dive detail page — full statistics, star rating, visibility, SAC rate, air consumed, buddy, divemaster, notes, an interactive map (OpenStreetMap via Leaflet), a depth/temperature profile chart rendered as SVG, and a photo carousel with thumbnail strip and full-screen lightbox
  • Optional reverse geocoding of dive site location information via Nominatim to resolve GPS coordinates to human-readable city/region names
  • Optional image processing — resizes and optimises dive photos for the web, with a cache to avoid reprocessing unchanged files

Requirements

  • Rust (edition 2024, stable toolchain)
  • Node.js 18+ and npm (to build and serve the generated site)

Building

git clone <repo>
cd dive_log
cargo build --release

The compiled binary is at target/release/dive_log.

Usage

dive_log --file <path/to/divelog.xml> [OPTIONS]

Options

Flag Short Default Description
--file <FILE> -f (required) Path to the Subsurface XML dive log file
--output <DIR> -o output Directory to write the generated Nuxt 4 site into
--geocode disabled Reverse-geocode dive sites that have GPS coordinates but no region/city data (see below). Results are cached in data/geocache.json.
--template <DIR> ./template/ if present Directory of customised Nuxt template files (see Customising the generated site)
--media <DIR> <log_dir>/media/ if present Directory containing dive images, organised into sub-folders by dive number (see Adding photos to dives).
--max-image-width <PIXELS> 2000 Maximum width in pixels for processed dive images. Images wider than this are scaled down proportionally; narrower images are left at their original size.
--help -h Print help

Basic usage example

dive_log --file ~/Documents/divelog.xml

Parses the log and writes the site to the ./output/ directory.

Custom output directory

dive_log --file ~/Documents/divelog.xml --output ~/Sites/my-dive-log

Dive Location Geocoding

Geocoding is available to look up region names for dive sites that have GPS information associated with them.

To ensure fast site generation, geocoding is disabled by default, and can be enabled with the --geocode flag e.g.

dive_log --file ~/Documents/divelog.xml --geocode

The geocoding feature uses the Nominatim API (OpenStreetMap). Nominatim's usage policy requires at most one request per second, so this adds roughly one second per site that needs geocoding.

Since this is a free service, results are cached in data/geocache.json file to avoid excessive API calls.

Geocoding cache

Results are persisted to data/geocache.json next to your dive log file. On later runs, any site whose GPS coordinates are already in the cache is resolved instantly without hitting the API — including sites where no locality was found (open water). The progress line reports how many lookups come from cache vs. API, so you can see at a glance how much work remains.

The cache file is a plain JSON object keyed by "lat,lon" strings and is safe to commit to version control so collaborators share the same cached results. But by default this file is excluded from commits via the .gitignore file. It is not written to the generated site.

Adding photos to dives

You can attach photos to any dive by placing them in a folder named after the dive number inside a media directory.

When running the tool, it will resize the images (can be set using the flag --max-image-width). By default, will resize to 2000px wide and optimise them for the web.

By default, dive_log looks for a media/ in the data/ directory and then searches for numbered subfolders within.

To add images to a logged dive, find the dive number in the subsurface, create a folder with the same name as the dive number and place the images in it.

After that you can run the tool again, and the images will be processed and added to the dive detail page.

It is possible to use a different folder for containing the dive media by passing directory path with the --media. But the numbered subfolder structure is still required to match the images to the relevant dive at run time.

dive_log --file data/divelog.xml --media /Volumes/Photos/dive-media

Folder structure

Inside the media directory, create one subfolder per dive named after its dive number (the number shown in Subsurface's dive list, not an internal ID):

media/
├── 42/           # photos for dive #42
│   ├── IMG_0001.JPG
│   └── IMG_0002.JPG
└── 43/           # photos for dive #43
    └── DSC_0123.jpg

Supported formats

JPEG (.jpg, .jpeg) and PNG (.png) files are processed when found in a numbered folder that matches a logged dive.

File name matching is case-insensitive, so .JPG, .JPEG, and .PNG are all recognised.

What the tool does

When dive_log runs and finds a media/ directory next to the log file, it automatically processes every image folder it finds:

  1. Loads each source image using its original pixel data.
  2. Resizes — if the image is wider than the maximum width (default 2 000 px), it is scaled down proportionally using a Lanczos3 (high-quality) filter. Images already at or below the maximum width are left at their original size. Control this with --max-image-width.
  3. Converts the image to RGB colour space (strips any alpha channel) and re-encodes it as a JPEG at quality 85 — a good balance between visual fidelity and file size.
  4. Writes the processed image to output/public/media/<dive_number>/<filename>.jpg so it is served as a static asset alongside the rest of the site.
  5. Updates the dive's metadata to record which image files belong to it, so the generated dive detail page knows what to display.

Output filenames always use the .jpg extension regardless of the source format.

Caching

Processing large batches of images can be slow. To avoid reprocessing files that have not changed, dive_log maintains a cache file at data/mediacache.json (next to the log file). Each entry records a fingerprint — the file size and modification time — for a source image. On subsequent runs:

  • If the fingerprint matches and the output file already exists, the image is skipped instantly.
  • If the source file has been replaced or modified (different size or mtime), it is reprocessed and the cache is updated.

The cache file is written only when at least one image is actually processed. It is a local scratch file and is excluded from version control via .gitignore.

Serving the generated site

After generation, install the dependencies with npm and start the development server:

cd output        # or whatever --output path you used
npm install
npm run dev      # live development server at http://localhost:3000

To build a fully static site for deployment to your web server:

npm run generate # outputs to output/.output/public/
npm run preview  # preview the static build locally

The generate command pre-renders every dive page as static HTML and bundles all assets, so the result can be hosted on any static file host (GitHub Pages, Netlify, S3, etc.) with no server-side runtime required.

To automate this process, I have personally just used a bash script to generate the site and then upload to my webserver using rsync.

Generated site structure

output/
├── nuxt.config.ts
├── package.json
├── tsconfig.json
├── app/
│   ├── app.vue                   # Root layout (header, footer, global styles)
│   ├── pages/
│   │   ├── index.vue             # Dive listing with search
│   │   └── dives/[id].vue        # Individual dive detail page
│   ├── components/
│   │   ├── DiveMap.vue           # Leaflet map (client-side only)
│   │   └── DiveProfile.vue       # SVG depth + temperature profile chart
│   └── assets/data/
│       ├── dives.json            # All dive metadata (imported at build time)
│       └── sites.json            # All dive site data including GPS and geo info
└── public/
    └── data/
    │   └── profiles/
    │       └── <id>.json         # Per-dive depth/temperature profile points
    └── media/                    # Processed dive photos (only present when media/ source exists)
        └── <dive_number>/
            └── <filename>.jpg    # Resized and optimised JPEG

Dive information Layout

Dive information is presented on the generated Vue-based site via two main page layouts:

Dive Listing page

Lists all the dives that are in your dive log presenting:

  • Dive number and date
  • Dive site name
  • Location (city/region/country when available)
  • Maximum depth
  • Duration
  • Water temperature
  • SAC rate (if recorded)
  • Tags

The search box filters across site name, location, date, buddy name, and tags.

Dive Detail page

The dive detail page is split into the following submodules:

Header — always shown

Field Source
Date & time Dive log
Rating ★ 15 stars from Subsurface
Visibility ★ 15 stars from Subsurface (shown alongside rating)

Dive Statistics panel

Field Source
Duration Dive log
Max / mean depth Dive computer
Water temperature Dive computer
Buddy Dive log
Divemaster Dive log
OTU / CNS Dive computer (oxygen tracking)
Tags Dive log
Equipment Cylinder size, O₂%, start/end pressures

Gas Information panel — shown when cylinder data is present

Displays a per-tank breakdown for each cylinder:

Field Source
Gas Mix type (Air or EANx%)
Start / end gas Cylinder pressures from dive computer samples
Gas consumed (per tank) Calculated from cylinder pressures × tank volume (L)
SAC rate (per tank) Calculated from gas consumed ÷ duration ÷ ambient pressure

Computer Information panel — shown when a dive computer model is recorded

Field Source
Computer Model name from dive computer
Extra data All <extradata> fields logged by the computer (firmware version, serial number, deco model, battery, etc.)

Location panel

Field Source
Map Interactive Leaflet map (OpenStreetMap) when GPS is available
Coordinates GPS from dive site
Country From dive site or resolved via geocoding

Notes, Photos, and Dive Profile panels appear below the two-column layout when the relevant data is present. The profile is an SVG chart of depth over time with a filled gradient, max-depth marker, and a temperature strip when sample data is recorded. The Photos panel shows a carousel with thumbnail strip and full-screen lightbox — see Adding photos to dives.

Customising the generated site

The template/ directory in this repository contains the base Nuxt 4 source files used to build the generated site. You can edit any of these files to change the site's look, layout, or behaviour — then simply re-run dive_log and your changes are used instead of the built-in defaults.

Template directory structure

template/
├── package.json                        # Node dependencies (nuxt, leaflet)
├── nuxt.config.ts                      # Nuxt configuration
├── tsconfig.json
├── public/
│   └── robots.txt                      # Crawler directives (served at /robots.txt)
└── app/
    ├── app.vue                         # Root layout — header, footer, global CSS variables
    ├── pages/
    │   ├── index.vue                   # Dive listing with search, stats bar
    │   ├── dives/[id].vue             # Dive detail page
    │   ├── tags/[tag].vue             # Filtered listing by tag
    │   └── countries/[country].vue    # Filtered listing by country
    └── components/
        ├── DiveMap.vue                 # Leaflet map (client-side only)
        └── DiveProfile.vue            # SVG depth + temperature profile chart

Common customisations

What to change File to edit
Colour scheme, fonts, global layout app/app.vue — edit the :root CSS variables
Site title and branding app/app.vue — edit the <header> block
Dive card layout and which stats are shown app/pages/index.vue
Dive detail statistics order or labels app/pages/dives/[id].vue
Map style or zoom level app/components/DiveMap.vue
Profile chart colours, size, or grid app/components/DiveProfile.vue
Search engine and crawler directives public/robots.txt

Note: The app/assets/data/ and public/data/ directories inside the output are always regenerated from your dive log — do not put custom files there.

Credits & Thanks

Thanks are due to Subsurface it's a fantastic project, and it makes downloading dives from your computer easy.

Licence

Copyright © 2026 Anthony Mills

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/.