From 317fa1bb92b0919f1087171923c223355c1a8dc9 Mon Sep 17 00:00:00 2001 From: Tayeb Chlyah Date: Sat, 7 Mar 2026 12:51:22 +0400 Subject: [PATCH] feat(client, server) #3: deep-link search results to matching sections --- client/components/common/search-results.vue | 4 +- .../common/common-pages-query-search.gql | 1 + server/graph/resolvers/page.js | 105 +++++++++++++++++- server/graph/schemas/page.graphql | 1 + 4 files changed, 104 insertions(+), 7 deletions(-) diff --git a/client/components/common/search-results.vue b/client/components/common/search-results.vue index 3c72945c..47fe22d5 100644 --- a/client/components/common/search-results.vue +++ b/client/components/common/search-results.vue @@ -214,10 +214,10 @@ export default { this.search = term }, goToPage(item) { - window.location.assign(`/${item.locale}/${item.path}`) + window.location.assign(`/${item.locale}/${item.path}${item.anchor || ''}`) }, goToPageInNewTab(item) { - window.open(`/${item.locale}/${item.path}`, '_blank') + window.open(`/${item.locale}/${item.path}${item.anchor || ''}`, '_blank') } }, apollo: { diff --git a/client/graph/common/common-pages-query-search.gql b/client/graph/common/common-pages-query-search.gql index 27b5e372..1db9cbb6 100644 --- a/client/graph/common/common-pages-query-search.gql +++ b/client/graph/common/common-pages-query-search.gql @@ -6,6 +6,7 @@ query ($query: String!, $page: Int, $limit: Int) { title description snippet + anchor path locale } diff --git a/server/graph/resolvers/page.js b/server/graph/resolvers/page.js index 54e56ad0..198f70ab 100644 --- a/server/graph/resolvers/page.js +++ b/server/graph/resolvers/page.js @@ -1,5 +1,8 @@ const _ = require('lodash') +const cheerio = require('cheerio') const graphHelper = require('../../helpers/graph') +const he = require('he') +const striptags = require('striptags') /* global WIKI */ @@ -36,6 +39,95 @@ function buildSearchSnippet(source = '', query = '') { return buildSnippetExcerpt(source, query) } +function normalizeSearchBlockText(value = '') { + return he.decode(striptags(value, [], ' ')) + .replace(/\s+/g, ' ') + .trim() +} + +function extractSearchSegments(rawHtml = '') { + if (!rawHtml) { + return [] + } + + const $ = cheerio.load(rawHtml) + const blocks = [] + let currentAnchor = '' + + $('h1,h2,h3,h4,h5,h6,p,li,td,th,blockquote,pre').each((idx, el) => { + const isHeader = /^h[1-6]$/i.test(el.name) + const anchor = isHeader + ? ($('.toc-anchor', el).first().attr('href') || ($(el).attr('id') ? `#${$(el).attr('id')}` : '')) + : currentAnchor + const textNode = isHeader ? $(el).clone().find('.toc-anchor').remove().end() : $(el) + const text = normalizeSearchBlockText(textNode.text()) + + if (!text) { + return + } + + if (isHeader) { + currentAnchor = anchor + } + + if (_.last(blocks)?.text === text && _.last(blocks)?.anchor === anchor) { + return + } + + blocks.push({ + text, + isHeader, + anchor + }) + }) + + const segments = [] + for (let i = 0; i < blocks.length; i++) { + const block = blocks[i] + const nextBlock = blocks[i + 1] + + if (block.isHeader && nextBlock && !nextBlock.isHeader) { + segments.push({ + text: `${block.text}: ${nextBlock.text}`, + anchor: block.anchor + }) + i++ + } else { + segments.push({ + text: block.text, + anchor: block.anchor + }) + } + } + + return segments +} + +function buildSearchTarget(rawHtml = '', fallbackText = '', query = '') { + const segments = extractSearchSegments(rawHtml) + const terms = getSearchTerms(query) + const matchedSegment = _.find(segments, segment => { + if (_.isEmpty(terms)) { + return false + } + + const normalizedText = segment.text.toLowerCase() + return _.some(terms, term => normalizedText.includes(term.toLowerCase())) + }) || _.first(segments) + + if (matchedSegment) { + return { + snippet: buildSnippetExcerpt(matchedSegment.text, query), + anchor: matchedSegment.anchor || '' + } + } + + return { + snippet: buildSearchSnippet(fallbackText, query), + anchor: '' + } +} + module.exports = { Query: { async pages() { return {} } @@ -111,19 +203,22 @@ module.exports = { pageContentByKey = _.fromPairs(pages.map(pageResult => ([ `${pageResult.locale}:${pageResult.path}`, - WIKI.models.pages.buildSearchContent(pageResult.render) || pageResult.description || pageResult.title || '' + buildSearchTarget(pageResult.render, pageResult.description || pageResult.title || '', args.query) ]))) } return { ...resp, results: pagedResults.map(r => { + const searchTarget = pageContentByKey[`${r.locale}:${r.path}`] || { + snippet: buildSearchSnippet(r.snippet || r.content || r.description || r.title || r.path || '', args.query), + anchor: '' + } + return { ...r, - snippet: buildSearchSnippet( - pageContentByKey[`${r.locale}:${r.path}`] || r.snippet || r.content || r.description || r.title || r.path || '', - args.query - ) + snippet: searchTarget.snippet, + anchor: searchTarget.anchor } }), totalHits: filteredResults.length diff --git a/server/graph/schemas/page.graphql b/server/graph/schemas/page.graphql index 65147b9d..ac8ceffb 100644 --- a/server/graph/schemas/page.graphql +++ b/server/graph/schemas/page.graphql @@ -267,6 +267,7 @@ type PageSearchResult { title: String! description: String! snippet: String! + anchor: String path: String! locale: String! }