Skip to content

Commit b9e563f

Browse files
committed
feat(P1-4): add self-contained bundled HTML for Python package distribution
Build a single jscircuit.html (192 KB) with all JS and PNG assets inlined as Base64 data-URLs, requiring no external files or server. - Add assetMap.js: static import map resolved at build time by esbuild - Refactor getImagePath to use ASSET_MAP lookup instead of runtime detection - Add bundle-html.mjs: produces IIFE bundle inlined into HTML template - Add build:standalone npm script for distribution builds - Add png-loader.mjs: custom ESM loader so tests handle .png imports - Add .DS_Store to .gitignore Acceptance: python -m http.server serves the single file; includable in qucat wheel.
1 parent 4d40331 commit b9e563f

6 files changed

Lines changed: 215 additions & 60 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ docs/dist/
1313
# Auto-generated configuration (built from gui.config.yaml)
1414
src/config/gui.config.js
1515

16-
compile.txt
16+
compile.txt.DS_Store

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
"description": "A web-based circuit editor for designing circuits and exporting netlists.",
55
"main": "src/gui/main.js",
66
"scripts": {
7-
"test": "npx mocha 'tests/**/*.test.js' --require ./tests/setup.js",
8-
"bundle": "esbuild src/gui/main.js --minify --format=esm --bundle --outdir=dist/static --asset-names=assets/[name] --loader:.png=dataurl --loader:.jpg=dataurl",
7+
"test": "npx mocha 'tests/**/*.test.js' --require ./tests/setup.js --node-option loader=./tests/png-loader.mjs",
8+
"bundle": "esbuild src/gui/main.js --minify --format=esm --bundle --outdir=dist/static --loader:.png=dataurl --loader:.jpg=dataurl",
9+
"bundle:html": "node scripts/bundle-html.mjs",
910
"copy": "cp -r assets dist/assets && cp src/gui/gui.html dist/jscircuit.html && rm -f dist/gui.html",
1011
"menu:build": "node scripts/build-menu-config.mjs",
1112
"build": "npm run menu:build && npm run bundle && npm run copy && npm run menu:build",
13+
"build:standalone": "npm run menu:build && npm run bundle:html",
1214
"serve": "npm run build && http-server -c-1",
1315
"docs:clean": "rm -rf docs/dist dist/docs_temp",
1416
"docs:build": "npm run docs:clean && npm run build && node scripts/sync-readme.mjs && jsdoc -c docs/jsdoc.conf.json -t node_modules/docdash && mkdir -p docs/dist && cp -r dist/docs_temp/* docs/dist/ && rm -rf dist/docs_temp && cp -r dist docs/dist/app && cp docs/custom-styles.css docs/dist/ && cp docs/qucat-logo.png docs/dist/ && cp docs/scripts/insert-logo.js docs/dist/scripts/ && cp -r assets docs/dist/assets && node scripts/inject-css.mjs",

scripts/bundle-html.mjs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
#!/usr/bin/env node
2+
/**
3+
* @file bundle-html.mjs
4+
* @description
5+
* Produces a single, self-contained `dist/jscircuit.html` with:
6+
* - All CSS inlined in <style> (already in gui.html)
7+
* - The esbuild bundle inlined in <script> (replaces the external src)
8+
* - All PNG assets inlined as Base64 data-URLs inside the JS bundle
9+
*
10+
* Usage: node scripts/bundle-html.mjs
11+
* Output: dist/jscircuit.html (single file, no external dependencies)
12+
*/
13+
14+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
15+
import { execSync } from 'child_process';
16+
import { dirname, resolve } from 'path';
17+
import { fileURLToPath } from 'url';
18+
19+
const __dirname = dirname(fileURLToPath(import.meta.url));
20+
const ROOT = resolve(__dirname, '..');
21+
22+
// ── 1. Run esbuild to produce an IIFE bundle with all assets inlined ──
23+
console.log('⏳ Bundling JS with esbuild (assets inlined as data-URLs)…');
24+
25+
execSync(
26+
[
27+
'npx esbuild src/gui/main.js',
28+
'--bundle',
29+
'--minify',
30+
'--format=iife', // IIFE so we can inline in a plain <script>
31+
'--loader:.png=dataurl',
32+
'--loader:.jpg=dataurl',
33+
'--outfile=dist/_bundle.js',
34+
].join(' '),
35+
{ cwd: ROOT, stdio: 'inherit' }
36+
);
37+
38+
const bundleJS = readFileSync(resolve(ROOT, 'dist/_bundle.js'), 'utf-8');
39+
40+
// ── 2. Read the HTML template ──────────────────────────────────────────
41+
const htmlTemplate = readFileSync(resolve(ROOT, 'src/gui/gui.html'), 'utf-8');
42+
43+
// ── 3. Replace the external <script> tag with an inline <script> ──────
44+
// Original: <script type="module" src="./static/main.js"></script>
45+
// Replace: <script>…bundled code…</script>
46+
const scriptTagRe = /<script\s+type="module"\s+src="[^"]*"><\/script>/;
47+
48+
if (!scriptTagRe.test(htmlTemplate)) {
49+
console.error('❌ Could not find the <script type="module" src="…"> tag in gui.html');
50+
process.exit(1);
51+
}
52+
53+
const selfContainedHTML = htmlTemplate.replace(
54+
scriptTagRe,
55+
`<script>\n${bundleJS}\n</script>`
56+
);
57+
58+
// ── 4. Write output ────────────────────────────────────────────────────
59+
mkdirSync(resolve(ROOT, 'dist'), { recursive: true });
60+
writeFileSync(resolve(ROOT, 'dist/jscircuit.html'), selfContainedHTML, 'utf-8');
61+
62+
// Clean up temp bundle
63+
const { unlinkSync } = await import('fs');
64+
try { unlinkSync(resolve(ROOT, 'dist/_bundle.js')); } catch { /* ignore */ }
65+
66+
const sizeKB = (Buffer.byteLength(selfContainedHTML, 'utf-8') / 1024).toFixed(1);
67+
console.log(`✅ dist/jscircuit.html (${sizeKB} KB — fully self-contained)`);

