@expofp/offline 3.11.12 → 3.12.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 CHANGED
@@ -1,57 +1,22 @@
1
- ## Offline: generating ZIPs
1
+ # @expofp/offline
2
2
 
3
- The `@expofp/offline` package provides a CLI (`expofp-offline`) that creates self-contained ZIP archives of floor plans. The ZIP can be opened in any browser without internet connectivity.
3
+ CLI (`expofp-offline`) that creates self-contained ZIP archives of floor plans — openable in any browser without internet connectivity.
4
4
 
5
- ### Usage
5
+ ## Usage
6
6
 
7
7
  ```bash
8
8
  npx @expofp/offline@<tag-or-version> <manifest-url> [options]
9
9
  ```
10
10
 
11
- Use a dist-tag or exact version to match the deployed floor plan (e.g. `@latest`, `@stable`, `@customer1`, `@3.0.0-alpha.9`).
11
+ | Option | Description | Default |
12
+ | ------------------------ | ------------------------------------------------ | -------------------------------------- |
13
+ | `-o`, `--output` | Output file path | `offline.zip` |
14
+ | `--debug-no-offline-map` | Disable automatic MapLibre basemap detection. | |
15
+ | `--debug-map-source` | Global PMTiles source URL/path for offline maps. | Latest available Protomaps daily build |
16
+ | `-h`, `--help` | Show help message | |
12
17
 
13
- | Option | Description | Default |
14
- | ------------------ | ------------------------------------------------------------------------ | -------------------------------------- |
15
- | `-o`, `--output` | Output file path | `offline.zip` |
16
- | `--map-debug` | Enable debug-only map flags. Do not use in production. | |
17
- | `--no-offline-map` | Disable automatic MapLibre basemap detection. Requires `--map-debug`. | |
18
- | `--map-source` | Global PMTiles source URL/path for offline maps. Requires `--map-debug`. | Latest available Protomaps daily build |
19
- | `-h`, `--help` | Show help message | |
20
- | `-v`, `--version` | Show version number | |
21
-
22
- ### Example
23
-
24
- ```bash
25
- npx @expofp/offline@stable https://demo.expofp.com/manifest.json -o offline.zip
26
- ```
27
-
28
- Offline MapLibre basemaps are inferred automatically from `__fpGeo.properties.config`:
29
-
30
- ```bash
31
- npx @expofp/offline@next https://demo.expofp.com/manifest.json -o offline.zip
32
- ```
33
-
34
- To skip automatic offline map generation for debugging only:
35
-
36
- ```bash
37
- npx @expofp/offline@next https://demo.expofp.com/manifest.json \
38
- --map-debug \
39
- --no-offline-map \
40
- -o offline.zip
41
- ```
42
-
43
- If `--map-source` is omitted, the CLI checks `https://build.protomaps.com/YYYYMMDD.pmtiles`
44
- starting from the current date and falls back to previous dates for up to 7 days.
45
- For debugging only, pass `--map-debug --map-source` explicitly to use a specific PMTiles URL or local file.
46
-
47
- Offline map extraction requires the `pmtiles` CLI binary to be available in `PATH`.
48
- This is separate from the `pmtiles` npm package used by the browser runtime.
49
- On macOS, install the CLI with `brew install pmtiles`.
50
-
51
- ### Debugging
52
-
53
- Enable debug output with the `DEBUG` env variable:
18
+ ## Example
54
19
 
55
20
  ```bash
56
- DEBUG=efp:offline:* npx @expofp/offline https://...
21
+ npx @expofp/offline https://demo.expofp.com/manifest.json -o offline.zip
57
22
  ```
package/dist/cli.js CHANGED
@@ -13,13 +13,11 @@ Arguments:
13
13
 
14
14
  Options:
15
15
  -o, --output Output file path (default: offline.zip)
16
- --map-debug
17
- Enable debug-only map flags. Do not use in production.
18
- --no-offline-map
19
- Disable automatic offline MapLibre basemap detection (requires --map-debug)
20
- --map-source <source>
16
+ --debug-no-offline-map
17
+ Disable automatic offline MapLibre basemap detection
18
+ --debug-map-source <source>
21
19
  Global PMTiles source URL/path used for the offline map
22
- Defaults to the latest available Protomaps daily build (requires --map-debug)
20
+ Defaults to the latest available Protomaps daily build
23
21
  -h, --help Show this help message
24
22
  -v, --version Show version number
