html-to-gutenberg
Version:
Transform any valid HTML string into fully editable WP Gutenberg blocks in seconds rather than hours.
449 lines (391 loc) • 14.9 kB
text/typescript
import { expect } from 'chai';
import fs from 'fs';
import http from 'http';
import os from 'os';
import path from 'path';
import extractAssets, {
h,
p,
d,
m,
u,
w,
$,
E,
g,
U,
v,
A,
F,
x,
P,
R,
D,
} from './vendor/fetch-page-assets/index.js';
const tinyPng = Buffer.from(
'89504e470d0a1a0a0000000d4948445200000001000000010802000000907753de0000000c4944415408d763f8ffff3f0005fe02fea557a90000000049454e44ae426082',
'hex'
);
const listen = (server: http.Server) => {
return new Promise<{ baseUrl: string; close: () => Promise<void> }>((resolve, reject) => {
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (!address || typeof address === 'string') {
reject(new Error('Unable to determine test server address.'));
return;
}
resolve({
baseUrl: `http://127.0.0.1:${address.port}`,
close: () =>
new Promise<void>((closeResolve, closeReject) => {
server.close((error) => {
if (error) {
closeReject(error);
return;
}
closeResolve();
});
}),
});
});
server.on('error', reject);
});
};
describe('fetch-page-assets helpers', () => {
it('logs through h and p only when verbose', () => {
const errors: string[] = [];
const logs: string[] = [];
const originalError = console.error;
const originalLog = console.log;
console.error = (message?: any) => errors.push(String(message));
console.log = (message?: any) => logs.push(String(message));
try {
h('failed', true);
h('ignored', false);
p('ok', true);
p('ignored', false);
} finally {
console.error = originalError;
console.log = originalLog;
}
expect(errors).to.deep.equal(['[Error] failed']);
expect(logs).to.deep.equal(['[Success] ok']);
});
it('covers the URL and path helpers', () => {
expect(d('//cdn.example.com')).to.equal(true);
expect(d('https://example.com')).to.equal(false);
expect(m('//cdn.example.com/image.png', 'http')).to.equal('http://cdn.example.com/image.png');
expect(m('https://example.com/image.png')).to.equal('https://example.com/image.png');
expect(u('/tmp/')).to.equal(true);
expect(u('/tmp')).to.equal(false);
expect(w('images/logo.png')).to.equal(true);
expect(w('//cdn.example.com/logo.png')).to.equal(false);
expect($(' "hello" ')).to.equal('hello');
expect(E('images/logo.png', 'https://example.com/base/page')).to.equal('https://example.com/base/images/logo.png');
expect(E('//cdn.example.com/logo.png', '', 'http')).to.equal('http://cdn.example.com/logo.png');
expect(E('images/logo.png', 'not a valid url')).to.equal('images/logo.png');
expect(g(['a', 'b', 'c'])).to.equal(path.join('a', 'b', 'c'));
expect(A('image.png?foo=1#bar')).to.equal('image.png');
expect(F('/tmp', 'asset.png?foo=1#bar')).to.equal(path.join('/tmp', 'asset.png'));
expect(x('archive.tar.gz')).to.deep.equal(['archive', 'tar', 'gz']);
expect(P({ Header: 'A', header: 'B' }, 'Header')).to.equal('A');
expect(P({ header: 'B' }, 'Header')).to.equal('B');
});
it('covers file utilities and header parsing helpers', () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-assets-helpers-'));
const nestedDir = path.join(tempRoot, 'nested', 'dir');
const html = '<img src="https://example.com/images/photo.png">';
const destinationPath = path.join(tempRoot, 'images');
const destinationFilePath = path.join(destinationPath, 'photo.png');
try {
U(nestedDir, false);
expect(fs.existsSync(nestedDir)).to.equal(true);
fs.mkdirSync(destinationPath, { recursive: true });
v(
{
parsedUrl: 'https://example.com/images/photo.png',
destinationFilePath,
},
html,
tempRoot,
true,
false
);
const writtenHtml = fs.readFileSync(path.join(tempRoot, 'index.html'), 'utf8');
expect(writtenHtml).to.include('images/photo.png');
expect(R({ 'Content-Disposition': 'attachment; filename="report"' }, 'fallback')).to.equal('report');
expect(R({ 'Content-Type': 'image/png' }, 'fallback')).to.equal('fallback.png');
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('covers download progress logging for valid and invalid ratios', () => {
const logs: string[] = [];
const originalLog = console.log;
console.log = (message?: any) => logs.push(String(message));
try {
D({ loaded: 50, total: 100 });
D({ loaded: Number.NaN, total: 100 });
} finally {
console.log = originalLog;
}
expect(logs).to.deep.equal(['Download progress: 50%', 'Download progress: 0%']);
});
});
describe('fetch-page-assets integration', () => {
it('downloads local file assets from file URLs', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-assets-local-'));
const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-assets-source-'));
const localFile = path.join(sourceDir, 'asset.txt');
fs.writeFileSync(localFile, 'local file content');
try {
const html = `<img src="file://${localFile}" />`;
const output = await extractAssets(html, {
basePath: tempRoot,
verbose: false,
maxRetryAttempts: 1,
retryDelay: 0,
});
expect(output).to.equal(html);
expect(fs.existsSync(path.join(tempRoot, localFile.replace(/^\//, '')))).to.equal(true);
} finally {
fs.rmSync(tempRoot, { recursive: true, force: true });
fs.rmSync(sourceDir, { recursive: true, force: true });
}
});
it('fetches remote HTML, follows redirects, and de-duplicates repeated asset downloads', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-assets-remote-'));
const requestCounts: Record<string, number> = {};
const server = http.createServer((req, res) => {
const url = req.url || '/';
requestCounts[url] = (requestCounts[url] || 0) + 1;
if (url === '/redirect-html') {
res.writeHead(302, { Location: '/page' });
res.end();
return;
}
if (url === '/page') {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<html>
<body>
<img src="/redirect-image.png" srcset="/ignored-1x.png 1x" sizes="100vw" />
<img src="/redirect-image.png" />
<source src="/video.mp4" />
<script src="/script.js"></script>
<link rel="stylesheet" href="/styles/site.css" />
<div style="background-image:url('/backgrounds/bg.png')"></div>
</body>
</html>
`);
return;
}
if (url === '/redirect-image.png') {
res.writeHead(302, { Location: '/images/logo-download' });
res.end();
return;
}
if (url === '/images/logo-download') {
res.writeHead(200, {
'Content-Type': 'image/png',
'Content-Disposition': 'attachment; filename="logo-file"',
});
res.end(tinyPng);
return;
}
if (url === '/video.mp4') {
res.writeHead(200, { 'Content-Type': 'video/mp4' });
res.end(Buffer.from('video'));
return;
}
if (url === '/script.js') {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end('window.__fetchedScript = true;');
return;
}
if (url === '/styles/site.css') {
res.writeHead(200, { 'Content-Type': 'text/css' });
res.end(".hero { background-image: url('/backgrounds/bg.png'); }");
return;
}
if (url === '/backgrounds/bg.png') {
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(tinyPng);
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
});
const { baseUrl, close } = await listen(server);
try {
const output = await extractAssets(`${baseUrl}/redirect-html`, {
basePath: tempRoot,
protocol: 'http',
verbose: false,
maxRetryAttempts: 1,
retryDelay: 0,
concurrency: 4,
});
expect(output).to.include('<img src="/redirect-image.png"');
expect(output).to.not.include('srcset=');
expect(output).to.not.include('sizes=');
expect(fs.existsSync(path.join(tempRoot, 'logo-file.png'))).to.equal(true);
expect(fs.existsSync(path.join(tempRoot, 'styles', 'site.css'))).to.equal(true);
expect(fs.existsSync(path.join(tempRoot, 'script.js'))).to.equal(true);
expect(fs.existsSync(path.join(tempRoot, 'backgrounds', 'bg.png'))).to.equal(true);
expect(requestCounts['/redirect-image.png']).to.equal(1);
expect(requestCounts['/images/logo-download']).to.equal(1);
} finally {
await close();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('handles invalid inputs, unsupported remote URLs, and failed asset downloads gracefully', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-assets-errors-'));
const errors: string[] = [];
const originalError = console.error;
console.error = (message?: any) => errors.push(String(message));
const server = http.createServer((req, res) => {
if (req.url === '/broken.png') {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('broken');
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
});
const { baseUrl, close } = await listen(server);
try {
const invalidInput = await extractAssets(123 as any, {
basePath: tempRoot,
verbose: true,
} as any);
const unsupportedProtocol = await extractAssets('ftp://example.com/file.html', {
basePath: tempRoot,
verbose: true,
});
const unresolvedRelative = await extractAssets('<img src="/missing.png" />', {
basePath: tempRoot,
verbose: true,
maxRetryAttempts: 1,
retryDelay: 0,
});
const failedDownload = await extractAssets(`<img src="${baseUrl}/broken.png" />`, {
basePath: tempRoot,
verbose: true,
maxRetryAttempts: 1,
retryDelay: 0,
});
expect(invalidInput).to.equal('');
expect(unsupportedProtocol).to.equal('');
expect(unresolvedRelative).to.equal('<img src="/missing.png" />');
expect(failedDownload).to.equal(`<img src="${baseUrl}/broken.png" />`);
expect(errors.some((message) => message.includes('Invalid user input'))).to.equal(true);
expect(errors.some((message) => message.includes('Invalid baseUrl'))).to.equal(true);
expect(errors.some((message) => message.includes('A source URL is required'))).to.equal(true);
expect(errors.some((message) => message.includes('HTTP error! Status: 500'))).to.equal(true);
} finally {
console.error = originalError;
await close();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('reuses a shared asset task cache and retries transient download failures', async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-assets-cache-'));
const sharedCache = new Map();
const requestCounts: Record<string, number> = {};
const server = http.createServer((req, res) => {
const url = req.url || '/';
requestCounts[url] = (requestCounts[url] || 0) + 1;
if (url === '/cached.png') {
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(tinyPng);
return;
}
if (url === '/flaky.png') {
if (requestCounts[url] === 1) {
req.socket.destroy();
return;
}
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(tinyPng);
return;
}
if (url === '/broken-page') {
res.writeHead(500, { 'Content-Type': 'text/plain' });
res.end('broken');
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
});
const { baseUrl, close } = await listen(server);
try {
await extractAssets(`<img src="${baseUrl}/cached.png" />`, {
basePath: tempRoot,
verbose: false,
maxRetryAttempts: 1,
retryDelay: 0,
_assetTaskCache: sharedCache,
});
await extractAssets(`<img src="${baseUrl}/cached.png" />`, {
basePath: tempRoot,
verbose: false,
maxRetryAttempts: 1,
retryDelay: 0,
_assetTaskCache: sharedCache,
});
await extractAssets(`<img src="${baseUrl}/flaky.png" />`, {
basePath: tempRoot,
verbose: false,
maxRetryAttempts: 2,
retryDelay: 0,
});
const brokenHtml = await extractAssets(`${baseUrl}/broken-page`, {
basePath: tempRoot,
verbose: false,
});
expect(requestCounts['/cached.png']).to.equal(1);
expect(requestCounts['/flaky.png']).to.equal(2);
expect(fs.existsSync(path.join(tempRoot, 'cached.png'))).to.equal(true);
expect(fs.existsSync(path.join(tempRoot, 'flaky.png'))).to.equal(true);
expect(brokenHtml).to.equal('');
} finally {
await close();
fs.rmSync(tempRoot, { recursive: true, force: true });
}
});
it('can upload extracted assets to mocked R2 and return asset metadata', async () => {
const server = http.createServer((req, res) => {
if (req.url === '/asset.png') {
res.writeHead(200, { 'Content-Type': 'image/png' });
res.end(tinyPng);
return;
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
});
const { baseUrl, close } = await listen(server);
process.env.HTG_R2_MOCK = '1';
process.env.CLOUDFLARE_R2_PUBLIC_BASE_URL = 'https://storage.example.com';
try {
const result = await extractAssets(`<img src="${baseUrl}/asset.png" />`, {
basePath: process.cwd(),
verbose: false,
uploadToR2: true,
returnDetails: true,
jobId: 'conv_123',
r2Prefix: 'generated/conv_123/assets',
});
expect(result.html).to.include(`${baseUrl}/asset.png`);
expect(result.assets).to.have.length(1);
expect(result.assets[0].name).to.equal('asset.png');
expect(result.assets[0].url).to.equal('https://storage.example.com/generated/conv_123/assets/asset.png');
expect(result.assets[0].path).to.equal('/generated/conv_123/assets/asset.png');
} finally {
delete process.env.HTG_R2_MOCK;
delete process.env.CLOUDFLARE_R2_PUBLIC_BASE_URL;
await close();
}
});
});