Files
HMDesign/scripts/h2-preview.mjs

63 lines
5.1 KiB
JavaScript

#!/usr/bin/env node
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import crypto from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { build } from 'esbuild';
const args = new Map();
for (let i = 2; i < process.argv.length; i += 1) {
const value = process.argv[i];
if (value.startsWith('--')) args.set(value.slice(2), process.argv[i + 1]?.startsWith('--') ? true : process.argv[++i]);
}
const input = args.get('input');
const output = args.get('output');
const screens = Number(args.get('screens') || 1);
const root = process.cwd();
const runId = `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(4).toString('hex')}`;
const reportDir = output ? path.resolve(output) : path.resolve(root, '_local', 'h2-preview', runId);
const failurePath = path.join(reportDir, 'verification-failure.json');
const rel = (p) => path.relative(root, p) || '.';
const fail = (code, message, details = {}) => {
fs.mkdirSync(reportDir, { recursive: true });
const report = { h2: 'H-2', status: 'FAIL', code, message, details, createdAt: new Date().toISOString(), input: input ? path.resolve(input) : null };
fs.writeFileSync(failurePath, JSON.stringify(report, null, 2) + '\n');
console.error(JSON.stringify(report, null, 2));
process.exitCode = 1;
};
if (!input) fail('INPUT_REQUIRED', 'Use --input with a .tsx/.jsx entry file or a directory containing one.');
else {
const inputPath = path.resolve(input);
const stat = fs.existsSync(inputPath) && fs.statSync(inputPath);
if (!stat) fail('INPUT_NOT_FOUND', `Input does not exist: ${inputPath}`);
else {
const entry = stat.isDirectory() ? ['App.tsx', 'index.tsx', 'main.tsx', 'App.jsx', 'index.jsx', 'main.jsx'].map((x) => path.join(inputPath, x)).find(fs.existsSync) : inputPath;
if (!entry || !/\.(tsx|jsx)$/.test(entry)) fail('INVALID_INPUT', 'Input must resolve to an independent React TSX/JSX entry.');
else if (path.resolve(reportDir) === path.resolve(root, 'dist') || path.resolve(reportDir) === path.resolve(root, 'storybook-static')) fail('PROTECTED_OUTPUT', 'Shared dist and Storybook output are not valid H-2 output paths.');
else if (fs.existsSync(path.join(reportDir, 'preview.html')) || fs.existsSync(path.join(reportDir, 'verification.json'))) fail('OUTPUT_EXISTS', 'Refusing to overwrite an existing H-2 output. Choose a new --output directory.');
else {
const before = requireGitStatus();
const outputBefore = fs.existsSync(reportDir) ? fs.readdirSync(reportDir) : [];
try {
fs.mkdirSync(reportDir, { recursive: true });
const temp = path.join(reportDir, '.bundle.js');
await build({ entryPoints: [entry], outfile: temp, bundle: true, format: 'iife', platform: 'browser', target: 'es2020', sourcemap: false, logLevel: 'silent', absWorkingDir: path.dirname(entry) });
const js = fs.readFileSync(temp, 'utf8');
fs.rmSync(temp, { force: true });
const source = fs.readFileSync(entry, 'utf8');
const urls = [...source.matchAll(/(?:src|href)\s*=\s*["']([^"']+)["']/gi)].map((m) => m[1]);
const external = urls.filter((u) => /^(https?:|\/\/)/i.test(u));
const unresolved = urls.filter((u) => !/^(data:|#|https?:|\/\/|\/)/i.test(u) && !fs.existsSync(path.resolve(path.dirname(entry), u.split('#')[0])));
if (external.length || unresolved.length) throw new Error(`External or unresolved asset references: ${JSON.stringify({ external, unresolved })}`);
const html = `<!doctype html><html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>H-2 Preview</title><style>html,body,#root{margin:0;min-height:100%;width:100%;}body{background:#fff;} </style></head><body><div id="root"></div><script>${js}\n//# sourceURL=h2-preview.js</script></body></html>`;
fs.writeFileSync(path.join(reportDir, 'preview.html'), html);
const verification = { h2: 'H-2', status: 'PASS', input: path.resolve(entry), output: path.join(reportDir, 'preview.html'), runtime: 'REACT_TSX', screens, checks: { isolatedBuild: true, singlePreviewHtml: true, fileUrlReady: true, externalUrls: external, unresolvedRelativePaths: unresolved, console: 'NOT_RUN', runtime: 'NOT_RUN', network: 'NOT_RUN', captures: 'NOT_RUN', protectedOutputsUnchanged: true, preexistingChanges: before, postRunChanges: requireGitStatus(), outputBefore, outputAfter: fs.readdirSync(reportDir) }, note: 'Browser/runtime/capture checks remain NOT_RUN until a browser runner executes them; NOT_RUN is not PASS.' };
fs.writeFileSync(path.join(reportDir, 'verification.json'), JSON.stringify(verification, null, 2) + '\n');
console.log(JSON.stringify({ status: 'PASS', preview: path.join(reportDir, 'preview.html'), verification: path.join(reportDir, 'verification.json') }, null, 2));
} catch (error) { fail('BUILD_OR_ASSET_CHECK_FAILED', error.message, { entry: rel(entry), output: reportDir }); }
}
}
}
function requireGitStatus() { try { return execFileSync('git', ['status', '--short'], { encoding: 'utf8' }).split(/\r?\n/).filter(Boolean); } catch { return ['UNAVAILABLE']; } }