transit-departures-widget 2.5.4 → 2.6.0

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 CHANGED
@@ -54,9 +54,38 @@ An demo of the widget is available at https://transit-departures-widget.blinktag
54
54
 
55
55
  The following transit agencies use `transit-departures-widget` on their websites:
56
56
 
57
+ - [County Connection](https://countyconnection.com)
58
+ - [Kings Area Regional Transit](https://kartbus.org)
57
59
  - [Marin Transit](https://marintransit.org/)
60
+ - [Mountain View Community Shuttle](https://mvcommunityshuttle.com)
58
61
  - [MVgo](https://mvgo.org/)
59
62
 
63
+ ## Quick Start
64
+
65
+ ### CLI
66
+
67
+ ```bash
68
+ npm install transit-departures-widget -g
69
+ transit-departures-widget --configPath ./config-sample.json
70
+ ```
71
+
72
+ Outputs are written to `html/<agency_key>/`:
73
+
74
+ - `index.html` (full page unless `noHead: true`)
75
+ - `data/routes.json`
76
+ - `data/stops.json`
77
+ - `css/`, `js/` (when `noHead` is false)
78
+
79
+ ### Programmatic
80
+
81
+ ```ts
82
+ import transitDeparturesWidget from 'transit-departures-widget'
83
+ import config from './config.json' assert { type: 'json' }
84
+
85
+ await transitDeparturesWidget(config)
86
+ // outputs to html/<agency_key>/ by default
87
+ ```
88
+
60
89
  ## Command Line Usage
61
90
 
62
91
  The `transit-departures-widget` command-line utility will download the GTFS file specified in `config.js` and then build the transit departures widget and save the HTML, CSS and JS in `html/:agency_key`.
@@ -89,20 +118,22 @@ Copy `config-sample.json` to `config.json` and then add your projects configurat
89
118
 
90
119
  cp config-sample.json config.json
91
120
 
92
- | option | type | description |
93
- | --------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------- |
94
- | [`agency`](#agency) | object | Information about the GTFS and GTFS-RT to be used. |
95
- | [`beautify`](#beautify) | boolean | Whether or not to beautify the HTML output. |
96
- | [`endDate`](#enddate) | string | A date in YYYYMMDD format to use to filter calendar.txt service. Optional, defaults to using all service in specified GTFS. |
97
- | [`includeCoordinates`](#includecoordinates) | boolean | Whether or not to include stop coordinates in JSON output. |
98
- | [`locale`](#locale) | string | The 2-letter code of the language to use for the interface. |
99
- | [`noHead`](#nohead) | boolean | Whether or not to skip the header and footer of the HTML document. |
100
- | [`refreshIntervalSeconds`](#refreshIntervalSeconds) | integer | How often the widget should refresh departure data in seconds. Optional, defaults to 20 seconds. |
101
- | [`skipImport`](#skipimport) | boolean | Whether or not to skip importing GTFS data into SQLite. |
102
- | [`sqlitePath`](#sqlitepath) | string | A path to an SQLite database. Optional, defaults to using an in-memory database. |
103
- | [`startDate`](#startdate) | string | A date in YYYYMMDD format to use to filter calendar.txt service. Optional, defaults to using all service in specified GTFS. |
104
- | [`templatePath`](#templatepath) | string | Path to custom pug template for rendering widget. |
105
- | [`timeFormat`](#timeFormat) | string | The format (12hour or 24hour) for the "as of" display. |
121
+ | option | type | default | notes |
122
+ | --------------------------------------------------- | ------- | --------- | ---------------------------------------------------------------------------------------------------------------------------- |
123
+ | [`agency`](#agency) | object | — | Required: GTFS static (url or path) and GTFS-RT trip updates URL. |
124
+ | [`assetPath`](#assetpath) | string | `''` | Prefix for assets in HTML; set when hosting assets elsewhere. |
125
+ | [`beautify`](#beautify) | boolean | `false` | Pretty-print HTML output. |
126
+ | [`endDate`](#enddate) | string | all svc | YYYYMMDD calendar filter upper bound. |
127
+ | [`includeCoordinates`](#includecoordinates) | boolean | `false` | Include stop lat/lon in `stops.json`. |
128
+ | [`locale`](#locale) | string | `en` | UI language code. |
129
+ | [`noHead`](#nohead) | boolean | `false` | If true, omit `<html>/<head>/<body>`; only widget markup is output. |
130
+ | [`outputPath`](#outputpath) | string | `./html/<agency_key>` | Where to write generated files. |
131
+ | [`refreshIntervalSeconds`](#refreshIntervalSeconds) | integer | `20` | Autorefresh interval on the widget page. |
132
+ | [`skipImport`](#skipimport) | boolean | `false` | Skip GTFS import if DB already populated (`sqlitePath` recommended). |
133
+ | [`sqlitePath`](#sqlitepath) | string | in-memory | Path to SQLite DB file; enables reusing imports across runs. |
134
+ | [`startDate`](#startdate) | string | all svc | YYYYMMDD calendar filter lower bound. |
135
+ | [`templatePath`](#templatepath) | string | built-in | Custom templates folder (expects `widget.pug` and `widget_full.pug`). |
136
+ | [`timeFormat`](#timeFormat) | string | `12hour` | `12hour` or `24hour` time display. |
106
137
 
107
138
  ### agency
108
139
 
@@ -114,7 +145,7 @@ Copy `config-sample.json` to `config.json` and then add your projects configurat
114
145
 
115
146
  `gtfs_static_path` is the local path to an agency's static GTFS on your local machine. Either `gtfs_static_url` or `gtfs_static_path` is required.
116
147
 
117
- `gtfs_rt_tripupdates_url` is the URL of an agency's GTFS-RT trip updates. Note that the GTFS-RT URL must support [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) in order for the widget to work.
148
+ `gtfs_rt_tripupdates_url` is the URL of an agency's GTFS-RT trip updates. Note that the GTFS-RT URL must support [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) in order for the widget to work. You may need to set up a proxy that adds CORS headers to your GTFS-RT URLS. [GTFS Realtime Proxy](https://github.com/BlinkTagInc/gtfs-realtime-proxy) is an open-source tool that you could use for adding CORS headers.
118
149
 
119
150
  - Specify a download URL for static GTFS:
120
151
 
@@ -192,6 +223,24 @@ Copy `config-sample.json` to `config.json` and then add your projects configurat
192
223
  "noHead": false
193
224
  ```
194
225
 
226
+ If `noHead` is `true`, you’ll embed the widget into an existing HTML page. See the examples below for including the generated assets.
227
+
228
+ ### assetPath
229
+
230
+ {String} Prefix to use when linking to generated assets (`css`, `js`). Useful if you host assets on a CDN or a different path from the HTML file.
231
+
232
+ ```
233
+ "assetPath": "/static/widget/"
234
+ ```
235
+
236
+ ### outputPath
237
+
238
+ {String} Path where generated files are written. Defaults to `./html/<agency_key>`.
239
+
240
+ ```
241
+ "outputPath": "/var/www/widget-output"
242
+ ```
243
+
195
244
  ### refreshIntervalSeconds
196
245
 
197
246
  {Integer} How often the widget should refresh departure data in seconds. Optional, defaults to 20 seconds.
@@ -248,14 +297,40 @@ After an initial run of `transit-departures-widget`, the GTFS data will be downl
248
297
 
249
298
  You can view an individual route HTML on demand by running the included Express app:
250
299
 
251
- node app
300
+ npm start
252
301
 
253
302
  By default, `transit-departures-widget` will look for a `config.json` file in the project root. To specify a different path for the configuration file:
254
303
 
255
- node app --configPath /path/to/your/custom-config.json
304
+ npm start -- --configPath /path/to/your/custom-config.json
256
305
 
257
306
  Once running, you can view the HTML in your browser at [localhost:3000](http://localhost:3000)
258
307
 
308
+ ## Embedding examples
309
+
310
+ ### Default (with head/footer)
311
+
312
+ If `noHead` is `false` (default), `index.html` is a complete HTML page with linked assets in `css/` and `js/` under the output directory. You can host that folder as-is (e.g., serve `html/<agency_key>/` from your web server root).
313
+
314
+ ### Headless embed (`noHead: true`)
315
+
316
+ When `noHead` is `true`, you get only the widget markup. Include the generated CSS/JS and mount it in your page:
317
+
318
+ ```html
319
+ <!doctype html>
320
+ <html>
321
+ <head>
322
+ <link rel="stylesheet" href="/path/to/css/transit-departures-widget-styles.css" />
323
+ </head>
324
+ <body>
325
+ <div id="tdw-app"></div>
326
+ <script src="/path/to/js/transit-departures-widget.js"></script>
327
+ <script>
328
+ // Transit Departures Widget initializes itself on load using data/routes.json and data/stops.json
329
+ </script>
330
+ </body>
331
+ </html>
332
+ ```
333
+
259
334
  ## Notes
260
335
 
261
336
  `transit-departures-widget` uses the [`node-gtfs`](https://github.com/blinktaginc/node-gtfs) library to handle importing and querying GTFS data.
package/dist/app/index.js CHANGED
@@ -1,37 +1,46 @@
1
1
  // src/app/index.ts
2
- import { readFileSync } from "node:fs";
3
- import { join as join3 } from "node:path";
2
+ import { dirname as dirname2, join as join3 } from "path";
3
+ import { fileURLToPath as fileURLToPath2 } from "url";
4
+ import { readFileSync } from "fs";
4
5
  import yargs from "yargs";
5
- import { openDb as openDb2 } from "gtfs";
6
- import untildify2 from "untildify";
6
+ import { hideBin } from "yargs/helpers";
7
+ import { openDb as openDb2, importGtfs } from "gtfs";
7
8
  import express from "express";
8
- import logger from "morgan";
9
-
10
- // src/lib/utils.ts
11
- import { join as join2 } from "path";
12
- import { openDb, getDirections, getRoutes, getStops, getTrips } from "gtfs";
13
- import { groupBy, last, maxBy, size, sortBy, uniqBy } from "lodash-es";
9
+ import { clone, omit } from "lodash-es";
10
+ import untildify2 from "untildify";
14
11
 
15
12
  // src/lib/file-utils.ts
16
- import { dirname, join, resolve } from "node:path";
17
- import { fileURLToPath } from "node:url";
13
+ import { dirname, join, resolve } from "path";
14
+ import { fileURLToPath } from "url";
15
+ import {
16
+ access,
17
+ cp,
18
+ copyFile,
19
+ mkdir,
20
+ readdir,
21
+ readFile,
22
+ rm
23
+ } from "fs/promises";
18
24
  import beautify from "js-beautify";
19
25
  import pug from "pug";
20
26
  import untildify from "untildify";
21
- function getPathToViewsFolder(config2) {
22
- if (config2.templatePath) {
23
- return untildify(config2.templatePath);
24
- }
27
+ function getPathToThisModuleFolder() {
25
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
26
- let viewsFolderPath;
29
+ let distFolderPath;
27
30
  if (__dirname.endsWith("/dist/bin") || __dirname.endsWith("/dist/app")) {
28
- viewsFolderPath = resolve(__dirname, "../../views/widget");
31
+ distFolderPath = resolve(__dirname, "../../");
29
32
  } else if (__dirname.endsWith("/dist")) {
30
- viewsFolderPath = resolve(__dirname, "../views/widget");
33
+ distFolderPath = resolve(__dirname, "../");
31
34
  } else {
32
- viewsFolderPath = resolve(__dirname, "views/widget");
35
+ distFolderPath = resolve(__dirname, "../../");
36
+ }
37
+ return distFolderPath;
38
+ }
39
+ function getPathToViewsFolder(config2) {
40
+ if (config2.templatePath) {
41
+ return untildify(config2.templatePath);
33
42
  }
34
- return viewsFolderPath;
43
+ return join(getPathToThisModuleFolder(), "views/default");
35
44
  }
36
45
  function getPathToTemplateFile(templateFileName, config2) {
37
46
  const fullTemplateFileName = config2.noHead !== true ? `${templateFileName}_full.pug` : `${templateFileName}.pug`;
@@ -47,14 +56,45 @@ async function renderFile(templateFileName, templateVars, config2) {
47
56
  }
48
57
 
49
58
  // src/lib/utils.ts
59
+ import { openDb, getDirections, getRoutes, getStops, getTrips } from "gtfs";
60
+ import { groupBy, last, maxBy, size, sortBy, uniqBy } from "lodash-es";
50
61
  import sqlString from "sqlstring-sqlite";
51
62
  import toposort from "toposort";
52
- import { I18n } from "i18n";
53
63
 
54
- // src/lib/log-utils.ts
64
+ // src/lib/logging/log.ts
65
+ import { clearLine, cursorTo } from "readline";
55
66
  import { noop } from "lodash-es";
56
67
  import * as colors from "yoctocolors";
57
- function logWarning(config2) {
68
+ var formatWarning = (text) => {
69
+ const warningMessage = `${colors.underline("Warning")}: ${text}`;
70
+ return colors.yellow(warningMessage);
71
+ };
72
+ var formatError = (error) => {
73
+ const messageText = error instanceof Error ? error.message : error;
74
+ const errorMessage = `${colors.underline("Error")}: ${messageText.replace(
75
+ "Error: ",
76
+ ""
77
+ )}`;
78
+ return colors.red(errorMessage);
79
+ };
80
+ var logInfo = (config2) => {
81
+ if (config2.verbose === false) {
82
+ return noop;
83
+ }
84
+ if (config2.logFunction) {
85
+ return config2.logFunction;
86
+ }
87
+ return (text, overwrite) => {
88
+ if (overwrite === true && process.stdout.isTTY) {
89
+ clearLine(process.stdout, 0);
90
+ cursorTo(process.stdout, 0);
91
+ } else {
92
+ process.stdout.write("\n");
93
+ }
94
+ process.stdout.write(text);
95
+ };
96
+ };
97
+ var logWarn = (config2) => {
58
98
  if (config2.logFunction) {
59
99
  return config2.logFunction;
60
100
  }
@@ -63,10 +103,59 @@ function logWarning(config2) {
63
103
  ${formatWarning(text)}
64
104
  `);
65
105
  };
106
+ };
107
+ var logError = (config2) => {
108
+ if (config2.logFunction) {
109
+ return config2.logFunction;
110
+ }
111
+ return (text) => {
112
+ process.stdout.write(`
113
+ ${formatError(text)}
114
+ `);
115
+ };
116
+ };
117
+ function createLogger(config2) {
118
+ return {
119
+ info: logInfo(config2),
120
+ warn: logWarn(config2),
121
+ error: logError(config2)
122
+ };
66
123
  }
67
- function formatWarning(text) {
68
- const warningMessage = `${colors.underline("Warning")}: ${text}`;
69
- return colors.yellow(warningMessage);
124
+
125
+ // src/lib/logging/messages.ts
126
+ var messages = {
127
+ noActiveCalendarsGlobal: "No active calendars found for the configured date range - returning empty routes and stops",
128
+ noActiveCalendarsForRoute: (routeId) => `route_id ${routeId} has no active calendars in range - skipping directions`,
129
+ noActiveCalendarsForDirection: (routeId, directionId) => `route_id ${routeId} direction ${directionId} has no active calendars in range - skipping stops`,
130
+ routeHasNoDirections: (routeId) => `route_id ${routeId} has no directions - skipping`,
131
+ stopNotFound: (routeId, directionId, stopId) => `stop_id ${stopId} for route ${routeId} direction ${directionId} not found - dropping`
132
+ };
133
+
134
+ // src/lib/config/defaults.ts
135
+ import { join as join2 } from "path";
136
+ import { I18n } from "i18n";
137
+ function setDefaultConfig(initialConfig) {
138
+ const defaults = {
139
+ beautify: false,
140
+ noHead: false,
141
+ refreshIntervalSeconds: 20,
142
+ skipImport: false,
143
+ timeFormat: "12hour",
144
+ includeCoordinates: false,
145
+ overwriteExistingFiles: true,
146
+ verbose: true
147
+ };
148
+ const config2 = Object.assign(defaults, initialConfig);
149
+ const viewsFolderPath = getPathToViewsFolder(config2);
150
+ const i18n = new I18n({
151
+ directory: join2(viewsFolderPath, "locales"),
152
+ defaultLocale: config2.locale,
153
+ updateFiles: false
154
+ });
155
+ const configWithI18n = Object.assign(config2, {
156
+ __: i18n.__
157
+ });
158
+ return configWithI18n;
70
159
  }
71
160
 
72
161
  // src/lib/utils.ts
@@ -99,12 +188,20 @@ function formatRouteName(route) {
99
188
  return routeName;
100
189
  }
101
190
  function getDirectionsForRoute(route, config2) {
191
+ const logger = createLogger(config2);
102
192
  const db = openDb(config2);
103
193
  const directions = getDirections({ route_id: route.route_id }, [
104
194
  "direction_id",
105
195
  "direction"
106
- ]);
196
+ ]).filter((direction) => direction.direction_id !== void 0).map((direction) => ({
197
+ direction_id: direction.direction_id,
198
+ direction: direction.direction
199
+ }));
107
200
  const calendars = getCalendarsForDateRange(config2);
201
+ if (calendars.length === 0) {
202
+ logger.warn(messages.noActiveCalendarsForRoute(route.route_id));
203
+ return [];
204
+ }
108
205
  if (directions.length === 0) {
109
206
  const headsigns = db.prepare(
110
207
  `SELECT direction_id, trip_headsign, count(*) AS count FROM trips WHERE route_id = ? AND service_id IN (${calendars.map((calendar) => `'${calendar.service_id}'`).join(", ")}) GROUP BY direction_id, trip_headsign`
@@ -145,11 +242,24 @@ function sortStopIdsBySequence(stoptimes) {
145
242
  Object.values(stoptimesGroupedByTrip),
146
243
  (stoptimes2) => size(stoptimes2)
147
244
  );
245
+ if (!longestTripStoptimes) {
246
+ return [];
247
+ }
148
248
  return longestTripStoptimes.map((stoptime) => stoptime.stop_id);
149
249
  }
150
- function getStopsForDirection(route, direction, config2) {
250
+ function getStopsForDirection(route, direction, config2, stopCache) {
251
+ const logger = createLogger(config2);
151
252
  const db = openDb(config2);
152
253
  const calendars = getCalendarsForDateRange(config2);
254
+ if (calendars.length === 0) {
255
+ logger.warn(
256
+ messages.noActiveCalendarsForDirection(
257
+ route.route_id,
258
+ direction.direction_id
259
+ )
260
+ );
261
+ return [];
262
+ }
153
263
  const whereClause = formatWhereClauses({
154
264
  direction_id: direction.direction_id,
155
265
  route_id: route.route_id,
@@ -169,14 +279,34 @@ function getStopsForDirection(route, direction, config2) {
169
279
  []
170
280
  );
171
281
  deduplicatedStopIds.pop();
172
- const stopFields = ["stop_id", "stop_name", "stop_code", "parent_station"];
282
+ const stopFields = [
283
+ "stop_id",
284
+ "stop_name",
285
+ "stop_code",
286
+ "parent_station"
287
+ ];
173
288
  if (config2.includeCoordinates) {
174
289
  stopFields.push("stop_lat", "stop_lon");
175
290
  }
176
- const stops = getStops({ stop_id: deduplicatedStopIds }, stopFields);
177
- return deduplicatedStopIds.map(
178
- (stopId) => stops.find((stop) => stop.stop_id === stopId)
179
- );
291
+ const missingStopIds = stopCache ? deduplicatedStopIds.filter((stopId) => !stopCache.has(stopId)) : deduplicatedStopIds;
292
+ const fetchedStops = missingStopIds.length ? getStops(
293
+ { stop_id: missingStopIds },
294
+ stopFields
295
+ ) : [];
296
+ if (stopCache) {
297
+ for (const stop of fetchedStops) {
298
+ stopCache.set(stop.stop_id, stop);
299
+ }
300
+ }
301
+ return deduplicatedStopIds.map((stopId) => {
302
+ const stop = stopCache?.get(stopId) ?? fetchedStops.find((candidate) => candidate.stop_id === stopId);
303
+ if (!stop) {
304
+ logger.warn(
305
+ messages.stopNotFound(route.route_id, direction.direction_id, stopId)
306
+ );
307
+ }
308
+ return stop;
309
+ }).filter(Boolean);
180
310
  }
181
311
  function generateTransitDeparturesWidgetHtml(config2) {
182
312
  const templateVars = {
@@ -186,23 +316,37 @@ function generateTransitDeparturesWidgetHtml(config2) {
186
316
  return renderFile("widget", templateVars, config2);
187
317
  }
188
318
  function generateTransitDeparturesWidgetJson(config2) {
319
+ const logger = createLogger(config2);
320
+ const calendars = getCalendarsForDateRange(config2);
321
+ if (calendars.length === 0) {
322
+ logger.warn(messages.noActiveCalendarsGlobal);
323
+ return { routes: [], stops: [] };
324
+ }
189
325
  const routes = getRoutes();
190
326
  const stops = [];
191
327
  const filteredRoutes = [];
192
- const calendars = getCalendarsForDateRange(config2);
328
+ const stopCache = /* @__PURE__ */ new Map();
193
329
  for (const route of routes) {
194
- route.route_full_name = formatRouteName(route);
195
- const directions = getDirectionsForRoute(route, config2);
330
+ const routeWithFullName = {
331
+ ...route,
332
+ route_full_name: formatRouteName(route)
333
+ };
334
+ const directions = getDirectionsForRoute(routeWithFullName, config2);
196
335
  if (directions.length === 0) {
197
- logWarning(config2)(
198
- `route_id ${route.route_id} has no directions - skipping`
199
- );
336
+ logger.warn(messages.routeHasNoDirections(route.route_id));
200
337
  continue;
201
338
  }
202
- for (const direction of directions) {
203
- const directionStops = getStopsForDirection(route, direction, config2);
339
+ const directionsWithData = directions.map((direction) => {
340
+ const directionStops = getStopsForDirection(
341
+ routeWithFullName,
342
+ direction,
343
+ config2,
344
+ stopCache
345
+ );
346
+ if (directionStops.length === 0) {
347
+ return null;
348
+ }
204
349
  stops.push(...directionStops);
205
- direction.stopIds = directionStops.map((stop) => stop?.stop_id);
206
350
  const trips = getTrips(
207
351
  {
208
352
  route_id: route.route_id,
@@ -213,25 +357,49 @@ function generateTransitDeparturesWidgetJson(config2) {
213
357
  },
214
358
  ["trip_id"]
215
359
  );
216
- direction.tripIds = trips.map((trip) => trip.trip_id);
360
+ return {
361
+ ...direction,
362
+ stopIds: directionStops.map((stop) => stop.stop_id),
363
+ tripIds: trips.map((trip) => trip.trip_id)
364
+ };
365
+ }).filter(Boolean);
366
+ if (directionsWithData.length === 0) {
367
+ continue;
217
368
  }
218
- route.directions = directions;
219
- filteredRoutes.push(route);
369
+ filteredRoutes.push({
370
+ ...routeWithFullName,
371
+ directions: directionsWithData
372
+ });
220
373
  }
221
- const sortedRoutes = sortBy(
222
- sortBy(filteredRoutes, (route) => route.route_short_name?.toLowerCase()),
223
- (route) => Number.parseInt(route.route_short_name, 10)
224
- );
225
- const parentStationIds = new Set(stops.map((stop) => stop?.parent_station));
374
+ const sortedRoutes = [...filteredRoutes].sort((a, b) => {
375
+ const aShort = a.route_short_name ?? "";
376
+ const bShort = b.route_short_name ?? "";
377
+ const aNum = Number.parseInt(aShort, 10);
378
+ const bNum = Number.parseInt(bShort, 10);
379
+ if (!Number.isNaN(aNum) && !Number.isNaN(bNum) && aNum !== bNum) {
380
+ return aNum - bNum;
381
+ }
382
+ if (Number.isNaN(aNum) && !Number.isNaN(bNum)) {
383
+ return 1;
384
+ }
385
+ if (!Number.isNaN(aNum) && Number.isNaN(bNum)) {
386
+ return -1;
387
+ }
388
+ return aShort.localeCompare(bShort, void 0, {
389
+ numeric: true,
390
+ sensitivity: "base"
391
+ });
392
+ });
393
+ const parentStationIds = new Set(stops.map((stop) => stop.parent_station));
226
394
  const parentStationStops = getStops(
227
395
  { stop_id: Array.from(parentStationIds) },
228
396
  ["stop_id", "stop_name", "stop_code", "parent_station"]
229
397
  );
230
398
  stops.push(
231
- ...parentStationStops.map((stop) => {
232
- stop.is_parent_station = true;
233
- return stop;
234
- })
399
+ ...parentStationStops.map((stop) => ({
400
+ ...stop,
401
+ is_parent_station: true
402
+ }))
235
403
  );
236
404
  const sortedStops = sortBy(uniqBy(stops, "stop_id"), "stop_name");
237
405
  return {
@@ -242,41 +410,21 @@ function generateTransitDeparturesWidgetJson(config2) {
242
410
  function removeNulls(data) {
243
411
  if (Array.isArray(data)) {
244
412
  return data.map(removeNulls).filter((item) => item !== null && item !== void 0);
245
- } else if (typeof data === "object" && data !== null) {
246
- return Object.entries(data).reduce((acc, [key, value]) => {
247
- const cleanedValue = removeNulls(value);
248
- if (cleanedValue !== null && cleanedValue !== void 0) {
249
- acc[key] = cleanedValue;
250
- }
251
- return acc;
252
- }, {});
413
+ } else if (data !== null && typeof data === "object" && Object.getPrototypeOf(data) === Object.prototype) {
414
+ return Object.entries(data).reduce(
415
+ (acc, [key, value]) => {
416
+ const cleanedValue = removeNulls(value);
417
+ if (cleanedValue !== null && cleanedValue !== void 0) {
418
+ acc[key] = cleanedValue;
419
+ }
420
+ return acc;
421
+ },
422
+ {}
423
+ );
253
424
  } else {
254
425
  return data;
255
426
  }
256
427
  }
257
- function setDefaultConfig(initialConfig) {
258
- const defaults = {
259
- beautify: false,
260
- noHead: false,
261
- refreshIntervalSeconds: 20,
262
- skipImport: false,
263
- timeFormat: "12hour",
264
- includeCoordinates: false,
265
- overwriteExistingFiles: true,
266
- verbose: true
267
- };
268
- const config2 = Object.assign(defaults, initialConfig);
269
- const viewsFolderPath = getPathToViewsFolder(config2);
270
- const i18n = new I18n({
271
- directory: join2(viewsFolderPath, "locales"),
272
- defaultLocale: config2.locale,
273
- updateFiles: false
274
- });
275
- const configWithI18n = Object.assign(config2, {
276
- __: i18n.__
277
- });
278
- return configWithI18n;
279
- }
280
428
  function formatWhereClause(key, value) {
281
429
  if (Array.isArray(value)) {
282
430
  let whereClause = `${sqlString.escapeId(key)} IN (${value.filter((v) => v !== null).map((v) => sqlString.escape(v)).join(", ")})`;
@@ -301,7 +449,7 @@ function formatWhereClauses(query) {
301
449
  }
302
450
 
303
451
  // src/app/index.ts
304
- var argv = yargs(process.argv).option("c", {
452
+ var argv = yargs(hideBin(process.argv)).option("c", {
305
453
  alias: "configPath",
306
454
  describe: "Path to config file",
307
455
  default: "./config.json",
@@ -316,12 +464,46 @@ config.assetPath = "/";
316
464
  config.logFunction = console.log;
317
465
  try {
318
466
  openDb2(config);
467
+ const gtfsPath = config.agency.gtfs_static_path;
468
+ const gtfsUrl = config.agency.gtfs_static_url;
469
+ if (!gtfsPath && !gtfsUrl) {
470
+ throw new Error(
471
+ "Missing GTFS source. Set `agency.gtfs_static_path` or `agency.gtfs_static_url` in config.json."
472
+ );
473
+ }
474
+ const agencyImportConfig = gtfsPath ? { path: gtfsPath } : { url: gtfsUrl };
475
+ const gtfsImportConfig = {
476
+ ...clone(omit(config, "agency")),
477
+ agencies: [agencyImportConfig]
478
+ };
479
+ await importGtfs(gtfsImportConfig);
319
480
  } catch (error) {
320
481
  console.error(
321
- `Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists and import GTFS before running this app.`
482
+ `Unable to open sqlite database "${config.sqlitePath}" defined as \`sqlitePath\` config.json. Ensure the parent directory exists and run gtfs-to-html to import GTFS before running this app.`
322
483
  );
323
484
  throw error;
324
485
  }
486
+ app.set("views", getPathToViewsFolder(config));
487
+ app.set("view engine", "pug");
488
+ app.use((req, res, next) => {
489
+ console.log(`${req.method} ${req.url}`);
490
+ next();
491
+ });
492
+ var staticAssetPath = config.templatePath === void 0 ? getPathToViewsFolder(config) : untildify2(config.templatePath);
493
+ app.use(express.static(staticAssetPath));
494
+ var frontendLibraryPaths = [
495
+ { route: "/js", package: "pbf", subPath: "dist" },
496
+ { route: "/js", package: "gtfs-realtime-pbf-js-module", subPath: "" },
497
+ { route: "/js", package: "accessible-autocomplete", subPath: "" },
498
+ { route: "/css", package: "accessible-autocomplete", subPath: "" }
499
+ ];
500
+ var resolvePackagePath = (packageName, subPath) => {
501
+ const packagePath = dirname2(fileURLToPath2(import.meta.resolve(packageName)));
502
+ return subPath ? join3(packagePath, subPath) : packagePath;
503
+ };
504
+ for (const { route, package: pkg, subPath } of frontendLibraryPaths) {
505
+ app.use(route, express.static(resolvePackagePath(pkg, subPath)));
506
+ }
325
507
  app.get("/", async (request, response, next) => {
326
508
  try {
327
509
  const html = await generateTransitDeparturesWidgetHtml(config);
@@ -346,11 +528,6 @@ app.get("/data/stops.json", async (request, response, next) => {
346
528
  next(error);
347
529
  }
348
530
  });
349
- app.set("views", getPathToViewsFolder(config));
350
- app.set("view engine", "pug");
351
- app.use(logger("dev"));
352
- var staticAssetPath = config.templatePath === void 0 ? getPathToViewsFolder(config) : untildify2(config.templatePath);
353
- app.use(express.static(staticAssetPath));
354
531
  app.use((req, res) => {
355
532
  res.status(404).send("Not Found");
356
533
  });