styled-map-package 5.0.0-pre.0 → 5.0.0-pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # styled-map-package
2
+
3
+ CLI for creating, viewing, and converting Styled Map Package (`.smp`) files.
4
+
5
+ An `.smp` file is a ZIP archive containing all the resources needed to serve a MapLibre vector styled map offline: style JSON, vector and raster tiles, glyphs (fonts), sprites, and metadata.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ npm install --global styled-map-package
11
+ ```
12
+
13
+ ## Commands
14
+
15
+ ### `smp download`
16
+
17
+ Download an online map style to a `.smp` file for offline use.
18
+
19
+ ```sh
20
+ smp download https://demotiles.maplibre.org/style.json \
21
+ --bbox '-180,-80,180,80' \
22
+ --zoom 5 \
23
+ --output demotiles.smp
24
+ ```
25
+
26
+ **Options:**
27
+
28
+ | Option | Description |
29
+ | ---------------------- | ------------------------------------------------ |
30
+ | `-o, --output <file>` | Output file (writes to stdout if omitted) |
31
+ | `-b, --bbox <w,s,e,n>` | Bounding box (west, south, east, north) |
32
+ | `-z, --zoom <number>` | Max zoom level (0-22) |
33
+ | `-t, --token <token>` | Mapbox access token (required for Mapbox styles) |
34
+
35
+ When run interactively, missing options are prompted for.
36
+
37
+ ### `smp view`
38
+
39
+ Preview a `.smp` file in a web browser.
40
+
41
+ ```sh
42
+ smp view demotiles.smp --open
43
+ ```
44
+
45
+ **Options:**
46
+
47
+ | Option | Description |
48
+ | --------------------- | -------------------------------- |
49
+ | `-o, --open` | Open in the default web browser |
50
+ | `-p, --port <number>` | Port to serve on (default: 3000) |
51
+
52
+ ### `smp mbtiles`
53
+
54
+ Convert an MBTiles file to a `.smp` file.
55
+
56
+ ```sh
57
+ smp mbtiles tiles.mbtiles --output map.smp
58
+ ```
59
+
60
+ **Options:**
61
+
62
+ | Option | Description |
63
+ | --------------------- | ------------------------------------------------ |
64
+ | `-o, --output <file>` | Output `.smp` file (writes to stdout if omitted) |
65
+
66
+ ## License
67
+
68
+ MIT
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { input, number } from '@inquirer/prompts'
3
- import { Command, InvalidArgumentError } from 'commander'
3
+ import { Command } from 'commander'
4
4
  import fs from 'fs'
5
5
  import { download } from 'styled-map-package-api/download'
6
6
  import {
@@ -10,6 +10,12 @@ import {
10
10
 
11
11
  import { Writable } from 'node:stream'
12
12
 
13
+ import {
14
+ parseBbox,
15
+ parseUrl,
16
+ parseZoom,
17
+ runDownload,
18
+ } from '../lib/commands/download.js'
13
19
  import { ttyReporter } from '../lib/reporters.js'
14
20
 
15
21
  const program = new Command()
@@ -29,152 +35,18 @@ program
29
35
  )
30
36
  .argument('[styleUrl]', 'URL to style to download', parseUrl)
31
37
  .action(async (styleUrl, { bbox, zoom, output, token }) => {
32
- const promptOutput =
33
- !output &&
34
- process.stdout.isTTY &&
35
- (!styleUrl || !bbox || zoom === undefined)
36
-
37
- if (!styleUrl) {
38
- styleUrl = await input({
39
- message: 'Style URL to download',
40
- required: true,
41
- validate: (value) => {
42
- try {
43
- new URL(value)
44
- return true
45
- } catch {
46
- return 'Please enter a valid URL.'
47
- }
48
- },
49
- })
50
- }
51
-
52
- if (!bbox) {
53
- const west = await number({
54
- message: 'Bounding box west',
55
- required: true,
56
- step: 'any',
57
- min: -180,
58
- max: 180,
59
- })
60
- const south = await number({
61
- message: 'Bounding box south',
62
- required: true,
63
- step: 'any',
64
- min: -90,
65
- max: 90,
66
- })
67
- const east = await number({
68
- message: 'Bounding box east',
69
- required: true,
70
- step: 'any',
71
- min: -180,
72
- max: 180,
73
- })
74
- const north = await number({
75
- message: 'Bounding box north',
76
- required: true,
77
- step: 'any',
78
- min: -90,
79
- max: 90,
80
- })
81
- if (
82
- west === undefined ||
83
- south === undefined ||
84
- east === undefined ||
85
- north === undefined
86
- ) {
87
- throw new InvalidArgumentError('Bounding box values are required.')
88
- }
89
- bbox = [west, south, east, north]
90
- }
91
-
92
- if (zoom === undefined) {
93
- zoom = await number({
94
- message: 'Max zoom level to download',
95
- required: true,
96
- min: 0,
97
- max: 22,
98
- })
99
- if (zoom === undefined) {
100
- throw new InvalidArgumentError('Zoom level is required.')
101
- }
102
- }
103
-
104
- if (
105
- (isMapboxURL(styleUrl) || styleUrl.startsWith(MAPBOX_API_URL)) &&
106
- !token
107
- ) {
108
- token = await input({
109
- message: 'Mapbox access token',
110
- required: true,
111
- })
112
- }
113
-
114
- if (promptOutput) {
115
- output = await input({
116
- message: 'Output filename (.smp extension will be added)',
117
- required: true,
118
- transformer: (value) =>
119
- value.endsWith('.smp') ? value : `${value}.smp`,
120
- })
121
- }
122
-
123
- if (output && !output.endsWith('.smp')) {
124
- output += '.smp'
125
- }
126
-
127
- const reporter = ttyReporter()
128
- const readStream = download({
129
- bbox,
130
- maxzoom: zoom,
131
- styleUrl,
132
- onprogress: (p) => reporter.write(p),
133
- accessToken: token,
38
+ await runDownload({ styleUrl, bbox, zoom, output, token }, {
39
+ download,
40
+ prompt: { input, number },
41
+ createOutputStream: (output) =>
42
+ output
43
+ ? Writable.toWeb(fs.createWriteStream(output))
44
+ : Writable.toWeb(process.stdout),
45
+ reporter: ttyReporter,
46
+ isMapboxURL,
47
+ mapboxApiUrl: MAPBOX_API_URL,
48
+ isTTY: !!process.stdout.isTTY,
134
49
  })
135
- const outputStream = output ? fs.createWriteStream(output) : process.stdout
136
- await readStream.pipeTo(Writable.toWeb(outputStream))
137
50
  })
138
51
 
139
52
  program.parseAsync(process.argv)
140
-
141
- /** @param {string} z */
142
- function parseZoom(z) {
143
- const zoom = parseInt(z)
144
- if (isNaN(zoom) || zoom < 0 || zoom > 22) {
145
- throw new InvalidArgumentError(
146
- 'Zoom must be a whole number (integer) between 0 and 22.',
147
- )
148
- }
149
- return zoom
150
- }
151
-
152
- /** @param {string} bbox */
153
- function parseBbox(bbox) {
154
- const bounds = bbox.split(',').map((s) => parseFloat(s.trim()))
155
- if (bounds.length !== 4) {
156
- throw new InvalidArgumentError(
157
- 'Bounding box must have 4 values separated by commas.',
158
- )
159
- }
160
- if (bounds.some(isNaN)) {
161
- throw new InvalidArgumentError('Bounding box values must be numbers.')
162
- }
163
- return /** @type {[number, number, number, number]} */ (bounds)
164
- }
165
-
166
- /** @param {string} url */
167
- function parseUrl(url) {
168
- try {
169
- return new URL(url).toString()
170
- } catch (e) {
171
- const message =
172
- e !== null &&
173
- typeof e === 'object' &&
174
- 'message' in e &&
175
- typeof e.message === 'string'
176
- ? e.message
177
- : 'Invalid URL'
178
- throw new InvalidArgumentError(message)
179
- }
180
- }
@@ -5,6 +5,8 @@ import { fromMBTiles } from 'styled-map-package-api/from-mbtiles'
5
5
  import fs from 'node:fs'
6
6
  import { Writable } from 'node:stream'
7
7
 
8
+ import { runMbtiles } from '../lib/commands/mbtiles.js'
9
+
8
10
  const program = new Command()
9
11
 
10
12
  program
@@ -12,8 +14,13 @@ program
12
14
  .option('-o, --output <file>', 'output smp file')
13
15
  .argument('<mbtiles>', 'MBTiles file to convert')
14
16
  .action(async (mbtilesPath, { output }) => {
15
- const dest = output ? fs.createWriteStream(output) : process.stdout
16
- await fromMBTiles(mbtilesPath).pipeTo(Writable.toWeb(dest))
17
+ await runMbtiles({ mbtilesPath, output }, {
18
+ fromMBTiles,
19
+ createOutputStream: (output) =>
20
+ output
21
+ ? Writable.toWeb(fs.createWriteStream(output))
22
+ : Writable.toWeb(process.stdout),
23
+ })
17
24
  })
18
25
 
19
26
  program.parseAsync(process.argv)
package/bin/smp-view.js CHANGED
@@ -1,72 +1,51 @@
1
1
  #!/usr/bin/env node
2
2
  import { createServerAdapter } from '@whatwg-node/server'
3
3
  import { Command } from 'commander'
4
- import fsPromises from 'fs/promises'
5
4
  import http from 'http'
6
- import { AutoRouter } from 'itty-router'
7
5
  import openApp from 'open'
8
6
  import { Reader } from 'styled-map-package-api/reader'
9
7
  import { createServer } from 'styled-map-package-api/server'
10
8
 
9
+ import fsPromises from 'node:fs/promises'
11
10
  import path from 'node:path'
12
11
 
12
+ import { runView } from '../lib/commands/view.js'
13
+
13
14
  const program = new Command()
14
15
 
15
16
  program
16
17
  .description('Preview a styled map package in a web browser')
17
18
  .option('-o, --open', 'open in the default web browser')
18
- .option('-p, --port <number>', 'port to serve on', parseInt, 3000)
19
+ .option('-p, --port <number>', 'port to serve on', (v) => parseInt(v), 3000)
19
20
  .argument('<file>', 'file to serve')
20
21
  .action(async (filepath, { open, port }) => {
21
- const address = await serve({ port, filepath })
22
- console.log(`server listening on ${address}`)
23
- if (open) {
24
- await openApp(address)
25
- }
22
+ await runView(
23
+ { port, filepath: path.relative(process.cwd(), filepath), open },
24
+ {
25
+ Reader,
26
+ createServer,
27
+ openApp,
28
+ log: (msg) => console.log(msg),
29
+ readViewerHtml: (filepath) => fsPromises.readFile(filepath),
30
+ listen: (port, handler) =>
31
+ new Promise((resolve, reject) => {
32
+ const server = http.createServer(
33
+ createServerAdapter(handler),
34
+ )
35
+ server.listen(port, '127.0.0.1', () => {
36
+ const address = server.address()
37
+ if (typeof address === 'string') {
38
+ resolve(`http://${address}`)
39
+ } else if (address === null) {
40
+ reject(new Error('Failed to get server address'))
41
+ } else {
42
+ resolve(`http://${address.address}:${address.port}`)
43
+ }
44
+ })
45
+ server.on('error', reject)
46
+ }),
47
+ },
48
+ )
26
49
  })
