import {unified} from 'unified'; import rehypeParse from 'rehype-parse'; import rehypeSanitize from 'rehype-sanitize'; import rehypeStringify from 'rehype-stringify'; import type {Root,RootContent,Element,ElementContent,Nodes} from 'hast'; import type {DocumentKind,LegalSection} from 'notabenda'; import {sha256} from 'notabenda/content'; import {AuthoringError} from './frontmatter.js'; const tags=['a','p','h2','h3','h4','h5','h6','strong','em','del','ul','ol','li','blockquote','pre','code','hr','br','div','span','aside','section','details','summary','table','thead','tbody','tfoot','tr','th','td','caption','img','sup','sub']; const globalAttributes=['id','className','title','lang','dir','role','ariaLabel','ariaLabelledBy','ariaDescribedBy','ariaHidden']; const attributes:Record={'*':globalAttributes,a:['href','rel','target'],img:['src','alt','width','height','loading','decoding'],ol:['start'],th:['scope','colSpan','rowSpan','align'],td:['colSpan','rowSpan','align'],details:['open']}; const unsupported=(detail:string):never=>{throw new AuthoringError('unsupported-content',detail);}; export const parseHtml=(html:string,fragment=true):Root=>unified().use(rehypeParse,{fragment}).parse(html) as Root; export const printHtml=(node:Root|Element):string=>String(unified().use(rehypeStringify).stringify(node.type==='root'?node:{type:'root',children:[node]})); export function elements(node:Root|Element):Element[]{return (node.children as RootContent[]).filter((v):v is Element=>v.type==='element');} function walk(node:Root|Element,visit:(node:Element)=>void):void {for(const child of elements(node)){visit(child);walk(child,visit);}} function cleanUrl(value:unknown,route:string,image=false):string { if(typeof value!=='string'||/[\s\u0000-\u001f\u007f\\]/u.test(value)||/%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/iu.test(value)||value.startsWith('//'))return unsupported('Use safe same-origin image paths and HTTPS, mailto, relative or fragment links.'); if(value.startsWith('#')&&!image)return value; if(/^mailto:[a-zA-Z0-9.!$&'*+/=^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)+$/u.test(value)&&!image)return value; try { const url=new URL(value,`https://notabenda.invalid${route}/`); if(url.protocol!=='https:'||url.username||url.password||image&&url.origin!=='https://notabenda.invalid')return unsupported('Images must use local paths; external links must use HTTPS.'); return url.origin==='https://notabenda.invalid'?url.pathname+url.search+url.hash:url.href; }catch{return unsupported('Invalid content URL.');} } /** Readable projection includes link destinations, image alternatives, lists and table boundaries. */ export function readable(node:Nodes):string { if(node.type==='text')return node.value; if(node.type!=='element'&&node.type!=='root')return ''; const contents=node.children.map(readable).join(''); if(node.type==='root')return contents; if(node.tagName==='a')return `${contents} (${String(node.properties.href??'')})`; if(node.tagName==='img')return String(node.properties.alt??'')?`Image: ${String(node.properties.alt)}`:''; if(node.tagName==='br')return '\n'; if(node.tagName==='li')return `• ${contents.trim()}\n`; if(['th','td'].includes(node.tagName))return contents.trim()+' | '; if(['p','blockquote','pre','ul','ol','table','tr','h2','h3','h4','h5','h6','aside','details','section','div'].includes(node.tagName))return contents.trim()+'\n\n'; return contents; } export interface AuthoredSection {model:LegalSection;node:Element} /** @requirements REQ-PRODUCT-AUTHORING REQ-PRODUCT-SAFE-CONTENT * Validate and sanitize the actual component output before hashing, projection or publication. */ export async function prepareHtml(html:string,kind:DocumentKind,route:string,sectionIds:Record):Promise { if(new TextEncoder().encode(html).length>49152)return unsupported('Rendered document exceeds the 48 KiB body limit.'); if(/