src/utils/assetMap.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* @file assetMap.js
3+
* @description
4+
* Static asset map for circuit element images.
5+
*
6+
* Each import is resolved **at bundle time** by esbuild (with `--loader:.png=dataurl`),
7+
* so the resulting bundle contains Base64 data-URLs and needs no external `/assets/` folder.
8+
*
9+
* Only circuit element images are included here — documentation-only assets
10+
* (logo.png, example-challenge.png) are excluded.
11+
*/
12+
13+
// ── Capacitor ──────────────────────────────────────────────
14+
import C from '../../assets/C.png';
15+
import C_hover from '../../assets/C_hover.png';
16+
import C_selected from '../../assets/C_selected.png';
17+
import C_hover_selected from '../../assets/C_hover_selected.png';
18+
19+
// ── Ground ─────────────────────────────────────────────────
20+
import G from '../../assets/G.png';
21+
import G_hover from '../../assets/G_hover.png';
22+
import G_selected from '../../assets/G_selected.png';
23+
import G_hover_selected from '../../assets/G_hover_selected.png';
24+
25+
// ── Junction ───────────────────────────────────────────────
26+
import J from '../../assets/J.png';
27+
import J_hover from '../../assets/J_hover.png';
28+
import J_selected from '../../assets/J_selected.png';
29+
import J_hover_selected from '../../assets/J_hover_selected.png';
30+
31+
// ── Inductor ───────────────────────────────────────────────
32+
import L from '../../assets/L.png';
33+
import L_hover from '../../assets/L_hover.png';
34+
import L_selected from '../../assets/L_selected.png';
35+
import L_hover_selected from '../../assets/L_hover_selected.png';
36+
37+
// ── Resistor ───────────────────────────────────────────────
38+
import R from '../../assets/R.png';
39+
import R_hover from '../../assets/R_hover.png';
40+
import R_selected from '../../assets/R_selected.png';
41+
import R_hover_selected from '../../assets/R_hover_selected.png';
42+
43+
/**
44+
* Lookup table: `ASSET_MAP[prefix][variant]` → data-URL string (in bundle)
45+
* or import path (resolved by the bundler).
46+
*
47+
* Prefix is the single-letter image prefix (C, G, J, L, R).
48+
* Variant is one of: default, hover, selected, hover_selected.
49+
*/
50+
export const ASSET_MAP = {
51+
C: { default: C, hover: C_hover, selected: C_selected, hover_selected: C_hover_selected },
52+
G: { default: G, hover: G_hover, selected: G_selected, hover_selected: G_hover_selected },
53+
J: { default: J, hover: J_hover, selected: J_selected, hover_selected: J_hover_selected },
54+
L: { default: L, hover: L_hover, selected: L_selected, hover_selected: L_hover_selected },
55+
R: { default: R, hover: R_hover, selected: R_selected, hover_selected: R_hover_selected },
56+
};

