UNPKG

@fontoxml/fontoxml-development-tools

Version:

Development tools for Fonto.

246 lines (223 loc) 7.03 kB
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), }); }); }