@expofp/offline 0.0.0-experimental.2632d13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,22 @@
1
+ # @expofp/offline
2
+
3
+ CLI (`expofp-offline`) that creates self-contained ZIP archives of floor plans — openable in any browser without internet connectivity.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx @expofp/offline@<tag-or-version> <manifest-url> [options]
9
+ ```
10
+
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 | |
17
+
18
+ ## Example
19
+
20
+ ```bash
21
+ npx @expofp/offline https://demo.expofp.com/manifest.json -o offline.zip
22
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=cli.d.ts.map
package/dist/cli.js ADDED
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
3
+ import { parseArgs } from 'node:util';
4
+ import { resolveFloorplanDir } from './lib/resolve-floorplan-dir.js';
5
+ import { saveOfflineZip } from './lib/save-offline-zip.js';
6
+ const HELP = `
7
+ Usage: expofp-offline <manifest-url> [options]
8
+
9
+ Create an offline ZIP archive of an ExpoFP floor plan.
10
+
11
+ Arguments:
12
+ manifest-url URL to the floor plan manifest JSON
13
+
14
+ Options:
15
+ -o, --output Output file path (default: offline.zip)
16
+ --debug-no-offline-map
17
+ Disable automatic offline MapLibre basemap detection
18
+ --debug-map-source <source>
19
+ Global PMTiles source URL/path used for the offline map
20
+ Defaults to the latest available Protomaps daily build
21
+ -h, --help Show this help message
22
+ -v, --version Show version number
23
+ `.trim();
24
+ async function main() {
25
+ const abortController = new AbortController();
26
+ const abortOnSigint = () => {
27
+ console.error('\nAborting offline ZIP creation...');
28
+ abortController.abort(new Error('Aborted by SIGINT'));
29
+ };
30
+ process.once('SIGINT', abortOnSigint);
31
+ const { values, positionals } = parseArgs({
32
+ args: process.argv.slice(2),
33
+ allowPositionals: true,
34
+ options: {
35
+ output: { type: 'string', short: 'o', default: 'offline.zip' },
36
+ 'debug-no-offline-map': { type: 'boolean', default: false },
37
+ 'debug-map-source': { type: 'string' },
38
+ help: { type: 'boolean', short: 'h', default: false },
39
+ version: { type: 'boolean', short: 'v', default: false },
40
+ },
41
+ });
42
+ if (values.help) {
43
+ console.log(HELP);
44
+ process.exit(0);
45
+ }
46
+ if (values.version) {
47
+ const require = createRequire(import.meta.url);
48
+ const pkg = require('../package.json');
49
+ console.log(pkg.version);
50
+ process.exit(0);
51
+ }
52
+ const manifestUrl = positionals[0];
53
+ if (!manifestUrl) {
54
+ console.error('Error: manifest URL is required.\n');
55
+ console.error(HELP);
56
+ process.exit(1);
57
+ }
58
+ // Validate URL
59
+ try {
60
+ new URL(manifestUrl);
61
+ }
62
+ catch {
63
+ console.error(`Error: "${manifestUrl}" is not a valid URL.\n`);
64
+ process.exit(1);
65
+ }
66
+ const outputPath = values.output;
67
+ if (!outputPath.endsWith('.zip')) {
68
+ console.error('Error: output path must end with .zip\n');
69
+ process.exit(1);
70
+ }
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');
73
+ process.exit(1);
74
+ }
75
+ const offlineMap = values['debug-map-source']
76
+ ? { mapSource: values['debug-map-source'] }
77
+ : undefined;
78
+ const inferOfflineMap = !values['debug-no-offline-map'];
79
+ console.log(`Resolving @expofp/floorplan runtime...`);
80
+ const runtimeBaseUrl = await resolveFloorplanDir();
81
+ console.log(`Creating offline ZIP from ${manifestUrl}...`);
82
+ try {
83
+ await saveOfflineZip({ $ref: manifestUrl }, outputPath, {
84
+ runtimeBaseUrl,
85
+ offlineMap,
86
+ inferOfflineMap,
87
+ signal: abortController.signal,
88
+ });
89
+ }
90
+ finally {
91
+ process.removeListener('SIGINT', abortOnSigint);
92
+ }
93
+ console.log(`Done! Offline ZIP saved to ${outputPath}`);
94
+ }
95
+ main().catch((err) => {
96
+ console.error('Error:', err instanceof Error ? err.message : err);
97
+ process.exit(1);
98
+ });
@@ -0,0 +1,5 @@
1
+ export { downloadOfflineZip } from './lib/download-offline-zip.js';
2
+ export { buildOfflineZip } from './lib/generate-offline-data.js';
3
+ export { saveOfflineZip } from './lib/save-offline-zip.js';
4
+ export type { OfflineOptions } from './lib/types.js';
5
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { downloadOfflineZip } from './lib/download-offline-zip.js';
2
+ export { buildOfflineZip } from './lib/generate-offline-data.js';
3
+ export { saveOfflineZip } from './lib/save-offline-zip.js';
@@ -0,0 +1,2 @@
1
+ export declare function withTimeout(signal: AbortSignal | undefined, ms: number): AbortSignal;
2
+ //# sourceMappingURL=abort-signal.d.ts.map
@@ -0,0 +1,6 @@
1
+ export function withTimeout(signal, ms) {
2
+ if (signal?.aborted)
3
+ return signal;
4
+ const timeout = AbortSignal.timeout(ms);
5
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
6
+ }
@@ -0,0 +1,9 @@
1
+ import { type LocalData } from './types.js';
2
+ export declare const FILE_FETCH_TIMEOUT_MS = 60000;
3
+ export declare function dataToFiles(data: AsyncIterable<LocalData>, options?: {
4
+ signal?: AbortSignal;
5
+ }): AsyncIterable<{
6
+ path: string;
7
+ data: Uint8Array;
8
+ }>;
9
+ //# sourceMappingURL=data-to-files.d.ts.map
@@ -0,0 +1,35 @@
1
+ import { readFileFromUrl } from '@expofp/utils';
2
+ import debug from 'debug';
3
+ import { withTimeout } from './abort-signal.js';
4
+ const log = debug('efp:offline:data-to-files');
5
+ export const FILE_FETCH_TIMEOUT_MS = 60_000;
6
+ export async function* dataToFiles(data, options) {
7
+ for await (const file of data) {
8
+ if ('url' in file) {
9
+ const signal = withTimeout(options?.signal, FILE_FETCH_TIMEOUT_MS);
10
+ const response = await readFileFromUrl(file.url, { signal });
11
+ if (!response.ok) {
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`);
18
+ }
19
+ else {
20
+ log('Bundling file from url', file.targetFilePath);
21
+ yield {
22
+ path: file.targetFilePath,
23
+ data: response.data,
24
+ };
25
+ }
26
+ }
27
+ else {
28
+ log('Bundling text file', file.targetFilePath);
29
+ yield {
30
+ path: file.targetFilePath,
31
+ data: new TextEncoder().encode(file.text),
32
+ };
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,5 @@
1
+ import { type Manifest } from '@expofp/config';
2
+ import { type Ref } from '@expofp/resolve';
3
+ import { type OfflineOptions } from './types.js';
4
+ export declare function downloadOfflineZip(manifest: Manifest | Ref<Manifest>, options: OfflineOptions): Promise<void>;
5
+ //# sourceMappingURL=download-offline-zip.d.ts.map
@@ -0,0 +1,14 @@
1
+ import { buildOfflineZip } from './generate-offline-data.js';
2
+ export async function downloadOfflineZip(manifest, options) {
3
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
4
+ throw new Error('downloadOfflineZip can only be used in a browser environment');
5
+ }
6
+ const blob = await buildOfflineZip(manifest, options);
7
+ const objectUrl = URL.createObjectURL(blob);
8
+ const a = document.createElement('a');
9
+ a.href = objectUrl;
10
+ a.download = 'offline.zip';
11
+ a.click();
12
+ // revoke to prevent race conditions with the download
13
+ setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000);
14
+ }
@@ -0,0 +1,14 @@
1
+ import { type Manifest } from '@expofp/config';
2
+ import { type Ref } from '@expofp/resolve';
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
+ */
11
+ export declare function generateOfflineData(inputManifest: Manifest | Ref<Manifest>, options: OfflineOptions): AsyncIterable<LocalData, void>;
12
+ /** Build a complete offline ZIP archive from a manifest. */
13
+ export declare function buildOfflineZip(manifest: Manifest | Ref<Manifest>, options: OfflineOptions): Promise<Blob>;
14
+ //# sourceMappingURL=generate-offline-data.d.ts.map
@@ -0,0 +1,160 @@
1
+ import { loadConfig, serializeConfigResources, } from '@expofp/config';
2
+ import { getResolved, resolve } from '@expofp/resolve';
3
+ import { buildZipArchive, deepClone, makeLocalPath, readFileFromUrl } from '@expofp/utils';
4
+ import debug from 'debug';
5
+ import { withTimeout } from './abort-signal.js';
6
+ import { dataToFiles, FILE_FETCH_TIMEOUT_MS } from './data-to-files.js';
7
+ import { generateOfflineMapData, inferOfflineMapOptions, validateOfflineMapOptions, } from './generate-offline-map-data.js';
8
+ import { generateRuntimeFilesData } from './generate-runtime-files-data.js';
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
+ */
17
+ export async function* generateOfflineData(inputManifest, options) {
18
+ const manifest = deepClone(await resolve(inputManifest, { signal: options.signal }));
19
+ log(`Generating offline files for expo: ${manifest.expo}`);
20
+ const config = await loadConfig(manifest, {});
21
+ const pathPrefix = config.expo;
22
+ // this is a requirement of mobile SDK, paths in the ZIP must be prefixed with the expo name
23
+ // TODO: make sure SDK works with unprefixed paths, and remove the prefixing logic from this function
24
+ const prefixPath = (path) => `${pathPrefix}/${path}`;
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) };
29
+ }
30
+ if (offlineMap) {
31
+ const mapData = generateOfflineMapData({
32
+ ...offlineMap,
33
+ fpSvgContext: getFpSvgJs(config),
34
+ signal: options.signal,
35
+ });
36
+ for await (const d of mapData.files) {
37
+ yield { ...d, targetFilePath: prefixPath(d.targetFilePath) };
38
+ }
39
+ localManifest.maplibre = { ...mapData.maplibre };
40
+ }
41
+ // add runtime files — manually iterate to prefix paths while capturing return value
42
+ const runtimeGen = generateRuntimeFilesData(options.runtimeBaseUrl);
43
+ let runtimeResult = await runtimeGen.next();
44
+ while (!runtimeResult.done) {
45
+ yield {
46
+ ...runtimeResult.value,
47
+ targetFilePath: prefixPath(runtimeResult.value.targetFilePath),
48
+ };
49
+ runtimeResult = await runtimeGen.next();
50
+ }
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
+ };
57
+ // generate index.html
58
+ yield {
59
+ text: getIndexHtml(entry, 'manifest.json'),
60
+ targetFilePath: prefixPath('index.html'),
61
+ };
62
+ }
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) {
133
+ if (options.inferOfflineMap) {
134
+ return await inferOfflineMapOptions(getFpSvgJs(config), options.offlineMap, options.signal);
135
+ }
136
+ if (options.offlineMap) {
137
+ return await validateOfflineMapOptions(options.offlineMap, options.signal);
138
+ }
139
+ return undefined;
140
+ }
141
+ /** Build a complete offline ZIP archive from a manifest. */
142
+ export async function buildOfflineZip(manifest, options) {
143
+ const data = generateOfflineData(manifest, options);
144
+ const files = dataToFiles(data, { signal: options.signal });
145
+ return await buildZipArchive(files);
146
+ }
147
+ /** Known path — file lives in `packages/floorplan/public/` and is copied to dist by Vite. */
148
+ const COMPAT_HELPER = 'compat-helper.js';
149
+ function getIndexHtml(entry, manifestRef) {
150
+ // entry is "./runtime/index.js", derive the runtime base path
151
+ const runtimeBase = entry.slice(0, entry.lastIndexOf('/') + 1);
152
+ return `\
153
+ <!DOCTYPE html>
154
+ <script src="${runtimeBase}${COMPAT_HELPER}"></script>
155
+ <script type="module">
156
+ import { load } from ${JSON.stringify(entry)};
157
+ await load({ $ref: ${JSON.stringify(manifestRef)} });
158
+ </script>
159
+ `;
160
+ }
@@ -0,0 +1,20 @@
1
+ import type { MaplibreOptions } from '@expofp/schema';
2
+ import { type LocalData, type OfflineMapOptions } from './types.js';
3
+ type FpSvgContext = Record<string, unknown>;
4
+ export type ValidatedOfflineMapOptions = OfflineMapOptions & {
5
+ mapSource: string;
6
+ fpSvgContext?: FpSvgContext;
7
+ };
8
+ export interface OfflineMapResult {
9
+ files: AsyncIterable<LocalData>;
10
+ maplibre: MaplibreOptions;
11
+ }
12
+ export declare function generateOfflineMapData(options: ValidatedOfflineMapOptions & {
13
+ fpSvgContext: FpSvgContext;
14
+ signal?: AbortSignal;
15
+ }): OfflineMapResult;
16
+ export declare function validateOfflineMapOptions(options: OfflineMapOptions, signal?: AbortSignal): Promise<ValidatedOfflineMapOptions>;
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>;
19
+ export {};
20
+ //# sourceMappingURL=generate-offline-map-data.d.ts.map