@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 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,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
+ });
@@ -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,8 @@
1
+ import type { LocalData } from './types.js';
2
+ export declare function dataToFiles(data: AsyncIterable<LocalData>, options?: {
3
+ signal?: AbortSignal;
4
+ }): AsyncIterable<{
5
+ path: string;
6
+ data: Uint8Array;
7
+ }>;
8
+ //# sourceMappingURL=data-to-files.d.ts.map
@@ -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,5 @@
1
+ import type { LocalDataFetch } from './types.js';
2
+ export declare function generateRuntimeFilesData(runtimeBaseUrl: string): AsyncGenerator<LocalDataFetch, {
3
+ entry: string;
4
+ }, void>;
5
+ //# sourceMappingURL=generate-runtime-files-data.d.ts.map
@@ -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,6 @@
1
+ import type { LocalDataFetch } from './types.js';
2
+ export declare function offlinizeAssetUrl(url: URL, variants?: string[]): Promise<{
3
+ url: string;
4
+ assets: LocalDataFetch[];
5
+ }>;
6
+ //# sourceMappingURL=offlinize-asset-url.d.ts.map
@@ -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,6 @@
1
+ import type { LocalData } from './types.js';
2
+ export declare function offlinizeAssetsInPlace(data: unknown, opts: {
3
+ legacyDataUrlBase?: string;
4
+ signal?: AbortSignal;
5
+ }): Promise<LocalData[]>;
6
+ //# sourceMappingURL=offlinize-assets-in-place.d.ts.map
@@ -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,6 @@
1
+ import type { LocalDataFetch } from './types.js';
2
+ export declare function offlinizeCssAssetText(css: string): Promise<{
3
+ css: string;
4
+ assets: LocalDataFetch[];
5
+ }>;
6
+ //# sourceMappingURL=offlinize-css-asset-text.d.ts.map
@@ -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
+ }