@fontoxml/fontoxml-development-tools
Version:
Development tools for Fonto.
260 lines (238 loc) • 7.16 kB
JavaScript
import fontoxpath from 'fontoxpath';
import { Node, parseXmlDocument } from 'slimdom';
const { TEXT_NODE, ELEMENT_NODE } = Node;
/** @typedef {import('slimdom').Document} Document */
/** @typedef {import('../../src/getAppConfig.js').DevCmsConfig} DevCmsConfig */
/**
* @typedef FindAndReplacePresearchConfig
*
* @property {string} blockElementXPathTest
* @property {string} outOfOrderElementXPathTest
* @property {string} removedElementXPathTest
*/
/**
* @typedef FindAndReplaceOptions
*
* @property {boolean} [isCaseSensitive]
* @property {boolean} [isWholeWordOnly]
*/
/** @type {FindAndReplacePresearchConfig} */
const DEFAULT_FIND_AND_REPLACE_PRESEARCH_CONFIG = {
blockElementXPathTest:
'self::p or self::para or self::paragraph or self::li or self::list-item or self::td or self::cell or self::entry',
outOfOrderElementXPathTest: 'self::fn or self::footnote',
removedElementXPathTest:
'self::prolog or self::meta or self::article-meta or self::head',
};
const DOCUMENTATION_FIND_AND_REPLACE_ADDON =
'https://documentation.fontoxml.com/latest/fontoxml-find-and-replace-3503bed4647f';
/**
* @param {FindAndReplacePresearchConfig} findAndReplacePresearchConfig
* @param {Document} dom
*
* @return {string}
*/
function getSearchableTextForDom(findAndReplacePresearchConfig, dom) {
return dom.childNodes.reduce(function flatten(searchableText, node) {
if (node.nodeType === TEXT_NODE) {
// Append text node data to the total of searchable text
return (searchableText += node.data);
}
if (
node.nodeType !== ELEMENT_NODE ||
// eslint-disable-next-line import/no-named-as-default-member
fontoxpath.evaluateXPathToBoolean(
findAndReplacePresearchConfig.removedElementXPathTest,
node
)
) {
// Not an element, therefore does not contribute to searchable text, OR
// the contents of this element is ignored in all search results
return searchableText;
}
if (
// eslint-disable-next-line import/no-named-as-default-member
fontoxpath.evaluateXPathToBoolean(
findAndReplacePresearchConfig.outOfOrderElementXPathTest,
node
)
) {
// Append results for an out-of-order element anywhere, but separated with newlines
return `${node.childNodes.reduce(
flatten,
''
)}\n\n${searchableText}`;
}
if (
// eslint-disable-next-line import/no-named-as-default-member
fontoxpath.evaluateXPathToBoolean(
findAndReplacePresearchConfig.blockElementXPathTest,
node
)
) {
// Separate results from within this element with a newline
searchableText += '\n\n';
}
// Recurse
return node.childNodes.reduce(flatten, searchableText);
}, '');
}
function getRevisionIdForDocumentId(
developmentCms,
editSessionToken,
documentId
) {
return new Promise((resolve, reject) =>
developmentCms.getLatestRevisionId(
documentId,
editSessionToken,
(error, revisionId) => (error ? reject(error) : resolve(revisionId))
)
);
}
/**
*
* @param {*} developmentCms
* @param {FindAndReplacePresearchConfig} findAndReplacePresearchConfig
* @param {string | undefined} editSessionToken
* @param {string} documentId
* @param {string} searchTerm
* @param {FindAndReplaceOptions} searchOptions
*
* @return {*}
*/
function getResultForDocumentId(
developmentCms,
findAndReplacePresearchConfig,
editSessionToken,
documentId,
searchTerm,
searchOptions,
) {
return new Promise((resolve, reject) =>
developmentCms.load(
documentId,
editSessionToken,
async (error, content) => {
if (error) {
reject(error);
return;
}
try {
const searchableText = getSearchableTextForDom(
findAndReplacePresearchConfig,
parseXmlDocument(content),
);
if (searchOptions.isWholeWordOnly) {
// TODO: Replace with RegExp.escape when it has landed in stage4
// https://github.com/tc39/proposal-regex-escaping/issues/58
const escapedSearchTermForRegExp = new RegExp(
`\\b${searchTerm.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&')}\\b`,
searchOptions.isCaseSensitive ? '' : 'i',
);
if (!searchableText.match(escapedSearchTermForRegExp)) {
resolve(null);
return;
}
} else if (
!(
searchOptions.isCaseSensitive
? searchableText
: searchableText.toLocaleLowerCase()
).includes(
searchOptions.isCaseSensitive
? searchTerm
: searchTerm.toLowerCase(),
)
) {
resolve(null);
return;
}
} catch (xPathError) {
// Either the XML could not be parsed, or the XPath could not be applied to it:
console.error(
`\nAn error occurred parsing or searching "${documentId}" in POST /document/presearch CMS contract:\n ${xPathError.stack}`
);
reject(xPathError);
return;
}
resolve({
status: 200,
body: {
documentId,
revisionId: await getRevisionIdForDocumentId(
developmentCms,
editSessionToken,
documentId
),
},
});
}
)
).catch((error) => ({
status: error.status === 404 ? 404 : 500,
body: {
documentId,
},
}));
}
let warningAboutMissingConfigurationWasLogged = false;
/**
* @param {DevCmsConfig} config
*/
export default function configureDocumentPresearchPostRouteHandler(config) {
/** @type {FindAndReplacePresearchConfig} */
const findAndReplacePresearchConfig = {
...DEFAULT_FIND_AND_REPLACE_PRESEARCH_CONFIG,
...config.findAndReplacePresearch,
};
return (req, res) => {
// Show an informative warning when the presearch endpoint is used by the client and there is no schema-specific
// configuration for its dev-cms stubbing.
if (
!config.findAndReplacePresearch &&
!warningAboutMissingConfigurationWasLogged
) {
console.warn(
`${
'\nThere is no configuration for the POST /document/presearch endpoint. Although default settings will now' +
' apply, this may lead to unexpected results in testing the Find and Replace functionality in the' +
' development environment. Please see '
}${DOCUMENTATION_FIND_AND_REPLACE_ADDON} for more information.`
);
warningAboutMissingConfigurationWasLogged = true;
}
/** @type {string | undefined} */
const editSessionToken = req.body?.context?.editSessionToken;
/** @type {string} */
const searchTerm = req.body?.query?.fulltext;
/** @type {FindAndReplaceOptions} */
const searchOptions = {
isCaseSensitive: !!req.body?.query?.isCaseSensitive,
isWholeWordOnly: !!req.body?.query?.isWholeWordOnly,
};
Promise.all(
req.body.documentIds.map((documentId) =>
getResultForDocumentId(
req.cms,
findAndReplacePresearchConfig,
editSessionToken,
documentId,
searchTerm,
searchOptions,
),
),
)
.then((matchingDocuments) =>
res
.status(200)
.set('content-type', 'application/json; charset=utf-8')
.json({
results: matchingDocuments.filter((match) => !!match),
})
)
.catch((error) => {
res.status(500).send(error);
});
};
}