From fef22a4d503f438c137e3da2c6e192d22a32e6cc Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sat, 15 Aug 2026 16:14:40 -0400 Subject: [PATCH 1/7] search: include _ and debounce --- znai-docs/znai/llm.txt | 167 +++++++++++++++++- .../src/doc-elements/search/SearchBox.jsx | 71 ++++---- .../doc-elements/search/flexSearch.test.ts | 47 +++++ .../src/doc-elements/search/flexSearch.ts | 48 ++++- 4 files changed, 288 insertions(+), 45 deletions(-) diff --git a/znai-docs/znai/llm.txt b/znai-docs/znai/llm.txt index 9071bff5e..4848613a0 100644 --- a/znai-docs/znai/llm.txt +++ b/znai-docs/znai/llm.txt @@ -146,8 +146,8 @@ answer-link: znai-from-export/introduction/getting-started#command-line ## CLI download Download and unzip -[znai](https://repo.maven.apache.org/maven2/org/testingisdocumenting/znai/znai-dist/1.90.1/znai-dist-1.90.1-znai.zip). -Add it to your `PATH`. +[znai](https://repo.maven.apache.org/maven2/org/testingisdocumenting/znai/znai-dist/1.91/znai-dist-1.91-znai.zip). Add +it to your `PATH`. ## Brew @@ -162,7 +162,7 @@ answer-link: znai-from-export/introduction/getting-started#maven-plugin org.testingisdocumenting.znai znai-maven-plugin - 1.90.1 + 1.91 ``` @@ -4399,6 +4399,66 @@ answer-link: znai-from-export/visuals/attention-signs#attention-block-types attention- ``` +# Visuals :: Attention Signs :: Custom Attention Block +answer-link: znai-from-export/visuals/attention-signs#custom-attention-block + +Use `attention-custom` when the built-in types are not enough. The free form parameter defines the type. It is used as a +CSS class name, exactly like `note`, `warning`, and the other built-in types. + +```markdown +```attention-custom my-type +hello world +``` +``` + + +`attention-custom` only provides the markup placeholders. Each guide is responsible for implementing the CSS for its own +types. + +Use in combination with `style.css`. Scope rules under `.theme-znai-dark` to define dark mode colors. + +```css +.znai-attention-block.my-type { + border-left: 3px solid #6f42c1; + background: #f3effb; +} + +.znai-attention-block.my-type .znai-attention-block-icon { + color: #6f42c1; +} + +.theme-znai-dark .znai-attention-block.my-type { + border-left-color: #b794f6; + background: rgba(159, 122, 234, 0.12); +} + +.theme-znai-dark .znai-attention-block.my-type .znai-attention-block-icon { + color: #b794f6; +} +``` + +# Visuals :: Attention Signs :: Custom Icon +answer-link: znai-from-export/visuals/attention-signs#custom-icon + +Unlike the built-in types, a custom type has no icon by default. Use the `icon` parameter to display one. + +To pick an icons to use go to [Feather icons](https://feathericons.com/). + +```markdown +```attention-custom my-type {icon: "zap"} +hello world +``` +``` + + +Combine `icon` with the optional `label` + +```markdown +```attention-custom my-type {icon: "zap", label: "Consider"} +hello world +``` +``` + # Visuals :: Images :: Standard Markdown answer-link: znai-from-export/visuals/images#standard-markdown @@ -4744,7 +4804,7 @@ scenario("capture screenshot") { "type" : "badge", "text" : "2", "x" : 367, - "y" : 486, + "y" : 285, "align" : "Center" } ], "pixelRatio" : 2 @@ -5114,6 +5174,15 @@ In presentation mode, rendered expressions will automatically scale to make use Note: Rendering is done by using [Mermaid](https://mermaid-js.github.io/mermaid/#/) library. +# Visuals :: Mermaid Diagrams :: Large Diagrams +answer-link: znai-from-export/visuals/mermaid-diagrams#large-diagrams + +A diagram that is too large to fit the page width is shrunk to fit and can become hard to read. Click the diagram to +open it in a full screen overlay where you can: + +- scroll/`wheel` to zoom towards the cursor +- drag to pan + # Visuals :: Mermaid Diagrams :: External File answer-link: znai-from-export/visuals/mermaid-diagrams#external-file @@ -8950,7 +9019,7 @@ scenario("capture screenshot") { "type" : "badge", "text" : "2", "x" : 367, - "y" : 486, + "y" : 285, "align" : "Center" } ], "pixelRatio" : 2 @@ -9239,8 +9308,8 @@ answer-link: znai-from-export/plugins/javascript-plugin#function-signature Your function receives three arguments: * `node` — the parent `div` to append content to. -* `args` — the parameters passed from markdown. Framework-level keys (`title`, `wide`, `className`, `anchorId`) are -handled by znai and not forwarded. +* `args` — the parameters passed from markdown. Framework-level keys (`title`, `wide`, `className`, `anchorId`, `height`) +are handled by znai and not forwarded. * `themeObservable` — live access to the current znai theme. ```javascript @@ -9305,6 +9374,39 @@ Pass `wide: true` to span the full page width, matching wide images and iframes. } ``` +# Plugins :: Javascript Plugin :: Height +answer-link: znai-from-export/plugins/javascript-plugin#height + +By default the block grows to fit whatever the function renders into it. Pass `height` to pin the block to a fixed size +— content past it scrolls inside the viewport znai gives the function. + +`height` accepts either a number (treated as pixels) or any CSS length string like `"320px"` or `"30vh"`. + +The `activityFeed` function below appends one row per event. Without `height`, all twelve rows render and the block +grows to fit them: + +```markdown +:include-javascript-function: activityFeed { + title: "deploys", + events: [ + {time: "09:14", action: "build started", detail: "commit a31f9b on main"}, + {time: "09:17", action: "tests passed", detail: "248 / 248 green"}, + ... + ] +} +``` + + +Add `height` and the same twelve events scroll inside a fixed-size box instead: + +```markdown +:include-javascript-function: activityFeed { + title: "deploys", + height: 220, + events: [/* same twelve events */] +} +``` + # Plugins :: Javascript Plugin :: Styling With A Class Name answer-link: znai-from-export/plugins/javascript-plugin#styling-with-a-class-name @@ -9469,6 +9571,52 @@ The function below powers the examples on this page. It reads the initial theme })(); ``` +```js +/* + * sample plugin showing a list of rows that grows to fit its content. + * + * the parent node sized by znai (`height` arg) is what constrains us — when + * unset, the feed renders all rows tall enough to show them all; when set, + * the same content scrolls inside the fixed viewport znai gave us. + */ +(function () { + function createElement(tagName, className, textContent) { + var el = document.createElement(tagName); + el.className = className; + if (textContent !== undefined) { + el.textContent = textContent; + } + return el; + } + + function buildRow(event) { + var row = createElement("div", "activity-feed-row"); + row.appendChild(createElement("span", "activity-feed-time", event.time || "")); + row.appendChild(createElement("span", "activity-feed-action", event.action || "")); + row.appendChild(createElement("span", "activity-feed-detail", event.detail || "")); + return row; + } + + window.activityFeed = function (node, args, themeObservable) { + var events = Array.isArray(args.events) ? args.events : []; + + var feed = createElement("div", "activity-feed"); + events.forEach(function (event) { + feed.appendChild(buildRow(event)); + }); + + node.appendChild(feed); + + function applyTheme(themeName) { + feed.classList.toggle("activity-feed-dark", themeName === "dark"); + } + + applyTheme(themeObservable.current); + themeObservable.subscribe(applyTheme); + }; +})(); +``` + # Plugins :: User Defined Plugins :: Overview answer-link: znai-from-export/plugins/user-defined-plugins#overview @@ -9941,6 +10089,11 @@ export PATH=$(pwd)/dist:$PATH znai --version ``` +# Release Notes :: 2026 :: 1.91 +answer-link: znai-from-export/release-notes/2026#191 + + + # Release Notes :: 2026 :: 1.90.1 answer-link: znai-from-export/release-notes/2026#1901 diff --git a/znai-reactjs/src/doc-elements/search/SearchBox.jsx b/znai-reactjs/src/doc-elements/search/SearchBox.jsx index cc234363a..1c5aec138 100644 --- a/znai-reactjs/src/doc-elements/search/SearchBox.jsx +++ b/znai-reactjs/src/doc-elements/search/SearchBox.jsx @@ -1,4 +1,5 @@ /* + * Copyright 2026 znai maintainers * Copyright 2019 TWO SIGMA OPEN SOURCE, LLC * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -14,43 +15,51 @@ * limitations under the License. */ -import React, {Component} from 'react' +import React, { Component } from "react"; + +const searchDebounceMs = 150; class SearchBox extends Component { - constructor(props) { - super(props) - this.state = {value: ""} - } + constructor(props) { + super(props); + this.state = { value: "" }; + } - render() { - return ( -
- this.dom = dom} - placeholder="Type terms to search..." - onKeyDown={this.onKeyDown} - value={this.state.value} - onChange={this.onInputChange}/> -
- ) - } + render() { + return ( +
+ (this.dom = dom)} + placeholder="Type terms to search..." + onKeyDown={this.onKeyDown} + value={this.state.value} + onChange={this.onInputChange} + /> +
+ ); + } - componentDidMount() { - this.dom.focus(); - } + componentDidMount() { + this.dom.focus(); + } - // TODO debounce? - onInputChange = (e) => { - const value = e.target.value - this.props.onChange(value) - this.setState({value}) - } + componentWillUnmount() { + clearTimeout(this.debounceTimer); + } + + onInputChange = (e) => { + const value = e.target.value; + this.setState({ value }); + + clearTimeout(this.debounceTimer); + this.debounceTimer = setTimeout(() => this.props.onChange(value), searchDebounceMs); + }; - onKeyDown = (e) => { - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - e.preventDefault() - } + onKeyDown = (e) => { + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); } + }; } -export default SearchBox +export default SearchBox; diff --git a/znai-reactjs/src/doc-elements/search/flexSearch.test.ts b/znai-reactjs/src/doc-elements/search/flexSearch.test.ts index 45518c97f..83ac08c9b 100644 --- a/znai-reactjs/src/doc-elements/search/flexSearch.test.ts +++ b/znai-reactjs/src/doc-elements/search/flexSearch.test.ts @@ -81,4 +81,51 @@ describe("flex search", () => { expect(truncateQueryByMinLength("typing sl", 3)).toEqual("typing"); expect(truncateQueryByMinLength("typing slo", 3)).toEqual("typing slo"); }); + + it("min query term length only counts searchable chars as the encoder strips the rest", () => { + expect(truncateQueryByMinLength("bu-", 3)).toEqual(""); + expect(truncateQueryByMinLength("c++", 3)).toEqual(""); + expect(truncateQueryByMinLength("bu_", 3)).toEqual("bu_"); + expect(truncateQueryByMinLength("bu_id", 3)).toEqual("bu_id"); + expect(truncateQueryByMinLength("c+ typing", 3)).toEqual("typing"); + }); + + it("underscore is part of code identifiers and matches by prefix", () => { + const index = createLocalSearchIndex(); + index.add({ + id: "id1", + title: "trading", + content: "use bu_id to identify business unit", + }); + index.add({ + id: "id2", + title: "building", + content: "how to build and bundle", + }); + + expect(searchWithHighlight(index, "bu_")).toEqual([ + { id: "id1", type: "content", termsToHighlight: ["bu_id"] }, + ]); + + expect(searchWithHighlight(index, "bu_id")).toEqual([ + { id: "id1", type: "content", termsToHighlight: ["bu_id"] }, + ]); + }); + + it("code identifier with underscore matches by its start", () => { + const index = createLocalSearchIndex(); + index.add({ + id: "id1", + title: "config", + content: "defines build_config for projects", + }); + + expect(searchWithHighlight(index, "build")).toEqual([ + { id: "id1", type: "content", termsToHighlight: ["build_config"] }, + ]); + + expect(searchWithHighlight(index, "build_c")).toEqual([ + { id: "id1", type: "content", termsToHighlight: ["build_config"] }, + ]); + }); }); diff --git a/znai-reactjs/src/doc-elements/search/flexSearch.ts b/znai-reactjs/src/doc-elements/search/flexSearch.ts index 234ef9d29..d4d6034b3 100644 --- a/znai-reactjs/src/doc-elements/search/flexSearch.ts +++ b/znai-reactjs/src/doc-elements/search/flexSearch.ts @@ -17,6 +17,18 @@ import { Document } from "flexsearch"; import FlexSearch from "flexsearch"; +// default encoder splits terms on any non alphanumeric char, keep underscore as part of terms +// so code identifiers like `bu_id` are indexed as is and can be found by typing `bu_` +function createSearchEncoder() { + return new FlexSearch.Encoder({ + include: { + letter: true, + number: true, + char: "_", + }, + }); +} + export function createLocalSearchIndex() { return new FlexSearch.Document({ preset: "score", @@ -24,6 +36,7 @@ export function createLocalSearchIndex() { context: true, store: true, resolution: 3, + encoder: createSearchEncoder(), document: { id: "id", index: [ @@ -31,8 +44,6 @@ export function createLocalSearchIndex() { field: "title", tokenize: "forward", }, - // for contentHigh use custom encoder that allows underscored and symbols like semicolons(?) - // maybe allow search from the middle as well { field: "contentHigh", tokenize: "forward", @@ -68,8 +79,17 @@ export interface SearchResult { } const highlightRegex = /@\w+\b/g; + +// highlight re-encodes the full stored content of every result, so cost per keystroke +// is linear in the number of results (flexsearch default is 100 per field) +const resultsPerFieldLimit = 30; + export function searchWithHighlight(index: Document, query: string) { - const searchResults = index.search(query, { enrich: true, highlight: { template: "@$1" } }); + const searchResults = index.search(query, { + enrich: true, + limit: resultsPerFieldLimit, + highlight: { template: "@$1" }, + }); const withHighlights: SearchResult[] = []; for (let idx = 0; idx < searchResults.length; idx++) { @@ -80,9 +100,14 @@ export function searchWithHighlight(index: Document, query: string) { const subResult = results[resultIdx]; let termsToHighlight: string[] = []; if (subResult.highlight) { - termsToHighlight = (subResult.highlight.match(highlightRegex) || []) - .map((term) => term.substring(1)) - .filter((term) => term.length > 2); + // highlight marks every occurrence, dedupe so the same word is not highlighted multiple times downstream + termsToHighlight = [ + ...new Set( + (subResult.highlight.match(highlightRegex) || []) + .map((term) => term.substring(1)) + .filter((term) => term.length > 2) + ), + ]; } withHighlights.push({ @@ -96,10 +121,19 @@ export function searchWithHighlight(index: Document, query: string) { return withHighlights; } +const nonSearchableCharsRegex = /[^\p{L}\p{N}_]+/gu; + export function truncateQueryByMinLength(query: string, minLength: number) { return query .split(" ") .map((e) => e.trim()) - .filter((e) => e.length >= minLength) + .filter((e) => effectiveTermLength(e) >= minLength) .join(" "); } + +// search encoder strips chars other than alphanumeric and underscore, e.g. "c++" is searched as the one char prefix "c", +// so only searchable chars count towards the min length, otherwise "c++" bypasses the guard +// and triggers an expensive short prefix search +function effectiveTermLength(term: string) { + return term.replace(nonSearchableCharsRegex, "").length; +} From 4a465e745d6795e41a5177432a2f406cab911240 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 16 Aug 2026 09:20:02 -0400 Subject: [PATCH 2/7] search: include _ and debounce --- .../default-elements/DocElement.tsx | 8 ++- .../page/default/DefaultPageContent.tsx | 6 +- .../doc-elements/read-more/ReadMore.test.tsx | 69 +++++++++++++++++++ .../src/doc-elements/read-more/ReadMore.tsx | 37 ++++++++-- .../src/doc-elements/search/SearchPopup.jsx | 5 +- .../src/doc-elements/search/SearchPreview.jsx | 9 +-- .../src/doc-elements/search/flexSearch.ts | 18 +++-- .../search/searchSnippetsContentMatch.test.ts | 52 ++++++++++++++ .../search/searchSnippetsContentMatch.ts | 53 ++++++++++++++ .../componentsHighlightUtils.ts | 17 ++++- 10 files changed, 245 insertions(+), 29 deletions(-) create mode 100644 znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx create mode 100644 znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.test.ts create mode 100644 znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.ts diff --git a/znai-reactjs/src/doc-elements/default-elements/DocElement.tsx b/znai-reactjs/src/doc-elements/default-elements/DocElement.tsx index 14c127a17..6fae03d06 100644 --- a/znai-reactjs/src/doc-elements/default-elements/DocElement.tsx +++ b/znai-reactjs/src/doc-elements/default-elements/DocElement.tsx @@ -21,7 +21,9 @@ export type ElementsLibraryMap = { [key: string]: any }; export type DocElementContent = DocElementPayload[]; interface CommonProps { - isPartOfSearch?: boolean; // when element is rendered in search preview or after section is selected as search result + // matched search terms, present only when element is rendered in search preview or after section + // is selected as search result; lets elements with hidden content decide what to reveal during search + searchSnippets?: string[]; noGap?: boolean; content?: DocElementContent; next?: DocElementPayload; @@ -41,7 +43,7 @@ export interface WithElementsLibrary { /** * uses a given set of components to render DocElements like links, paragraphs, code blocks, etc */ -export function DocElement({ content, elementsLibrary, isPartOfSearch }: DocElementProps) { +export function DocElement({ content, elementsLibrary, searchSnippets }: DocElementProps) { if (!content) { return null; } @@ -52,7 +54,7 @@ export function DocElement({ content, elementsLibrary, isPartOfSearch }: DocElem while (contentProvider.peekCurrent()) { const found = findRenderComponent(elementsLibrary, contentProvider); const ElementToUse = found.component; - const propsToUse = { isPartOfSearch, ...found.propsToUse }; + const propsToUse = { searchSnippets, ...found.propsToUse }; if (!ElementToUse) { console.warn("can't find component to display: " + JSON.stringify(contentProvider.peekCurrent())); diff --git a/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx b/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx index b37052182..22163f408 100644 --- a/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx +++ b/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx @@ -63,14 +63,14 @@ export function DefaultPageContent(props: Props) { const renderedSections = content!.map((section) => { // @ts-ignore const id = section.id; - const isPartOfSearch = isSearchResultOnThisPage && id === searchResultId.pageSectionId; + const isSelectedAsSearchResult = isSearchResultOnThisPage && id === searchResultId.pageSectionId; return ( ); }); diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx new file mode 100644 index 000000000..27b2c0699 --- /dev/null +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx @@ -0,0 +1,69 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { render, fireEvent } from "@testing-library/react"; +import React from "react"; + +import { ReadMore } from "./ReadMore"; +import { DocElement, DocElementContent } from "../default-elements/DocElement"; + +const elementsLibrary: any = { + DocElement, + TestText: ({ text }: { text: string }) =>
{text}
, +}; + +const content: DocElementContent = [{ type: "TestText", text: "hidden cancel_trade details" } as any]; + +function renderReadMore(extraProps: object = {}) { + return render( + + ); +} + +describe("ReadMore", () => { + it("keeps collapsed content mounted on a regular page for the highlight engine", () => { + const { container } = renderReadMore(); + + expect(container.querySelector(".znai-read-more")).toHaveClass("collapsed"); + expect(container.querySelector(".test-text")).not.toBeNull(); + }); + + it("reveals content during search and highlights terms when it contains matched terms", () => { + const { container } = renderReadMore({ searchSnippets: ["cancel_trade"] }); + + expect(container.querySelector(".znai-read-more")).toHaveClass("expanded"); + expect(container.querySelector(".test-text")).not.toBeNull(); + expect(container.querySelector("mark")).not.toBeNull(); + }); + + it("does not mount content during search when no terms match", () => { + const { container } = renderReadMore({ searchSnippets: ["deploy"] }); + + expect(container.querySelector(".znai-read-more")).toHaveClass("collapsed"); + expect(container.querySelector(".test-text")).toBeNull(); + }); + + it("mounts content on manual reveal during search", () => { + const { container } = renderReadMore({ searchSnippets: ["deploy"] }); + + expect(container.querySelector(".test-text")).toBeNull(); + + fireEvent.click(container.querySelector(".znai-read-more-title-block")!); + + expect(container.querySelector(".test-text")).not.toBeNull(); + }); +}); diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx index 2b91a01c7..bb6e70f94 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx @@ -14,23 +14,48 @@ * limitations under the License. */ -import React, { useRef, useState } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { DocElementProps } from "../default-elements/DocElement"; import { Icon } from "../icons/Icon"; import { useHighlightOfHiddenElement } from "../text-selection/componentsHighlightUtils"; +import { contentMatchesSearchSnippets } from "../search/searchSnippetsContentMatch"; +import { highlightSearchResultAndMaybeScroll } from "../search/searchResultHighlighter"; import "./ReadMore.css"; interface Props extends DocElementProps { title: string; } -export function ReadMore({ title, content, isPartOfSearch, elementsLibrary }: Props) { - const [expanded, setExpanded] = useState(() => isPartOfSearch); +export function ReadMore({ title, content, searchSnippets, elementsLibrary }: Props) { + const isPartOfSearch = searchSnippets !== undefined; + + // during search auto reveal only when content has matched search terms, + // pages with dozens of read more blocks are too expensive to render and highlight fully expanded + const [expanded, setExpanded] = useState( + () => searchSnippets !== undefined && contentMatchesSearchSnippets(content, searchSnippets) + ); const containerRef = useRef(null); const hiddenContainerRef = useRef(null); - const hasHiddenHighlightedElement = useHighlightOfHiddenElement(containerRef, hiddenContainerRef, expanded); + const hasHiddenHighlightedElement = useHighlightOfHiddenElement( + containerRef, + hiddenContainerRef, + expanded, + isPartOfSearch + ); + + // highlight search terms inside revealed content, manually revealed content + // is not covered by the initial search highlight pass + useEffect(() => { + if (expanded && searchSnippets && containerRef.current) { + highlightSearchResultAndMaybeScroll(containerRef.current, searchSnippets, false); + } + }, [expanded]); + + // during search, collapsed content is not mounted to avoid rendering and highlighting hidden blocks, + // regular pages keep hidden content mounted so the highlight engine can find it + const renderContent = expanded || !isPartOfSearch; const expandedClassName = expanded ? "expanded" : "collapsed"; const topClassName = "znai-read-more content-block " + expandedClassName; @@ -49,7 +74,9 @@ export function ReadMore({ title, content, isPartOfSearch, elementsLibrary }: Pr
{summary}
- + {renderContent && ( + + )}
); diff --git a/znai-reactjs/src/doc-elements/search/SearchPopup.jsx b/znai-reactjs/src/doc-elements/search/SearchPopup.jsx index 98895319f..c19a3b49a 100644 --- a/znai-reactjs/src/doc-elements/search/SearchPopup.jsx +++ b/znai-reactjs/src/doc-elements/search/SearchPopup.jsx @@ -81,6 +81,9 @@ class SearchPopup extends Component { const { elementsLibrary } = this.props; const { search } = this.state; + // key by content identity so the preview remounts and re-highlights only when the result or matched terms change + const previewKey = ids[selectedIdx] + "#" + previewDetails.snippets.join(" "); + return (
@@ -93,7 +96,7 @@ class SearchPopup extends Component { />
- +
); diff --git a/znai-reactjs/src/doc-elements/search/SearchPreview.jsx b/znai-reactjs/src/doc-elements/search/SearchPreview.jsx index 8ae921705..29333ab37 100644 --- a/znai-reactjs/src/doc-elements/search/SearchPreview.jsx +++ b/znai-reactjs/src/doc-elements/search/SearchPreview.jsx @@ -23,16 +23,17 @@ class SearchPreview extends Component { this.highlight(); } - componentDidUpdate(_prevProp, _prevState) { - this.highlight(); + shouldComponentUpdate() { + // SearchPopup keys the preview by result id and matched terms, any content change remounts it + return false; } render() { - const { section, elementsLibrary } = this.props; + const { section, snippets, elementsLibrary } = this.props; const key = section.id + "#" + section.title; return (
(this.dom = dom)}> - +
); } diff --git a/znai-reactjs/src/doc-elements/search/flexSearch.ts b/znai-reactjs/src/doc-elements/search/flexSearch.ts index d4d6034b3..ba6afeb24 100644 --- a/znai-reactjs/src/doc-elements/search/flexSearch.ts +++ b/znai-reactjs/src/doc-elements/search/flexSearch.ts @@ -19,15 +19,13 @@ import FlexSearch from "flexsearch"; // default encoder splits terms on any non alphanumeric char, keep underscore as part of terms // so code identifiers like `bu_id` are indexed as is and can be found by typing `bu_` -function createSearchEncoder() { - return new FlexSearch.Encoder({ - include: { - letter: true, - number: true, - char: "_", - }, - }); -} +const searchEncoder = new FlexSearch.Encoder({ + include: { + letter: true, + number: true, + char: "_", + }, +}); export function createLocalSearchIndex() { return new FlexSearch.Document({ @@ -36,7 +34,7 @@ export function createLocalSearchIndex() { context: true, store: true, resolution: 3, - encoder: createSearchEncoder(), + encoder: searchEncoder, document: { id: "id", index: [ diff --git a/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.test.ts b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.test.ts new file mode 100644 index 000000000..c5689fb61 --- /dev/null +++ b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { contentMatchesSearchSnippets } from "./searchSnippetsContentMatch"; +import { DocElementContent } from "../default-elements/DocElement"; + +const content: DocElementContent = [ + { + type: "Paragraph", + content: [{ type: "SimpleText", text: "use cancel_trade to abort" } as any], + }, + { type: "Snippet", snippet: "def my_func():\n pass" } as any, +]; + +describe("contentMatchesSearchSnippets", () => { + it("matches term inside nested text", () => { + expect(contentMatchesSearchSnippets(content, ["cancel_trade"])).toBe(true); + expect(contentMatchesSearchSnippets(content, ["my_func"])).toBe(true); + expect(contentMatchesSearchSnippets(content, ["deploy", "abort"])).toBe(true); + }); + + it("ignores letters case", () => { + expect(contentMatchesSearchSnippets(content, ["CANCEL_TRADE"])).toBe(true); + }); + + it("no match when terms are not part of content", () => { + expect(contentMatchesSearchSnippets(content, ["deploy"])).toBe(false); + }); + + it("does not match against doc element type names", () => { + expect(contentMatchesSearchSnippets(content, ["Paragraph"])).toBe(false); + }); + + it("no match when terms list is empty or content is missing", () => { + expect(contentMatchesSearchSnippets(content, [])).toBe(false); + expect(contentMatchesSearchSnippets(undefined, ["abort"])).toBe(false); + }); +}); diff --git a/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.ts b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.ts new file mode 100644 index 000000000..144168808 --- /dev/null +++ b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DocElementContent } from "../default-elements/DocElement"; + +/** + * checks if doc elements content contains any of the matched search terms. + * lets elements with hidden content (e.g. read more) decide whether to auto reveal it + * during search instead of rendering and highlighting every hidden block. + */ +export function contentMatchesSearchSnippets(content: DocElementContent | undefined, searchSnippets: string[]) { + if (!content || searchSnippets.length === 0) { + return false; + } + + const loweredSnippets = searchSnippets.map((snippet) => snippet.toLowerCase()); + return anyTextValueMatches(content, loweredSnippets); +} + +function anyTextValueMatches(value: unknown, loweredSnippets: string[]): boolean { + if (typeof value === "string") { + const text = value.toLowerCase(); + return loweredSnippets.some((snippet) => text.includes(snippet)); + } + + if (Array.isArray(value)) { + return value.some((entry) => anyTextValueMatches(entry, loweredSnippets)); + } + + if (typeof value === "object" && value !== null) { + for (const key in value) { + // type holds doc element names like Snippet and is not a visible text + if (key !== "type" && anyTextValueMatches((value as Record)[key], loweredSnippets)) { + return true; + } + } + } + + return false; +} diff --git a/znai-reactjs/src/doc-elements/text-selection/componentsHighlightUtils.ts b/znai-reactjs/src/doc-elements/text-selection/componentsHighlightUtils.ts index c8c9ee08f..d6b777f14 100644 --- a/znai-reactjs/src/doc-elements/text-selection/componentsHighlightUtils.ts +++ b/znai-reactjs/src/doc-elements/text-selection/componentsHighlightUtils.ts @@ -28,13 +28,20 @@ Hook below attempts to encapsulate this logic. export function useHighlightOfHiddenElement( containerRef: MutableRefObject, hiddenContainerRef: MutableRefObject, - contentVisibilityTrigger: any + contentVisibilityTrigger: any, + // during search, content reveal is driven by matched search terms instead, + // skip user driven highlights re-apply to avoid page wide highlight passes on every search interaction + disabled?: boolean ) { const restoreFirstHighlightElementFunRef = useRef<(() => void) | null>(null); const [hasHiddenHighlightedElement, setHasHiddenHighlightedElement] = useState(false); const onlyOnce = useRef(false); useEffect(() => { + if (disabled) { + return; + } + const listener = { onUserDrivenTextHighlight: (firstHighlightElement: HTMLElement, hideBubble: () => void) => { if ( @@ -57,10 +64,14 @@ export function useHighlightOfHiddenElement( return () => { removeHighlightedTextListener(listener); }; - }, []); + }, [disabled]); // this may not work with Tabs useEffect(() => { + if (disabled) { + return; + } + if (restoreFirstHighlightElementFunRef.current && onlyOnce.current) { restoreFirstHighlightElementFunRef.current(); restoreFirstHighlightElementFunRef.current = null; @@ -68,7 +79,7 @@ export function useHighlightOfHiddenElement( // TODO only reapply for the specific highlight(s) that are affected maybe somehow(?) // or make sure that the scroll to the selected question is triggered everytime someone toggles read more reapplyTextHighlights(); - }, [contentVisibilityTrigger]); + }, [contentVisibilityTrigger, disabled]); return hasHiddenHighlightedElement; } From 38a3af21c20c21377c166e235497e96752c6f750 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 16 Aug 2026 20:09:01 -0400 Subject: [PATCH 3/7] search: include _ and debounce --- .../hidden-content/hiddenContentUtils.ts | 55 +++++++++++++++++++ .../src/doc-elements/read-more/ReadMore.css | 6 ++ .../doc-elements/read-more/ReadMore.test.tsx | 15 +++++ .../src/doc-elements/read-more/ReadMore.tsx | 6 +- 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts diff --git a/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts b/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts new file mode 100644 index 000000000..c841b99f4 --- /dev/null +++ b/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RefObject, useEffect, useLayoutEffect } from "react"; + +/** + * hides collapsible content with hidden="until-found" so browser find-in-page can still match it, + * onFindInPageReveal is invoked when the browser reveals the content on a match so the owner + * can sync its expanded state. + * + * the attribute is set manually because react normalizes hidden to a boolean and drops the value. + * browsers without until-found support treat it as plain hidden which matches display none behavior + */ +export function useHiddenUntilFound( + hiddenContainerRef: RefObject, + hidden: boolean, + onFindInPageReveal: () => void +) { + useLayoutEffect(() => { + const hiddenContainer = hiddenContainerRef.current; + if (!hiddenContainer) { + return; + } + + if (hidden) { + hiddenContainer.setAttribute("hidden", "until-found"); + } else { + hiddenContainer.removeAttribute("hidden"); + } + }, [hidden]); + + // browser fires beforematch right before revealing hidden content matched by find-in-page + useEffect(() => { + const hiddenContainer = hiddenContainerRef.current; + if (!hiddenContainer) { + return; + } + + hiddenContainer.addEventListener("beforematch", onFindInPageReveal); + return () => hiddenContainer.removeEventListener("beforematch", onFindInPageReveal); + }, [onFindInPageReveal]); +} diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.css b/znai-reactjs/src/doc-elements/read-more/ReadMore.css index ad82af422..104677bbf 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.css +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.css @@ -26,6 +26,12 @@ padding-top: 8px; } +/* collapsed content uses hidden="until-found" which hides via content-visibility and keeps the element own box, + remove the padding so the collapsed block takes no space */ +.znai-read-more-content[hidden] { + padding: 0; +} + .znai-read-more-title-block { display: flex; align-items: center; diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx index 27b2c0699..3d30d7ed1 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx @@ -42,6 +42,21 @@ describe("ReadMore", () => { expect(container.querySelector(".test-text")).not.toBeNull(); }); + it("hides collapsed content with until-found so browser find-in-page can match it", () => { + const { container } = renderReadMore(); + + expect(container.querySelector(".znai-read-more-content")).toHaveAttribute("hidden", "until-found"); + }); + + it("expands when browser find-in-page reveals hidden content", () => { + const { container } = renderReadMore(); + + fireEvent(container.querySelector(".znai-read-more-content")!, new Event("beforematch")); + + expect(container.querySelector(".znai-read-more")).toHaveClass("expanded"); + expect(container.querySelector(".znai-read-more-content")).not.toHaveAttribute("hidden"); + }); + it("reveals content during search and highlights terms when it contains matched terms", () => { const { container } = renderReadMore({ searchSnippets: ["cancel_trade"] }); diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx index bb6e70f94..d2d4d2633 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx @@ -20,6 +20,7 @@ import { DocElementProps } from "../default-elements/DocElement"; import { Icon } from "../icons/Icon"; import { useHighlightOfHiddenElement } from "../text-selection/componentsHighlightUtils"; +import { useHiddenUntilFound } from "../hidden-content/hiddenContentUtils"; import { contentMatchesSearchSnippets } from "../search/searchSnippetsContentMatch"; import { highlightSearchResultAndMaybeScroll } from "../search/searchResultHighlighter"; import "./ReadMore.css"; @@ -53,6 +54,8 @@ export function ReadMore({ title, content, searchSnippets, elementsLibrary }: Pr } }, [expanded]); + useHiddenUntilFound(hiddenContainerRef, !expanded, () => setExpanded(true)); + // during search, collapsed content is not mounted to avoid rendering and highlighting hidden blocks, // regular pages keep hidden content mounted so the highlight engine can find it const renderContent = expanded || !isPartOfSearch; @@ -69,11 +72,10 @@ export function ReadMore({ title, content, searchSnippets, elementsLibrary }: Pr {title} ); - const style = expanded ? { display: "block" } : { display: "none" }; return (
{summary} -
+
{renderContent && ( )} From b3ccacf1aeee2ad89bc2df589dc79f396558d0c7 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 16 Aug 2026 21:31:59 -0400 Subject: [PATCH 4/7] search: include _ and debounce --- .../page/default/DefaultPageContent.tsx | 4 +- .../doc-elements/read-more/ReadMore.test.tsx | 3 +- .../src/doc-elements/read-more/ReadMore.tsx | 11 +-- .../src/doc-elements/search/SearchPreview.jsx | 13 ++- .../search/searchResultHighlighter.test.ts | 82 +++++++++++++++++++ .../search/searchResultHighlighter.ts | 57 ++++++++++--- 6 files changed, 137 insertions(+), 33 deletions(-) create mode 100644 znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts diff --git a/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx b/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx index 22163f408..d0f4dc440 100644 --- a/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx +++ b/znai-reactjs/src/doc-elements/page/default/DefaultPageContent.tsx @@ -20,7 +20,7 @@ import { afterTitleId } from "../../../layout/classNamesAndIds"; import { DocElementProps } from "../../default-elements/DocElement"; import { SearchResultId } from "../../search/SearchResultId"; import { TocItem } from "../../../structure/TocItem"; -import { highlightSearchResultAndMaybeScroll, removeSearchHighlight } from "../../search/searchResultHighlighter"; +import { removeSearchHighlight, startSearchHighlightSession } from "../../search/searchResultHighlighter"; interface Props extends DocElementProps { tocItem: TocItem; @@ -41,7 +41,7 @@ export function DefaultPageContent(props: Props) { useEffect(() => { if (searchSnippetsToHighlight && isSearchResultOnThisPage && contentRootDom) { - highlightSearchResultAndMaybeScroll(contentRootDom, searchSnippetsToHighlight, false); + return startSearchHighlightSession(contentRootDom, searchSnippetsToHighlight); } }, [searchSnippetsToHighlight]); diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx index 3d30d7ed1..208df6a5a 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx @@ -57,12 +57,11 @@ describe("ReadMore", () => { expect(container.querySelector(".znai-read-more-content")).not.toHaveAttribute("hidden"); }); - it("reveals content during search and highlights terms when it contains matched terms", () => { + it("reveals content during search when it contains matched terms", () => { const { container } = renderReadMore({ searchSnippets: ["cancel_trade"] }); expect(container.querySelector(".znai-read-more")).toHaveClass("expanded"); expect(container.querySelector(".test-text")).not.toBeNull(); - expect(container.querySelector("mark")).not.toBeNull(); }); it("does not mount content during search when no terms match", () => { diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx index d2d4d2633..fb30e7600 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { useEffect, useRef, useState } from "react"; +import React, { useRef, useState } from "react"; import { DocElementProps } from "../default-elements/DocElement"; import { Icon } from "../icons/Icon"; @@ -22,7 +22,6 @@ import { Icon } from "../icons/Icon"; import { useHighlightOfHiddenElement } from "../text-selection/componentsHighlightUtils"; import { useHiddenUntilFound } from "../hidden-content/hiddenContentUtils"; import { contentMatchesSearchSnippets } from "../search/searchSnippetsContentMatch"; -import { highlightSearchResultAndMaybeScroll } from "../search/searchResultHighlighter"; import "./ReadMore.css"; interface Props extends DocElementProps { @@ -46,14 +45,6 @@ export function ReadMore({ title, content, searchSnippets, elementsLibrary }: Pr isPartOfSearch ); - // highlight search terms inside revealed content, manually revealed content - // is not covered by the initial search highlight pass - useEffect(() => { - if (expanded && searchSnippets && containerRef.current) { - highlightSearchResultAndMaybeScroll(containerRef.current, searchSnippets, false); - } - }, [expanded]); - useHiddenUntilFound(hiddenContainerRef, !expanded, () => setExpanded(true)); // during search, collapsed content is not mounted to avoid rendering and highlighting hidden blocks, diff --git a/znai-reactjs/src/doc-elements/search/SearchPreview.jsx b/znai-reactjs/src/doc-elements/search/SearchPreview.jsx index 29333ab37..67d814551 100644 --- a/znai-reactjs/src/doc-elements/search/SearchPreview.jsx +++ b/znai-reactjs/src/doc-elements/search/SearchPreview.jsx @@ -16,11 +16,15 @@ */ import React, { Component } from "react"; -import { highlightSearchResultAndMaybeScroll } from "./searchResultHighlighter.ts"; +import { startSearchHighlightSession } from "./searchResultHighlighter.ts"; class SearchPreview extends Component { componentDidMount() { - this.highlight(); + this.disposeHighlightSession = startSearchHighlightSession(this.dom, this.props.snippets); + } + + componentWillUnmount() { + this.disposeHighlightSession(); } shouldComponentUpdate() { @@ -37,11 +41,6 @@ class SearchPreview extends Component {
); } - - highlight() { - const { snippets } = this.props; - highlightSearchResultAndMaybeScroll(this.dom, snippets); - } } export default SearchPreview; diff --git a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts new file mode 100644 index 000000000..cfc6e32ec --- /dev/null +++ b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, describe, expect, it } from "vitest"; +import { removeSearchHighlight, startSearchHighlightSession } from "./searchResultHighlighter"; + +let root: HTMLElement; +let disposeSession: (() => void) | undefined; + +function startSession(snippets: string[]) { + root = document.createElement("div"); + root.innerHTML = "

use cancel_trade to abort

"; + document.body.appendChild(root); + + disposeSession = startSearchHighlightSession(root, snippets); +} + +function mountLateContent() { + const lateMounted = document.createElement("div"); + lateMounted.innerHTML = "

late mounted cancel_trade details

"; + root.appendChild(lateMounted); + return lateMounted; +} + +// MutationObserver delivers records in a microtask +function observerDelivery() { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +afterEach(() => { + disposeSession?.(); + root.remove(); +}); + +describe("startSearchHighlightSession", () => { + it("highlights snippets present at session start", () => { + startSession(["cancel_trade"]); + + expect(root.querySelectorAll("mark").length).toBe(1); + }); + + it("highlights content mounted after session start", async () => { + startSession(["cancel_trade"]); + const lateMounted = mountLateContent(); + + await observerDelivery(); + + expect(lateMounted.querySelectorAll("mark").length).toBe(1); + // marks added by the session itself must not re-trigger another marking pass + expect(root.querySelectorAll("mark").length).toBe(2); + }); + + it("stops highlighting late mounted content after dispose", async () => { + startSession(["cancel_trade"]); + disposeSession!(); + + const lateMounted = mountLateContent(); + await observerDelivery(); + + expect(lateMounted.querySelector("mark")).toBeNull(); + }); + + it("removeSearchHighlight removes session marks", () => { + startSession(["cancel_trade"]); + removeSearchHighlight(root); + + expect(root.querySelector("mark")).toBeNull(); + }); +}); diff --git a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts index 7b56b86fc..d4abe2da3 100644 --- a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts +++ b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts @@ -18,7 +18,51 @@ // @ts-ignore import Mark from "mark.js/dist/mark.js"; -export function highlightSearchResultAndMaybeScroll(root: HTMLElement, snippets: string[], scroll: boolean) { +const observeConfig = { childList: true, subtree: true }; + +/** + * highlights search snippets inside root and keeps highlighting content that mounts later, + * e.g. a read more block revealed while a search result is displayed. + * content components stay unaware of the highlight mechanics this way. + * + * returned dispose stops watching for late mounted content, + * highlights are removed separately with removeSearchHighlight + */ +export function startSearchHighlightSession(root: HTMLElement, snippets: string[]): () => void { + markSnippets(root, snippets); + + const observer = new MutationObserver((mutations) => { + const lateMounted: HTMLElement[] = []; + for (const mutation of mutations) { + for (const node of Array.from(mutation.addedNodes)) { + if (node instanceof HTMLElement && node.tagName !== "MARK") { + lateMounted.push(node); + } + } + } + + if (lateMounted.length === 0) { + return; + } + + // pause observing while marking so mark.js own dom changes don't re-trigger this callback + observer.disconnect(); + lateMounted.forEach((lateMountedRoot) => markSnippets(lateMountedRoot, snippets)); + observer.takeRecords(); + observer.observe(root, observeConfig); + }); + + observer.observe(root, observeConfig); + + return () => observer.disconnect(); +} + +export function removeSearchHighlight(root: HTMLElement) { + const mark = new Mark(root); + mark.unmark({}); +} + +function markSnippets(root: HTMLElement, snippets: string[]) { const mark = new Mark(root); mark.unmark({ done: () => { @@ -30,18 +74,7 @@ export function highlightSearchResultAndMaybeScroll(root: HTMLElement, snippets: diacritics: false, ignorePunctuation: ["(", ")", ";", "[", "]", "-", "_", ".", ",", '"', "'", "~"], accuracy: "partially", - done: () => { - const marked = root.querySelector("mark"); - if (marked && scroll) { - marked.scrollIntoView(); - } - }, }); }, }); } - -export function removeSearchHighlight(root: HTMLElement) { - const mark = new Mark(root); - mark.unmark({}); -} From 72c60c617d0e0aca636efc53620194cdb65f4e3f Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 23 Aug 2026 18:37:25 -0400 Subject: [PATCH 5/7] search: include _ and debounce --- .../src/doc-elements/bullets/bulletUtils.js | 28 +-- .../contentTreeWalker.test.ts | 110 +++++++++ .../default-elements/contentTreeWalker.ts | 83 +++++++ .../hidden-content/hiddenContent.css | 24 ++ .../hidden-content/hiddenContentUtils.ts | 22 +- .../page/page-tabs/pageTabsContentUtils.ts | 23 +- .../doc-elements/read-more/ReadMore.test.tsx | 30 +++ .../src/doc-elements/read-more/ReadMore.tsx | 4 +- .../src/doc-elements/search/QueryResult.ts | 28 ++- .../src/doc-elements/search/Search.js | 77 ++++-- .../src/doc-elements/search/Search.test.ts | 89 +++++++ .../src/doc-elements/search/SearchPreview.jsx | 3 +- .../doc-elements/search/flexSearch.test.ts | 220 +++++++++++------- .../src/doc-elements/search/flexSearch.ts | 92 ++++---- .../search/searchResultHighlighter.test.ts | 33 ++- .../search/searchResultHighlighter.ts | 10 +- .../search/searchSnippetsContentMatch.test.ts | 6 + .../search/searchSnippetsContentMatch.ts | 36 +-- .../src/doc-elements/tabs/tabsUtils.js | 20 +- 19 files changed, 692 insertions(+), 246 deletions(-) create mode 100644 znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.test.ts create mode 100644 znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.ts create mode 100644 znai-reactjs/src/doc-elements/hidden-content/hiddenContent.css create mode 100644 znai-reactjs/src/doc-elements/search/Search.test.ts diff --git a/znai-reactjs/src/doc-elements/bullets/bulletUtils.js b/znai-reactjs/src/doc-elements/bullets/bulletUtils.js index 5229dcc1d..374f362a3 100644 --- a/znai-reactjs/src/doc-elements/bullets/bulletUtils.js +++ b/znai-reactjs/src/doc-elements/bullets/bulletUtils.js @@ -15,6 +15,8 @@ * limitations under the License. */ +import { walkContentNodes } from '../default-elements/contentTreeWalker' + export function startsWithIcon(content) { return content && content.length && content[0].type === 'Paragraph' && @@ -64,29 +66,17 @@ export function extractTextLinesEmphasisOrFull(content) { function extractText(listItem, emphasisedOnly) { const result = [] - collectTextRecursively(result, listItem.content, emphasisedOnly, false) - - return capitalizeFirstLetter(result.join(" ")) -} - -function collectTextRecursively(result, content, emphasisedOnly, withinEmphasis) { - if (! content) { - return - } + walkContentNodes(listItem.content, (item, ancestors) => { + if (item.type !== "SimpleText") { + return + } - content.forEach(item => { - if (item.type === "SimpleText") { - if (emphasisedOnly && withinEmphasis) { - result.push(item.text) - } else if (! emphasisedOnly) { - result.push(item.text) - } - } else { - collectTextRecursively(result, item.content, emphasisedOnly, withinEmphasis || isEmphasis(item)) + if (! emphasisedOnly || ancestors.some(isEmphasis)) { + result.push(item.text) } }) - return result + return capitalizeFirstLetter(result.join(" ")) } function isEmphasis(docElement) { diff --git a/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.test.ts b/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.test.ts new file mode 100644 index 000000000..25bfc35b8 --- /dev/null +++ b/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { someTextValue, walkContentNodes } from "./contentTreeWalker"; +import { DocElementContent } from "./DocElement"; + +const nestedContent: DocElementContent = [ + { + type: "Section", + content: [ + { + type: "Paragraph", + content: [{ type: "SimpleText", text: "hello" } as any], + }, + { type: "Snippet" }, + ], + }, + { type: "Meta" }, +]; + +describe("walkContentNodes", () => { + it("visits nodes depth-first passing ancestors chain", () => { + const visited: string[] = []; + const stopped = walkContentNodes(nestedContent, (node, ancestors) => { + visited.push([...ancestors, node].map((n) => n.type).join(">")); + }); + + expect(stopped).toBe(false); + expect(visited).toEqual([ + "Section", + "Section>Paragraph", + "Section>Paragraph>SimpleText", + "Section>Snippet", + "Meta", + ]); + }); + + it("does not descend into children when visitor returns skip-children", () => { + const visited: string[] = []; + walkContentNodes(nestedContent, (node) => { + visited.push(node.type); + if (node.type === "Paragraph") { + return "skip-children"; + } + }); + + expect(visited).toEqual(["Section", "Paragraph", "Snippet", "Meta"]); + }); + + it("ends the walk early when visitor returns stop", () => { + const visited: string[] = []; + const stopped = walkContentNodes(nestedContent, (node) => { + visited.push(node.type); + if (node.type === "Paragraph") { + return "stop"; + } + }); + + expect(stopped).toBe(true); + expect(visited).toEqual(["Section", "Paragraph"]); + }); + + it("handles missing content", () => { + const stopped = walkContentNodes(undefined, () => "stop"); + expect(stopped).toBe(false); + }); +}); + +describe("someTextValue", () => { + const value = { + type: "Paragraph", + content: [{ type: "SimpleText", text: "hello world" }], + meta: { title: "nested title" }, + }; + + it("matches strings in nested arrays and objects under any prop", () => { + expect(someTextValue(value, (text) => text === "hello world")).toBe(true); + expect(someTextValue(value, (text) => text === "nested title")).toBe(true); + expect(someTextValue(value, (text) => text === "missing")).toBe(false); + }); + + it("skips doc element type names", () => { + expect(someTextValue(value, (text) => text === "Paragraph")).toBe(false); + expect(someTextValue(value, (text) => text === "SimpleText")).toBe(false); + }); + + it("stops scanning after the first match", () => { + const scanned: string[] = []; + someTextValue([{ text: "one" }, { text: "two" }], (text) => { + scanned.push(text); + return text === "one"; + }); + + expect(scanned).toEqual(["one"]); + }); +}); diff --git a/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.ts b/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.ts new file mode 100644 index 000000000..a41cb89bf --- /dev/null +++ b/znai-reactjs/src/doc-elements/default-elements/contentTreeWalker.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DocElementContent, DocElementPayload } from "./DocElement"; + +export type ContentWalkControl = "continue" | "skip-children" | "stop"; + +export type ContentNodeVisitor = (node: DocElementPayload, ancestors: DocElementPayload[]) => ContentWalkControl | void; + +/** + * depth-first walk over doc element nodes, recursing through nested content arrays. + * visitor receives each node plus its ancestors chain (walk root first) and can return + * "skip-children" to avoid descending into a node or "stop" to end the whole walk early. + * returns true when the walk was stopped early by the visitor. + */ +export function walkContentNodes(content: DocElementContent | undefined, visit: ContentNodeVisitor): boolean { + if (!content) { + return false; + } + + // single ancestors array is mutated in place to avoid allocations per node, + // visitors must copy it if they need to keep it after returning + return walk(content, []); + + function walk(nodes: DocElementContent, ancestors: DocElementPayload[]): boolean { + for (const node of nodes) { + const control = visit(node, ancestors); + if (control === "stop") { + return true; + } + + if (control !== "skip-children" && Array.isArray(node.content)) { + ancestors.push(node); + const stopped = walk(node.content, ancestors); + ancestors.pop(); + + if (stopped) { + return true; + } + } + } + + return false; + } +} + +/** + * early-exit scan over every string in the tree: strings inside arrays and nested objects under any prop, + * not just content. returns true as soon as the predicate matches a string value. + */ +export function someTextValue(value: unknown, predicate: (text: string) => boolean): boolean { + if (typeof value === "string") { + return predicate(value); + } + + if (Array.isArray(value)) { + return value.some((entry) => someTextValue(entry, predicate)); + } + + if (typeof value === "object" && value !== null) { + for (const key in value) { + // type holds doc element names like Snippet and is not a visible text + if (key !== "type" && someTextValue((value as Record)[key], predicate)) { + return true; + } + } + } + + return false; +} diff --git a/znai-reactjs/src/doc-elements/hidden-content/hiddenContent.css b/znai-reactjs/src/doc-elements/hidden-content/hiddenContent.css new file mode 100644 index 000000000..87b88da70 --- /dev/null +++ b/znai-reactjs/src/doc-elements/hidden-content/hiddenContent.css @@ -0,0 +1,24 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* normalize.css resets [hidden] to display none, which would also apply to hidden="until-found". + a display none subtree is invisible to find-in-page, so the reset silently defeats the reveal: + browsers hide until-found content via content-visibility and need the element renderable. + revert restores the browser default. browsers without until-found support never get this + attribute value, useHiddenUntilFound falls back to plain hidden there */ +[hidden="until-found"] { + display: revert; +} diff --git a/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts b/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts index c841b99f4..68e9fdcec 100644 --- a/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts +++ b/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts @@ -14,7 +14,9 @@ * limitations under the License. */ -import { RefObject, useEffect, useLayoutEffect } from "react"; +import { RefObject, useEffect, useLayoutEffect, useRef } from "react"; + +import "./hiddenContent.css"; /** * hides collapsible content with hidden="until-found" so browser find-in-page can still match it, @@ -36,12 +38,17 @@ export function useHiddenUntilFound( } if (hidden) { - hiddenContainer.setAttribute("hidden", "until-found"); + hiddenContainer.setAttribute("hidden", supportsHiddenUntilFound() ? "until-found" : ""); } else { hiddenContainer.removeAttribute("hidden"); } }, [hidden]); + // latest callback is kept in a ref so callers can pass inline arrows + // without re-subscribing the listener on every render + const onFindInPageRevealRef = useRef(onFindInPageReveal); + onFindInPageRevealRef.current = onFindInPageReveal; + // browser fires beforematch right before revealing hidden content matched by find-in-page useEffect(() => { const hiddenContainer = hiddenContainerRef.current; @@ -49,7 +56,12 @@ export function useHiddenUntilFound( return; } - hiddenContainer.addEventListener("beforematch", onFindInPageReveal); - return () => hiddenContainer.removeEventListener("beforematch", onFindInPageReveal); - }, [onFindInPageReveal]); + const listener = () => onFindInPageRevealRef.current(); + hiddenContainer.addEventListener("beforematch", listener); + return () => hiddenContainer.removeEventListener("beforematch", listener); + }, []); +} + +function supportsHiddenUntilFound() { + return "onbeforematch" in document.body; } diff --git a/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts b/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts index e2e09f268..f0f7504b9 100644 --- a/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts +++ b/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts @@ -15,6 +15,7 @@ */ import { DocElementContent, DocElementPayload } from "../../default-elements/DocElement"; +import { walkContentNodes } from "../../default-elements/contentTreeWalker"; const TAB_CONTENT_TYPE = "TabContent"; @@ -28,25 +29,15 @@ interface SectionPayload extends DocElementPayload { * searches recursively through nested content (e.g. TabContent inside AttentionBlock) */ export function extractTabIds(pageContent: DocElementContent | undefined): string[] { - if (!pageContent) { - return []; - } - const allTabIds: string[] = []; - collect(pageContent); + walkContentNodes(pageContent, (el) => { + const tabId = (el as { tabId?: string }).tabId; + if (el.type === TAB_CONTENT_TYPE && tabId) { + allTabIds.push(tabId); + } + }); return [...new Set(allTabIds)]; - - function collect(content: DocElementContent): void { - for (const el of content) { - if (el.type === TAB_CONTENT_TYPE && el.tabId) { - allTabIds.push(el.tabId); - } - if (Array.isArray(el.content)) { - collect(el.content); - } - } - } } /** diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx index 208df6a5a..ab599480b 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx @@ -21,6 +21,26 @@ import React from "react"; import { ReadMore } from "./ReadMore"; import { DocElement, DocElementContent } from "../default-elements/DocElement"; +// jsdom implements onbeforematch on a prototype, remove it there to mimic browsers without support +function withoutBeforematchSupport(test: () => void) { + let owner: any = document.body; + while (owner && !Object.getOwnPropertyDescriptor(owner, "onbeforematch")) { + owner = Object.getPrototypeOf(owner); + } + const descriptor = owner && Object.getOwnPropertyDescriptor(owner, "onbeforematch"); + if (owner) { + delete owner.onbeforematch; + } + + try { + test(); + } finally { + if (owner && descriptor) { + Object.defineProperty(owner, "onbeforematch", descriptor); + } + } +} + const elementsLibrary: any = { DocElement, TestText: ({ text }: { text: string }) =>
{text}
, @@ -48,6 +68,16 @@ describe("ReadMore", () => { expect(container.querySelector(".znai-read-more-content")).toHaveAttribute("hidden", "until-found"); }); + it("falls back to plain hidden when the browser has no beforematch support", () => { + // hiddenContent.css reverts the normalize display none reset for until-found, + // so non supporting browsers must not receive that attribute value or content would show + withoutBeforematchSupport(() => { + const { container } = renderReadMore(); + + expect(container.querySelector(".znai-read-more-content")).toHaveAttribute("hidden", ""); + }); + }); + it("expands when browser find-in-page reveals hidden content", () => { const { container } = renderReadMore(); diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx index fb30e7600..22bd6b441 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx @@ -33,9 +33,7 @@ export function ReadMore({ title, content, searchSnippets, elementsLibrary }: Pr // during search auto reveal only when content has matched search terms, // pages with dozens of read more blocks are too expensive to render and highlight fully expanded - const [expanded, setExpanded] = useState( - () => searchSnippets !== undefined && contentMatchesSearchSnippets(content, searchSnippets) - ); + const [expanded, setExpanded] = useState(() => contentMatchesSearchSnippets(content, searchSnippets)); const containerRef = useRef(null); const hiddenContainerRef = useRef(null); const hasHiddenHighlightedElement = useHighlightOfHiddenElement( diff --git a/znai-reactjs/src/doc-elements/search/QueryResult.ts b/znai-reactjs/src/doc-elements/search/QueryResult.ts index 7d312b943..db850863d 100644 --- a/znai-reactjs/src/doc-elements/search/QueryResult.ts +++ b/znai-reactjs/src/doc-elements/search/QueryResult.ts @@ -15,22 +15,32 @@ * limitations under the License. */ -import { SearchResult } from "./flexSearch"; +import { deriveTermsToHighlight } from "./flexSearch"; export default class QueryResult { - private readonly queryResultsById: Record; - constructor(queryResults: SearchResult[]) { - this.queryResultsById = queryResults.reduce((acc, searchResult) => { - acc[searchResult.id] = searchResult; - return acc; - }, {} as Record); + private readonly ids: string[]; + private readonly encodedQueryTerms: string[]; + private readonly textToHighlightById: (id: string) => string; + // terms are derived lazily per id, only the displayed result pays for it + private readonly termsToHighlightById: Record = {}; + + constructor(ids: string[], encodedQueryTerms: string[], textToHighlightById: (id: string) => string) { + this.ids = ids; + this.encodedQueryTerms = encodedQueryTerms; + this.textToHighlightById = textToHighlightById; } getIds() { - return Object.keys(this.queryResultsById); + return this.ids; } getSnippetsToHighlight(id: string) { - return this.queryResultsById[id].termsToHighlight; + let terms = this.termsToHighlightById[id]; + if (!terms) { + terms = deriveTermsToHighlight(this.encodedQueryTerms, this.textToHighlightById(id)); + this.termsToHighlightById[id] = terms; + } + + return terms; } } diff --git a/znai-reactjs/src/doc-elements/search/Search.js b/znai-reactjs/src/doc-elements/search/Search.js index 2142f79b3..6111eba6f 100644 --- a/znai-reactjs/src/doc-elements/search/Search.js +++ b/znai-reactjs/src/doc-elements/search/Search.js @@ -16,13 +16,15 @@ */ import QueryResult from "./QueryResult"; -import { searchWithHighlight, truncateQueryByMinLength } from "./flexSearch.js"; +import { encodeSearchQuery, searchIds, truncateQueryByMinLength } from "./flexSearch.js"; class Search { constructor(allPages) { this.allPages = allPages; this.searchIdx = window.znaiSearchIdx; this.searchDataById = mapById(window.znaiSearchData); + // built lazily on first preview lookup, the constructor runs on doc load even if search is never used + this.sectionByIndexId = null; } static convertIndexIdToSectionCoords(indexId) { @@ -31,14 +33,25 @@ class Search { } search(term) { - const matches = searchWithHighlight(this.searchIdx, truncateQueryByMinLength(term, 3)); - return new QueryResult(matches); + const query = truncateQueryByMinLength(term, 3); + const ids = searchIds(this.searchIdx, query); + return new QueryResult(ids, encodeSearchQuery(query), (id) => this._textToHighlightById(id)); } findSearchEntryById(id) { return this.searchDataById[id]; } + // same text pieces the index docs are built from, see populateLocalSearchIndexWithData + _textToHighlightById(id) { + const searchEntry = this.searchDataById[id]; + if (!searchEntry) { + return ""; + } + + return [searchEntry.pageTitle, searchEntry.pageSection, searchEntry.textStandard, searchEntry.textHigh].join(" "); + } + previewDetails(id, queryResult) { const section = this._findSectionById(id); const snippets = queryResult.getSnippetsToHighlight(id); @@ -47,31 +60,53 @@ class Search { } _findSectionById(indexId) { + if (this.sectionByIndexId === null) { + this.sectionByIndexId = buildSectionByIndexId(this.allPages); + } + const sectionCoords = Search.convertIndexIdToSectionCoords(indexId); + const key = sectionLookupKey(sectionCoords.dirName, sectionCoords.fileName, sectionCoords.pageSectionId || ""); - const matching = []; + const section = this.sectionByIndexId.get(key); + if (section === undefined) { + console.error("expected section associated with", indexId); + } - this.allPages.pages.forEach((p) => { - const tocItem = p.tocItem; + return section; + } +} - const sections = p.content.filter((de) => { - return ( - tocItem.dirName === sectionCoords.dirName && - tocItem.fileName === sectionCoords.fileName && - de.type === "Section" && - (!sectionCoords.pageSectionId || de.id === sectionCoords.pageSectionId) - ); - }); +function sectionLookupKey(dirName, fileName, pageSectionId) { + return dirName + "@@" + fileName + "@@" + pageSectionId; +} - sections.forEach((s) => matching.push(s)); +// index sections both by their id and by page alone (empty section id means first section of the page) +function buildSectionByIndexId(allPages) { + const result = new Map(); + + allPages.pages.forEach((p) => { + const tocItem = p.tocItem; + + p.content.forEach((de) => { + if (de.type !== "Section") { + return; + } + + const pageKey = sectionLookupKey(tocItem.dirName, tocItem.fileName, ""); + if (!result.has(pageKey)) { + result.set(pageKey, de); + } + + if (de.id) { + const idKey = sectionLookupKey(tocItem.dirName, tocItem.fileName, de.id); + if (!result.has(idKey)) { + result.set(idKey, de); + } + } }); + }); - if (!matching) { - console.error("expected section associated with", indexId); - } - - return matching[0]; - } + return result; } function mapById(searchData) { diff --git a/znai-reactjs/src/doc-elements/search/Search.test.ts b/znai-reactjs/src/doc-elements/search/Search.test.ts new file mode 100644 index 000000000..18d766afc --- /dev/null +++ b/znai-reactjs/src/doc-elements/search/Search.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2026 znai maintainers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import Search from "./Search"; + +const sectionOne = { type: "Section", id: "section-one", title: "Section One" }; +const sectionTwo = { type: "Section", id: "section-two", title: "Section Two" }; +const otherPageSection = { type: "Section", id: "other-section", title: "Other Section" }; + +const allPages = { + pages: [ + { + tocItem: { dirName: "chapter", fileName: "page" }, + content: [{ type: "Paragraph" }, sectionOne, sectionTwo], + }, + { + tocItem: { dirName: "chapter", fileName: "other-page" }, + content: [otherPageSection], + }, + ], +}; + +function createSearch() { + // search constructor reads pre-built index and data from window globals + (window as any).znaiSearchIdx = {}; + (window as any).znaiSearchData = []; + + return new Search(allPages); +} + +describe("Search", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("finds section by full id", () => { + const search = createSearch(); + const queryResult = { getSnippetsToHighlight: () => ["snippet"] }; + + const previewDetails = search.previewDetails("chapter@@page@@section-two", queryResult); + expect(previewDetails.section).toBe(sectionTwo); + expect(previewDetails.snippets).toEqual(["snippet"]); + }); + + it("finds first section of the page when page section id is empty", () => { + const search = createSearch(); + + expect(search._findSectionById("chapter@@page@@")).toBe(sectionOne); + expect(search._findSectionById("chapter@@other-page@@")).toBe(otherPageSection); + }); + + it("returns undefined and logs error for unknown id", () => { + const search = createSearch(); + + expect(search._findSectionById("chapter@@page@@no-such-section")).toBeUndefined(); + expect(console.error).toHaveBeenCalledWith("expected section associated with", "chapter@@page@@no-such-section"); + }); + + it("builds section lookup lazily once and reuses it on repeated calls", () => { + const search = createSearch(); + expect(search.sectionByIndexId).toBeNull(); + + search._findSectionById("chapter@@page@@section-one"); + const lookup = search.sectionByIndexId; + expect(lookup).not.toBeNull(); + + search._findSectionById("chapter@@page@@section-two"); + search._findSectionById("chapter@@other-page@@"); + expect(search.sectionByIndexId).toBe(lookup); + }); +}); diff --git a/znai-reactjs/src/doc-elements/search/SearchPreview.jsx b/znai-reactjs/src/doc-elements/search/SearchPreview.jsx index 67d814551..e10e6de0d 100644 --- a/znai-reactjs/src/doc-elements/search/SearchPreview.jsx +++ b/znai-reactjs/src/doc-elements/search/SearchPreview.jsx @@ -34,9 +34,8 @@ class SearchPreview extends Component { render() { const { section, snippets, elementsLibrary } = this.props; - const key = section.id + "#" + section.title; return ( -
(this.dom = dom)}> +
(this.dom = dom)}>
); diff --git a/znai-reactjs/src/doc-elements/search/flexSearch.test.ts b/znai-reactjs/src/doc-elements/search/flexSearch.test.ts index 83ac08c9b..7972c7bb1 100644 --- a/znai-reactjs/src/doc-elements/search/flexSearch.test.ts +++ b/znai-reactjs/src/doc-elements/search/flexSearch.test.ts @@ -14,66 +14,90 @@ * limitations under the License. */ -import { describe } from "vitest"; -import { createLocalSearchIndex, searchWithHighlight, truncateQueryByMinLength } from "./flexSearch"; +import { describe, expect, it, vi } from "vitest"; +import { createLocalSearchIndex, encodeSearchQuery, searchIds, truncateQueryByMinLength } from "./flexSearch"; +import QueryResult from "./QueryResult"; + +// type alias instead of interface so the implicit index signature satisfies flexsearch DocumentData +type TestDoc = { + id: string; + title?: string; + content?: string; + contentHigh?: string; +}; + +function createIndexAndTextById(docs: TestDoc[]) { + const index = createLocalSearchIndex(); + const textById: Record = {}; + docs.forEach((doc) => { + index.add(doc); + textById[doc.id] = [doc.title, doc.content, doc.contentHigh].filter(Boolean).join(" "); + }); + + return { index, textById }; +} + +function searchQueryResult(docs: TestDoc[], query: string) { + const { index, textById } = createIndexAndTextById(docs); + const ids = searchIds(index, query); + return new QueryResult(ids, encodeSearchQuery(query), (id) => textById[id]); +} describe("flex search", () => { it("multiple words highlight", () => { - const index = createLocalSearchIndex(); - index.add({ - id: "id1", - title: "some title", - content: "running webs is future for you", - }); - index.add({ - id: "id2", - title: "some title", - content: "running fast into the future for you", - }); - index.add({ - id: "id3", - title: "running future", - content: "brown fox", - }); - - const result = searchWithHighlight(index, "ru future"); - expect(result).toEqual([ - { id: "id3", type: "title", termsToHighlight: [] }, - { - id: "id1", - type: "content", - termsToHighlight: ["running", "future"], - }, - { - id: "id2", - type: "content", - termsToHighlight: ["running", "future"], - }, - ]); + const queryResult = searchQueryResult( + [ + { + id: "id1", + title: "some title", + content: "running webs is future for you", + }, + { + id: "id2", + title: "some title", + content: "running fast into the future for you", + }, + { + id: "id3", + title: "running future", + content: "brown fox", + }, + ], + "ru future" + ); + + expect(queryResult.getIds()).toEqual(["id3", "id1", "id2"]); + expect(queryResult.getSnippetsToHighlight("id3")).toEqual(["running", "future"]); + expect(queryResult.getSnippetsToHighlight("id1")).toEqual(["running", "future"]); + expect(queryResult.getSnippetsToHighlight("id2")).toEqual(["running", "future"]); }); it("high content should go first", () => { - const index = createLocalSearchIndex(); - index.add({ - id: "id1", - title: "some title", - content: "running webs is future for you", - }); - index.add({ - id: "id3", - title: "running future", - content: "apiCall", - }); - index.add({ - id: "id2", - title: "some title", - contentHigh: "apiCall", - }); - const result = searchWithHighlight(index, "apicall"); - expect(result).toEqual([ - { id: "id2", type: "contentHigh", termsToHighlight: ["apiCall"] }, - { id: "id3", type: "content", termsToHighlight: ["apiCall"] }, - ]); + const queryResult = searchQueryResult( + [ + { + id: "id1", + title: "some title", + content: "running webs is future for you", + }, + { + id: "id3", + title: "running future", + content: "apiCall", + }, + { + id: "id2", + title: "some title", + contentHigh: "apiCall", + }, + ], + "apicall" + ); + + expect(queryResult.getIds()).toEqual(["id2", "id3"]); + // terms keep the original doc casing so downstream highlighting can locate them in the dom + expect(queryResult.getSnippetsToHighlight("id2")).toEqual(["apiCall"]); + expect(queryResult.getSnippetsToHighlight("id3")).toEqual(["apiCall"]); }); it("min query term length", () => { @@ -91,41 +115,63 @@ describe("flex search", () => { }); it("underscore is part of code identifiers and matches by prefix", () => { - const index = createLocalSearchIndex(); - index.add({ - id: "id1", - title: "trading", - content: "use bu_id to identify business unit", - }); - index.add({ - id: "id2", - title: "building", - content: "how to build and bundle", - }); - - expect(searchWithHighlight(index, "bu_")).toEqual([ - { id: "id1", type: "content", termsToHighlight: ["bu_id"] }, - ]); - - expect(searchWithHighlight(index, "bu_id")).toEqual([ - { id: "id1", type: "content", termsToHighlight: ["bu_id"] }, - ]); + const docs: TestDoc[] = [ + { + id: "id1", + title: "trading", + content: "use bu_id to identify business unit", + }, + { + id: "id2", + title: "building", + content: "how to build and bundle", + }, + ]; + + const partialQueryResult = searchQueryResult(docs, "bu_"); + expect(partialQueryResult.getIds()).toEqual(["id1"]); + expect(partialQueryResult.getSnippetsToHighlight("id1")).toEqual(["bu_id"]); + + const fullQueryResult = searchQueryResult(docs, "bu_id"); + expect(fullQueryResult.getIds()).toEqual(["id1"]); + expect(fullQueryResult.getSnippetsToHighlight("id1")).toEqual(["bu_id"]); }); it("code identifier with underscore matches by its start", () => { - const index = createLocalSearchIndex(); - index.add({ - id: "id1", - title: "config", - content: "defines build_config for projects", - }); - - expect(searchWithHighlight(index, "build")).toEqual([ - { id: "id1", type: "content", termsToHighlight: ["build_config"] }, - ]); - - expect(searchWithHighlight(index, "build_c")).toEqual([ - { id: "id1", type: "content", termsToHighlight: ["build_config"] }, - ]); + const docs: TestDoc[] = [ + { + id: "id1", + title: "config", + content: "defines build_config for projects", + }, + ]; + + const prefixQueryResult = searchQueryResult(docs, "build"); + expect(prefixQueryResult.getIds()).toEqual(["id1"]); + expect(prefixQueryResult.getSnippetsToHighlight("id1")).toEqual(["build_config"]); + + const underscoreQueryResult = searchQueryResult(docs, "build_c"); + expect(underscoreQueryResult.getIds()).toEqual(["id1"]); + expect(underscoreQueryResult.getSnippetsToHighlight("id1")).toEqual(["build_config"]); + }); + + it("terms to highlight are derived lazily on demand and memoized per id", () => { + const textToHighlightById = vi.fn((id: string) => + id === "id1" ? "running webs is future for you" : "running fast into the future" + ); + + const queryResult = new QueryResult(["id1", "id2"], encodeSearchQuery("ru future"), textToHighlightById); + expect(textToHighlightById).not.toHaveBeenCalled(); + + const terms = queryResult.getSnippetsToHighlight("id1"); + expect(terms).toEqual(["running", "future"]); + expect(textToHighlightById).toHaveBeenCalledTimes(1); + + // repeated access reuses the memoized terms without re-deriving + expect(queryResult.getSnippetsToHighlight("id1")).toBe(terms); + expect(textToHighlightById).toHaveBeenCalledTimes(1); + + expect(queryResult.getSnippetsToHighlight("id2")).toEqual(["running", "future"]); + expect(textToHighlightById).toHaveBeenCalledTimes(2); }); }); diff --git a/znai-reactjs/src/doc-elements/search/flexSearch.ts b/znai-reactjs/src/doc-elements/search/flexSearch.ts index ba6afeb24..7b229861a 100644 --- a/znai-reactjs/src/doc-elements/search/flexSearch.ts +++ b/znai-reactjs/src/doc-elements/search/flexSearch.ts @@ -70,56 +70,66 @@ export function populateLocalSearchIndexWithData(index: Document, data: string[] }); } -export interface SearchResult { - id: string; - type?: string; - termsToHighlight: string[]; -} - -const highlightRegex = /@\w+\b/g; - -// highlight re-encodes the full stored content of every result, so cost per keystroke -// is linear in the number of results (flexsearch default is 100 per field) +// flexsearch default is 100 results per field const resultsPerFieldLimit = 30; -export function searchWithHighlight(index: Document, query: string) { - const searchResults = index.search(query, { - enrich: true, - limit: resultsPerFieldLimit, - highlight: { template: "@$1" }, - }); +// per keystroke search fetches ids only, no enrich/highlight, so cost does not grow +// with the size of the stored content, highlight terms are derived lazily by QueryResult +// when a result is actually displayed +export function searchIds(index: Document, query: string): string[] { + const searchResults = index.search(query, { limit: resultsPerFieldLimit }); - const withHighlights: SearchResult[] = []; + // union across fields keeping first occurrence, so title matches stay ranked before content matches + const ids: string[] = []; + const seen = new Set(); for (let idx = 0; idx < searchResults.length; idx++) { - const forFieldParent = searchResults[idx]; - const results = forFieldParent.result; - - for (let resultIdx = 0; resultIdx < results.length; resultIdx++) { - const subResult = results[resultIdx]; - let termsToHighlight: string[] = []; - if (subResult.highlight) { - // highlight marks every occurrence, dedupe so the same word is not highlighted multiple times downstream - termsToHighlight = [ - ...new Set( - (subResult.highlight.match(highlightRegex) || []) - .map((term) => term.substring(1)) - .filter((term) => term.length > 2) - ), - ]; + const forFieldResult = searchResults[idx].result; + for (let resultIdx = 0; resultIdx < forFieldResult.length; resultIdx++) { + const id = forFieldResult[resultIdx].toString(); + if (!seen.has(id)) { + seen.add(id); + ids.push(id); } - - withHighlights.push({ - id: subResult.id.toString(), - type: forFieldParent.field, - termsToHighlight, - }); } } - return withHighlights; + return ids; +} + +export function encodeSearchQuery(query: string): string[] { + return searchEncoder.encode(query); } -const nonSearchableCharsRegex = /[^\p{L}\p{N}_]+/gu; +// encoder normalizes words (lowercase, letter dedupe: "running" -> "runing"), so encoded tokens +// can't be handed to the dom highlighter, split the raw text with the encoder's own word splitter instead, +// the splitter is not part of the public typings but is derived from the include config above +const encoderWordSplit = (searchEncoder as unknown as { split: RegExp }).split; + +// index uses tokenize: "forward", so words whose encoded form prefix matches an encoded query term +// mirror what flexsearch matched, searchEncoder is the single source of truth for tokenization +export function deriveTermsToHighlight(encodedQueryTerms: string[], text: string): string[] { + if (encodedQueryTerms.length === 0) { + return []; + } + + // dedupe so the same word is not highlighted multiple times downstream, + // short terms produce too much highlight noise + const terms = new Set(); + const words = text.split(encoderWordSplit); + for (let idx = 0; idx < words.length; idx++) { + const word = words[idx]; + if (word.length <= 2 || terms.has(word)) { + continue; + } + + const encodedWord = searchEncoder.encode(word); + if (encodedWord.some((token) => encodedQueryTerms.some((queryTerm) => token.startsWith(queryTerm)))) { + terms.add(word); + } + } + + return [...terms]; +} export function truncateQueryByMinLength(query: string, minLength: number) { return query @@ -133,5 +143,5 @@ export function truncateQueryByMinLength(query: string, minLength: number) { // so only searchable chars count towards the min length, otherwise "c++" bypasses the guard // and triggers an expensive short prefix search function effectiveTermLength(term: string) { - return term.replace(nonSearchableCharsRegex, "").length; + return searchEncoder.encode(term).join("").length; } diff --git a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts index cfc6e32ec..ff1ef2060 100644 --- a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts +++ b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts @@ -20,14 +20,18 @@ import { removeSearchHighlight, startSearchHighlightSession } from "./searchResu let root: HTMLElement; let disposeSession: (() => void) | undefined; -function startSession(snippets: string[]) { +function startSession(snippets: string[], html = "

use cancel_trade to abort

") { root = document.createElement("div"); - root.innerHTML = "

use cancel_trade to abort

"; + root.innerHTML = html; document.body.appendChild(root); disposeSession = startSearchHighlightSession(root, snippets); } +function markedTexts() { + return Array.from(root.querySelectorAll("mark")).map((mark) => mark.textContent); +} + function mountLateContent() { const lateMounted = document.createElement("div"); lateMounted.innerHTML = "

late mounted cancel_trade details

"; @@ -80,3 +84,28 @@ describe("startSearchHighlightSession", () => { expect(root.querySelector("mark")).toBeNull(); }); }); + +// underscore is part of indexed symbols (see flexSearch.ts encoder), so the highlighter +// treats it as a literal character of the snippet, not as ignorable punctuation +describe("underscore as part of searched symbols", () => { + it("highlights underscore identifier surrounded by text and punctuation", () => { + startSession(["my_func"], "

call my_func() now

"); + + expect(markedTexts()).toEqual(["my_func"]); + }); + + it("highlights prefix snippet occurrences including the underscore", () => { + // accuracy "partially" matches the snippet anywhere, so a forward tokenization prefix + // like bu_id also highlights the start of a longer identifier + startSession(["bu_id"], "

use bu_id here and bu_id_extra there

"); + + expect(markedTexts()).toEqual(["bu_id", "bu_id"]); + }); + + it("does not match text with underscore when snippet has none", () => { + // canceltrade and cancel_trade are different symbols now that underscore is not ignorable + startSession(["canceltrade"], "

use cancel_trade to abort

"); + + expect(root.querySelector("mark")).toBeNull(); + }); +}); diff --git a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts index d4abe2da3..acc76dc38 100644 --- a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts +++ b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts @@ -47,8 +47,7 @@ export function startSearchHighlightSession(root: HTMLElement, snippets: string[ // pause observing while marking so mark.js own dom changes don't re-trigger this callback observer.disconnect(); - lateMounted.forEach((lateMountedRoot) => markSnippets(lateMountedRoot, snippets)); - observer.takeRecords(); + markSnippets(lateMounted, snippets); observer.observe(root, observeConfig); }); @@ -62,7 +61,9 @@ export function removeSearchHighlight(root: HTMLElement) { mark.unmark({}); } -function markSnippets(root: HTMLElement, snippets: string[]) { +// mark.js accepts a single element or an array of elements as context, +// late mounted nodes from one react commit are marked in a single pass +function markSnippets(root: HTMLElement | HTMLElement[], snippets: string[]) { const mark = new Mark(root); mark.unmark({ done: () => { @@ -72,7 +73,8 @@ function markSnippets(root: HTMLElement, snippets: string[]) { caseSensitive: false, ignoreJoiners: false, diacritics: false, - ignorePunctuation: ["(", ")", ";", "[", "]", "-", "_", ".", ",", '"', "'", "~"], + // underscore is deliberately not here: it is part of indexed symbols like cancel_trade + ignorePunctuation: ["(", ")", ";", "[", "]", "-", ".", ",", '"', "'", "~"], accuracy: "partially", }); }, diff --git a/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.test.ts b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.test.ts index c5689fb61..f755ef544 100644 --- a/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.test.ts +++ b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.test.ts @@ -47,6 +47,12 @@ describe("contentMatchesSearchSnippets", () => { it("no match when terms list is empty or content is missing", () => { expect(contentMatchesSearchSnippets(content, [])).toBe(false); + expect(contentMatchesSearchSnippets(content, undefined)).toBe(false); expect(contentMatchesSearchSnippets(undefined, ["abort"])).toBe(false); }); + + it("treats terms as plain text and not as regexp", () => { + expect(contentMatchesSearchSnippets(content, ["my_func():"])).toBe(true); + expect(contentMatchesSearchSnippets(content, ["c.n..l"])).toBe(false); + }); }); diff --git a/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.ts b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.ts index 144168808..129835f2b 100644 --- a/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.ts +++ b/znai-reactjs/src/doc-elements/search/searchSnippetsContentMatch.ts @@ -15,39 +15,27 @@ */ import { DocElementContent } from "../default-elements/DocElement"; +import { someTextValue } from "../default-elements/contentTreeWalker"; /** * checks if doc elements content contains any of the matched search terms. * lets elements with hidden content (e.g. read more) decide whether to auto reveal it * during search instead of rendering and highlighting every hidden block. */ -export function contentMatchesSearchSnippets(content: DocElementContent | undefined, searchSnippets: string[]) { - if (!content || searchSnippets.length === 0) { +export function contentMatchesSearchSnippets( + content: DocElementContent | undefined, + searchSnippets: string[] | undefined +) { + if (!content || !searchSnippets || searchSnippets.length === 0) { return false; } - const loweredSnippets = searchSnippets.map((snippet) => snippet.toLowerCase()); - return anyTextValueMatches(content, loweredSnippets); + // single case-insensitive alternation checks every snippet in one scan per string, + // instead of lowering and rescanning every string in the content tree per snippet + const anySnippetRegex = new RegExp(searchSnippets.map(escapeRegexChars).join("|"), "i"); + return someTextValue(content, (text) => anySnippetRegex.test(text)); } -function anyTextValueMatches(value: unknown, loweredSnippets: string[]): boolean { - if (typeof value === "string") { - const text = value.toLowerCase(); - return loweredSnippets.some((snippet) => text.includes(snippet)); - } - - if (Array.isArray(value)) { - return value.some((entry) => anyTextValueMatches(entry, loweredSnippets)); - } - - if (typeof value === "object" && value !== null) { - for (const key in value) { - // type holds doc element names like Snippet and is not a visible text - if (key !== "type" && anyTextValueMatches((value as Record)[key], loweredSnippets)) { - return true; - } - } - } - - return false; +function escapeRegexChars(text: string) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } diff --git a/znai-reactjs/src/doc-elements/tabs/tabsUtils.js b/znai-reactjs/src/doc-elements/tabs/tabsUtils.js index 21e68d816..fd6f868d3 100644 --- a/znai-reactjs/src/doc-elements/tabs/tabsUtils.js +++ b/znai-reactjs/src/doc-elements/tabs/tabsUtils.js @@ -14,26 +14,20 @@ * limitations under the License. */ +import { walkContentNodes } from '../default-elements/contentTreeWalker' + export function contentTabNames(content) { const result = [] - collectTabNamesRecursively(result, content) - - return result -} - -function collectTabNamesRecursively(result, content) { - if (!content || content.length === 0) { - return - } - - content.forEach(e => { + walkContentNodes(content, e => { if (e.type === 'Tabs') { addMissingTabNames(result, e) - } else { - collectTabNamesRecursively(result, e.content) + // nested per tab elements live under tabsContent and are handled above, not walked into + return "skip-children" } }) + + return result } function addMissingTabNames(result, tabsDocEl) { From 9f482237110304b3c17f5019d97e301ae46c1950 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 23 Aug 2026 18:37:35 -0400 Subject: [PATCH 6/7] search: include _ and debounce --- .../page/page-tabs/pageTabsContentUtils.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts b/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts index f0f7504b9..50f72e05f 100644 --- a/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts +++ b/znai-reactjs/src/doc-elements/page/page-tabs/pageTabsContentUtils.ts @@ -19,11 +19,6 @@ import { walkContentNodes } from "../../default-elements/contentTreeWalker"; const TAB_CONTENT_TYPE = "TabContent"; -interface SectionPayload extends DocElementPayload { - id: string; - title: string; -} - /** * extracts unique tab IDs from all TabContent elements in page content, preserving order. * searches recursively through nested content (e.g. TabContent inside AttentionBlock) @@ -49,10 +44,7 @@ export function extractTabIds(pageContent: DocElementContent | undefined): strin * * returns sections with their content filtered */ -export function buildContentForTab( - pageContent: DocElementContent, - selectedTabId: string -): DocElementContent { +export function buildContentForTab(pageContent: DocElementContent, selectedTabId: string): DocElementContent { return pageContent.map((section) => { if (!section.content) { return section; From 92673067f755ce9f62d0f768c3f728e1e4ea0fc1 Mon Sep 17 00:00:00 2001 From: MykolaGolubyev Date: Sun, 23 Aug 2026 19:39:48 -0400 Subject: [PATCH 7/7] search: include _ and debounce --- .../add-2026-08-23-search-and-read-more.md | 3 + znai-docs/znai/release-notes/2026.md | 4 ++ .../hidden-content/hiddenContentUtils.ts | 16 ++--- .../doc-elements/read-more/ReadMore.test.tsx | 21 +++++++ .../src/doc-elements/read-more/ReadMore.tsx | 11 +++- .../search/searchResultHighlighter.test.ts | 60 +++++++++++++++++++ .../search/searchResultHighlighter.ts | 18 ++++++ 7 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 znai-docs/znai/release-notes/1.92/add-2026-08-23-search-and-read-more.md diff --git a/znai-docs/znai/release-notes/1.92/add-2026-08-23-search-and-read-more.md b/znai-docs/znai/release-notes/1.92/add-2026-08-23-search-and-read-more.md new file mode 100644 index 000000000..f5f0befea --- /dev/null +++ b/znai-docs/znai/release-notes/1.92/add-2026-08-23-search-and-read-more.md @@ -0,0 +1,3 @@ +* Add: Search on the page with [Read More](visuals/read-more) blocks auto opens the blocks +* Add: Optimize guide level search rendering and debounce +* Add: Guide level search keeps `_` symbol as it is often part of the search term diff --git a/znai-docs/znai/release-notes/2026.md b/znai-docs/znai/release-notes/2026.md index 351aaaa0c..524f4030d 100644 --- a/znai-docs/znai/release-notes/2026.md +++ b/znai-docs/znai/release-notes/2026.md @@ -1,3 +1,7 @@ +# 1.92 + +:include-markdowns: 1.92 + # 1.91 :include-markdowns: 1.91 diff --git a/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts b/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts index 68e9fdcec..3acf8ff4d 100644 --- a/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts +++ b/znai-reactjs/src/doc-elements/hidden-content/hiddenContentUtils.ts @@ -20,8 +20,9 @@ import "./hiddenContent.css"; /** * hides collapsible content with hidden="until-found" so browser find-in-page can still match it, - * onFindInPageReveal is invoked when the browser reveals the content on a match so the owner - * can sync its expanded state. + * onReveal is invoked on a beforematch event so the owner can sync its expanded state. + * both the browser (find-in-page match) and the search highlighter (snippet marked inside + * hidden content) deliver reveals through that one event. * * the attribute is set manually because react normalizes hidden to a boolean and drops the value. * browsers without until-found support treat it as plain hidden which matches display none behavior @@ -29,7 +30,7 @@ import "./hiddenContent.css"; export function useHiddenUntilFound( hiddenContainerRef: RefObject, hidden: boolean, - onFindInPageReveal: () => void + onReveal: () => void ) { useLayoutEffect(() => { const hiddenContainer = hiddenContainerRef.current; @@ -46,17 +47,18 @@ export function useHiddenUntilFound( // latest callback is kept in a ref so callers can pass inline arrows // without re-subscribing the listener on every render - const onFindInPageRevealRef = useRef(onFindInPageReveal); - onFindInPageRevealRef.current = onFindInPageReveal; + const onRevealRef = useRef(onReveal); + onRevealRef.current = onReveal; - // browser fires beforematch right before revealing hidden content matched by find-in-page + // browser fires beforematch right before revealing hidden content matched by find-in-page, + // the search highlighter dispatches the same event for highlights inside hidden content useEffect(() => { const hiddenContainer = hiddenContainerRef.current; if (!hiddenContainer) { return; } - const listener = () => onFindInPageRevealRef.current(); + const listener = () => onRevealRef.current(); hiddenContainer.addEventListener("beforematch", listener); return () => hiddenContainer.removeEventListener("beforematch", listener); }, []); diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx index ab599480b..0391aee67 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.test.tsx @@ -94,6 +94,27 @@ describe("ReadMore", () => { expect(container.querySelector(".test-text")).not.toBeNull(); }); + it("keeps content mounted when snippets arrive after mount, highlighter reveals it via beforematch", () => { + const { container, rerender } = renderReadMore(); + + rerender( + + ); + + // still mounted and collapsed, waiting for the highlight session to dispatch beforematch + expect(container.querySelector(".znai-read-more")).toHaveClass("collapsed"); + expect(container.querySelector(".test-text")).not.toBeNull(); + + fireEvent(container.querySelector(".znai-read-more-content")!, new Event("beforematch")); + + expect(container.querySelector(".znai-read-more")).toHaveClass("expanded"); + }); + it("does not mount content during search when no terms match", () => { const { container } = renderReadMore({ searchSnippets: ["deploy"] }); diff --git a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx index 22bd6b441..466a09aa3 100644 --- a/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx +++ b/znai-reactjs/src/doc-elements/read-more/ReadMore.tsx @@ -34,6 +34,7 @@ export function ReadMore({ title, content, searchSnippets, elementsLibrary }: Pr // during search auto reveal only when content has matched search terms, // pages with dozens of read more blocks are too expensive to render and highlight fully expanded const [expanded, setExpanded] = useState(() => contentMatchesSearchSnippets(content, searchSnippets)); + const containerRef = useRef(null); const hiddenContainerRef = useRef(null); const hasHiddenHighlightedElement = useHighlightOfHiddenElement( @@ -45,9 +46,13 @@ export function ReadMore({ title, content, searchSnippets, elementsLibrary }: Pr useHiddenUntilFound(hiddenContainerRef, !expanded, () => setExpanded(true)); - // during search, collapsed content is not mounted to avoid rendering and highlighting hidden blocks, - // regular pages keep hidden content mounted so the highlight engine can find it - const renderContent = expanded || !isPartOfSearch; + // captured at mount: a block that first rendered outside of search keeps content mounted + // when snippets arrive later, unmounting would leave nothing to highlight and reveal + const [wasMountedOutsideSearch] = useState(!isPartOfSearch); + + // during search preview, collapsed content is not mounted to avoid rendering and highlighting hidden blocks, + // on regular pages hidden content stays mounted so the highlight engine and find-in-page can reach it + const renderContent = expanded || !isPartOfSearch || wasMountedOutsideSearch; const expandedClassName = expanded ? "expanded" : "collapsed"; const topClassName = "znai-read-more content-block " + expandedClassName; diff --git a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts index ff1ef2060..36f2cdd18 100644 --- a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts +++ b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.test.ts @@ -85,6 +85,66 @@ describe("startSearchHighlightSession", () => { }); }); +// highlights landing inside hidden="until-found" content (e.g. collapsed read more) trigger +// the same beforematch event browser find-in-page uses, owning components reveal through one path +describe("hidden content reveal", () => { + function trackReveal(container: Element) { + const revealed = { count: 0 }; + container.addEventListener("beforematch", () => revealed.count++); + return revealed; + } + + function createRoot(html: string) { + root = document.createElement("div"); + root.innerHTML = html; + document.body.appendChild(root); + } + + it("fires beforematch on a hidden container that received a highlight", () => { + createRoot(''); + const revealed = trackReveal(root.querySelector("[hidden]")!); + + disposeSession = startSearchHighlightSession(root, ["cancel_trade"]); + + expect(revealed.count).toBe(1); + }); + + it("does not fire beforematch when hidden content has no highlight", () => { + createRoot(''); + const revealed = trackReveal(root.querySelector("[hidden]")!); + + disposeSession = startSearchHighlightSession(root, ["cancel_trade"]); + + expect(revealed.count).toBe(0); + }); + + it("fires beforematch on every hidden ancestor of a highlight, like nested until-found regions", () => { + createRoot(''); + const outerRevealed = trackReveal(root.querySelector(".outer")!); + const innerRevealed = trackReveal(root.querySelector(".outer [hidden]")!); + + disposeSession = startSearchHighlightSession(root, ["cancel_trade"]); + + expect(innerRevealed.count).toBe(1); + // outer count includes the inner event only if it bubbled, beforematch does not bubble + expect(outerRevealed.count).toBe(1); + }); + + it("fires beforematch for hidden content mounted after session start", async () => { + createRoot("

visible cancel_trade

"); + disposeSession = startSearchHighlightSession(root, ["cancel_trade"]); + + const lateMounted = document.createElement("div"); + lateMounted.innerHTML = ''; + const revealed = trackReveal(lateMounted.querySelector("[hidden]")!); + root.appendChild(lateMounted); + + await observerDelivery(); + + expect(revealed.count).toBe(1); + }); +}); + // underscore is part of indexed symbols (see flexSearch.ts encoder), so the highlighter // treats it as a literal character of the snippet, not as ignorable punctuation describe("underscore as part of searched symbols", () => { diff --git a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts index acc76dc38..bd285fdb2 100644 --- a/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts +++ b/znai-reactjs/src/doc-elements/search/searchResultHighlighter.ts @@ -76,7 +76,25 @@ function markSnippets(root: HTMLElement | HTMLElement[], snippets: string[]) { // underscore is deliberately not here: it is part of indexed symbols like cancel_trade ignorePunctuation: ["(", ")", ";", "[", "]", "-", ".", ",", '"', "'", "~"], accuracy: "partially", + done: () => revealHiddenMatches(root), }); }, }); } + +// highlights can land inside content collapsed with hidden="until-found", e.g. a read more block +// on a page a search result points to. fire the same beforematch event the browser fires for +// find-in-page matches, so owning components reveal through their one existing reveal path +function revealHiddenMatches(root: HTMLElement | HTMLElement[]) { + const hiddenContainers = new Set(); + for (const element of Array.isArray(root) ? root : [root]) { + for (const mark of Array.from(element.querySelectorAll("mark[data-markjs]"))) { + // reveal every hidden ancestor, matching browser behavior for nested until-found regions + for (let hidden = mark.closest("[hidden]"); hidden; hidden = hidden.parentElement?.closest("[hidden]") ?? null) { + hiddenContainers.add(hidden); + } + } + } + + hiddenContainers.forEach((container) => container.dispatchEvent(new Event("beforematch"))); +}