@open-press/core 1.1.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,28 +1,37 @@
1
1
  import {
2
+ useCallback,
3
+ useEffect,
2
4
  useMemo,
3
5
  useRef,
6
+ useState,
4
7
  type CSSProperties,
5
8
  type MouseEvent as ReactMouseEvent,
6
9
  type RefCallback,
7
10
  type RefObject,
8
11
  } from "react";
9
- import { BookOpen, ExternalLink, X } from "lucide-react";
12
+ import { ExternalLink, Ruler } from "lucide-react";
10
13
  import {
11
14
  collectBookmarkIndex,
12
15
  createAnchorPageMap,
13
16
  createPageObjectEntityId,
14
17
  getProjectIdentity,
18
+ getSourceBlockMap,
15
19
  resolveAnchorPageIndex,
16
20
  type DeploymentInfo,
17
21
  type HtmlPageBlock,
18
22
  type ReaderDocument,
19
23
  } from "../document-model";
20
24
  import type { InspectorState } from "../workbench/inspector";
25
+ import { groupSourceBlocksByPath } from "../workbench/inspector";
21
26
  import { useReaderRuntime } from "./useReaderRuntime";
22
27
  import { Bookmarks, CurrentPagePanel } from "./ReaderNavigationPanel";
23
28
  import type { DisplayPage } from "./readerTypes";
24
29
  import { usePageViewportScale } from "./usePageViewportScale";
25
30
  import type { PageLayoutMode } from "./pageViewportScaleModel";
31
+ import { PageZoomControl, SearchControl, type SearchControlSearcher } from "../workbench/actions";
32
+ import { WorkbenchShell } from "../workbench/shell";
33
+ import { formatPageGeometrySpec } from "../workbench/workbenchFormatters";
34
+ import { searchCorpus, type SearchCorpus } from "../shared";
26
35
 
27
36
  export const PUBLIC_DRAWER_BREAKPOINT = 1185;
28
37
  export type ViewMode = "paged";