25
23
  `.trim();
@@ -30,16 +28,13 @@ async function main() {
30
28
  abortController.abort(new Error('Aborted by SIGINT'));
31
29
  };
32
30
  process.once('SIGINT', abortOnSigint);
33
- const rawArgs = process.argv.slice(2);
34
- const noOfflineMap = rawArgs.includes('--no-offline-map');
35
31
  const { values, positionals } = parseArgs({
36
- args: rawArgs.filter((arg) => arg !== '--no-offline-map'),
32
+ args: process.argv.slice(2),
37
33
  allowPositionals: true,
38
- allowNegative: true,
39
34
  options: {
40
35
  output: { type: 'string', short: 'o', default: 'offline.zip' },
41
- 'map-debug': { type: 'boolean', default: false },
42
- 'map-source': { type: 'string' },
36
+ 'debug-no-offline-map': { type: 'boolean', default: false },
37
+ 'debug-map-source': { type: 'string' },
43
38
  help: { type: 'boolean', short: 'h', default: false },
44
39
  version: { type: 'boolean', short: 'v', default: false },
45
40
  },
@@ -73,20 +68,14 @@ async function main() {
73
68
  console.error('Error: output path must end with .zip\n');
74
69
  process.exit(1);
75
70
  }
76
- if (!values['map-debug'] && noOfflineMap) {
77
- console.error('Error: --no-offline-map requires --map-debug and is for debug only\n');
71
+ if (values['debug-no-offline-map'] && values['debug-map-source']) {
72
+ console.error('Error: --debug-map-source cannot be used with --debug-no-offline-map\n');
78
73
  process.exit(1);
79
74
  }
80
- if (!values['map-debug'] && values['map-source']) {
81
- console.error('Error: --map-source requires --map-debug and is for debug only\n');
82
- process.exit(1);
83
- }
84
- if (noOfflineMap && values['map-source']) {
85
- console.error('Error: --map-source cannot be used with --no-offline-map\n');
86
- process.exit(1);
87
- }
88
- const offlineMap = values['map-source'] ? { mapSource: values['map-source'] } : undefined;
89
- const inferOfflineMap = !noOfflineMap;
75
+ const offlineMap = values['debug-map-source']
76
+ ? { mapSource: values['debug-map-source'] }
77
+ : undefined;
78
+ const inferOfflineMap = !values['debug-no-offline-map'];
90
79
  console.log(`Resolving @expofp/floorplan runtime...`);
91
80
  const runtimeBaseUrl = await resolveFloorplanDir();
92
81
  console.log(`Creating offline ZIP from ${manifestUrl}...`);
@@ -1,4 +1,5 @@
1
1
  import { type LocalData } from './types.js';
2
+ export declare const FILE_FETCH_TIMEOUT_MS = 60000;
2
3
  export declare function dataToFiles(data: AsyncIterable<LocalData>, options?: {
3
4
  signal?: AbortSignal;
4
5
  }): AsyncIterable<{
@@ -2,14 +2,19 @@ import { readFileFromUrl } from '@expofp/utils';
2
2
  import debug from 'debug';
3
3
  import { withTimeout } from './abort-signal.js';
4
4
  const log = debug('efp:offline:data-to-files');
5
- const FILE_FETCH_TIMEOUT_MS = 60_000;
5
+ export const FILE_FETCH_TIMEOUT_MS = 60_000;
6
6
  export async function* dataToFiles(data, options) {
7
7
  for await (const file of data) {
8
8
  if ('url' in file) {
9
9
  const signal = withTimeout(options?.signal, FILE_FETCH_TIMEOUT_MS);
10
10
  const response = await readFileFromUrl(file.url, { signal });
11
11
  if (!response.ok) {
12
- console.warn(`Skipping file ${file.url} as it could not be downloaded`);
12
+ // only expected-missing resources (fabricated variant URLs) may be
13
+ // skipped — an archive silently missing anything else is broken
14
+ if (!file.optional) {
15
+ throw new Error(`Failed to download ${file.url}`);
16
+ }
17
+ console.warn(`Skipping optional file ${file.url} as it could not be downloaded`);
13
18
  }
14
19
  else {
15
20
  log('Bundling file from url', file.targetFilePath);
@@ -1,4 +1,5 @@
1
- import { type Manifest, type Ref } from '@expofp/data';
1
+ import { type Manifest } from '@expofp/config';
2
+ import { type Ref } from '@expofp/resolve';
2
3
  import { type OfflineOptions } from './types.js';
3
4
  export declare function downloadOfflineZip(manifest: Manifest | Ref<Manifest>, options: OfflineOptions): Promise<void>;
4
5
  //# sourceMappingURL=download-offline-zip.d.ts.map
@@ -1,5 +1,13 @@
1
- import { type Manifest, type Ref } from '@expofp/data';
1
+ import { type Manifest } from '@expofp/config';
2
+ import { type Ref } from '@expofp/resolve';
2
3
  import { type LocalData, type OfflineOptions } from './types.js';
4
+ /**
5
+ * An offline copy is nothing but a serialized config: `loadConfig` produces
6
+ * the complete event data document (refs warm, every asset URL absolute), and
7
+ * `serializeConfigResources` walks it — this package only decides WHERE
8
+ * things go (page-relative archive-local paths, so the copy resolves against
9
+ * wherever `index.html` is mounted) and zips the result with the runtime.
10
+ */
3
11
  export declare function generateOfflineData(inputManifest: Manifest | Ref<Manifest>, options: OfflineOptions): AsyncIterable<LocalData, void>;
4
12
  /** Build a complete offline ZIP archive from a manifest. */
5
13
  export declare function buildOfflineZip(manifest: Manifest | Ref<Manifest>, options: OfflineOptions): Promise<Blob>;
@@ -1,38 +1,42 @@
1
- import { resolve } from '@expofp/resolve';
2
- import { buildZipArchive, deepClone } from '@expofp/utils';
1
+ import { loadConfig, serializeConfigResources, } from '@expofp/config';
2
+ import { getResolved, resolve } from '@expofp/resolve';
3
+ import { buildZipArchive, deepClone, makeLocalPath, readFileFromUrl } from '@expofp/utils';
3
4
  import debug from 'debug';
4
- import { dataToFiles } from './data-to-files.js';
5
- import { generateOfflineDataLegacy } from './generate-offline-data-legacy.js';
5
+ import { withTimeout } from './abort-signal.js';
6
+ import { dataToFiles, FILE_FETCH_TIMEOUT_MS } from './data-to-files.js';
6
7
  import { generateOfflineMapData, inferOfflineMapOptions, validateOfflineMapOptions, } from './generate-offline-map-data.js';
7
8
  import { generateRuntimeFilesData } from './generate-runtime-files-data.js';
8
- import { offlinizeAssetsInPlace } from './offlinize-assets-in-place.js';
9
9
  const log = debug('efp:offline:generate-offline-data');
10
+ /**
11
+ * An offline copy is nothing but a serialized config: `loadConfig` produces
12
+ * the complete event data document (refs warm, every asset URL absolute), and
13
+ * `serializeConfigResources` walks it — this package only decides WHERE
14
+ * things go (page-relative archive-local paths, so the copy resolves against
15
+ * wherever `index.html` is mounted) and zips the result with the runtime.
16
+ */
10
17
  export async function* generateOfflineData(inputManifest, options) {
11
18
  const manifest = deepClone(await resolve(inputManifest, { signal: options.signal }));
12
19
  log(`Generating offline files for expo: ${manifest.expo}`);
13
- const pathPrefix = manifest.expo;
20
+ const config = await loadConfig(manifest, {});
21
+ const pathPrefix = config.expo;
14
22
  // this is a requirement of mobile SDK, paths in the ZIP must be prefixed with the expo name
15
23
  // TODO: make sure SDK works with unprefixed paths, and remove the prefixing logic from this function
16
24
  const prefixPath = (path) => `${pathPrefix}/${path}`;
17
- const legacyDataUrlBase = manifest.legacyDataUrlBase;
18
- let maplibre;
19
- const offlineMap = await resolveOfflineMapOptions(manifest, options);
20
- if (manifest.legacyDataUrlBase) {
21
- for await (const d of generateOfflineDataLegacy(manifest, options)) {
22
- yield { ...d, targetFilePath: prefixPath(d.targetFilePath) };
23
- }
25
+ const offlineMap = await resolveOfflineMapOptions(config, options);
26
+ const { files, localManifest } = await serializeConfig(config, options.signal);
27
+ for (const d of files) {
28
+ yield { ...d, targetFilePath: prefixPath(d.targetFilePath) };
24
29
  }
25
30
  if (offlineMap) {
26
- const mapData = generateOfflineMapData({ ...manifest, legacyDataUrlBase }, { ...offlineMap, signal: options.signal });
31
+ const mapData = generateOfflineMapData({
32
+ ...offlineMap,
33
+ fpSvgContext: getFpSvgJs(config),
34
+ signal: options.signal,
35
+ });
27
36
  for await (const d of mapData.files) {
28
37
  yield { ...d, targetFilePath: prefixPath(d.targetFilePath) };
29
38
  }
30
- maplibre = mapData.maplibre;
31
- }
32
- // transform entire manifest + assets
33
- const data = await offlinizeAssetsInPlace(manifest, options);
34
- for (const d of data) {
35
- yield { ...d, targetFilePath: prefixPath(d.targetFilePath) };
39
+ localManifest.maplibre = { ...mapData.maplibre };
36
40
  }
37
41
  // add runtime files — manually iterate to prefix paths while capturing return value
38
42
  const runtimeGen = generateRuntimeFilesData(options.runtimeBaseUrl);
@@ -45,15 +49,89 @@ export async function* generateOfflineData(inputManifest, options) {
45
49
  runtimeResult = await runtimeGen.next();
46
50
  }
47
51
  const { entry } = runtimeResult.value;
52
+ // the archive's manifest — the whole event data document, everything resolved
53
+ yield {
54
+ text: JSON.stringify(localManifest, null, 2),
55
+ targetFilePath: prefixPath('manifest.json'),
56
+ };
48
57
  // generate index.html
49
58
  yield {
50
- text: getIndexHtml(entry, manifest, maplibre),
59
+ text: getIndexHtml(entry, 'manifest.json'),
51
60
  targetFilePath: prefixPath('index.html'),
52
61
  };
53
62
  }
54
- async function resolveOfflineMapOptions(manifest, options) {
63
+ /**
64
+ * The storage policy behind `serializeConfigResources`: assets land at their
65
+ * `makeLocalPath`, referenced documents are saved as `.json` files, and
66
+ * everything is addressed with **page-relative** URLs — bare `makeLocalPath`
67
+ * output, no leading slash — so the archive resolves wherever `index.html` is
68
+ * mounted (inside the expo folder, a parent at `/<expo>/`, or the SDK mount)
69
+ * alike. Non-remote (already-local or `data:`) resources pass through as-is.
70
+ */
71
+ async function serializeConfig(config, signal) {
72
+ const files = [];
73
+ const seenPaths = new Set();
74
+ const add = (entry) => {
75
+ if (!seenPaths.has(entry.targetFilePath)) {
76
+ seenPaths.add(entry.targetFilePath);
77
+ files.push(entry);
78
+ }
79
+ };
80
+ const isRemote = (url) => /^https?:\/\//i.test(url);
81
+ const store = {
82
+ async asset(url, optional) {
83
+ if (!isRemote(url))
84
+ return url;
85
+ const targetFilePath = await makeLocalPath(new URL(url));
86
+ add({ url: new URL(url), targetFilePath, optional });
87
+ return targetFilePath;
88
+ },
89
+ async document(refUrl, document) {
90
+ const targetFilePath = documentPath(await makeLocalPath(new URL(refUrl)));
91
+ add({ text: JSON.stringify(document), targetFilePath });
92
+ return targetFilePath;
93
+ },
94
+ // @import'ed stylesheets (e.g. Google Fonts css2) are spliced into the
95
+ // importing CSS rather than saved as files — an extension-less saved
96
+ // stylesheet would be served with the wrong MIME type, and splicing lets
97
+ // the ordinary asset pass localize the fonts the import pulls in.
98
+ async stylesheetText(url) {
99
+ const response = await readFileFromUrl(new URL(url), {
100
+ signal: withTimeout(signal, FILE_FETCH_TIMEOUT_MS),
101
+ });
102
+ if (!response.ok)
103
+ throw new Error(`Failed to download @import'ed stylesheet ${url}`);
104
+ return new TextDecoder().decode(response.data);
105
+ },
106
+ documentError(refUrl, error) {
107
+ // The wayfinding graph is optional — an expo without one is normal, so
108
+ // (like the fabricated __small/__tiny logo variants) warn and drop the
109
+ // ref. Any other document missing means a broken archive: fail the build.
110
+ if (refUrl === config.wfDataJsRef?.$ref) {
111
+ console.warn(`Skipping wayfinding graph ${refUrl} as it could not be resolved`, error);
112
+ return;
113
+ }
114
+ throw error;
115
+ },
116
+ };
117
+ const localManifest = await serializeConfigResources(config, store, { signal });
118
+ return { files, localManifest };
119
+ }
120
+ /** Saved documents are resolved JSON, whatever the live file was called. */
121
+ function documentPath(localPath) {
122
+ if (localPath.endsWith('.json'))
123
+ return localPath;
124
+ return localPath.replace(/\.js$/i, '') + '.json';
125
+ }
126
+ /** The eagerly-resolved drawing payload (loadConfig warmed the ref). */
127
+ function getFpSvgJs(config) {
128
+ if (!config.fpSvgJsRef)
129
+ throw new Error('config has no drawing (fpSvgJsRef)');
130
+ return getResolved(config.fpSvgJsRef);
131
+ }
132
+ async function resolveOfflineMapOptions(config, options) {
55
133
  if (options.inferOfflineMap) {
56
- return await inferOfflineMapOptions(manifest, options.offlineMap, options.signal);
134
+ return await inferOfflineMapOptions(getFpSvgJs(config), options.offlineMap, options.signal);
57
135
  }
58
136
  if (options.offlineMap) {
59
137
  return await validateOfflineMapOptions(options.offlineMap, options.signal);
@@ -68,7 +146,7 @@ export async function buildOfflineZip(manifest, options) {
68
146
  }
69
147
  /** Known path — file lives in `packages/floorplan/public/` and is copied to dist by Vite. */
70
148
  const COMPAT_HELPER = 'compat-helper.js';
71
- function getIndexHtml(entry, manifest, maplibre) {
149
+ function getIndexHtml(entry, manifestRef) {
72
150
  // entry is "./runtime/index.js", derive the runtime base path
73
151
  const runtimeBase = entry.slice(0, entry.lastIndexOf('/') + 1);
74
152
  return `\
@@ -76,22 +154,7 @@ function getIndexHtml(entry, manifest, maplibre) {
76
154
  <script src="${runtimeBase}${COMPAT_HELPER}"></script>
77
155
  <script type="module">
78
156
  import { load } from ${JSON.stringify(entry)};
79
- const options = ${JSON.stringify({ ...manifest, maplibre })};
80
- if (options.maplibre && options.maplibre.offline && options.maplibre.styleUrl) {
81
- const styleUrl = new URL(options.maplibre.styleUrl, window.location.href);
82
- const style = await fetch(styleUrl).then((response) => response.json());
83
- const resolveStyleUrl = (url) => new URL(url, styleUrl).href.replaceAll('%7B', '{').replaceAll('%7D', '}');
84
- if (typeof style.sprite === 'string') style.sprite = resolveStyleUrl(style.sprite);
85
- if (typeof style.glyphs === 'string') style.glyphs = resolveStyleUrl(style.glyphs);
86
- for (const source of Object.values(style.sources || {})) {
87
- if (source && typeof source.url === 'string' && source.url.startsWith('pmtiles://')) {
88
- source.url = 'pmtiles://' + new URL(source.url.slice('pmtiles://'.length), styleUrl).href;
89
- }
90
- }
91
- options.maplibre.style = style;
92
- delete options.maplibre.styleUrl;
93
- }
94
- await load(options);
157
+ await load({ $ref: ${JSON.stringify(manifestRef)} });
95
158
  </script>
96
159
  `;
97
160
  }
@@ -1,5 +1,4 @@
1
- import { type Manifest } from '@expofp/data';
2
- import type { MaplibreOptions } from '@expofp/floorplan';
1
+ import type { MaplibreOptions } from '@expofp/schema';
3
2
  import { type LocalData, type OfflineMapOptions } from './types.js';
4
3
  type FpSvgContext = Record<string, unknown>;
5
4
  export type ValidatedOfflineMapOptions = OfflineMapOptions & {
@@ -10,10 +9,12 @@ export interface OfflineMapResult {
10
9
  files: AsyncIterable<LocalData>;
11
10
  maplibre: MaplibreOptions;
12
11
  }
13
- export declare function generateOfflineMapData(manifest: Manifest, options: ValidatedOfflineMapOptions & {
12
+ export declare function generateOfflineMapData(options: ValidatedOfflineMapOptions & {
13
+ fpSvgContext: FpSvgContext;
14
14
  signal?: AbortSignal;
15
15
  }): OfflineMapResult;
16
16
  export declare function validateOfflineMapOptions(options: OfflineMapOptions, signal?: AbortSignal): Promise<ValidatedOfflineMapOptions>;
17
- export declare function inferOfflineMapOptions(manifest: Manifest, options: OfflineMapOptions | undefined, signal?: AbortSignal): Promise<ValidatedOfflineMapOptions | undefined>;
17
+ /** An offline map applies only when the (already-loaded) drawing is geo-referenced. */
18
+ export declare function inferOfflineMapOptions(fpSvgContext: FpSvgContext, options: OfflineMapOptions | undefined, signal?: AbortSignal): Promise<ValidatedOfflineMapOptions | undefined>;
18
19
  export {};
19
20
  //# sourceMappingURL=generate-offline-map-data.d.ts.map
@@ -1,7 +1,5 @@
1
1
  import { Box, projectLocalToGps } from '@expofp/geometry';
2
- import { resolve } from '@expofp/resolve';
3
2
  import debug from 'debug';
4
- import { execScriptInSandbox } from './exec-script-in-sandbox.js';
5
3
  const log = debug('efp:offline:generate-offline-map-data');
6
4
  const MAPS_BASE_PATH = 'maps/';
7
5
  const MAP_STYLE_PATH = `${MAPS_BASE_PATH}style.json`;
@@ -34,16 +32,13 @@ const ENGLISH_LABEL_FIELD = [
34
32
  ['get', 'name'],
35
33
  ];
36
34
  const tempDirExitCleanups = new Map();
37
- export function generateOfflineMapData(manifest, options) {
35
+ export function generateOfflineMapData(options) {
38
36
  const maplibre = {
39
37
  styleUrl: `./${MAP_STYLE_PATH}`,
40
38
  offline: true,
41
39
  };
42
40
  async function* files() {
43
- if (!manifest.legacyDataUrlBase) {
44
- throw new Error('Offline map generation requires legacyDataUrlBase in the manifest.');
45
- }
46
- const bbox = await derivePaddedVenueBbox(manifest, options.fpSvgContext, options.signal);
41
+ const bbox = derivePaddedVenueBbox(options.fpSvgContext);
47
42
  log('Extracting PMTiles for bbox', bbox.join(','));
48
43
  const pmtilesFile = await extractPmtiles(options.mapSource, bbox, options.signal);
49
44
  try {
@@ -75,10 +70,8 @@ export async function validateOfflineMapOptions(options, signal) {
75
70
  await assertPmtilesCliAvailable(signal);
76
71
  return { ...options, mapSource };
77
72
  }
78
- export async function inferOfflineMapOptions(manifest, options, signal) {
79
- if (!manifest.legacyDataUrlBase)
80
- return undefined;
81
- const fpSvgContext = await resolveFpSvgContext(manifest, signal);
73
+ /** An offline map applies only when the (already-loaded) drawing is geo-referenced. */
74
+ export async function inferOfflineMapOptions(fpSvgContext, options, signal) {
82
75
  const fpGeo = fpSvgContext.__fpGeo;
83
76
  if (!fpGeo?.properties?.config)
84
77
  return undefined;
@@ -97,7 +90,7 @@ async function resolveDefaultMapSource(signal) {
97
90
  throw new Error(`Unable to check default offline map source ${source}: HTTP ${response.status}`);
98
91
  }
99
92
  }
100
- throw new Error(`Could not find a default Protomaps PMTiles build from the last ${DEFAULT_MAP_SOURCE_LOOKBACK_DAYS} days. Specify --map-source explicitly.`);
93
+ throw new Error(`Could not find a default Protomaps PMTiles build from the last ${DEFAULT_MAP_SOURCE_LOOKBACK_DAYS} days. Specify --debug-map-source explicitly.`);
101
94
  }
102
95
  function createDefaultMapSource(dayOffset) {
103
96
  const date = new Date();
@@ -107,8 +100,7 @@ function createDefaultMapSource(dayOffset) {
107
100
  const day = String(date.getUTCDate()).padStart(2, '0');
108
101
  return `${DEFAULT_MAP_SOURCE_BASE}/${year}${month}${day}.pmtiles`;
109
102
  }
110
- async function derivePaddedVenueBbox(manifest, fpSvgContext, signal) {
111
- const ctx = fpSvgContext ?? (await resolveFpSvgContext(manifest, signal));
103
+ function derivePaddedVenueBbox(ctx) {
112
104
  const fpGeo = ctx.__fpGeo;
113
105
  const config = fpGeo?.properties?.config;
114
106
  if (!config) {
@@ -157,15 +149,6 @@ async function derivePaddedVenueBbox(manifest, fpSvgContext, signal) {
157
149
  const latPadding = padH / M_PER_DEG_LAT;
158
150
  return [minLng - lngPadding, minLat - latPadding, maxLng + lngPadding, maxLat + latPadding];
159
151
  }
160
- async function resolveFpSvgContext(manifest, signal) {
161
- if (!manifest.legacyDataUrlBase) {
162
- throw new Error('Offline map generation requires legacyDataUrlBase in the manifest.');
163
- }
164
- const version = await resolve(manifest.legacyDataVersion, { signal })
165
- .then((json) => json?.version)
166
- .catch(() => Date.now().toString());
167
- return await execScriptInSandbox(`${manifest.legacyDataUrlBase}fp.svg.js?v=${version}`, signal);
168
- }
169
152
  // The offline map extent — the whole SVG canvas, NOT the fit box: the full
170
153
  // viewBox unioned with the explicit authored area (`__viewbox` / `rect#VIEWBOX`).
171
154
  // Basing this on the
@@ -238,7 +221,7 @@ async function assertPmtilesCliAvailable(signal) {
238
221
  async function assertValidMapSource(mapSource) {
239
222
  const source = mapSource.trim();
240
223
  if (!source || source !== mapSource || source.startsWith('-')) {
241
- throw new Error('Offline map --map-source must be an http(s)/file URL or absolute path.');
224
+ throw new Error('Offline map --debug-map-source must be an http(s)/file URL or absolute path.');
242
225
  }
243
226
  const path = await import('node:path');
244
227
  if (path.isAbsolute(source) || path.win32.isAbsolute(source))
@@ -251,7 +234,7 @@ async function assertValidMapSource(mapSource) {
251
234
  catch {
252
235
  // Fall through to the consistent validation error below.
253
236
  }
254
- throw new Error('Offline map --map-source must be an http(s)/file URL or absolute path.');
237
+ throw new Error('Offline map --debug-map-source must be an http(s)/file URL or absolute path.');
255
238
  }
256
239
  async function extractPmtiles(mapSource, bbox, signal) {
257
240
  const { mkdtemp } = await import('node:fs/promises');
@@ -1,4 +1,5 @@
1
- import { type Manifest, type Ref } from '@expofp/data';
1
+ import { type Manifest } from '@expofp/config';
2
+ import { type Ref } from '@expofp/resolve';
2
3
  import { type OfflineOptions } from './types.js';
3
4
  export declare function saveOfflineZip(manifest: Manifest | Ref<Manifest>, path: string, options: OfflineOptions): Promise<void>;
4
5
  //# sourceMappingURL=save-offline-zip.d.ts.map
@@ -2,6 +2,8 @@ export type LocalData = LocalDataFetch | LocalDataText;
2
2
  export interface LocalDataFetch {
3
3
  url: URL;
4
4
  targetFilePath: string;
5
+ /** An expected-missing resource (fabricated variant URLs): skip, don't fail, when the download fails. */
6
+ optional?: boolean;
5
7
  }
6
8
  export interface LocalDataText {
7
9
  text: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/offline",
3
- "version": "3.11.12",
3
+ "version": "3.12.1",
4
4
  "type": "module",
5
5
  "description": "CLI tool for creating offline copies of ExpoFP floor plans",
6
6
  "homepage": "https://developer.expofp.com/",
@@ -31,10 +31,11 @@
31
31
  "dependencies": {
32
32
  "debug": "^4.4.3",
33
33
  "tslib": "^2.3.0",
34
- "@expofp/data": "3.11.12",
35
- "@expofp/floorplan": "3.11.12",
36
- "@expofp/resolve": "3.11.12",
37
- "@expofp/geometry": "3.11.12",
38
- "@expofp/utils": "3.11.12"
34
+ "@expofp/config": "3.12.1",
35
+ "@expofp/geometry": "3.12.1",
36
+ "@expofp/floorplan": "3.12.1",
37
+ "@expofp/resolve": "3.12.1",
38
+ "@expofp/schema": "3.12.1",
39
+ "@expofp/utils": "3.12.1"
39
40
  }
40
41
  }
@@ -1,3 +0,0 @@
1
- /** Fetch a legacy data script and execute it in a compatibility vm context. */
2
- export declare function execScriptInSandbox(url: string, signal?: AbortSignal): Promise<Record<string, unknown>>;
3
- //# sourceMappingURL=exec-script-in-sandbox.d.ts.map
@@ -1,44 +0,0 @@
1
- import { fetchWithRetry } from '@expofp/utils';
2
- import { withTimeout } from './abort-signal.js';
3
- const SCRIPT_FETCH_TIMEOUT_MS = 60_000;
4
- const SCRIPT_EXECUTION_TIMEOUT_MS = 5_000;
5
- const LEGACY_SCRIPT_ALLOWED_ORIGINS_ENV = 'EXPOFP_OFFLINE_LEGACY_SCRIPT_ORIGINS';
6
- /** Fetch a legacy data script and execute it in a compatibility vm context. */
7
- export async function execScriptInSandbox(url, signal) {
8
- assertTrustedLegacyScriptUrl(url);
9
- const fetchSignal = withTimeout(signal, SCRIPT_FETCH_TIMEOUT_MS);
10
- const response = await fetchWithRetry(url, { signal: fetchSignal });
11
- if (!response.ok) {
12
- throw new Error(`Failed to fetch script (HTTP ${response.status}): ${url}`);
13
- }
14
- const code = await response.text();
15
- const { runInNewContext } = await import('node:vm');
16
- const sandbox = {};
17
- sandbox.window = sandbox; // legacy scripts expect window === globalThis
18
- // The vm context is not a security boundary; origin validation above is required.
19
- runInNewContext(code, sandbox, { timeout: SCRIPT_EXECUTION_TIMEOUT_MS });
20
- return sandbox;
21
- }
22
- function assertTrustedLegacyScriptUrl(url) {
23
- const parsed = new URL(url);
24
- if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
25
- throw new Error(`Legacy script URL must use http or https: ${url}`);
26
- }
27
- const allowedOrigins = getAllowedLegacyScriptOrigins();
28
- if (isDefaultAllowedHost(parsed.hostname) || allowedOrigins.includes(parsed.origin))
29
- return;
30
- throw new Error(`Legacy script host is not allowed: ${parsed.hostname}. Add ${parsed.origin} to ${LEGACY_SCRIPT_ALLOWED_ORIGINS_ENV} to allow it.`);
31
- }
32
- function getAllowedLegacyScriptOrigins() {
33
- return (process.env[LEGACY_SCRIPT_ALLOWED_ORIGINS_ENV] ?? '')
34
- .split(',')
35
- .map((origin) => origin.trim())
36
- .filter(Boolean);
37
- }
38
- function isDefaultAllowedHost(hostname) {
39
- return (hostname === 'expofp.com' ||
40
- hostname.endsWith('.expofp.com') ||
41
- hostname === 'localhost' ||
42
- hostname === '127.0.0.1' ||
43
- hostname === '::1');
44
- }
@@ -1,6 +0,0 @@
1
- import { type Manifest } from '@expofp/data';
2
- import { type LocalData } from './types.js';
3
- export declare function generateOfflineDataLegacy(manifest: Manifest, options: {
4
- signal?: AbortSignal;
5
- }): AsyncIterable<LocalData>;
6
- //# sourceMappingURL=generate-offline-data-legacy.d.ts.map
@@ -1,86 +0,0 @@
1
- import { resolve } from '@expofp/resolve';
2
- import { deepClone, makeLocalPath } from '@expofp/utils';
3
- import debug from 'debug';
4
- import { execScriptInSandbox } from './exec-script-in-sandbox.js';
5
- import { offlinizeAssetsInPlace } from './offlinize-assets-in-place.js';
6
- const log = debug('efp:offline:generate-offline-data-legacy');
7
- export async function* generateOfflineDataLegacy(manifest, options) {
8
- if (!manifest.legacyDataUrlBase) {
9
- throw new Error('Manifest does not have legacyDataUrlBase');
10
- }
11
- const { signal } = options;
12
- const legacyDataUrlBase = manifest.legacyDataUrlBase;
13
- const version = await resolve(manifest.legacyDataVersion, { signal })
14
- .then((json) => json?.version)
15
- .catch(() => {
16
- log('Could not resolve legacyDataVersion, proceeding without it');
17
- return Date.now().toString();
18
- });
19
- const localDataUrlBase = await makeLocalPath(new URL(legacyDataUrlBase));
20
- // data.js — execute in isolated vm context to extract __data
21
- const dataCtx = await execScriptInSandbox(`${legacyDataUrlBase}data.js?v=${version}`, signal);
22
- const data = deepClone(dataCtx.__data);
23
- yield* offlinizeDataJs(data, legacyDataUrlBase, signal);
24
- // Asset paths in data are absolute (e.g. "https-demo-expofp-com/data/exhibitors/...").
25
- // The runtime resolves them relative to legacyDataUrlBase, so strip that prefix
26
- // to avoid doubling (e.g. "base/base/exhibitors/...").
27
- stripPathPrefix(data, localDataUrlBase);
28
- yield {
29
- text: `__data = ${JSON.stringify(data, null, 2)};`,
30
- targetFilePath: `${localDataUrlBase}data.js`,
31
- };
32
- // wf.data.js
33
- const wfDataUrl = `${legacyDataUrlBase}wf.data.js?v=${version}`;
34
- yield { url: new URL(wfDataUrl), targetFilePath: `${localDataUrlBase}wf.data.js` };
35
- // fp.svg.js — execute in isolated vm context to extract __fpLayers
36
- const svgCtx = await execScriptInSandbox(`${legacyDataUrlBase}fp.svg.js?v=${version}`, signal);
37
- yield {
38
- url: new URL(`${legacyDataUrlBase}fp.svg.js?v=${version}`),
39
- targetFilePath: `${localDataUrlBase}fp.svg.js`,
40
- };
41
- // fp.svg.{layer}.js
42
- const fpLayers = svgCtx.__fpLayers || [];
43
- for (const layer of fpLayers) {
44
- const layerFile = `fp.svg.${layer.name}.js`;
45
- const layerUrl = `${legacyDataUrlBase}${layerFile}?v=${version}`;
46
- yield { url: new URL(layerUrl), targetFilePath: `${localDataUrlBase}${layerFile}` };
47
- }
48
- manifest.legacyDataUrlBase = localDataUrlBase;
49
- }
50
- /** Strip a path prefix from all string values in a data tree (in place). */
51
- function stripPathPrefix(node, prefix) {
52
- if (Array.isArray(node)) {
53
- for (let i = 0; i < node.length; i++) {
54
- const el = node[i];
55
- if (typeof el === 'string' && el.startsWith(prefix)) {
56
- node[i] = el.slice(prefix.length);
57
- }
58
- else if (el && typeof el === 'object') {
59
- stripPathPrefix(el, prefix);
60
- }
61
- }
62
- return;
63
- }
64
- if (!node || typeof node !== 'object') {
65
- return;
66
- }
67
- const obj = node;
68
- for (const key of Object.keys(obj)) {
69
- const val = obj[key];
70
- if (typeof val === 'string' && val.startsWith(prefix)) {
71
- obj[key] = val.slice(prefix.length);
72
- }
73
- else if (val && typeof val === 'object') {
74
- stripPathPrefix(val, prefix);
75
- }
76
- }
77
- }
78
- async function* offlinizeDataJs(data, legacyDataUrlBase, signal) {
79
- if (data.customCss) {
80
- console.warn('data.customCss is deprecated, renaming to data.customCssAssetText');
81
- data.customCssAssetText = data.customCss;
82
- delete data.customCss;
83
- }
84
- const fetches = await offlinizeAssetsInPlace(data, { legacyDataUrlBase, signal });
85
- yield* fetches;
86
- }
@@ -1,6 +0,0 @@
1
- import { type LocalDataFetch } from './types.js';
2
- export declare function offlinizeAssetUrl(url: URL, variants?: string[]): Promise<{
3
- url: string;
4
- assets: LocalDataFetch[];
5
- }>;
6
- //# sourceMappingURL=offlinize-asset-url.d.ts.map
@@ -1,35 +0,0 @@
1
- import { makeLocalPath } from '@expofp/utils';
2
- import debug from 'debug';
3
- const log = debug('efp:offline:offlinize-asset-url');
4
- export async function offlinizeAssetUrl(url, variants = []) {
5
- // main
6
- const targetFilePath = await makeLocalPath(url);
7
- const assets = [];
8
- log(`Offlinized asset: ${url.href} -> ${targetFilePath}`);
9
- assets.push({ url, targetFilePath });
10
- // extra variants (don’t mutate JSON; just add extra fetches)
11
- for (const variant of variants) {
12
- const variantUrl = withVariant(url, variant);
13
- const variantTargetFilePath = await makeLocalPath(url, variant);
14
- assets.push({ url: variantUrl, targetFilePath: variantTargetFilePath });
15
- log(`Offlinized variant: ${variantUrl.href} -> ${variantTargetFilePath}`);
16
- }
17
- return {
18
- url: targetFilePath,
19
- assets,
20
- };
21
- function withVariant(url, variant) {
22
- const u = new URL(url); // clone
23
- const pathname = u.pathname;
24
- const lastSlash = pathname.lastIndexOf('/');
25
- const filename = lastSlash >= 0 ? pathname.slice(lastSlash + 1) : pathname;
26
- const dot = filename.lastIndexOf('.');
27
- const nextFilename = dot > 0
28
- ? `${filename.slice(0, dot)}${variant}${filename.slice(dot)}`
29
- : `${filename}${variant}`;
30
- u.pathname =
31
- lastSlash >= 0 ? `${pathname.slice(0, lastSlash + 1)}${nextFilename}` : nextFilename;
32
- // IMPORTANT: do not touch u.search
33
- return u;
34
- }
35
- }
@@ -1,6 +0,0 @@
1
- import { type LocalData } from './types.js';
2
- export declare function offlinizeAssetsInPlace(data: unknown, opts: {
3
- legacyDataUrlBase?: string;
4
- signal?: AbortSignal;
5
- }): Promise<LocalData[]>;
6
- //# sourceMappingURL=offlinize-assets-in-place.d.ts.map
@@ -1,137 +0,0 @@
1
- import { fetchWithRetry, makeLocalPath } from '@expofp/utils';
2
- import debug from 'debug';
3
- import { offlinizeAssetUrl } from './offlinize-asset-url.js';
4
- import { offlinizeCssAssetText } from './offlinize-css-asset-text.js';
5
- const log = debug('efp:offline:offlinize-assets-in-place');
6
- const ASSET_URL_SUFFIX = 'AssetUrl';
7
- const CSS_ASSET_TEXT_SUFFIX = 'CssAssetText';
8
- // Legacy fields, remove in future versions
9
- const LEGACY_ASSET_URL_FIELDS = ['logo', 'gallery', 'leadingImageUrl', 'photoFile', 'logoFile'];
10
- const LEGACY_EXHIBITOR_LOGO_VARIANTS = ['__small', '__tiny'];
11
- const LEGACY_CSS_ASSET_TEXT_FIELD = 'customCss';
12
- export async function offlinizeAssetsInPlace(data, opts) {
13
- const { legacyDataUrlBase } = opts;
14
- const fetches = [];
15
- const fetchedUrls = new Map(); // targetPath to LocalData
16
- const mergeFetches = (newFetches) => {
17
- // push only new fetches
18
- // error if duplicate targetFilePath with different content
19
- for (const nf of newFetches) {
20
- const existing = fetchedUrls.get(nf.targetFilePath);
21
- // test only if both have url (i.e., are not JSON)
22
- if (existing && 'url' in nf && 'url' in existing) {
23
- const isSame = existing.url.href === nf.url.href;
24
- if (!isSame) {
25
- throw new Error(`Conflicting fetch for targetFilePath ${nf.targetFilePath}: ${existing.url.href} vs ${nf.url.href}`);
26
- }
27
- }
28
- else {
29
- fetchedUrls.set(nf.targetFilePath, nf);
30
- fetches.push(nf);
31
- }
32
- }
33
- };
34
- const processedRefs = new Map(); // Cache for $ref documents
35
- const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
36
- const toAbsoluteUrl = (value) => legacyDataUrlBase ? new URL(value, legacyDataUrlBase) : new URL(value);
37
- const enableLegacy = !!legacyDataUrlBase;
38
- async function walk(node) {
39
- if (Array.isArray(node)) {
40
- for (const el of node) {
41
- if (el && typeof el === 'object') {
42
- await walk(el);
43
- }
44
- }
45
- return;
46
- }
47
- if (!isPlainObject(node))
48
- return;
49
- // Handle $ref field
50
- if ('$ref' in node && typeof node.$ref === 'string') {
51
- const refValue = node.$ref;
52
- if (refValue) {
53
- const url = toAbsoluteUrl(refValue);
54
- const fragment = url.hash; // Preserve JSON pointer fragment
55
- // Create URL without fragment for fetching
56
- const urlWithoutFragment = new URL(url.href);
57
- urlWithoutFragment.hash = '';
58
- const urlKey = urlWithoutFragment.href;
59
- const targetFilePath = await makeLocalPath(urlWithoutFragment);
60
- // Update $ref to local path with preserved fragment
61
- node.$ref = targetFilePath + fragment;
62
- // Check if we've already processed this $ref URL
63
- if (!processedRefs.has(urlKey)) {
64
- // Mark as fetched to prevent duplicates
65
- log(`Offlinizing $ref: ${refValue} -> ${targetFilePath}${fragment}`);
66
- // Fetch and recursively process the referenced document
67
- const response = await fetchWithRetry(urlKey, { signal: opts.signal });
68
- if (response.ok) {
69
- const referencedData = (await response.json());
70
- processedRefs.set(urlKey, referencedData);
71
- log(`Processing referenced document: ${urlKey}`);
72
- // Recursively offlinize assets in the referenced document
73
- const refFetches = await offlinizeAssetsInPlace(referencedData, opts);
74
- fetches.push(...refFetches);
75
- // Store the transformed referenced data as a separate JSON file
76
- fetches.push({
77
- targetFilePath,
78
- text: JSON.stringify(referencedData, null, 2),
79
- });
80
- }
81
- else {
82
- throw new Error(`Failed to fetch $ref URL: ${urlKey}, status: ${response.status}`);
83
- }
84
- }
85
- else {
86
- log(`Skipping already processed $ref: ${refValue}`);
87
- }
88
- }
89
- }
90
- for (const key of Object.keys(node)) {
91
- const value = node[key];
92
- // CSS asset text handling
93
- if (typeof value === 'string' &&
94
- (key.endsWith(CSS_ASSET_TEXT_SUFFIX) || key === LEGACY_CSS_ASSET_TEXT_FIELD)) {
95
- const { css, assets } = await offlinizeCssAssetText(value);
96
- node[key] = css;
97
- mergeFetches(assets);
98
- continue;
99
- }
100
- // URL asset handling
101
- if (typeof value === 'string' && key.endsWith(ASSET_URL_SUFFIX)) {
102
- const { url: localUrl, assets } = await offlinizeAssetUrl(toAbsoluteUrl(value));
103
- node[key] = localUrl;
104
- mergeFetches(assets);
105
- continue;
106
- }
107
- // Legacy asset URL fields handling
108
- if (enableLegacy && LEGACY_ASSET_URL_FIELDS.includes(key) && typeof value === 'string') {
109
- let variants = [];
110
- if (key === 'logo' || (value.includes('exhibitor/') && value.includes('/media/'))) {
111
- variants = LEGACY_EXHIBITOR_LOGO_VARIANTS;
112
- }
113
- const { url: localUrl, assets } = await offlinizeAssetUrl(toAbsoluteUrl(value), variants);
114
- node[key] = localUrl;
115
- mergeFetches(assets);
116
- continue;
117
- }
118
- // Legacy asset URL fields handling for arrays
119
- if (enableLegacy && LEGACY_ASSET_URL_FIELDS.includes(key) && Array.isArray(value)) {
120
- for (let i = 0; i < value.length; i++) {
121
- const el = value[i];
122
- if (typeof el === 'string') {
123
- const { url: localUrl, assets } = await offlinizeAssetUrl(toAbsoluteUrl(el));
124
- value[i] = localUrl;
125
- mergeFetches(assets);
126
- }
127
- }
128
- continue;
129
- }
130
- if (value && typeof value === 'object') {
131
- await walk(value);
132
- }
133
- }
134
- }
135
- await walk(data);
136
- return fetches;
137
- }
@@ -1,6 +0,0 @@
1
- import { type LocalDataFetch } from './types.js';
2
- export declare function offlinizeCssAssetText(css: string): Promise<{
3
- css: string;
4
- assets: LocalDataFetch[];
5
- }>;
6
- //# sourceMappingURL=offlinize-css-asset-text.d.ts.map
@@ -1,52 +0,0 @@
1
- import { makeLocalPath } from '@expofp/utils';
2
- import debug from 'debug';
3
- const log = debug('efp:offline:offlinize-css-asset-text');
4
- export async function offlinizeCssAssetText(css) {
5
- const assets = [];
6
- const urlRegex = /url\(\s*(['"]?)(.*?)\1\s*\)/g;
7
- // Collect all URL matches first
8
- const matches = [];
9
- let regexMatch;
10
- while ((regexMatch = urlRegex.exec(css)) !== null) {
11
- matches.push({
12
- match: regexMatch[0],
13
- quote: regexMatch[1],
14
- urlString: regexMatch[2],
15
- index: regexMatch.index,
16
- });
17
- }
18
- // Process all URLs and await their local paths
19
- const replacements = await Promise.all(matches.map(async ({ match, quote, urlString }) => {
20
- // Do not process data: URLs
21
- if (urlString.startsWith('data:')) {
22
- return { original: match, replacement: match };
23
- }
24
- // Only process if it's a valid URL
25
- try {
26
- const url = new URL(urlString);
27
- const localPath = await makeLocalPath(url);
28
- assets.push({
29
- url,
30
- targetFilePath: localPath,
31
- });
32
- log(`Offlinized CSS asset: ${urlString} -> ${localPath}`);
33
- return {
34
- original: match,
35
- replacement: `url(${quote}${localPath}${quote})`,
36
- };
37
- }
38
- catch {
39
- console.warn(`Invalid URL in CSS: ${urlString}`);
40
- return { original: match, replacement: match };
41
- }
42
- }));
43
- // Apply all replacements
44
- let processedCss = css;
45
- for (const { original, replacement } of replacements) {
46
- processedCss = processedCss.replace(original, replacement);
47
- }
48
- return {
49
- css: processedCss,
50
- assets,
51
- };
52
- }