27
50
 
28
51
  program.parseAsync(process.argv)
29
-
30
- /**
31
- * Serve a styled map package on the given port (defaults to 3000).
32
- *
33
- * @param {object} opts
34
- * @param {number} [opts.port]
35
- * @param {string} opts.filepath
36
- * @returns
37
- */
38
- async function serve({ port = 3000, filepath }) {
39
- const reader = new Reader(path.relative(process.cwd(), filepath))
40
- const smpServer = createServer({ base: '/map' })
41
-
42
- const router = AutoRouter()
43
- router.get('/', async () => {
44
- const index = await fsPromises.readFile(
45
- new URL('../map-viewer/index.html', import.meta.url),
46
- )
47
- return new Response(new Uint8Array(index), {
48
- headers: {
49
- 'Content-Type': 'text/html',
50
- 'Content-Length': String(index.byteLength),
51
- 'Cache-Control': 'public, max-age=0',
52
- },
53
- })
54
- })
55
- router.all('/map/*', (request) => {
56
- return smpServer.fetch(request, reader)
57
- })
58
- const server = http.createServer(createServerAdapter(router.fetch))
59
- return new Promise((resolve, reject) => {
60
- server.listen(port, '127.0.0.1', () => {
61
- const address = server.address()
62
- if (typeof address === 'string') {
63
- resolve(`http://${address}`)
64
- } else if (address === null) {
65
- reject(new Error('Failed to get server address'))
66
- } else {
67
- resolve(`http://${address.address}:${address.port}`)
68
- }
69
- })
70
- server.on('error', reject)
71
- })
72
- }
@@ -0,0 +1,188 @@
1
+ import { InvalidArgumentError } from 'commander'
2
+
3
+ /**
4
+ * @param {string} z
5
+ * @returns {number}
6
+ */
7
+ export function parseZoom(z) {
8
+ const zoom = parseInt(z)
9
+ if (isNaN(zoom) || zoom < 0 || zoom > 22) {
10
+ throw new InvalidArgumentError(
11
+ 'Zoom must be a whole number (integer) between 0 and 22.',
12
+ )
13
+ }
14
+ return zoom
15
+ }
16
+
17
+ /**
18
+ * @param {string} bbox
19
+ * @returns {[number, number, number, number]}
20
+ */
21
+ export function parseBbox(bbox) {
22
+ const bounds = bbox.split(',').map((s) => parseFloat(s.trim()))
23
+ if (bounds.length !== 4) {
24
+ throw new InvalidArgumentError(
25
+ 'Bounding box must have 4 values separated by commas.',
26
+ )
27
+ }
28
+ if (bounds.some(isNaN)) {
29
+ throw new InvalidArgumentError('Bounding box values must be numbers.')
30
+ }
31
+ return /** @type {[number, number, number, number]} */ (bounds)
32
+ }
33
+
34
+ /**
35
+ * @param {string} url
36
+ * @returns {string}
37
+ */
38
+ export function parseUrl(url) {
39
+ try {
40
+ return new URL(url).toString()
41
+ } catch (e) {
42
+ const message =
43
+ e !== null &&
44
+ typeof e === 'object' &&
45
+ 'message' in e &&
46
+ typeof e.message === 'string'
47
+ ? e.message
48
+ : 'Invalid URL'
49
+ throw new InvalidArgumentError(message)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * @typedef {object} DownloadOptions
55
+ * @property {string | undefined} styleUrl
56
+ * @property {[number, number, number, number] | undefined} bbox
57
+ * @property {number | undefined} zoom
58
+ * @property {string | undefined} output
59
+ * @property {string | undefined} token
60
+ */
61
+
62
+ /**
63
+ * @typedef {object} DownloadDeps
64
+ * @property {(opts: any) => ReadableStream} download
65
+ * @property {{ input: (opts: any) => Promise<string>, number: (opts: any) => Promise<number | undefined> }} prompt
66
+ * @property {(output: string | undefined) => WritableStream | import('node:fs').WriteStream} createOutputStream
67
+ * @property {() => { write: (p: any) => void }} reporter
68
+ * @property {(url: string) => boolean} isMapboxURL
69
+ * @property {string} mapboxApiUrl
70
+ * @property {boolean} isTTY
71
+ */
72
+
73
+ /**
74
+ * @param {DownloadOptions} options
75
+ * @param {DownloadDeps} deps
76
+ */
77
+ export async function runDownload(
78
+ { styleUrl, bbox, zoom, output, token },
79
+ deps,
80
+ ) {
81
+ const { download, prompt, isMapboxURL, mapboxApiUrl, isTTY } = deps
82
+
83
+ const promptOutput =
84
+ !output && isTTY && (!styleUrl || !bbox || zoom === undefined)
85
+
86
+ if (!styleUrl) {
87
+ styleUrl = await prompt.input({
88
+ message: 'Style URL to download',
89
+ required: true,
90
+ validate: (/** @type {string} */ value) => {
91
+ try {
92
+ new URL(value)
93
+ return true
94
+ } catch {
95
+ return 'Please enter a valid URL.'
96
+ }
97
+ },
98
+ })
99
+ }
100
+
101
+ if (!bbox) {
102
+ const west = await prompt.number({
103
+ message: 'Bounding box west',
104
+ required: true,
105
+ step: 'any',
106
+ min: -180,
107
+ max: 180,
108
+ })
109
+ const south = await prompt.number({
110
+ message: 'Bounding box south',
111
+ required: true,
112
+ step: 'any',
113
+ min: -90,
114
+ max: 90,
115
+ })
116
+ const east = await prompt.number({
117
+ message: 'Bounding box east',
118
+ required: true,
119
+ step: 'any',
120
+ min: -180,
121
+ max: 180,
122
+ })
123
+ const north = await prompt.number({
124
+ message: 'Bounding box north',
125
+ required: true,
126
+ step: 'any',
127
+ min: -90,
128
+ max: 90,
129
+ })
130
+ if (
131
+ west === undefined ||
132
+ south === undefined ||
133
+ east === undefined ||
134
+ north === undefined
135
+ ) {
136
+ throw new InvalidArgumentError('Bounding box values are required.')
137
+ }
138
+ bbox = [west, south, east, north]
139
+ }
140
+
141
+ if (zoom === undefined) {
142
+ zoom = await prompt.number({
143
+ message: 'Max zoom level to download',
144
+ required: true,
145
+ min: 0,
146
+ max: 22,
147
+ })
148
+ if (zoom === undefined) {
149
+ throw new InvalidArgumentError('Zoom level is required.')
150
+ }
151
+ }
152
+
153
+ if ((isMapboxURL(styleUrl) || styleUrl.startsWith(mapboxApiUrl)) && !token) {
154
+ token = await prompt.input({
155
+ message: 'Mapbox access token',
156
+ required: true,
157
+ })
158
+ }
159
+
160
+ if (promptOutput) {
161
+ output = await prompt.input({
162
+ message: 'Output filename (.smp extension will be added)',
163
+ required: true,
164
+ transformer: (/** @type {string} */ value) =>
165
+ value.endsWith('.smp') ? value : `${value}.smp`,
166
+ })
167
+ }
168
+
169
+ if (output && !output.endsWith('.smp')) {
170
+ output += '.smp'
171
+ }
172
+
173
+ const reporter = deps.reporter()
174
+ const readStream = download({
175
+ bbox,
176
+ maxzoom: zoom,
177
+ styleUrl,
178
+ onprogress: (/** @type {any} */ p) => reporter.write(p),
179
+ accessToken: token,
180
+ })
181
+ const outputStream = deps.createOutputStream(output)
182
+ await readStream.pipeTo(
183
+ outputStream instanceof WritableStream
184
+ ? outputStream
185
+ : // @ts-ignore - Writable.toWeb compatibility
186
+ outputStream,
187
+ )
188
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * @typedef {object} MbtilesOptions
3
+ * @property {string} mbtilesPath
4
+ * @property {string | undefined} output
5
+ */
6
+
7
+ /**
8
+ * @typedef {object} MbtilesDeps
9
+ * @property {(path: string) => ReadableStream} fromMBTiles
10
+ * @property {(output: string | undefined) => WritableStream} createOutputStream
11
+ */
12
+
13
+ /**
14
+ * @param {MbtilesOptions} options
15
+ * @param {MbtilesDeps} deps
16
+ */
17
+ export async function runMbtiles({ mbtilesPath, output }, deps) {
18
+ const readStream = deps.fromMBTiles(mbtilesPath)
19
+ const outputStream = deps.createOutputStream(output)
20
+ await readStream.pipeTo(outputStream)
21
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @typedef {object} ViewOptions
3
+ * @property {number} port
4
+ * @property {string} filepath
5
+ * @property {boolean} [open]
6
+ */
7
+
8
+ /**
9
+ * @typedef {object} ViewDeps
10
+ * @property {new (filepath: string) => any} Reader
11
+ * @property {(opts: any) => { fetch: (req: any, reader: any) => any }} createServer
12
+ * @property {(port: number, handler: any) => Promise<import('node:http').Server>} listen
13
+ * @property {(url: string) => Promise<void>} openApp
14
+ * @property {(url: string) => void} log
15
+ * @property {(url: string) => Promise<Uint8Array>} readViewerHtml
16
+ */
17
+
18
+ /**
19
+ * @param {ViewOptions} options
20
+ * @param {ViewDeps} deps
21
+ * @returns {Promise<string>} The address the server is listening on
22
+ */
23
+ export async function runView({ port, filepath, open }, deps) {
24
+ const { Reader, createServer, openApp, log, readViewerHtml } = deps
25
+
26
+ const reader = new Reader(filepath)
27
+ const smpServer = createServer({ base: '/map' })
28
+
29
+ /** @param {Request} request */
30
+ const handler = async (request) => {
31
+ const url = new URL(request.url)
32
+ if (url.pathname === '/') {
33
+ const index = await readViewerHtml(
34
+ new URL('../../map-viewer/index.html', import.meta.url).pathname,
35
+ )
36
+ return new Response(index, {
37
+ headers: {
38
+ 'Content-Type': 'text/html',
39
+ 'Content-Length': String(index.byteLength),
40
+ 'Cache-Control': 'public, max-age=0',
41
+ },
42
+ })
43
+ }
44
+ if (url.pathname.startsWith('/map/')) {
45
+ return smpServer.fetch(request, reader)
46
+ }
47
+ return new Response('Not found', { status: 404 })
48
+ }
49
+
50
+ const address = await deps.listen(port, handler)
51
+
52
+ log(`server listening on ${address}`)
53
+ if (open) {
54
+ await openApp(address)
55
+ }
56
+ return address
57
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "styled-map-package",
3
- "version": "5.0.0-pre.0",
3
+ "version": "5.0.0-pre.1",
4
4
  "description": "CLI for creating, viewing, and converting Styled Map Package (.smp) files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,12 +13,12 @@
13
13
  "map-viewer"
14
14
  ],
15
15
  "scripts": {
16
- "test": "echo 'No CLI tests yet'"
16
+ "test": "vitest run"
17
17
  },
18
18
  "keywords": [],
19
19
  "license": "MIT",
20
20
  "dependencies": {
21
- "styled-map-package-api": "^5.0.0-pre.0",
21
+ "styled-map-package-api": "^5.0.0-pre.1",
22
22
  "@inquirer/prompts": "^6.0.1",
23
23
  "@whatwg-node/server": "^0.10.17",
24
24
  "chalk": "^5.4.1",
@@ -31,6 +31,11 @@
31
31
  "pretty-ms": "^9.2.0",
32
32
  "readable-stream": "^4.7.0"
33
33
  },
34
+ "devDependencies": {
35
+ "execa": "^9.4.0",
36
+ "tempy": "^3.1.0",
37
+ "vitest": "^3.2.4"
38
+ },
34
39
  "repository": {
35
40
  "type": "git",
36
41
  "url": "git+https://github.com/digidem/styled-map-package.git",