@@ -41,29 +50,60 @@ export function PublicViewer({
41
50
  }) {
42
51
  const sourceContainerRef = useRef<HTMLDivElement | null>(null);
43
52
  const displayPages = pages;
44
- const viewModeState = useViewMode();
45
- const { viewMode } = viewModeState;
53
+ const { viewMode } = useViewMode();
46
54
  const bookmarks = collectBookmarkIndex(displayPages);
47
55
  const anchorPageMap = useMemo(() => createAnchorPageMap(displayPages), [displayPages]);
48
56
  const reader = useReaderRuntime({
49
57
  pageCount: displayPages.length,
58
+ leftPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
50
59
  rightPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
51
60
  });
52
- usePageViewportScale({
61
+ const [pageLayoutMode, setPageLayoutMode] = useState<PageLayoutMode>("single");
62
+ const pageViewport = usePageViewportScale({
53
63
  stageRef: reader.stageRef,
54
64
  pageContainerRef: sourceContainerRef,
55
65
  pageCount: displayPages.length,
56
- layoutMode: "single",
66
+ layoutMode: pageLayoutMode,
57
67
  });
58
68
  const currentPage = displayPages[reader.currentPageIndex];
59
69
  const staticPdfHref = deploymentInfo.pdf;
60
70
  const projectIdentity = getProjectIdentity(document.meta);
71
+ const pressType = document.meta.type === "slides" ? "slides" : "pages";
72
+ const pageGeometry = formatPageGeometrySpec(document.theme);
73
+ const sourceBlocksByPath = useMemo(
74
+ () => groupSourceBlocksByPath(getSourceBlockMap(document)),
75
+ [document],
76
+ );
61
77
 
62
- const drawerOpen = reader.rightPanelOpen;
78
+ // Static searcher: lazy-fetch /openpress/search-corpus.json on first
79
+ // query, cache for subsequent searches, then run the same literal-match
80
+ // logic the dev endpoint uses — no backend required for deployed pages.
81
+ const corpusRef = useRef<SearchCorpus | null>(null);
82
+ const corpusFetchRef = useRef<Promise<SearchCorpus> | null>(null);
83
+ const staticSearcher = useCallback<SearchControlSearcher>(async ({ query, scope, signal }) => {
84
+ if (!corpusRef.current) {
85
+ if (!corpusFetchRef.current) {
86
+ corpusFetchRef.current = fetch("/openpress/search-corpus.json", { cache: "force-cache" })
87
+ .then(async (response) => {
88
+ if (!response.ok) throw new Error(`Failed to load search corpus (${response.status})`);
89
+ return (await response.json()) as SearchCorpus;
90
+ })
91
+ .catch((error) => {
92
+ corpusFetchRef.current = null;
93
+ throw error;
94
+ });
95
+ }
96
+ const corpus = await corpusFetchRef.current;
97
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
98
+ corpusRef.current = corpus;
99
+ }
100
+ if (signal.aborted) throw new DOMException("Aborted", "AbortError");
101
+ return searchCorpus(corpusRef.current, { query, scope, caseSensitive: false });
102
+ }, []);
63
103
 
64
104
  const selectPublicPage = (pageIndex: number, options?: { behavior?: ScrollBehavior }) => {
65
105
  reader.setPage(pageIndex, options);
66
- if (window.innerWidth < PUBLIC_DRAWER_BREAKPOINT && drawerOpen) reader.toggleRightPanel();
106
+ if (window.innerWidth < PUBLIC_DRAWER_BREAKPOINT && reader.leftPanelOpen) reader.toggleLeftPanel();
67
107
  };
68
108
 
69
109
  const selectPublicAnchor = (anchorId: string, pageIndex?: number) => {
@@ -73,84 +113,112 @@ export function PublicViewer({
73
113
  return true;
74
114
  };
75
115
 
76
- const appClassName = [
77
- "reader-app openpress-reader-app openpress-public-viewer is-ready",
78
- drawerOpen ? "" : "is-closed-right",
79
- ].filter(Boolean).join(" ");
80
-
81
116
  const handleOpenStaticPdf = () => {
82
117
  if (!staticPdfHref) return;
83
118
  window.open(staticPdfHref, "_blank", "noopener,noreferrer");
84
119
  };
85
120
 
86
121
  return (
87
- <main className="openpress-workbench openpress-public-shell" style={style} data-openpress-public-viewer="true" aria-label={`${document.meta.title} 公開頁`}>
88
- <div
89
- className={appClassName}
90
- data-openpress-react-runtime="true"
91
- data-openpress-view-mode={viewMode}
92
- >
93
- {drawerOpen && (
94
- <div className="openpress-public-scrim" aria-hidden="true" onClick={reader.toggleRightPanel} />
95
- )}
96
- <button type="button" className="openpress-public-fab" aria-label="開啟目錄" onClick={reader.toggleRightPanel}>
97
- <BookOpen size={20} aria-hidden="true" />
98
- </button>
122
+ <WorkbenchShell
123
+ style={style}
124
+ viewMode={viewMode}
125
+ pressType={pressType}
126
+ inspectorMode={false}
127
+ leftPanelOpen={reader.leftPanelOpen}
128
+ rightPanelOpen={false}
129
+ onToggleLeftPanel={reader.toggleLeftPanel}
130
+ onToggleRightPanel={reader.toggleLeftPanel}
131
+ withRightPanel={false}
132
+ publicViewer
133
+ >
134
+ <WorkbenchShell.Toolbar>
135
+ <div className="openpress-workbench-toolbar__group" aria-label="輸出">
136
+ <button
137
+ type="button"
138
+ className="openpress-workbench-toolbar-action"
139
+ data-openpress-public-export
140
+ disabled={!staticPdfHref}
141
+ onClick={handleOpenStaticPdf}
142
+ title={staticPdfHref ? "開啟 PDF" : "PDF 未部署"}
143
+ aria-label={staticPdfHref ? "開啟 PDF" : "PDF 未部署"}
144
+ >
145
+ <ExternalLink aria-hidden="true" />
146
+ <span className="openpress-workbench-toolbar-action__label">
147
+ {staticPdfHref ? "開啟 PDF" : "PDF 未部署"}
148
+ </span>
149
+ </button>
150
+ </div>
151
+ <div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--page" aria-label="頁面規格">
152
+ <button
153
+ type="button"
154
+ className="openpress-workbench-page-geometry"
155
+ data-openpress-page-geometry
156
+ title={pageGeometry.title}
157
+ aria-label={`頁面規格 ${pageGeometry.title}`}
158
+ >
159
+ <Ruler aria-hidden="true" />
160
+ <span className="openpress-workbench-page-geometry__label">{pageGeometry.label}</span>
161
+ <span className="openpress-workbench-page-geometry__dimensions">{pageGeometry.dimensions}</span>
162
+ </button>
163
+ <PageZoomControl
164
+ scaleMode={pageViewport.scaleMode}
165
+ scaleLabel={pageViewport.scaleLabel}
166
+ pageLayoutMode={pageLayoutMode}
167
+ onScaleModeChange={pageViewport.setScaleMode}
168
+ onPageLayoutModeChange={setPageLayoutMode}
169
+ />
170
+ <SearchControl
171
+ sourceBlocksByPath={sourceBlocksByPath}
172
+ onSelectPage={selectPublicPage}
173
+ searcher={staticSearcher}
174
+ />
175
+ </div>
176
+ </WorkbenchShell.Toolbar>
99
177
 
100
- <section className="openpress-workbench__stage openpress-public-viewer__stage" aria-label="公開文件頁面">
101
- <main className="reader-stage" tabIndex={-1} ref={reader.stageRef}>
102
- <PublicPage
103
- pages={displayPages}
104
- currentPageIndex={reader.currentPageIndex}
105
- devMode={false}
106
- sourceContainerRef={sourceContainerRef}
107
- registerPage={reader.registerPage}
108
- onInternalAnchorNavigate={selectPublicAnchor}
109
- />
110
- </main>
178
+ <WorkbenchShell.LeftPanel>
179
+ <section className="openpress-public-identity" aria-label="文件資訊">
180
+ <strong>
181
+ <span className="openpress-public-title-main">{projectIdentity.name}</span>
182
+ {projectIdentity.subtitle ? <span className="openpress-public-title-sub">{projectIdentity.subtitle}</span> : null}
183
+ </strong>
184
+ {projectIdentity.label ? <span>{projectIdentity.label}</span> : null}
111
185
  </section>
112
-
113
- <aside className="reader-side-nav openpress-workspace-panel openpress-public-navigation" aria-label="文件導覽">
114
- <button type="button" className="openpress-public-drawer-close" aria-label="關閉目錄" onClick={reader.toggleRightPanel}>
115
- <X size={16} aria-hidden="true" />
116
- </button>
117
- <section className="openpress-public-identity" aria-label="文件資訊">
118
- <strong>
119
- <span className="openpress-public-title-main">{projectIdentity.name}</span>
120
- {projectIdentity.subtitle ? <span className="openpress-public-title-sub">{projectIdentity.subtitle}</span> : null}
121
- </strong>
122
- {projectIdentity.label ? <span>{projectIdentity.label}</span> : null}
123
- </section>
124
- <div className="openpress-public-actions" aria-label="文件操作">
125
- <button
126
- type="button"
127
- className="openpress-public-export-button"
128
- data-openpress-public-export
129
- disabled={!staticPdfHref}
130
- onClick={handleOpenStaticPdf}
131
- >
132
- <ExternalLink aria-hidden="true" />
133
- {!staticPdfHref ? "PDF 未部署" : "開啟 PDF"}
134
- </button>
135
- </div>
136
- <section id="openpress-bookmarks" className="openpress-panel-section openpress-panel-section--bookmarks" aria-label="章節書籤">
186
+ {bookmarks.length > 0 ? (
187
+ <section
188
+ id="openpress-bookmarks"
189
+ className="openpress-panel-section openpress-panel-section--bookmarks"
190
+ aria-label="章節書籤"
191
+ >
137
192
  <nav className="reader-bookmarks" aria-label="章節導覽" data-openpress-react-bookmarks="true">
138
193
  <div className="reader-bookmarks-rail" aria-hidden="true" />
139
194
  <Bookmarks items={bookmarks} currentPageIndex={reader.currentPageIndex} onSelectPage={selectPublicPage} />
140
195
  </nav>
141
196
  </section>
142
- <CurrentPagePanel
143
- currentPageLabel={reader.currentPageLabel}
144
- totalPageLabel={reader.totalPageLabel}
145
- progressPercent={reader.progressPercent}
146
- title={currentPage?.title || document.meta.title}
147
- pageLabelPrefix="頁"
148
- showHeading={false}
149
- showTitle={false}
197
+ ) : null}
198
+ <CurrentPagePanel
199
+ currentPageLabel={reader.currentPageLabel}
200
+ totalPageLabel={reader.totalPageLabel}
201
+ progressPercent={reader.progressPercent}
202
+ title={currentPage?.title || document.meta.title}
203
+ pageLabelPrefix="頁"
204
+ showHeading={false}
205
+ showTitle={false}
206
+ />
207
+ </WorkbenchShell.LeftPanel>
208
+
209
+ <WorkbenchShell.MainContent>
210
+ <main className="reader-stage" tabIndex={-1} ref={reader.stageRef}>
211
+ <PublicPage
212
+ pages={displayPages}
213
+ currentPageIndex={reader.currentPageIndex}
214
+ sourceContainerRef={sourceContainerRef}
215
+ registerPage={reader.registerPage}
216
+ onInternalAnchorNavigate={selectPublicAnchor}
217
+ pageLayoutMode={pageLayoutMode}
150
218
  />
151
- </aside>
152
- </div>
153
- </main>
219
+ </main>
220
+ </WorkbenchShell.MainContent>
221
+ </WorkbenchShell>
154
222
  );
155
223
  }
156
224
 
@@ -171,6 +239,30 @@ export function PrintDocument({
171
239
  const displayPages = pages;
172
240
  const registerPage = () => () => undefined;
173
241
 
242
+ // Mirror the per-document page geometry vars onto :root so the @page
243
+ // rule in print-route.css can resolve them. CSS custom properties set
244
+ // on <main> never reach @page in any browser; without this, headless
245
+ // Chrome falls back to the workspace theme default (210mm × 297mm A4)
246
+ // and slide/social/landscape presses print onto the wrong paper.
247
+ useEffect(() => {
248
+ if (typeof document === "undefined" || typeof window === "undefined") return undefined;
249
+ const root = window.document.documentElement;
250
+ const overrides: Array<[string, string]> = [];
251
+ for (const [key, value] of Object.entries(style)) {
252
+ if (typeof key === "string" && key.startsWith("--") && typeof value === "string") {
253
+ overrides.push([key, value]);
254
+ }
255
+ }
256
+ const previous = overrides.map(([key]) => [key, root.style.getPropertyValue(key)] as const);
257
+ overrides.forEach(([key, value]) => root.style.setProperty(key, value));
258
+ return () => {
259
+ previous.forEach(([key, value]) => {
260
+ if (value) root.style.setProperty(key, value);
261
+ else root.style.removeProperty(key);
262
+ });
263
+ };
264
+ }, [style]);
265
+
174
266
  return (
175
267
  <main
176
268
  className="openpress-print-document"
@@ -181,7 +273,6 @@ export function PrintDocument({
181
273
  <PublicPage
182
274
  pages={displayPages}
183
275
  currentPageIndex={0}
184
- devMode={false}
185
276
  sourceContainerRef={sourceContainerRef}
186
277
  registerPage={registerPage}
187
278
  exposeSourceData
@@ -193,7 +284,6 @@ export function PrintDocument({
193
284
  export function PublicPage({
194
285
  pages,
195
286
  currentPageIndex,
196
- devMode,
197
287
  sourceContainerRef,
198
288
  registerPage,
199
289
  exposeSourceData = false,
@@ -203,7 +293,6 @@ export function PublicPage({
203
293
  }: {
204
294
  pages: DisplayPage[];
205
295
  currentPageIndex: number;
206
- devMode: boolean;
207
296
  sourceContainerRef: RefObject<HTMLDivElement | null>;
208
297
  registerPage: (pageIndex: number) => RefCallback<HTMLElement>;
209
298
  exposeSourceData?: boolean;
@@ -66,13 +66,18 @@ export function SlidePresentationPage({
66
66
  const handleKeyDown = (event: KeyboardEvent) => {
67
67
  if (isEditableTarget(event.target)) return;
68
68
  if (event.key === "Escape") {
69
- event.preventDefault();
69
+ // Esc is reserved for exiting browser fullscreen. The chrome HUD
70
+ // already exposes explicit "re-enter fullscreen" and "close"
71
+ // buttons; navigating out of the presenter from a stray keystroke
72
+ // would yank the user back to the workspace shell unexpectedly
73
+ // (and racily, since the same Esc that triggered the browser's
74
+ // fullscreen exit is also delivered to this handler with
75
+ // fullscreenElement already null).
70
76
  const activeDocument = globalThis.document;
71
77
  if (activeDocument.fullscreenElement && activeDocument.exitFullscreen) {
78
+ event.preventDefault();
72
79
  void activeDocument.exitFullscreen();
73
- return;
74
80
  }
75
- onExitPresentation?.(currentPageIndexRef.current);
76
81
  return;
77
82
  }
78
83
  if (event.key === " " || event.code === "Space") {
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  export interface UsePanelStateOptions {
4
4
  leftPanelBreakpoint?: number;
@@ -31,26 +31,35 @@ export function usePanelState({
31
31
  const [rightPanelOpen, setRightPanelOpen] = useState(false);
32
32
  const [leftPanelOpen, setLeftPanelOpen] = useState(false);
33
33
 
34
+ // The auto-close-on-narrow rule is a *resize* response, not a state-change
35
+ // response. Keep current panel state in a ref so the resize listener can read
36
+ // it without re-subscribing every toggle — otherwise toggling a drawer open
37
+ // in a narrow viewport would re-run this effect, call handleResize
38
+ // synchronously, see "open + below breakpoint", and immediately close the
39
+ // panel the user just opened.
40
+ const panelStateRef = useRef({ leftPanelOpen, rightPanelOpen });
41
+ panelStateRef.current = { leftPanelOpen, rightPanelOpen };
42
+
34
43
  useEffect(() => {
35
44
  if (typeof window === "undefined") return undefined;
36
45
 
37
46
  const handleResize = () => {
38
- const closeLeftPanel = leftPanelOpen && !shouldOpenLeftPanel();
39
- const closeRightPanel = rightPanelOpen && !shouldOpenRightPanel();
47
+ const { leftPanelOpen: lo, rightPanelOpen: ro } = panelStateRef.current;
48
+ const closeLeftPanel = lo && !shouldOpenLeftPanel();
49
+ const closeRightPanel = ro && !shouldOpenRightPanel();
40
50
 
41
51
  if (closeLeftPanel) setLeftPanelOpen(false);
42
52
  if (closeRightPanel) setRightPanelOpen(false);
43
53
  if (closeLeftPanel || closeRightPanel) onAfterResize?.();
44
54
  };
45
55
 
46
- handleResize();
47
56
  window.addEventListener("resize", handleResize);
48
57
  window.visualViewport?.addEventListener("resize", handleResize);
49
58
  return () => {
50
59
  window.removeEventListener("resize", handleResize);
51
60
  window.visualViewport?.removeEventListener("resize", handleResize);
52
61
  };
53
- }, [leftPanelOpen, rightPanelOpen, shouldOpenLeftPanel, shouldOpenRightPanel, onAfterResize]);
62
+ }, [shouldOpenLeftPanel, shouldOpenRightPanel, onAfterResize]);
54
63
 
55
64
  const toggleLeftPanel = useCallback(() => setLeftPanelOpen((open) => !open), []);
56
65
  const toggleRightPanel = useCallback(() => setRightPanelOpen((open) => !open), []);
@@ -2,3 +2,4 @@ export * from "./frameScheduler";
2
2
  export * from "./numberUtils";
3
3
  export * from "./Panel";
4
4
  export * from "./runtimeMode";
5
+ export * from "./staticSearch";
@@ -0,0 +1,174 @@
1
+ // Browser-safe literal-substring search over the build-time search
2
+ // corpus (<outputDir>/openpress/search-corpus.json). Mirrors the
3
+ // `searchSourceText` logic in engine/runtime/source-text-tools.mjs so
4
+ // public deploys can search without the /__openpress/search dev endpoint.
5
+
6
+ export type SearchScope = "content" | "all";
7
+
8
+ export interface SearchCorpusFile {
9
+ scope: string;
10
+ file: string;
11
+ path: string;
12
+ text: string;
13
+ }
14
+
15
+ export interface SearchCorpus {
16
+ kind: "search-corpus";
17
+ version: number;
18
+ files: SearchCorpusFile[];
19
+ }
20
+
21
+ export interface SearchReportFile {
22
+ scope: string;
23
+ file: string;
24
+ path: string;
25
+ matchCount: number;
26
+ }
27
+
28
+ export interface SearchReportMatch {
29
+ id: string;
30
+ scope: string;
31
+ file: string;
32
+ path: string;
33
+ line: number;
34
+ column: number;
35
+ index: number;
36
+ text: string;
37
+ preview: string;
38
+ }
39
+
40
+ export interface SearchReport {
41
+ ok?: boolean;
42
+ kind: "search";
43
+ query: string;
44
+ scope: SearchScope;
45
+ caseSensitive: boolean;
46
+ matchCount: number;
47
+ files: SearchReportFile[];
48
+ matches: SearchReportMatch[];
49
+ message?: string;
50
+ }
51
+
52
+ export interface SearchCorpusQueryOptions {
53
+ query: string;
54
+ scope?: SearchScope;
55
+ caseSensitive?: boolean;
56
+ }
57
+
58
+ export function searchCorpus(corpus: SearchCorpus, options: SearchCorpusQueryOptions): SearchReport {
59
+ const query = options.query;
60
+ const scope: SearchScope = options.scope ?? "content";
61
+ const caseSensitive = options.caseSensitive ?? false;
62
+ const matches: SearchReportMatch[] = [];
63
+
64
+ if (!query) {
65
+ return { kind: "search", query, scope, caseSensitive, matchCount: 0, files: [], matches: [] };
66
+ }
67
+
68
+ for (const file of corpus.files) {
69
+ const rawMatches = findLiteralMatches(file.text, query, { caseSensitive });
70
+ for (const match of rawMatches) {
71
+ matches.push({
72
+ id: `match-${String(matches.length + 1).padStart(4, "0")}`,
73
+ scope: file.scope,
74
+ file: file.file,
75
+ path: file.path,
76
+ line: match.line,
77
+ column: match.column,
78
+ index: match.index,
79
+ text: match.text,
80
+ preview: match.preview,
81
+ });
82
+ }
83
+ }
84
+
85
+ return {
86
+ kind: "search",
87
+ query,
88
+ scope,
89
+ caseSensitive,
90
+ matchCount: matches.length,
91
+ files: summarizeFiles(matches),
92
+ matches,
93
+ };
94
+ }
95
+
96
+ interface RawMatch {
97
+ line: number;
98
+ column: number;
99
+ index: number;
100
+ text: string;
101
+ preview: string;
102
+ }
103
+
104
+ function findLiteralMatches(text: string, query: string, options: { caseSensitive: boolean }): RawMatch[] {
105
+ if (!query) return [];
106
+ const matches: RawMatch[] = [];
107
+ forEachLine(text, ({ line, lineNumber, lineOffset }) => {
108
+ for (const range of findLineMatches(line, query, options)) {
109
+ matches.push({
110
+ line: lineNumber,
111
+ column: range.start + 1,
112
+ index: lineOffset + range.start,
113
+ text: line.slice(range.start, range.end),
114
+ preview: previewLine(line, range.start, range.end),
115
+ });
116
+ }
117
+ });
118
+ return matches;
119
+ }
120
+
121
+ function findLineMatches(line: string, query: string, { caseSensitive }: { caseSensitive: boolean }) {
122
+ const haystack = caseSensitive ? line : line.toLowerCase();
123
+ const needle = caseSensitive ? query : query.toLowerCase();
124
+ const ranges: { start: number; end: number }[] = [];
125
+ let cursor = 0;
126
+ while (needle && cursor <= haystack.length) {
127
+ const start = haystack.indexOf(needle, cursor);
128
+ if (start < 0) break;
129
+ const end = start + needle.length;
130
+ ranges.push({ start, end });
131
+ cursor = end;
132
+ }
133
+ return ranges;
134
+ }
135
+
136
+ function previewLine(line: string, start: number, end: number) {
137
+ const previewStart = Math.max(0, start - 40);
138
+ const previewEnd = Math.min(line.length, end + 40);
139
+ const prefix = previewStart > 0 ? "..." : "";
140
+ const suffix = previewEnd < line.length ? "..." : "";
141
+ return `${prefix}${line.slice(previewStart, previewEnd)}${suffix}`;
142
+ }
143
+
144
+ function forEachLine(
145
+ text: string,
146
+ visit: (info: { line: string; ending: string; lineNumber: number; lineOffset: number }) => void,
147
+ ) {
148
+ const lineRe = /([^\r\n]*)(\r\n|\n|\r|$)/g;
149
+ let lineNumber = 1;
150
+ let offset = 0;
151
+ let match: RegExpExecArray | null;
152
+ while ((match = lineRe.exec(text))) {
153
+ const [full, line, ending] = match;
154
+ if (full === "") break;
155
+ visit({ line, ending, lineNumber, lineOffset: offset });
156
+ offset += full.length;
157
+ lineNumber += 1;
158
+ }
159
+ }
160
+
161
+ function summarizeFiles(matches: SearchReportMatch[]): SearchReportFile[] {
162
+ const grouped = new Map<string, SearchReportFile>();
163
+ for (const match of matches) {
164
+ const current = grouped.get(match.path) ?? {
165
+ scope: match.scope,
166
+ file: match.file,
167
+ path: match.path,
168
+ matchCount: 0,
169
+ };
170
+ current.matchCount += 1;
171
+ grouped.set(match.path, current);
172
+ }
173
+ return Array.from(grouped.values());
174
+ }