Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ jobs:
- name: Verify browser build output
run: test -f dist/browser/index.browser.js

# Metro 0.87 requires Node >= 22.13
- name: Install Metro fixture dependencies
if: matrix.node-version == 22
run: npm ci --prefix test/rn-metro-check

- name: Verify browser build bundles with Metro (React Native)
if: matrix.node-version == 22
run: node scripts/verify-rn-bundle.js

- name: Upload coverage reports
uses: codecov/codecov-action@v3
with:
Expand Down
44 changes: 2 additions & 42 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
"format:check": "prettier --check \"src/**/*.{js,ts}\" \"test/**/*.{js,ts}\" \"*.{js,ts,json,md}\"",
"smoke:browser": "node scripts/smoke-browser-bundle.js",
"verify:browser-build": "node scripts/verify-browser-build.js",
"verify:rn-bundle": "npm run build:browser && node scripts/verify-rn-bundle.js",
"test": "npm run build && jest",
"test:watch": "npm run build && jest --watch",
"test:coverage": "npm run build && jest --coverage",
Expand Down Expand Up @@ -177,7 +178,6 @@
"typescript": "~5.5.0"
},
"dependencies": {
"@types/xml2js": "^0.4.14",
"adm-zip": "^0.5.16",
"axios": "^1.11.0",
"better-sqlite3": "^12.5.0",
Expand All @@ -187,7 +187,6 @@
"jszip": "^3.10.1",
"plist": "^3.1.0",
"sql.js": "^1.14.1",
"xml2js": "^0.6.2",
"yauzl": "^3.2.0"
},
"bin": {
Expand Down
14 changes: 14 additions & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,20 @@ General-purpose utility scripts.
- **image-map.js** - Image mapping and analysis tools
- **bench.js** - Run simple performance benchmarks for load/extract/process paths

### 🌐 Browser verification

- **verify-browser-build.js** - Confirms the compiled browser distribution
exists and is non-empty (`dist/browser/index.browser.js` and the shared
`dist/index.browser.d.ts`). Run with `npm run verify:browser-build`.
- **verify-rn-bundle.js** - Bundles a copy of `dist/browser` with the real Metro
bundler (isolated `test/rn-metro-check` fixture using `babel-preset-expo`) to
prove it works in React Native / Expo, including Node core modules reached
transitively through dependencies. Run with `npm run verify:rn-bundle`; Metro
is installed only in the fixture.
- **smoke-browser-bundle.js** - End-to-end web smoke test: builds the browser
bundle and the Vite demo (`examples/vitedemo`), then checks the output for
stray Node references. Run with `npm run smoke:browser`.

## 🚀 Usage

Most scripts can be run from the repository root:
Expand Down
86 changes: 86 additions & 0 deletions scripts/verify-rn-bundle.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use strict';

/**
* React Native (Metro/Hermes) bundler probe.
*
* Asks the actual React Native bundler to bundle the built browser
* distribution. Because it runs the real resolver, it also catches Node core
* modules pulled in transitively by dependencies (for example `xml2js`
* requiring `events`/`timers`), which a scan of our own compiled files cannot
* see.
*
* Metro is installed in the isolated fixture at test/rn-metro-check so it does
* not become a runtime dependency of the library. Run:
*
* npm run verify:rn-bundle # build + probe
* node scripts/verify-rn-bundle.js # probe an existing dist/browser
*
* The fixture is intentionally self-contained: `dist/browser` is copied to
* `test/rn-metro-check/lib` and only the library's browser-safe runtime
* dependencies are installed there, so Metro fails on a stray Node core module
* exactly as it would in a stock React Native / Expo app.
*/

const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');

const projectRoot = path.resolve(__dirname, '..');
const fixtureDir = path.join(projectRoot, 'test', 'rn-metro-check');
const browserDist = path.join(projectRoot, 'dist', 'browser');
const browserEntry = path.join(browserDist, 'index.browser.js');
const fixtureLib = path.join(fixtureDir, 'lib');
const outputFile = path.join(fixtureDir, 'rn-bundle-output.js');

function fail(message) {
console.error(`\nReact Native bundle verification failed.\n\n${message}\n`);
process.exit(1);
}

if (!fs.existsSync(browserEntry)) {
fail('Browser build not found. Run "npm run build:browser" first.');
}

const metroBin = path.join(
fixtureDir,
'node_modules',
'.bin',
process.platform === 'win32' ? 'metro.cmd' : 'metro'
);

if (!fs.existsSync(metroBin)) {
fail(
'Metro fixture dependencies are not installed.\n' +
`Run: npm --prefix ${path.relative(projectRoot, fixtureDir)} install`
);
}

function cleanup() {
fs.rmSync(fixtureLib, { recursive: true, force: true });
fs.rmSync(outputFile, { force: true });
}

try {
cleanup();
fs.cpSync(browserDist, fixtureLib, { recursive: true });

execFileSync(
metroBin,
['build', 'index.js', '--platform', 'ios', '--dev', 'true', '--out', outputFile],
{ cwd: fixtureDir, stdio: 'inherit' }
);
} catch (error) {
cleanup();
fail(
'Metro could not bundle dist/browser. The browser distribution is not ' +
'React Native / Expo compatible (see the Metro error above). This is ' +
'typically a Node core module such as `fs`, `path`, `events`, `stream`, ' +
'`timers` or `util` reached statically or through a dependency.'
);
} finally {
cleanup();
}

console.log(
'\nMetro successfully bundled the browser distribution — React Native / Expo compatible.'
);
41 changes: 41 additions & 0 deletions src/utils/xml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { XMLParser, XMLValidator } from 'fast-xml-parser';

const parser = new XMLParser({
ignoreAttributes: false,
attributesGroupName: '$',
attributeNamePrefix: '',
parseTagValue: false,
parseAttributeValue: false,
trimValues: false,
jPath: true,
isArray: (_name, jPath, _isLeafNode, isAttribute) => {
if (isAttribute) {
return false;
}
const path = typeof jPath === 'string' ? jPath : jPath.toString();
return path.includes('.');
},
});

export interface XmlParseError extends Error {
line?: number;
column?: number;
}

/**
* Parse an XML string, throwing on malformed input.
*
* @param xml XML document as a string
* @returns Parsed XML object (xml2js-compatible shape)
*/
export function parseXmlString(xml: string): any {
const validation = XMLValidator.validate(xml);
if (validation !== true) {
const err = (validation as { err?: { msg?: string; line?: number; col?: number } })?.err;
const error = new Error(err?.msg ? `Invalid XML: ${err.msg}` : 'Invalid XML') as XmlParseError;
error.line = err?.line;
error.column = err?.col;
throw error;
}
return parser.parse(xml);
}
16 changes: 5 additions & 11 deletions src/validation/gridsetValidator.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
/* eslint-disable @typescript-eslint/no-unsafe-return */
import * as xml2js from 'xml2js';
import { BaseValidator } from './baseValidator';
import { ValidationResult } from './validationTypes';
import {
Expand All @@ -11,6 +10,7 @@ import {
getBasename,
toUint8Array,
} from '../utils/io';
import { parseXmlString } from '../utils/xml';

/**
* Validator for Grid3/Smartbox Gridset files (.gridset, .gridsetx)
Expand Down Expand Up @@ -46,8 +46,7 @@ export class GridsetValidator extends BaseValidator {
// Try to parse as XML and check for a Grid 3 <Grid> root element
try {
const contentStr = typeof content === 'string' ? content : decodeText(toUint8Array(content));
const parser = new xml2js.Parser();
const result = await parser.parseStringPromise(contentStr);
const result = parseXmlString(contentStr);
return !!(result && (result.Grid || result.grid));
} catch {
return false;
Expand Down Expand Up @@ -112,9 +111,8 @@ export class GridsetValidator extends BaseValidator {
let xmlObj: any = null;
await this.add_check('xml_parse', 'valid XML', async () => {
try {
const parser = new xml2js.Parser();
const contentStr = decodeText(content).replace(/^\uFEFF/, '');
xmlObj = await parser.parseStringPromise(contentStr);
xmlObj = parseXmlString(contentStr);
} catch (e: any) {
this.err(`Failed to parse XML: ${e.message}`, true);
}
Expand Down Expand Up @@ -163,10 +161,7 @@ export class GridsetValidator extends BaseValidator {
await this.add_check(`grid_xml[${entry}]`, `grid structure: ${entry}`, async () => {
try {
const gridXml = await zip.readFile(entry);
const parser = new xml2js.Parser();
const xmlObj = await parser.parseStringPromise(
decodeText(toUint8Array(gridXml)).replace(/^\uFEFF/, '')
);
const xmlObj = parseXmlString(decodeText(toUint8Array(gridXml)).replace(/^\uFEFF/, ''));
const grid = xmlObj.Grid || xmlObj.grid;
if (!grid) {
this.err(`${entry}: missing root <Grid> element`);
Expand All @@ -189,8 +184,7 @@ export class GridsetValidator extends BaseValidator {
} else {
try {
const settingsXml = await zip.readFile(settingsEntry);
const parser = new xml2js.Parser();
const xmlObj = await parser.parseStringPromise(
const xmlObj = parseXmlString(
decodeText(toUint8Array(settingsXml)).replace(/^\uFEFF/, '')
);
const settings =
Expand Down
8 changes: 3 additions & 5 deletions src/validation/snapValidator.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/require-await */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import * as xml2js from 'xml2js';
import JSZip from 'jszip';
import { BaseValidator } from './baseValidator';
import { ValidationResult } from './validationTypes';
import { defaultFileAdapter, FileAdapter, getBasename, toUint8Array } from '../utils/io';
import { openSqliteDatabase } from '../utils/sqlite';
import { parseXmlString } from '../utils/xml';

/**
* Validator for Snap files (.spb, .sps)
Expand Down Expand Up @@ -278,8 +278,7 @@ export class SnapValidator extends BaseValidator {
await this.add_check('settings_format', 'settings file format', async () => {
try {
const content = await entry.async('string');
const parser = new xml2js.Parser();
const xml = await parser.parseStringPromise(content);
const xml = parseXmlString(content);

// Check for expected root element
if (!xml.settings && !xml.Settings && !xml.page && !xml.Page) {
Expand Down Expand Up @@ -309,8 +308,7 @@ export class SnapValidator extends BaseValidator {
await this.add_check(`page[${index}]`, `page file ${index}: ${entry.name}`, async () => {
try {
const content = await entry.async('string');
const parser = new xml2js.Parser();
const xml = await parser.parseStringPromise(content);
const xml = parseXmlString(content);

const page = xml.page || xml.Page;
if (!page) {
Expand Down
Loading