// XML/XHTML parsing + serialization on top of linkedom.
//
// Replaces the browser DOMParser/XMLSerializer used by FilesPage.html.
// safeSerialize is a verbatim port of source line 3997.
//
// ---------------------------------------------------------------------------
// linkedom quirks measured on the installed version (probe results, not guesses).
// EVERY port site must go through the shims below — never call linkedom's NS
// APIs directly, they are wrong in ways that silently return nothing:
//
// 1. `getElementsByTagName('*')` returns an EMPTY NodeList. Unusable.
// `querySelectorAll('*')` works and is what byLocalName uses instead.
// 2. `getElementsByTagNameNS('*', name)` matches the *qualified* nodeName, so
// `getElementsByTagNameNS('*','creator')` finds 0 elements in an OPF whose
// metadata uses ``. This is the single most dangerous quirk —
// it fails silently and would break title/author/identifier extraction.
// 3. `element.localName` returns the QUALIFIED name including the prefix
// ("dc:creator", "svg:svg"), not the local part. byLocalName strips it.
// 4. `getAttributeNS(ns, local)` always returns null; attributes are keyed by
// their qualified name. getAttrNS therefore tries getAttribute('local')
// then getAttribute('prefix:local').
// 5. `querySelector('image[*|href]')` THROWS ("Namespaced attributes are not
// yet supported by css-select"). Never use namespaced CSS selectors.
// 6. `namespaceURI` is unreliable (an OPF reports the XHTML NS), so
// createElementNS callers pass an explicit constant NS instead of reading
// it off a neighbouring node.
// 7. `doc.toString()` already emits the declaration and produces no
// `xmlns:ns0` prefix noise, so safeSerialize's fix-ups are harmless no-ops
// here. They are kept verbatim anyway per the port map.
// 8. There is no `parsererror` element convention, and linkedom recovers from
// mismatched/unclosed tags rather than failing. Only truly element-less
// input yields a null documentElement. Detection is therefore: it threw,
// documentElement is null, or documentElement is a .
// ---------------------------------------------------------------------------
import { DOMParser } from 'linkedom';
/** Strip a namespace prefix from a qualified name: "dc:creator" -> "creator". */
function localPart(name) {
const n = name || '';
const i = n.indexOf(':');
return i === -1 ? n : n.slice(i + 1);
}
/** Prefix of a qualified name, or '' when unprefixed. */
function prefixPart(name) {
const n = name || '';
const i = n.indexOf(':');
return i === -1 ? '' : n.slice(0, i);
}
/** The qualified tag name of an element, tolerating either linkedom accessor. */
function qualifiedName(el) {
return el.localName || el.nodeName || '';
}
function parse(text, mimeType) {
try {
const doc = new DOMParser().parseFromString(text, mimeType);
if (!doc || !doc.documentElement) return { doc: null, ok: false };
if (localPart(qualifiedName(doc.documentElement)) === 'parsererror') {
return { doc: null, ok: false };
}
return { doc, ok: true };
} catch (e) {
return { doc: null, ok: false };
}
}
/** Parse OPF/NCX/generic XML. Returns {doc, ok}; ok:false routes to regex fallbacks. */
export function parseXml(text) {
return parse(text, 'text/xml');
}
/** Parse XHTML content documents. Returns {doc, ok}. */
export function parseXhtml(text) {
return parse(text, 'application/xhtml+xml');
}
/**
* Every element under `root` whose local name (prefix stripped) equals `name`.
* Replaces every `getElementsByTagNameNS('*', name)` call in the source
* (sites: 4021/4024/4028, 4057, 4082-4083, 4303/4323/4329, 3905-3906).
*
* When `root` is a Document the document element itself is included (matching
* the source's document-level lookups for ); when `root` is an Element
* only descendants are considered (matching `svg.getElementsByTagName('image')`).
*/
export function byLocalName(root, name) {
if (!root) return [];
let nodes;
try {
nodes = root.querySelectorAll('*');
} catch (e) {
return [];
}
const out = [];
for (const el of nodes) {
if (localPart(qualifiedName(el)) === name) out.push(el);
}
return out;
}
/**
* Read a namespaced attribute the way the source's fallback chains do
* (3907-3920 role/opf:role, 4204-4206 and 4268 xlink:href).
*
* Tries the bare local name, then each candidate prefix, then any attribute
* whose qualified name ends in `:localName`, then getAttributeNS in a
* try/catch (which linkedom always answers with null, but a future/other
* implementation may not).
*
* `preferPrefixed` flips the first two steps. The source's two chains differ in
* precedence: the opf:role chain (3907-3920) reads the bare attribute first,
* while the xlink:href chain (4204-4206, 4268) reads the namespaced one first
* and only falls back to a bare `href`. Pass true at the xlink sites.
*/
export function getAttrNS(el, localName, nsUri, prefixes = [], preferPrefixed = false) {
if (!el) return null;
const tryGet = n => {
try {
const v = el.getAttribute(n);
return v === null || v === undefined ? null : v;
} catch (e) { return null; }
};
const tryPrefixes = () => {
for (const p of prefixes) {
const v = tryGet(`${p}:${localName}`);
if (v) return v;
}
return null;
};
if (preferPrefixed) {
const pre = tryPrefixes();
if (pre) return pre;
const bare = tryGet(localName);
if (bare) return bare;
} else {
const direct = tryGet(localName);
if (direct) return direct;
const pre = tryPrefixes();
if (pre) return pre;
}
// Any prefix at all — covers documents using a non-conventional prefix.
try {
for (const attr of el.attributes || []) {
const n = attr.name || '';
if (prefixPart(n) && localPart(n) === localName) {
if (attr.value) return attr.value;
}
}
} catch (e) { /* ignore */ }
if (nsUri) {
try {
const v = el.getAttributeNS(nsUri, localName);
if (v) return v;
} catch (e) { /* linkedom: always null, never throws — belt and braces */ }
}
return null;
}
/**
* Serialize an XML doc back to string, preserving the original declaration
* and cleaning up XMLSerializer namespace prefix noise (xmlns:ns0 etc). (3997)
*/
export function safeSerialize(doc, originalContent) {
let result = doc.toString();
// Restore declaration if original had one
if (originalContent && /^\s*<\?xml\b/.test(originalContent) && !/^\s*<\?xml\b/.test(result)) {
const declMatch = originalContent.match(/^\s*(<\?xml[^?]*\?>)/);
if (declMatch) result = declMatch[1] + '\n' + result;
}
// Clean up XMLSerializer namespace prefix noise (xmlns:ns0="..." ns0:attr="...")
result = result.replace(/ xmlns:ns\d+="[^"]*"/g, '');
result = result.replace(/ ns\d+:/g, ' ');
// linkedom-specific: unlike the browser XMLSerializer (which emits no
// declaration at all, hence the restore above), linkedom INVENTS an
// `` even for input that had none.
// Drop it again so the contract matches the source's: the output carries a
// declaration exactly when the input did.
//
// Note the encoding is deliberately NOT restored verbatim when the original
// did have one. safeReadText decodes legacy encodings and everything is
// re-encoded as UTF-8 on write, so linkedom's `utf-8` is the truthful label —
// echoing back an original `encoding="windows-1252"` would mis-declare it.
if (originalContent && !/^\s*<\?xml\b/.test(originalContent)) {
result = result.replace(/^\s*<\?xml[^?]*\?>\s*/, '');
}
return result;
}