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});