1- import { mkdirSync , statSync } from "node:fs" ;
1+ import { mkdirSync , readFileSync , statSync , writeFileSync } from "node:fs" ;
22import { join } from "node:path" ;
33import { execa } from "execa" ;
44import type { StateStore } from "../state.js" ;
55import { curlAtomic } from "./atomic-download.js" ;
66
7+ export interface StyleEntry {
8+ /** Style slug — becomes the subdirectory name under `tile-styles/`. */
9+ id : string ;
10+ /** GitHub `<owner>/<repo>`, used for both raw style.json and sprite GH-Pages base URL. */
11+ repo : string ;
12+ /** Branch to pull `style.json` from. */
13+ branch : string ;
14+ /**
15+ * Base name / path of the sprite files on the repo's GH Pages deployment,
16+ * relative to `https://<owner>.github.io/<repo>/`. Most styles put them at
17+ * the root (`sprite`), but osm-liberty puts them at `sprites/osm-liberty`.
18+ * We'll fetch `<basePath>.json`, `<basePath>.png`, `<basePath>@2x.{json,png}`
19+ * and save them locally as `sprite.{json,png}` / `sprite@2x.{json,png}`.
20+ */
21+ spritePath ?: string ;
22+ }
23+
724export interface StyleAssetUrls {
825 fonts : string ;
9- sprites : string ;
10- styles : { id : string ; url : string } [ ] ;
26+ styles : StyleEntry [ ] ;
1127}
1228
1329export function resolveStyleAssetUrls ( ) : StyleAssetUrls {
1430 return {
31+ // openmaptiles/fonts — pre-built PBF glyph stacks (Metropolis, Noto Sans,
32+ // Open Sans, etc.) that tileserver-gl serves at /fonts/<stack>/<range>.pbf.
1533 fonts : "https://github.com/openmaptiles/fonts/releases/latest/download/v2.0.zip" ,
16- sprites :
17- "https://github.com/openmaptiles/osm-bright-gl-style/releases/latest/download/sprite.zip" ,
34+ // Per-style fetch: style.json from raw GitHub + sprite.{json,png}(@2x)
35+ // from the style repo's GitHub Pages deployment. Matches the historical
36+ // `infra/docker/manage.sh` behaviour — there is no release-asset zip with
37+ // sprites, and tileserver-gl expects `styles/<name>/style.json` alongside
38+ // the matching `sprite.*` files in the same folder.
1839 styles : [
19- { id : "osm-bright" , url : "https://api.maptiler.com/maps/openstreetmap/style.json" } ,
20- { id : "positron" , url : "https://api.maptiler.com/maps/positron/style.json" } ,
21- { id : "dark-matter" , url : "https://api.maptiler.com/maps/darkmatter/style.json" } ,
40+ { id : "osm-bright" , repo : "openmaptiles/osm-bright-gl-style" , branch : "master" } ,
41+ { id : "dark-matter" , repo : "openmaptiles/dark-matter-gl-style" , branch : "master" } ,
42+ { id : "positron" , repo : "openmaptiles/positron-gl-style" , branch : "master" } ,
43+ {
44+ id : "osm-liberty" ,
45+ repo : "maputnik/osm-liberty" ,
46+ branch : "gh-pages" ,
47+ spritePath : "sprites/osm-liberty" ,
48+ } ,
2249 ] ,
2350 } ;
2451}
2552
53+ const SPRITE_SUFFIXES = [ ".json" , ".png" , "@2x.json" , "@2x.png" ] ;
54+
2655export interface DownloadStyleOptions {
2756 dataDir : string ;
2857 store : StateStore ;
2958}
3059
60+ /**
61+ * Rewrite a fetched MapLibre style so tileserver-gl serves it from local
62+ * resources instead of MapTiler Cloud / GitHub Pages:
63+ *
64+ * - every `sources[].type === "vector"` → `{ type: "vector", url: "mbtiles://{openmapx}" }`
65+ * (tileserver-gl resolves `{openmapx}` to the MBTiles data source in
66+ * `config.json`'s `data` block)
67+ * - `glyphs = "{fontstack}/{range}.pbf"` → served from the mounted fonts volume
68+ * - `sprite = "{styleJsonFolder}/sprite"` → sibling sprite files next to style.json
69+ */
70+ function patchStyleForLocal ( styleJson : Record < string , unknown > ) : Record < string , unknown > {
71+ const patched = { ...styleJson } ;
72+ const sources = ( patched . sources as Record < string , Record < string , unknown > > ) ?? { } ;
73+ const newSources : Record < string , Record < string , unknown > > = { } ;
74+ for ( const [ name , src ] of Object . entries ( sources ) ) {
75+ if ( src . type === "vector" ) {
76+ newSources [ name ] = { type : "vector" , url : "mbtiles://{openmapx}" } ;
77+ } else {
78+ newSources [ name ] = src ;
79+ }
80+ }
81+ patched . sources = newSources ;
82+ patched . glyphs = "{fontstack}/{range}.pbf" ;
83+ patched . sprite = "{styleJsonFolder}/sprite" ;
84+ return patched ;
85+ }
86+
87+ async function downloadOneStyle (
88+ entry : StyleEntry ,
89+ targetDir : string ,
90+ ) : Promise < { styleJson : string ; spriteFiles : string [ ] } > {
91+ mkdirSync ( targetDir , { recursive : true } ) ;
92+
93+ // style.json from raw.githubusercontent.com
94+ const styleUrl = `https://raw.githubusercontent.com/${ entry . repo } /${ entry . branch } /style.json` ;
95+ const styleJsonPath = join ( targetDir , "style.json" ) ;
96+ await curlAtomic ( styleUrl , styleJsonPath ) ;
97+
98+ // Patch in place so tileserver-gl can serve the style from local paths.
99+ try {
100+ const raw = JSON . parse ( readFileSync ( styleJsonPath , "utf-8" ) ) as Record < string , unknown > ;
101+ const patched = patchStyleForLocal ( raw ) ;
102+ writeFileSync ( styleJsonPath , `${ JSON . stringify ( patched , null , 2 ) } \n` , "utf-8" ) ;
103+ } catch ( err ) {
104+ throw new Error ( `style ${ entry . id } : failed to patch style.json (${ ( err as Error ) . message } )` ) ;
105+ }
106+
107+ // sprite.* from GH Pages. Each suffix is best-effort — @2x variants
108+ // occasionally 404. The base path defaults to `sprite` (root of the gh-pages
109+ // deployment), but some styles (e.g. osm-liberty) nest them under
110+ // `sprites/<name>`; we still save them locally as `sprite.{json,png}` so
111+ // the patched `sprite: "{styleJsonFolder}/sprite"` reference resolves.
112+ const [ owner , repoName ] = entry . repo . split ( "/" ) ;
113+ const remoteBase = entry . spritePath ?? "sprite" ;
114+ const spriteOrigin = `https://${ owner } .github.io/${ repoName } ` ;
115+ const downloaded : string [ ] = [ ] ;
116+ for ( const suffix of SPRITE_SUFFIXES ) {
117+ const url = `${ spriteOrigin } /${ remoteBase } ${ suffix } ` ;
118+ const target = join ( targetDir , `sprite${ suffix } ` ) ;
119+ try {
120+ await curlAtomic ( url , target ) ;
121+ downloaded . push ( `sprite${ suffix } ` ) ;
122+ } catch {
123+ // best-effort
124+ }
125+ }
126+ if ( ! downloaded . includes ( "sprite.json" ) || ! downloaded . includes ( "sprite.png" ) ) {
127+ throw new Error (
128+ `style ${ entry . id } : required sprite files not reachable at ${ spriteOrigin } /${ remoteBase } .{json,png}` ,
129+ ) ;
130+ }
131+
132+ return { styleJson : styleJsonPath , spriteFiles : downloaded } ;
133+ }
134+
31135export async function downloadStyle ( opts : DownloadStyleOptions ) : Promise < void > {
32- // Asset families live at top-level paths under data/ so they don't collide
33- // with any consumer service's data dir (e.g. `data/tileserver/` is the
34- // tileserver consumer's hardlink target dir).
35136 const fontsDir = join ( opts . dataDir , "tile-fonts" ) ;
36- const spritesDir = join ( opts . dataDir , "tile-sprites" ) ;
37137 const stylesDir = join ( opts . dataDir , "tile-styles" ) ;
38138 mkdirSync ( fontsDir , { recursive : true } ) ;
39- mkdirSync ( spritesDir , { recursive : true } ) ;
40139 mkdirSync ( stylesDir , { recursive : true } ) ;
41140
42141 const urls = resolveStyleAssetUrls ( ) ;
43142
143+ // Fonts (shared across every style).
44144 const fontsZip = join ( opts . dataDir , "tile-fonts.zip" ) ;
45145 await curlAtomic ( urls . fonts , fontsZip ) ;
46146 await execa ( "unzip" , [ "-qo" , fontsZip , "-d" , fontsDir ] , { stdio : "inherit" } ) ;
@@ -53,7 +153,17 @@ export async function downloadStyle(opts: DownloadStyleOptions): Promise<void> {
53153 path : fontsDir ,
54154 } ) ;
55155
56- const spritesZip = join ( opts . dataDir , "tile-sprites.zip" ) ;
57- await curlAtomic ( urls . sprites , spritesZip ) ;
58- await execa ( "unzip" , [ "-qo" , spritesZip , "-d" , spritesDir ] , { stdio : "inherit" } ) ;
156+ // One subdirectory per style, each with its own style.json + sprite files.
157+ for ( const entry of urls . styles ) {
158+ const styleDir = join ( stylesDir , entry . id ) ;
159+ const result = await downloadOneStyle ( entry , styleDir ) ;
160+ opts . store . upsert ( {
161+ type : "tile-styles" ,
162+ id : entry . id ,
163+ url : `https://raw.githubusercontent.com/${ entry . repo } /${ entry . branch } /style.json` ,
164+ sizeBytes : statSync ( result . styleJson ) . size ,
165+ downloadedAt : new Date ( ) . toISOString ( ) ,
166+ path : styleDir ,
167+ } ) ;
168+ }
59169}
0 commit comments