src/utils/getImagePath.js

Lines changed: 53 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,82 +2,78 @@
22
* @file getImagePath.js
33
* @description
44
* Resolves image path for circuit element icons based on type and UI variant.
5-
* Compatible with browser (using import.meta.url), Node test environments (via `mock` flag),
6-
* and Jupyter Widget environments (using conditional imports).
5+
*
6+
* In **bundled** mode the images are resolved from a static asset map that
7+
* esbuild inlines as Base64 data-URLs — no external `/assets/` folder needed.
8+
*
9+
* In **test / Node** mode (when `mock: true`) a plain path string is returned
10+
* so unit tests keep working without a bundler.
711
*
812
* @example
9-
* getImagePath("resistor") // → file:///.../R.png (browser) or imported asset (jupyter)
10-
* getImagePath("resistor", "hover") // → file:///.../R_hover.png or imported asset
11-
* getImagePath("resistor", "hover", { mock: true }) // → /assets/R_hover.png (tests)
13+
* getImagePath("resistor") // → data:image/png;base64,…
14+
* getImagePath("resistor", "hover") // → data:image/png;base64,…
15+
* getImagePath("resistor", "hover", { mock: true }) // → /assets/R_hover.png
1216
*/
1317

1418
import { ElementRegistry } from '../domain/factories/ElementRegistry.js';
19+
import { ASSET_MAP } from './assetMap.js';
1520

