enketo-transformer
Version:
Library that transforms ODK-compliant XForms into a format that Enketo can consume
515 lines (514 loc) • 17.8 kB
JavaScript
import "./dom/node/Document.js";
import "./dom/node/Node.js";
import "./dom/node/Element.js";
import { XPathResult } from "./dom/node/XPathResult.js";
import { DOMParser } from "./dom/node/DOMParser.js";
import { XSLTProcessor } from "./dom/node/XSLTProcessor.js";
import { NodeTypes } from "./dom/shared.js";
import xslForm from "./xsl/openrosa2html5form.xsl.js";
import xslModel from "./xsl/openrosa2xmlmodel.xsl.js";
import { parseLanguage } from "./language.js";
import { markdownToHTML } from "./markdown.js";
import { NAMESPACES } from "./shared.js";
import { escapeURLPath, getMediaPath } from "./url.js";
const getPreprocess = (survey) => {
if (String("node") === "node" && typeof survey.preprocess === "function") {
return survey.preprocess;
}
};
const transform = async (survey) => {
const { xform, markdown, media, openclinica, theme } = survey;
const xsltParams = openclinica ? {
openclinica: 1
} : {};
const mediaMap = Object.fromEntries(
Object.entries(media || {}).map((entry) => entry.map(escapeURLPath))
);
const domParser = new DOMParser();
const xslFormDoc = domParser.parseFromString(xslForm, "text/xml");
let xformDoc = domParser.parseFromString(xform, "text/xml");
const preprocess = getPreprocess(survey);
if (typeof preprocess === "function") {
const { libxmljs } = await import("libxslt");
xformDoc = preprocess.call(libxmljs, xformDoc);
}
processBinaryDefaults(xformDoc, mediaMap);
processItemsets(xformDoc);
const htmlDoc = xslTransform(xslFormDoc, xformDoc, xsltParams);
correctHTMLDocHierarchy(htmlDoc);
correctAction(htmlDoc, "setgeopoint");
correctAction(htmlDoc, "setvalue");
transformAppearances(htmlDoc);
replaceTheme(htmlDoc, theme);
replaceMediaSources(htmlDoc, mediaMap);
const languageMap = replaceLanguageTags(htmlDoc);
const form = markdown !== false ? renderMarkdown(htmlDoc, mediaMap) : docToString(htmlDoc);
const xslModelDoc = domParser.parseFromString(xslModel, "text/xml");
const xmlDoc = xslTransform(xslModelDoc, xformDoc);
correctModelNamespaces(xslModelDoc, xformDoc, xmlDoc);
replaceMediaSources(xmlDoc, mediaMap);
addInstanceIdNodeIfMissing(xmlDoc);
const model = docToString(xmlDoc);
delete survey.xform;
delete survey.media;
delete survey.preprocess;
delete survey.markdown;
delete survey.openclinica;
return Object.assign(survey, {
form,
model,
languageMap,
transformerVersion: "4.2.0"
});
};
const xslTransform = (xslDoc, xmlDoc, xsltParams = {}) => {
const xsltProcessor = new XSLTProcessor();
xsltProcessor.importStylesheet(xslDoc);
Object.entries(xsltParams).forEach(([key, value]) => {
xsltProcessor.setParameter(null, key, value);
});
return xsltProcessor.transformToDocument(xmlDoc);
};
const getNamespaceResolver = (namespaces) => ({
lookupNamespaceURI: (prefix) => namespaces[prefix] ?? null
});
const isDocument = (node) => node.nodeType === NodeTypes.DOCUMENT_NODE;
const evaluateXPathExpression = (context, expression, resultType, namespaces) => {
const namespaceResolver = namespaces == null ? null : getNamespaceResolver(namespaces);
const doc = isDocument(context) ? context : context.ownerDocument;
if (doc == null) {
throw new Error("Could not find owner document");
}
return doc.evaluate(expression, context, namespaceResolver, resultType);
};
const getNodesByXPathExpression = (context, expression, namespaces) => {
var _a;
const results = [];
const result = evaluateXPathExpression(
context,
expression,
XPathResult.ORDERED_NODE_SNAPSHOT_TYPE,
namespaces
);
for (let i = 0; i < (result.snapshotLength ?? 0); i += 1) {
results.push((_a = result.snapshotItem) == null ? void 0 : _a.call(result, i));
}
return results;
};
const getNodeByXPathExpression = (context, expression, namespaces) => {
const { singleNodeValue } = evaluateXPathExpression(
context,
expression,
XPathResult.FIRST_ORDERED_NODE_TYPE,
namespaces
);
return singleNodeValue;
};
const processBinaryDefaults = (doc, mediaMap) => {
getNodesByXPathExpression(
doc,
'/h:html/h:head/xmlns:model/xmlns:bind[@type="binary"]',
NAMESPACES
).forEach((bind) => {
const nodeset = bind.getAttribute("nodeset");
if (nodeset) {
const path = `/h:html/h:head/xmlns:model/xmlns:instance${nodeset.replace(
/\//g,
"/xmlns:"
)}`;
const dataNode = getNodeByXPathExpression(doc, path, NAMESPACES);
if (dataNode) {
const text = dataNode.textContent ?? "";
if (/^[a-zA-Z]+:\/\//.test(text)) {
const value = getMediaPath(mediaMap, text);
const escapedText = escapeURLPath(text);
dataNode.setAttribute("src", value);
dataNode.textContent = escapedText;
}
}
}
});
};
const correctAction = (doc, localName = "setvalue") => {
getNodesByXPathExpression(
doc,
`//*[contains(@class, "question")]//label/input[@data-${localName}]`
).forEach((setValueEl) => {
const { parentElement } = setValueEl;
if (parentElement != null) {
const clone = setValueEl.cloneNode(true);
parentElement.replaceWith(clone);
}
});
getNodesByXPathExpression(
doc,
`//label[contains(@class, "${localName}")]/input[@data-${localName}]`
).forEach((setValueEl) => {
var _a;
const name = setValueEl.getAttribute("name");
const questionSameName = getNodeByXPathExpression(
doc,
`//*[@name="${name}" and ( contains(../@class, 'question') or contains(../../@class, 'option-wrapper')) and not(@type='hidden')]`
);
if (questionSameName) {
[`data-${localName}`, "data-event"].forEach((name2) => {
questionSameName.setAttribute(
name2,
setValueEl.getAttribute(name2) ?? name2
);
});
(_a = setValueEl.parentElement) == null ? void 0 : _a.remove();
}
});
};
const replaceTheme = (doc, theme) => {
const HAS_THEME = /(theme-)[^"'\s]+/;
if (!theme) {
return;
}
const form = getNodeByXPathExpression(doc.documentElement, "/root/form");
if (form == null) {
throw new Error("Form is missing");
}
const formClass = form.getAttribute("class");
if (formClass != null && HAS_THEME.test(formClass)) {
form.setAttribute("class", formClass.replace(HAS_THEME, `$1${theme}`));
} else {
form.setAttribute("class", `${formClass ?? ""} theme-${theme}`);
}
};
const replaceMediaSources = (root, mediaMap) => {
if (!mediaMap) {
return;
}
getNodesByXPathExpression(root, "//*[@src] | //a[@href]").forEach(
(mediaEl) => {
const attribute = mediaEl.nodeName.toLowerCase() === "a" ? "href" : "src";
const src = mediaEl.getAttribute(attribute);
if (src == null) {
return;
}
const replacement = getMediaPath(mediaMap, src);
if (replacement) {
mediaEl.setAttribute(attribute, replacement);
}
}
);
const formLogo = mediaMap["form_logo.png"];
const formLogoEl = getNodeByXPathExpression(
root,
'//*[@class="form-logo"]'
);
if (formLogo && formLogoEl) {
const formLogoImg = root.createElement("img");
formLogoImg.setAttribute("src", formLogo);
formLogoImg.setAttribute("alt", "form logo");
formLogoEl.append(formLogoImg);
}
};
const replaceLanguageTags = (doc) => {
const languageMap = {};
const languageElements = getNodesByXPathExpression(
doc,
'/root/form/select[@id="form-languages"]/option'
);
const languages = languageElements.map((el) => {
const lang = el.textContent ?? "";
return parseLanguage(lang, getLanguageSampleText(doc, lang));
});
if (languages.length === 0) {
languages.push(parseLanguage("", getLanguageSampleText(doc, "")));
}
languageElements.forEach((el, index) => {
const val = el.getAttribute("value");
if (val && val !== languages[index].tag) {
languageMap[val] = languages[index].tag;
}
el.setAttribute("data-dir", languages[index].directionality);
el.setAttribute("value", languages[index].tag);
el.textContent = languages[index].description;
});
languages.forEach(({ sourceLanguage, tag }) => {
if (sourceLanguage === tag) {
return;
}
getNodesByXPathExpression(
doc,
`/root/form//*[@lang="${sourceLanguage}"]`
).forEach((el) => {
el.setAttribute("lang", tag);
});
});
const langSelectorElement = getNodeByXPathExpression(
doc,
"/root/form/*[@data-default-lang]"
);
if (langSelectorElement) {
const defaultLang = langSelectorElement.getAttribute("data-default-lang");
languages.some(({ sourceLanguage, tag }) => {
if (sourceLanguage === defaultLang) {
langSelectorElement.setAttribute("data-default-lang", tag);
return true;
}
return false;
});
}
return languageMap;
};
const getLanguageSampleText = (doc, language) => {
var _a;
const langSampleEl = getNodeByXPathExpression(
doc,
`/root/form//span[contains(@class, "or-hint") and @lang="${language}" and normalize-space() and not(./text() = '-')]`
) || getNodeByXPathExpression(
doc,
`/root/form//span[@lang="${language}" and normalize-space() and not(./text() = '-')]`
);
return ((_a = langSampleEl == null ? void 0 : langSampleEl.textContent) == null ? void 0 : _a.trim()) || "nothing";
};
const addInstanceIdNodeIfMissing = (doc) => {
const xformsPath = "/xmlns:root/xmlns:model/xmlns:instance/*/xmlns:meta/xmlns:instanceID";
const openrosaPath = "/xmlns:root/xmlns:model/xmlns:instance/*/orx:meta/orx:instanceID";
const instanceIdEl = getNodeByXPathExpression(
doc,
`${xformsPath} | ${openrosaPath}`,
NAMESPACES
);
if (!instanceIdEl) {
const rootEl = getNodeByXPathExpression(
doc,
"/xmlns:root/xmlns:model/xmlns:instance/*",
NAMESPACES
);
const metaEl = getNodeByXPathExpression(
doc,
"/xmlns:root/xmlns:model/xmlns:instance/*/xmlns:meta",
NAMESPACES
);
const instanceID = doc.createElementNS(NAMESPACES.xmlns, "instanceID");
if (metaEl) {
metaEl.append(instanceID);
} else if (rootEl) {
const meta = doc.createElementNS(NAMESPACES.xmlns, "meta");
rootEl.append(meta);
meta.append(instanceID);
}
}
};
const renderMarkdown = (htmlDoc, mediaMap) => {
const replacements = {};
getNodesByXPathExpression(
htmlDoc,
'/root/form//span[contains(@class, "or-output")]'
).forEach((el, index) => {
const key = `---output-${index}`;
const textNode = el.firstChild.cloneNode(true);
replacements[key] = el.outerHTML;
textNode.textContent = key;
el.replaceWith(textNode);
});
const domParser = new DOMParser();
getNodesByXPathExpression(
htmlDoc,
'/root/form//span[contains(@class, "question-label") or contains(@class, "or-hint")]'
).forEach((el, index) => {
let key;
const original = el.textContent.replace("<", "<").replace(">", ">");
let rendered = markdownToHTML(original);
if (original !== rendered) {
const tempDoc = domParser.parseFromString(
`<root class="temporary-root">${rendered}</root>`,
"text/html"
);
correctHTMLDocHierarchy(tempDoc);
replaceMediaSources(tempDoc, mediaMap);
rendered = docToString(tempDoc);
key = `$$$${index}`;
replacements[key] = rendered;
el.textContent = key;
}
});
let htmlStr = docToString(htmlDoc);
Object.keys(replacements).reverse().forEach((key) => {
const replacement = replacements[key];
if (replacement) {
htmlStr = htmlStr.replace(key, () => replacement);
}
});
return htmlStr;
};
const docToString = (doc) => {
const { outerHTML } = doc.documentElement;
return outerHTML.replace(/^<[^>]+>/, "").replace(/<\/[^>]+>(?!.|\n)$/, "");
};
const version = "b56f916619066a31f8b9d1530784c257";
const sheets = {
xslForm,
xslModel
};
var transformer_default = {
transform,
version: "b56f916619066a31f8b9d1530784c257",
NAMESPACES,
sheets,
escapeURLPath
};
const correctHTMLDocHierarchy = (doc) => {
const { documentElement } = doc;
if (documentElement.nodeName.toLowerCase() === "html") {
const root = getNodeByXPathExpression(doc, "/html/body/root");
if (root == null) {
throw new Error("Missing root node.");
}
documentElement.replaceWith(root);
}
};
const substringBefore = (haystack, needle) => haystack.split(needle, 1)[0];
const substringAfter = (haystack, needle) => haystack.substring(haystack.indexOf(needle) + needle.length);
const stripFilter = (expression) => expression.replace(/\[.*?\]/g, "");
const processItemsets = (xformDoc) => {
const itemsets = getNodesByXPathExpression(
xformDoc,
"//xmlns:itemset",
NAMESPACES
);
itemsets.forEach((itemset) => {
const valueEl = getNodeByXPathExpression(
itemset,
"./xmlns:value",
NAMESPACES
);
const valueRef = (valueEl == null ? void 0 : valueEl.getAttribute("ref")) ?? "";
const labelEl = getNodeByXPathExpression(
itemset,
"./xmlns:label",
NAMESPACES
);
const labelRef = (labelEl == null ? void 0 : labelEl.getAttribute("ref")) ?? "";
const nodeset = itemset.getAttribute("nodeset") ?? "";
const iwq = substringBefore(substringAfter(nodeset, "instance("), ")");
let instancePathTemp;
const nodesetIncludesRandomize = nodeset.includes("randomize(");
if (nodesetIncludesRandomize && nodeset.includes(",")) {
instancePathTemp = substringBefore(
substringAfter(nodeset, ")"),
","
);
} else if (nodesetIncludesRandomize) {
instancePathTemp = substringBefore(
substringAfter(nodeset, ")"),
")"
);
} else {
instancePathTemp = substringAfter(nodeset, ")");
}
const instancePath = instancePathTemp.replace(/\//g, "/xf:");
const instancePathNoFilter = stripFilter(instancePath);
const instanceId = iwq.substring(1, iwq.length - 1);
const itextPath = `/h:html/h:head/xf:model/xf:instance[@id="${instanceId}"]${instancePathNoFilter}`;
itemset.setAttribute("valueRef", valueRef);
itemset.setAttribute("labelRef", labelRef);
itemset.setAttribute("itextPath", `${itextPath}`);
const [, labelNodeName] = labelRef.match(/itext\((.*)\)/) ?? [];
if (labelNodeName != null) {
const labelPath = `${itextPath.replace(
/\/xf:/g,
"/xmlns:"
)}/*[name() = "${labelNodeName}"]`;
const items = getNodesByXPathExpression(
xformDoc,
labelPath,
NAMESPACES
);
itemset.append(...items.map((item) => item.cloneNode(true)));
}
});
};
const transformAppearances = (doc) => {
const appearanceElements = getNodesByXPathExpression(
doc,
"//*[@data-appearances]"
);
appearanceElements.forEach((element) => {
var _a, _b, _c;
const selectType = element.hasAttribute("data-appearances-select-type");
if (selectType) {
element.removeAttribute("data-appearances-select-type");
}
const appearances = ((_a = element.getAttribute("data-appearances")) == null ? void 0 : _a.trim().toLowerCase().split(/\s+/)) ?? [];
const appearanceClasses = appearances.flatMap((appearance) => {
const results = [`or-appearance-${appearance}`];
if (selectType) {
if (appearance === "horizontal") {
results.push("or-appearance-columns");
}
if (appearance === "horizontal-compact") {
results.push("or-appearance-columns-pack");
}
if (appearance === "compact") {
results.push(
"or-appearance-columns-pack",
"or-appearance-no-buttons"
);
}
if (appearance.startsWith("compact-")) {
results.push(
appearance.replace("compact-", ""),
"or-appearance-no-buttons"
);
}
}
return results;
});
const classes = ((_c = (_b = element.getAttribute("class")) == null ? void 0 : _b.trim()) == null ? void 0 : _c.split(/\s+/)) ?? [];
const className = classes.flatMap(
(className2) => className2 === "or-appearances-placeholder" ? appearanceClasses : className2
).join(" ");
element.setAttribute("class", className);
element.removeAttribute("data-appearances");
});
};
const XMLNS_URI = "http://www.w3.org/2000/xmlns/";
const correctModelNamespaces = (xslDoc, xformDoc, modelDoc) => {
var _a;
if (typeof document === "undefined") {
return;
}
const { documentElement: xslRoot } = xslDoc;
const instanceRoots = getNodesByXPathExpression(
modelDoc,
"/xmlns:root/xmlns:model/xmlns:instance/*",
NAMESPACES
);
const model = (_a = instanceRoots[0].parentElement) == null ? void 0 : _a.parentElement;
const xformModel = getNodeByXPathExpression(
xformDoc,
"/h:html/h:head/xmlns:model",
NAMESPACES
);
if (model == null || xformModel == null) {
throw new Error("XForm is missing a model element.");
}
instanceRoots.forEach((instanceRoot) => {
const xformModelAttrNamespaces = [...xformModel.attributes].filter(
({ name }) => name !== "xmlns" && !name.startsWith("xmlns:")
).map(({ namespaceURI }) => namespaceURI);
const missingNamespaceAttrs = [
...xformDoc.documentElement.attributes
].filter(
({ name, value }) => (name === "xmlns" || name.startsWith("xmlns:")) && !xslRoot.hasAttribute(name) && !xformModelAttrNamespaces.includes(value) && !instanceRoot.hasAttribute(name)
);
missingNamespaceAttrs.forEach(({ name, value }) => {
instanceRoot.setAttributeNS(XMLNS_URI, name, value);
});
});
};
export {
NAMESPACES,
transformer_default as default,
escapeURLPath,
sheets,
transform,
version
};
//# sourceMappingURL=transformer.js.map