@fontoxml/fontoxml-development-tools
Version:
Development tools for Fonto.
246 lines (223 loc) • 7.03 kB
JavaScript
import fontoxpath from 'fontoxpath';
import { Node, parseXmlDocument } from 'slimdom';
import asyncRouteWithLockCleanupHandler from '../asyncRouteWithLockCleanupHandler.js';
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
return `${searchableText}\n\n${node.childNodes.reduce(flatten, '')}\n\n`;
}
// Recurse
return node.childNodes.reduce(flatten, searchableText);
}, '');
}
/**
*
* @param {DevelopmentCms} developmentCms
* @param {(filePath: string) => Promise<DevCmsFileLock>} acquireLock
* @param {FindAndReplacePresearchConfig} findAndReplacePresearchConfig
* @param {string | undefined} editSessionToken
* @param {string} documentId
* @param {string} searchTerm
* @param {FindAndReplaceOptions} searchOptions
*
* @return {*}
*/
async function getResultForDocumentId(
developmentCms,
acquireLock,
findAndReplacePresearchConfig,
editSessionToken,
documentId,
searchTerm,
searchOptions,
) {
let fileLock;
let contentAndLatestRevisionId;
try {
fileLock = await acquireLock(documentId);
contentAndLatestRevisionId =
await developmentCms.getFileAndLatestRevisionId(
documentId,
editSessionToken,
fileLock,
);
} catch (_error) {
return {
status: 500,
body: {
documentId,
},
};
} finally {
fileLock?.release();
}
if (!contentAndLatestRevisionId) {
return {
status: 404,
body: {
documentId,
},
};
}
try {
const searchableText = getSearchableTextForDom(
findAndReplacePresearchConfig,
parseXmlDocument(contentAndLatestRevisionId.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)) {
return null;
}
} else if (
!(
searchOptions.isCaseSensitive
? searchableText
: searchableText.toLocaleLowerCase()
).includes(
searchOptions.isCaseSensitive ? searchTerm : searchTerm.toLowerCase(),
)
) {
return null;
}
} 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}`,
);
throw xPathError;
}
return {
status: 200,
body: {
documentId,
revisionId: contentAndLatestRevisionId.revisionId,
},
};
}
let warningAboutMissingConfigurationWasLogged = false;
/**
* @param {DevCmsConfig} config
*/
export default function configureDocumentPresearchPostRouteHandler(config) {
/** @type {FindAndReplacePresearchConfig} */
const findAndReplacePresearchConfig = {
...DEFAULT_FIND_AND_REPLACE_PRESEARCH_CONFIG,
...config.findAndReplacePresearch,
};
return asyncRouteWithLockCleanupHandler(async (acquireLock, 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,
};
const matchingDocuments = await Promise.all(
req.body.documentIds.map((documentId) =>
getResultForDocumentId(
req.cms,
acquireLock,
findAndReplacePresearchConfig,
editSessionToken,
documentId,
searchTerm,
searchOptions,
),
),
);
res
.status(200)
.set('content-type', 'application/json; charset=utf-8')
.json({
results: matchingDocuments.filter((match) => !!match),
});
});
}