1621
/**
17-
* Detects if we're running in Node.js environment
22+
* Map a registered element type to its single-letter image prefix.
23+
* @param {string} type
24+
* @returns {string}
1825
*/
19-
function isNode() {
20-
return typeof process !== 'undefined' && process.versions && process.versions.node;
26+
function prefixForType(type) {
27+
const registeredTypes = ElementRegistry.getTypes();
28+
const typeMap = {};
29+
30+
registeredTypes.forEach(registeredType => {
31+
let prefix;
32+
switch (registeredType.toLowerCase()) {
33+
case 'inductor':
34+
prefix = 'L';
35+
break;
36+
default:
37+
prefix = registeredType.charAt(0).toUpperCase();
38+
}
39+
typeMap[registeredType.toLowerCase()] = prefix;
40+
});
41+
42+
return typeMap[type.toLowerCase()] || type.charAt(0).toUpperCase();
2143
}
2244

2345
/**
2446
* Resolves image path based on circuit element type and optional UI variant.
25-
* Automatically detects environment and uses appropriate loading strategy.
26-
* Gets the image prefix from ElementRegistry to avoid hardcoded mappings.
2747
*
2848
* @param {string} type - Element type (e.g., "resistor", "capacitor").
29-
* @param {string} [variant="default"] - UI variant (e.g., "hover", "selected").
49+
* @param {string} [variant="default"] - UI variant: "default", "hover", "selected", "hover_selected".
3050
* @param {Object} [options]
31-
* @param {boolean} [options.mock=false] - If true, returns a simplified mock path for test environments.
32-
* @returns {string|Promise<string>} Path to the asset.
51+
* @param {boolean} [options.mock=false] - If true, returns a simplified mock path (for tests).
52+
* @returns {string|Promise<string>} Resolved data-URL or path string.
3353
*/
3454
export async function getImagePath(type, variant = "default", { mock = false } = {}) {
35-
if (!type || typeof type !== "string") {
36-
throw new Error("Invalid or unknown type");
37-
}
38-
39-
// Build mapping from ElementRegistry types to image prefixes
40-
const registeredTypes = ElementRegistry.getTypes();
41-
const typeMap = {};
42-
43-
// Create mapping based on registered types
44-
registeredTypes.forEach(registeredType => {
45-
// Use a simple convention: first character uppercase for most types
46-
// Special cases can be handled as needed
47-
let prefix;
48-
switch (registeredType.toLowerCase()) {
49-
case 'inductor':
50-
prefix = 'L'; // Inductor uses L, not I
51-
break;
52-
default:
53-
prefix = registeredType.charAt(0).toUpperCase();
55+
if (!type || typeof type !== "string") {
56+
throw new Error("Invalid or unknown type");
5457
}
55-
typeMap[registeredType.toLowerCase()] = prefix;
56-
});
57-
58-
const base = typeMap[type.toLowerCase()] || type.charAt(0).toUpperCase();
59-
const suffix = variant === "default" ? "" : `_${variant}`;
60-
const filename = `${base}${suffix}.png`;
6158

62-
// Mock path for test environments
63-
if (mock) {
64-
return `/assets/${filename}`;
65-
}
59+
const base = prefixForType(type);
60+
const suffix = variant === "default" ? "" : `_${variant}`;
61+
const filename = `${base}${suffix}.png`;
6662

67-
// Node.js environment - use URL-based approach
68-
if (isNode()) {
69-
const path = `/assets/${filename}`;
70-
return new URL(path, import.meta.url).href;
71-
}
63+
// Mock path for test environments
64+
if (mock) {
65+
return `/assets/${filename}`;
66+
}
7267

73-
// Browser environment - try dynamic import first (for bundlers like webpack/vite)
74-
// If that fails, fallback to URL-based approach
75-
try {
76-
const importedAsset = await import(`../../assets/${filename}`);
77-
return importedAsset.default || importedAsset;
78-
} catch (error) {
79-
// Fallback to URL-based approach if dynamic import fails
80-
const path = `/assets/${filename}`;
81-
return new URL(path, import.meta.url).href;
82-
}
68+
// ── Bundled asset lookup ──────────────────────────────────────
69+
const entry = ASSET_MAP[base];
70+
if (entry) {
71+
const dataUrl = entry[variant];
72+
if (dataUrl) {
73+
return dataUrl;
74+
}
75+
}
76+
77+
// ── Fallback: runtime URL (dev-server / unbundled) ──────────
78+
return `/assets/${filename}`;
8379
}

tests/png-loader.mjs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @file png-loader.mjs
3+
* @description
4+
* Node.js custom ESM loader that stubs out .png and .jpg imports.
5+
*
6+
* When tests `import` a module that transitively imports a `.png` file
7+
* (e.g. via assetMap.js), Node would crash with ERR_UNKNOWN_FILE_EXTENSION.
8+
* This loader intercepts those imports and returns a harmless empty-string
9+
* default export so the test process can continue.
10+
*
11+
* Usage (in package.json test script or mocha config):
12+
* --loader ./tests/png-loader.mjs
13+
*/
14+
15+
/**
16+
* Resolve hook — default behaviour, just pass through.
17+
*/
18+
export async function resolve(specifier, context, nextResolve) {
19+
return nextResolve(specifier, context);
20+
}
21+
22+
/**
23+
* Load hook — intercept .png / .jpg files and return a stub module.
24+
*/
25+
export async function load(url, context, nextLoad) {
26+
if (url.endsWith('.png') || url.endsWith('.jpg')) {
27+
return {
28+
format: 'module',
29+
source: 'export default "";',
30+
shortCircuit: true,
31+
};
32+
}
33+
return nextLoad(url, context);
34+
}

0 commit comments

Comments
 (0)