@expofp/offline 3.0.0-alpha.3
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/dist/cli.d.ts +3 -0
- package/dist/cli.js +66 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/lib/data-to-files.d.ts +8 -0
- package/dist/lib/data-to-files.js +27 -0
- package/dist/lib/download-offline-zip.d.ts +4 -0
- package/dist/lib/download-offline-zip.js +12 -0
- package/dist/lib/generate-offline-data-legacy.d.ts +6 -0
- package/dist/lib/generate-offline-data-legacy.js +102 -0
- package/dist/lib/generate-offline-data.d.ts +6 -0
- package/dist/lib/generate-offline-data.js +43 -0
- package/dist/lib/generate-runtime-files-data.d.ts +5 -0
- package/dist/lib/generate-runtime-files-data.js +16 -0
- package/dist/lib/offlinize-asset-url.d.ts +6 -0
- package/dist/lib/offlinize-asset-url.js +35 -0
- package/dist/lib/offlinize-assets-in-place.d.ts +6 -0
- package/dist/lib/offlinize-assets-in-place.js +139 -0
- package/dist/lib/offlinize-css-asset-text.d.ts +6 -0
- package/dist/lib/offlinize-css-asset-text.js +52 -0
- package/dist/lib/resolve-floorplan-dir.d.ts +8 -0
- package/dist/lib/resolve-floorplan-dir.js +34 -0
- package/dist/lib/save-offline-zip.d.ts +4 -0
- package/dist/lib/save-offline-zip.js +21 -0
- package/dist/lib/types.d.ts +14 -0
- package/dist/lib/types.js +1 -0
- package/package.json +38 -0
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
-h, --help Show this help message
|
|
17
|
+
-v, --version Show version number
|
|
18
|
+
`.trim();
|
|
19
|
+
async function main() {
|
|
20
|
+
const { values, positionals } = parseArgs({
|
|
21
|
+
allowPositionals: true,
|
|
22
|
+
options: {
|
|
23
|
+
output: { type: 'string', short: 'o', default: 'offline.zip' },
|
|
24
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
25
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
if (values.help) {
|
|
29
|
+
console.log(HELP);
|
|
30
|
+
process.exit(0);
|
|
31
|
+
}
|
|
32
|
+
if (values.version) {
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const pkg = require('../package.json');
|
|
35
|
+
console.log(pkg.version);
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
const manifestUrl = positionals[0];
|
|
39
|
+
if (!manifestUrl) {
|
|
40
|
+
console.error('Error: manifest URL is required.\n');
|
|
41
|
+
console.error(HELP);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
// Validate URL
|
|
45
|
+
try {
|
|
46
|
+
new URL(manifestUrl);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
console.error(`Error: "${manifestUrl}" is not a valid URL.\n`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
const outputPath = values.output;
|
|
53
|
+
if (!outputPath.endsWith('.zip')) {
|
|
54
|
+
console.error('Error: output path must end with .zip\n');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
console.log(`Resolving @expofp/floorplan runtime...`);
|
|
58
|
+
const runtimeBaseUrl = await resolveFloorplanDir();
|
|
59
|
+
console.log(`Creating offline ZIP from ${manifestUrl}...`);
|
|
60
|
+
await saveOfflineZip({ $ref: manifestUrl }, outputPath, { runtimeBaseUrl });
|
|
61
|
+
console.log(`Done! Offline ZIP saved to ${outputPath}`);
|
|
62
|
+
}
|
|
63
|
+
main().catch((err) => {
|
|
64
|
+
console.error('Error:', err instanceof Error ? err.message : err);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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,27 @@
|
|
|
1
|
+
import { readFileFromUrl } from '@expofp/utils';
|
|
2
|
+
import debug from 'debug';
|
|
3
|
+
const log = debug('efp:offline:data-to-files');
|
|
4
|
+
export async function* dataToFiles(data, options) {
|
|
5
|
+
for await (const file of data) {
|
|
6
|
+
if ('url' in file) {
|
|
7
|
+
const response = await readFileFromUrl(file.url, { signal: options?.signal });
|
|
8
|
+
if (!response.ok) {
|
|
9
|
+
console.warn(`Skipping file ${file.url} as it could not be downloaded`);
|
|
10
|
+
}
|
|
11
|
+
else {
|
|
12
|
+
log('Bundling file from url', file.targetFilePath);
|
|
13
|
+
yield {
|
|
14
|
+
path: file.targetFilePath,
|
|
15
|
+
data: response.data,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
log('Bundling text file', file.targetFilePath);
|
|
21
|
+
yield {
|
|
22
|
+
path: file.targetFilePath,
|
|
23
|
+
data: new TextEncoder().encode(file.text),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Manifest, Ref } from '@expofp/data';
|
|
2
|
+
import type { OfflineOptions } from './types.js';
|
|
3
|
+
export declare function downloadOfflineZip(manifest: Manifest | Ref<Manifest>, options: OfflineOptions): Promise<void>;
|
|
4
|
+
//# sourceMappingURL=download-offline-zip.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
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 a = document.createElement('a');
|
|
8
|
+
a.href = URL.createObjectURL(blob);
|
|
9
|
+
a.download = 'offline.zip';
|
|
10
|
+
a.click();
|
|
11
|
+
URL.revokeObjectURL(a.href);
|
|
12
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
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
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { resolve } from '@expofp/resolve';
|
|
2
|
+
import { deepClone, makeLocalPath, niceFetch } from '@expofp/utils';
|
|
3
|
+
import debug from 'debug';
|
|
4
|
+
import { offlinizeAssetsInPlace } from './offlinize-assets-in-place.js';
|
|
5
|
+
const log = debug('efp:offline:generate-offline-data-legacy');
|
|
6
|
+
export async function* generateOfflineDataLegacy(manifest, options) {
|
|
7
|
+
if (!manifest.legacyDataUrlBase) {
|
|
8
|
+
throw new Error('Manifest does not have legacyDataUrlBase');
|
|
9
|
+
}
|
|
10
|
+
const { signal } = options;
|
|
11
|
+
const legacyDataUrlBase = manifest.legacyDataUrlBase;
|
|
12
|
+
const version = await resolve(manifest.legacyDataVersion)
|
|
13
|
+
.then((json) => json?.version)
|
|
14
|
+
.catch(() => {
|
|
15
|
+
log('Could not resolve legacyDataVersion, proceeding without it');
|
|
16
|
+
return Date.now().toString();
|
|
17
|
+
});
|
|
18
|
+
const localDataUrlBase = await makeLocalPath(new URL(legacyDataUrlBase));
|
|
19
|
+
// data.js — execute in isolated vm context to extract __data
|
|
20
|
+
const dataCtx = await execScriptInSandbox(`${legacyDataUrlBase}data.js?v=${version}`, signal);
|
|
21
|
+
const data = deepClone(dataCtx.__data);
|
|
22
|
+
yield* offlinizeDataJs(data, legacyDataUrlBase, signal);
|
|
23
|
+
// Asset paths in data are absolute (e.g. "https-demo-expofp-com/data/exhibitors/...").
|
|
24
|
+
// The runtime resolves them relative to legacyDataUrlBase, so strip that prefix
|
|
25
|
+
// to avoid doubling (e.g. "base/base/exhibitors/...").
|
|
26
|
+
stripPathPrefix(data, localDataUrlBase);
|
|
27
|
+
yield {
|
|
28
|
+
text: `__data = ${JSON.stringify(data, null, 2)};`,
|
|
29
|
+
targetFilePath: `${localDataUrlBase}data.js`,
|
|
30
|
+
};
|
|
31
|
+
// wf.data.js
|
|
32
|
+
const wfDataUrl = `${legacyDataUrlBase}wf.data.js?v=${version}`;
|
|
33
|
+
yield { url: new URL(wfDataUrl), targetFilePath: `${localDataUrlBase}wf.data.js` };
|
|
34
|
+
// fp.svg.js — execute in isolated vm context to extract __fpLayers
|
|
35
|
+
const svgCtx = await execScriptInSandbox(`${legacyDataUrlBase}fp.svg.js?v=${version}`, signal);
|
|
36
|
+
yield {
|
|
37
|
+
url: new URL(`${legacyDataUrlBase}fp.svg.js?v=${version}`),
|
|
38
|
+
targetFilePath: `${localDataUrlBase}fp.svg.js`,
|
|
39
|
+
};
|
|
40
|
+
// fp.svg.{layer}.js
|
|
41
|
+
const fpLayers = svgCtx.__fpLayers || [];
|
|
42
|
+
for (const layer of fpLayers) {
|
|
43
|
+
const layerFile = `fp.svg.${layer.name}.js`;
|
|
44
|
+
const layerUrl = `${legacyDataUrlBase}${layerFile}?v=${version}`;
|
|
45
|
+
yield { url: new URL(layerUrl), targetFilePath: `${localDataUrlBase}${layerFile}` };
|
|
46
|
+
}
|
|
47
|
+
manifest.legacyDataUrlBase = localDataUrlBase;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Fetch a script and execute it in an isolated Node.js vm context.
|
|
51
|
+
* Returns the sandbox object containing any globals the script set.
|
|
52
|
+
* Uses dynamic import so the module can still be loaded in browser bundles
|
|
53
|
+
* (the function itself is Node-only).
|
|
54
|
+
*/
|
|
55
|
+
async function execScriptInSandbox(url, signal) {
|
|
56
|
+
const response = await niceFetch(url, { signal });
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`Failed to fetch script (HTTP ${response.status}): ${url}`);
|
|
59
|
+
}
|
|
60
|
+
const code = await response.text();
|
|
61
|
+
const { runInNewContext } = await import('node:vm');
|
|
62
|
+
const sandbox = {};
|
|
63
|
+
sandbox.window = sandbox; // legacy scripts expect window === globalThis
|
|
64
|
+
runInNewContext(code, sandbox);
|
|
65
|
+
return sandbox;
|
|
66
|
+
}
|
|
67
|
+
/** Strip a path prefix from all string values in a data tree (in place). */
|
|
68
|
+
function stripPathPrefix(node, prefix) {
|
|
69
|
+
if (Array.isArray(node)) {
|
|
70
|
+
for (let i = 0; i < node.length; i++) {
|
|
71
|
+
if (typeof node[i] === 'string' && node[i].startsWith(prefix)) {
|
|
72
|
+
node[i] = node[i].slice(prefix.length);
|
|
73
|
+
}
|
|
74
|
+
else if (node[i] && typeof node[i] === 'object') {
|
|
75
|
+
stripPathPrefix(node[i], prefix);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (!node || typeof node !== 'object') {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const obj = node;
|
|
84
|
+
for (const key of Object.keys(obj)) {
|
|
85
|
+
const val = obj[key];
|
|
86
|
+
if (typeof val === 'string' && val.startsWith(prefix)) {
|
|
87
|
+
obj[key] = val.slice(prefix.length);
|
|
88
|
+
}
|
|
89
|
+
else if (val && typeof val === 'object') {
|
|
90
|
+
stripPathPrefix(val, prefix);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
async function* offlinizeDataJs(data, legacyDataUrlBase, signal) {
|
|
95
|
+
if (data.customCss) {
|
|
96
|
+
console.warn('data.customCss is deprecated, renaming to data.customCssAssetText');
|
|
97
|
+
data.customCssAssetText = data.customCss;
|
|
98
|
+
delete data.customCss;
|
|
99
|
+
}
|
|
100
|
+
const fetches = await offlinizeAssetsInPlace(data, { legacyDataUrlBase, signal });
|
|
101
|
+
yield* fetches;
|
|
102
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Manifest, Ref } from '@expofp/data';
|
|
2
|
+
import type { LocalData, OfflineOptions } from './types.js';
|
|
3
|
+
export declare function generateOfflineData(inputManifest: Manifest | Ref<Manifest>, options: OfflineOptions): AsyncIterable<LocalData, void>;
|
|
4
|
+
/** Build a complete offline ZIP archive from a manifest. */
|
|
5
|
+
export declare function buildOfflineZip(manifest: Manifest | Ref<Manifest>, options: OfflineOptions): Promise<Blob>;
|
|
6
|
+
//# sourceMappingURL=generate-offline-data.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { resolve } from '@expofp/resolve';
|
|
2
|
+
import { buildZipArchive, deepClone } from '@expofp/utils';
|
|
3
|
+
import debug from 'debug';
|
|
4
|
+
import { dataToFiles } from './data-to-files.js';
|
|
5
|
+
import { generateOfflineDataLegacy } from './generate-offline-data-legacy.js';
|
|
6
|
+
import { generateRuntimeFilesData } from './generate-runtime-files-data.js';
|
|
7
|
+
import { offlinizeAssetsInPlace } from './offlinize-assets-in-place.js';
|
|
8
|
+
const log = debug('efp:offline:generate-offline-data');
|
|
9
|
+
export async function* generateOfflineData(inputManifest, options) {
|
|
10
|
+
const manifest = deepClone(await resolve(inputManifest));
|
|
11
|
+
log(`Generating offline files for expo: ${manifest.expo}`);
|
|
12
|
+
if (manifest.legacyDataUrlBase) {
|
|
13
|
+
yield* generateOfflineDataLegacy(manifest, options);
|
|
14
|
+
}
|
|
15
|
+
// transform entire manifest + assets
|
|
16
|
+
const data = await offlinizeAssetsInPlace(manifest, options);
|
|
17
|
+
for (const d of data) {
|
|
18
|
+
yield d;
|
|
19
|
+
}
|
|
20
|
+
// add runtime files
|
|
21
|
+
const { entry } = yield* generateRuntimeFilesData(options.runtimeBaseUrl);
|
|
22
|
+
// generate index.html
|
|
23
|
+
yield {
|
|
24
|
+
text: getIndexHtml(entry, manifest),
|
|
25
|
+
targetFilePath: './index.html',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Build a complete offline ZIP archive from a manifest. */
|
|
29
|
+
export async function buildOfflineZip(manifest, options) {
|
|
30
|
+
const data = generateOfflineData(manifest, options);
|
|
31
|
+
const files = dataToFiles(data, { signal: options.signal });
|
|
32
|
+
return await buildZipArchive(files);
|
|
33
|
+
}
|
|
34
|
+
function getIndexHtml(entry, manifest) {
|
|
35
|
+
return `\
|
|
36
|
+
<!DOCTYPE html>
|
|
37
|
+
<script type="module">
|
|
38
|
+
import { load } from ${JSON.stringify(entry)};
|
|
39
|
+
await load(${JSON.stringify(manifest)});
|
|
40
|
+
console.info('loaded');
|
|
41
|
+
</script>
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { importJsonModule } from '@expofp/utils';
|
|
2
|
+
import debug from 'debug';
|
|
3
|
+
const log = debug('efp:offline:generate-runtime-files-data');
|
|
4
|
+
export async function* generateRuntimeFilesData(runtimeBaseUrl) {
|
|
5
|
+
const bundleJsonUrl = new URL('bundle.json', runtimeBaseUrl).href;
|
|
6
|
+
const bundleJson = await importJsonModule(bundleJsonUrl);
|
|
7
|
+
log(`Generating runtime files from ${bundleJsonUrl}, ${bundleJson.files.length} files found`);
|
|
8
|
+
const basePath = './runtime/';
|
|
9
|
+
for (const file of bundleJson.files) {
|
|
10
|
+
yield {
|
|
11
|
+
url: new URL(file.file, runtimeBaseUrl),
|
|
12
|
+
targetFilePath: `${basePath}${file.file}`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
return { entry: `${basePath}${bundleJson.entry}` };
|
|
16
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { makeLocalPath, niceFetch } 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
|
+
// TODO: add AbortSignal support
|
|
13
|
+
export async function offlinizeAssetsInPlace(data, opts) {
|
|
14
|
+
const { legacyDataUrlBase } = opts;
|
|
15
|
+
const fetches = [];
|
|
16
|
+
const fetchedUrls = new Map(); // targetPath to LocalData
|
|
17
|
+
const mergeFetches = (newFetches) => {
|
|
18
|
+
// push only new fetches
|
|
19
|
+
// error if duplicate targetFilePath with different content
|
|
20
|
+
for (const nf of newFetches) {
|
|
21
|
+
const existing = fetchedUrls.get(nf.targetFilePath);
|
|
22
|
+
// test only if both have url (i.e., are not JSON)
|
|
23
|
+
if (existing && 'url' in nf && 'url' in existing) {
|
|
24
|
+
const isSame = existing.url.href === nf.url.href;
|
|
25
|
+
if (!isSame) {
|
|
26
|
+
throw new Error(`Conflicting fetch for targetFilePath ${nf.targetFilePath}: ${existing.url.href} vs ${nf.url.href}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
fetchedUrls.set(nf.targetFilePath, nf);
|
|
31
|
+
fetches.push(nf);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const processedRefs = new Map(); // Cache for $ref documents
|
|
36
|
+
const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
37
|
+
const toAbsoluteUrl = (value) => legacyDataUrlBase ? new URL(value, legacyDataUrlBase) : new URL(value);
|
|
38
|
+
const enableLegacy = !!legacyDataUrlBase;
|
|
39
|
+
async function walk(node) {
|
|
40
|
+
if (Array.isArray(node)) {
|
|
41
|
+
for (let i = 0; i < node.length; i++) {
|
|
42
|
+
const el = node[i];
|
|
43
|
+
if (el && typeof el === 'object') {
|
|
44
|
+
await walk(el);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!isPlainObject(node))
|
|
50
|
+
return;
|
|
51
|
+
// Handle $ref field
|
|
52
|
+
if ('$ref' in node && typeof node.$ref === 'string') {
|
|
53
|
+
const refValue = node.$ref;
|
|
54
|
+
if (refValue) {
|
|
55
|
+
const url = toAbsoluteUrl(refValue);
|
|
56
|
+
const fragment = url.hash; // Preserve JSON pointer fragment
|
|
57
|
+
// Create URL without fragment for fetching
|
|
58
|
+
const urlWithoutFragment = new URL(url.href);
|
|
59
|
+
urlWithoutFragment.hash = '';
|
|
60
|
+
const urlKey = urlWithoutFragment.href;
|
|
61
|
+
const targetFilePath = await makeLocalPath(urlWithoutFragment);
|
|
62
|
+
// Update $ref to local path with preserved fragment
|
|
63
|
+
node.$ref = targetFilePath + fragment;
|
|
64
|
+
// Check if we've already processed this $ref URL
|
|
65
|
+
if (!processedRefs.has(urlKey)) {
|
|
66
|
+
// Mark as fetched to prevent duplicates
|
|
67
|
+
log(`Offlinizing $ref: ${refValue} -> ${targetFilePath}${fragment}`);
|
|
68
|
+
// Fetch and recursively process the referenced document
|
|
69
|
+
const response = await niceFetch(urlKey, { signal: opts.signal });
|
|
70
|
+
if (response.ok) {
|
|
71
|
+
const referencedData = await response.json();
|
|
72
|
+
processedRefs.set(urlKey, referencedData);
|
|
73
|
+
log(`Processing referenced document: ${urlKey}`);
|
|
74
|
+
// Recursively offlinize assets in the referenced document
|
|
75
|
+
const refFetches = await offlinizeAssetsInPlace(referencedData, opts);
|
|
76
|
+
fetches.push(...refFetches);
|
|
77
|
+
// Store the transformed referenced data as a separate JSON file
|
|
78
|
+
fetches.push({
|
|
79
|
+
targetFilePath,
|
|
80
|
+
text: JSON.stringify(referencedData, null, 2),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
throw new Error(`Failed to fetch $ref URL: ${urlKey}, status: ${response.status}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
log(`Skipping already processed $ref: ${refValue}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (const key of Object.keys(node)) {
|
|
93
|
+
const value = node[key];
|
|
94
|
+
// CSS asset text handling
|
|
95
|
+
if (typeof value === 'string' &&
|
|
96
|
+
(key.endsWith(CSS_ASSET_TEXT_SUFFIX) || key === LEGACY_CSS_ASSET_TEXT_FIELD)) {
|
|
97
|
+
const { css, assets } = await offlinizeCssAssetText(value);
|
|
98
|
+
node[key] = css;
|
|
99
|
+
mergeFetches(assets);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// URL asset handling
|
|
103
|
+
if (typeof value === 'string' && key.endsWith(ASSET_URL_SUFFIX)) {
|
|
104
|
+
const { url: localUrl, assets } = await offlinizeAssetUrl(toAbsoluteUrl(value));
|
|
105
|
+
node[key] = localUrl;
|
|
106
|
+
mergeFetches(assets);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
// Legacy asset URL fields handling
|
|
110
|
+
if (enableLegacy && LEGACY_ASSET_URL_FIELDS.includes(key) && typeof value === 'string') {
|
|
111
|
+
let variants = [];
|
|
112
|
+
if (key === 'logo' || (value.includes('exhibitor/') && value.includes('/media/'))) {
|
|
113
|
+
variants = LEGACY_EXHIBITOR_LOGO_VARIANTS;
|
|
114
|
+
}
|
|
115
|
+
const { url: localUrl, assets } = await offlinizeAssetUrl(toAbsoluteUrl(value), variants);
|
|
116
|
+
node[key] = localUrl;
|
|
117
|
+
mergeFetches(assets);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// Legacy asset URL fields handling for arrays
|
|
121
|
+
if (enableLegacy && LEGACY_ASSET_URL_FIELDS.includes(key) && Array.isArray(value)) {
|
|
122
|
+
for (let i = 0; i < value.length; i++) {
|
|
123
|
+
const el = value[i];
|
|
124
|
+
if (typeof el === 'string') {
|
|
125
|
+
const { url: localUrl, assets } = await offlinizeAssetUrl(toAbsoluteUrl(el));
|
|
126
|
+
value[i] = localUrl;
|
|
127
|
+
mergeFetches(assets);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (value && typeof value === 'object') {
|
|
133
|
+
await walk(value);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
await walk(data);
|
|
138
|
+
return fetches;
|
|
139
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Locates the `@expofp/floorplan` browser build directory on disk and
|
|
3
|
+
* returns a `file://` URL string (with trailing slash) pointing at it.
|
|
4
|
+
*
|
|
5
|
+
* Throws if the package or its `bundle.json` cannot be found.
|
|
6
|
+
*/
|
|
7
|
+
export declare function resolveFloorplanDir(): Promise<string>;
|
|
8
|
+
//# sourceMappingURL=resolve-floorplan-dir.d.ts.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { importFsPromises } from '@expofp/utils';
|
|
5
|
+
/**
|
|
6
|
+
* Locates the `@expofp/floorplan` browser build directory on disk and
|
|
7
|
+
* returns a `file://` URL string (with trailing slash) pointing at it.
|
|
8
|
+
*
|
|
9
|
+
* Throws if the package or its `bundle.json` cannot be found.
|
|
10
|
+
*/
|
|
11
|
+
export async function resolveFloorplanDir() {
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
let pkgJsonPath;
|
|
14
|
+
try {
|
|
15
|
+
pkgJsonPath = require.resolve('@expofp/floorplan/package.json');
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Error('Could not find @expofp/floorplan. ' +
|
|
19
|
+
'Ensure it is installed (it should be a dependency of @expofp/offline).');
|
|
20
|
+
}
|
|
21
|
+
const pkgDir = path.dirname(pkgJsonPath);
|
|
22
|
+
const browserDir = path.join(pkgDir, 'dist', 'browser');
|
|
23
|
+
const bundleJsonPath = path.join(browserDir, 'bundle.json');
|
|
24
|
+
const fs = await importFsPromises();
|
|
25
|
+
try {
|
|
26
|
+
await fs.access(bundleJsonPath);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error(`Found @expofp/floorplan at ${pkgDir} but dist/browser/bundle.json is missing. ` +
|
|
30
|
+
'Ensure the package has been built (pnpm nx build-browser @expofp/floorplan).');
|
|
31
|
+
}
|
|
32
|
+
// Return file:// URL with trailing slash so new URL(file, base) works
|
|
33
|
+
return pathToFileURL(browserDir).href + '/';
|
|
34
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Manifest, Ref } from '@expofp/data';
|
|
2
|
+
import type { OfflineOptions } from './types.js';
|
|
3
|
+
export declare function saveOfflineZip(manifest: Manifest | Ref<Manifest>, path: string, options: OfflineOptions): Promise<void>;
|
|
4
|
+
//# sourceMappingURL=save-offline-zip.d.ts.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { importFsPromises } from '@expofp/utils';
|
|
2
|
+
import debug from 'debug';
|
|
3
|
+
import { buildOfflineZip } from './generate-offline-data.js';
|
|
4
|
+
const log = debug('efp:offline:save-offline-zip');
|
|
5
|
+
export async function saveOfflineZip(manifest, path, options) {
|
|
6
|
+
if (typeof process === 'undefined' || !process.versions?.node) {
|
|
7
|
+
throw new Error('saveOfflineZip can only be used in a Node.js environment');
|
|
8
|
+
}
|
|
9
|
+
if (!path) {
|
|
10
|
+
throw new Error('Path is required to save the ZIP file');
|
|
11
|
+
}
|
|
12
|
+
if (!path.endsWith('.zip')) {
|
|
13
|
+
throw new Error('The specified path must end with .zip');
|
|
14
|
+
}
|
|
15
|
+
log('Saving offline ZIP bundle to', path);
|
|
16
|
+
const blob = await buildOfflineZip(manifest, options);
|
|
17
|
+
const arrayBuffer = await blob.arrayBuffer();
|
|
18
|
+
const fs = await importFsPromises();
|
|
19
|
+
await fs.writeFile(path, Buffer.from(arrayBuffer));
|
|
20
|
+
log('Saved offline ZIP bundle to', path);
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export type LocalData = LocalDataFetch | LocalDataText;
|
|
2
|
+
export interface LocalDataFetch {
|
|
3
|
+
url: URL;
|
|
4
|
+
targetFilePath: string;
|
|
5
|
+
}
|
|
6
|
+
export interface LocalDataText {
|
|
7
|
+
text: string;
|
|
8
|
+
targetFilePath: string;
|
|
9
|
+
}
|
|
10
|
+
export interface OfflineOptions {
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
runtimeBaseUrl: string;
|
|
13
|
+
}
|
|
14
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@expofp/offline",
|
|
3
|
+
"version": "3.0.0-alpha.3",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "CLI tool for creating offline copies of ExpoFP floor plans",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"main": "./dist/index.js",
|
|
11
|
+
"module": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
"./package.json": "./package.json",
|
|
15
|
+
".": {
|
|
16
|
+
"@expofp/source": "./src/index.ts",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"bin": {
|
|
23
|
+
"expofp-offline": "./dist/cli.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist",
|
|
27
|
+
"!**/*.tsbuildinfo",
|
|
28
|
+
"!**/*.map"
|
|
29
|
+
],
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"debug": "^4.4.3",
|
|
32
|
+
"tslib": "^2.3.0",
|
|
33
|
+
"@expofp/data": "3.0.0-alpha.3",
|
|
34
|
+
"@expofp/floorplan": "3.0.0-alpha.3",
|
|
35
|
+
"@expofp/resolve": "3.0.0-alpha.3",
|
|
36
|
+
"@expofp/utils": "3.0.0-alpha.3"
|
|
37
|
+
}
|
|
38
|
+
}
|