const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
const {createRequire} = require('node:module');
const assert = require('node:assert/strict');
const deps = createRequire(path.join(process.env.A07_DEPENDENCY_ROOT || __dirname, 'package.json'));
const {htmlToLexical} = deps('@tryghost/kg-html-to-lexical');
const {LexicalHTMLRenderer} = deps('@tryghost/kg-lexical-html-renderer');
const {DEFAULT_NODES} = deps('@tryghost/kg-default-nodes');
const {JSDOM} = deps('jsdom');
const output = path.resolve(process.argv[2] || __dirname);
fs.mkdirSync(output, {recursive: true});
const sha = text => crypto.createHash('sha256').update(text).digest('hex');
const base = 'https://example.com/guides/move-a-draft/';
const source = fs.readFileSync(path.join(__dirname, 'source.html'), 'utf8');
const baseline = fs.readFileSync(path.join(__dirname, 'baseline-source.html'), 'utf8');
const heading = '
Move a reviewed draft into the CMS
';
assert.equal(source.split(heading).length, 2, 'One heading must be repaired');
const repairedSource = source.replace(heading, `\n${heading}\n`);
const renderer = new LexicalHTMLRenderer({nodes: DEFAULT_NODES, onError: error => { throw error; }});
const renderOptions = {target: 'html'};
function inspect(html) {
const doc = new JSDOM(html, {url: base}).window.document;
return {
heading: {text:doc.querySelector('h2')?.textContent, id:doc.querySelector('h2')?.id},
top_level_steps: [...doc.querySelectorAll('ol > li')].map(li => [...li.childNodes].filter(n=>n.nodeType===3).map(n=>n.textContent).join('').trim()),
nested_steps: [...doc.querySelectorAll('ol > li > ul > li')].map(li => li.textContent),
table: {caption:doc.querySelector('table > caption')?.textContent, rows:[...doc.querySelectorAll('table tr')].map(tr=>[...tr.children].map(cell=>({tag:cell.tagName,text:cell.textContent,scope:cell.getAttribute('scope')})))},
code: doc.querySelector('pre > code')?.textContent,
figure: {src:doc.querySelector('figure > img')?.getAttribute('src'), alt:doc.querySelector('figure > img')?.alt, caption:doc.querySelector('figure > figcaption')?.textContent},
links: [...doc.querySelectorAll('a')].map(a => ({text:a.textContent, href:a.getAttribute('href'), resolved:a.href, fragment_target_exists:a.getAttribute('href').startsWith('#') ? !!doc.getElementById(a.hash.slice(1)) : null}))
};
}
function comparison(reference, candidate) {
const equal = (a,b) => JSON.stringify(a)===JSON.stringify(b);
return {
nested_list:equal(reference.top_level_steps,candidate.top_level_steps)&&equal(reference.nested_steps,candidate.nested_steps),
table_headers_cells_caption:equal(reference.table,candidate.table),
code_text_and_whitespace:equal(reference.code,candidate.code),
figure_caption_alt_and_source:equal(reference.figure,candidate.figure),
relative_link_destinations:equal(reference.links.map(l=>l.resolved),candidate.links.map(l=>l.resolved)),
in_page_link_target:candidate.links.filter(l=>l.href.startsWith('#')).every(l=>l.fragment_target_exists),
original_heading_id:reference.heading.id===candidate.heading.id
};
}
function preview(title, html) {
return `${title}${title}. Local library test with a simple preview stylesheet; this is not a hosted Ghost theme.${html}\n`;
}
(async () => {
const variants = {baseline:baseline,converted:source,repaired:repairedSource};
const results = {};
const hashes = {};
function write(name, text) { fs.writeFileSync(path.join(output,name),text); hashes[name]=sha(text); }
write('repaired-source.html',repairedSource);
write('original-preview.html',preview('Original source',source));
fs.copyFileSync(path.join(__dirname,'workflow.svg'),path.join(output,'workflow.svg'));
for (const [name,input] of Object.entries(variants)) {
const state = htmlToLexical(input);
const html = await renderer.render(JSON.stringify(state),renderOptions);
const prefix = name==='baseline' ? 'baseline-converted' : name;
write(`${prefix}.lexical.json`,JSON.stringify(state,null,2)+'\n');
write(`${prefix}.html`,html+'\n');
if(name!=='baseline') write(`${prefix}-preview.html`,preview(name==='converted'?'Converted source':'Repaired source',html));
results[name]={state_node_types:state.root.children.map(node=>node.type),snapshot:inspect(html),checks:comparison(inspect(input),inspect(html))};
}
for(const key of ['nested_list','table_headers_cells_caption','code_text_and_whitespace','figure_caption_alt_and_source','relative_link_destinations']) {
assert.equal(results.baseline.checks[key],true,`baseline: ${key}`);
assert.equal(results.converted.checks[key],true,`converted: ${key}`);
assert.equal(results.repaired.checks[key],true,`repaired: ${key}`);
}
assert.equal(results.converted.checks.in_page_link_target,false,'Expected actual broken anchor');
assert.equal(results.repaired.checks.in_page_link_target,true,'Expected repaired anchor');
assert.equal(results.repaired.checks.original_heading_id,true,'Expected original heading identity');
const packages={};
for(const name of ['@tryghost/kg-html-to-lexical','@tryghost/kg-lexical-html-renderer','@tryghost/kg-default-nodes','jsdom','lexical']) {
const packagePath=path.join(path.dirname(deps.resolve('jsdom/package.json')),'..',...name.split('/'),'package.json');
packages[name]=JSON.parse(fs.readFileSync(packagePath,'utf8')).version;
}
const receipt={article_id:'A07',captured_at:new Date().toISOString(),scope:'Local official Ghost HTML-to-Lexical and HTML renderer libraries; synthetic fixture; no hosted Ghost API/editor/theme or publication',node_version:process.version,packages,converter_options:'default',renderer_constructor:'nodes: DEFAULT_NODES; onError: rethrow',render_options:renderOptions,comparison_base_url:base,input_sha256:{source:sha(source),baseline:sha(baseline),repaired_source:sha(repairedSource),package_lock:sha(fs.readFileSync(path.join(__dirname,'package-lock.json')))},source_snapshot:inspect(source),results,output_sha256:hashes,assertions:'pass',limits:['No browser screenshot, visual, actual clipboard, keyboard or screen-reader check in this receipt. Main owns those checks.','Relative href resolution is compared at the declared same synthetic base URL; destination HTTP availability was not tested.','A different page base, Ghost release, theme, editor operation, sanitizer or email target can behave differently.','HTML-card preservation does not establish rich-text editability or safe rendering of arbitrary untrusted HTML.']};
fs.writeFileSync(path.join(output,'result.json'),JSON.stringify(receipt,null,2)+'\n');
console.log(JSON.stringify({assertions:receipt.assertions,output_dir:output,source_sha256:receipt.input_sha256.source,loss:results.converted.checks.in_page_link_target,repair:results.repaired.checks.in_page_link_target}));
})().catch(error=>{console.error(error);process.exitCode=1});