@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 +11 -46
- package/dist/cli.js +13 -24
- package/dist/lib/data-to-files.d.ts +1 -0
- package/dist/lib/data-to-files.js +7 -2
- package/dist/lib/download-offline-zip.d.ts +2 -1
- package/dist/lib/generate-offline-data.d.ts +9 -1
- package/dist/lib/generate-offline-data.js +103 -40
- package/dist/lib/generate-offline-map-data.d.ts +5 -4
- package/dist/lib/generate-offline-map-data.js +8 -25
- package/dist/lib/save-offline-zip.d.ts +2 -1
- package/dist/lib/types.d.ts +2 -0
- package/package.json +7 -6
- package/dist/lib/exec-script-in-sandbox.d.ts +0 -3
- package/dist/lib/exec-script-in-sandbox.js +0 -44
- package/dist/lib/generate-offline-data-legacy.d.ts +0 -6
- package/dist/lib/generate-offline-data-legacy.js +0 -86
- package/dist/lib/offlinize-asset-url.d.ts +0 -6
- package/dist/lib/offlinize-asset-url.js +0 -35
- package/dist/lib/offlinize-assets-in-place.d.ts +0 -6
- package/dist/lib/offlinize-assets-in-place.js +0 -137
- package/dist/lib/offlinize-css-asset-text.d.ts +0 -6
- package/dist/lib/offlinize-css-asset-text.js +0 -52
package/README.md
CHANGED
|
@@ -1,57 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
# @expofp/offline
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
CLI (`expofp-offline`) that creates self-contained ZIP archives of floor plans — openable in any browser without internet connectivity.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Usage
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npx @expofp/offline@<tag-or-version> <manifest-url> [options]
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
17
|
-
|
|
18
|
-
--
|
|
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
|
|
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:
|
|
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
|
|
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 (
|
|
77
|
-
console.error('Error: --
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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}...`);
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 {
|
|
2
|
-
import {
|
|
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 {
|
|
5
|
-
import {
|
|
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
|
|
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
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
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({
|
|
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
|
|
59
|
+
text: getIndexHtml(entry, 'manifest.json'),
|
|
51
60
|
targetFilePath: prefixPath('index.html'),
|
|
52
61
|
};
|
|
53
62
|
}
|
|
54
|
-
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -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.
|
|
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/
|
|
35
|
-
"@expofp/
|
|
36
|
-
"@expofp/
|
|
37
|
-
"@expofp/
|
|
38
|
-
"@expofp/
|
|
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,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,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,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,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
|
-
}
|