stylegallery 0.1.11 → 0.1.12
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.
- package/AGENTS.md +2 -2
- package/GOVERNANCE.md +1 -1
- package/README.md +28 -35
- package/consumer-reference/agent-native/README.md +5 -5
- package/consumer-reference/agent-native/v2/material-registry.json +1 -1
- package/design-engineering/loading-and-feedback.md +1 -0
- package/design-engineering/native-interaction-contracts.md +2 -0
- package/design-engineering/state-management/patterns/latest-request-wins.md +2 -0
- package/design-engineering/state-management/patterns/optimistic-overlay.md +1 -0
- package/design-engineering/state-management/patterns/single-flight.md +2 -0
- package/design-engineering/state-management/patterns/unsaved-navigation.md +2 -0
- package/design-engineering/state-management/patterns/url-state.md +1 -0
- package/design-engineering/state-management/recipes/delete-undo.md +1 -0
- package/design-engineering/text-input-and-internationalization.md +1 -0
- package/expression/directions/nocturne-editorial.md +1 -0
- package/expression/directions/warm-print.md +1 -0
- package/expression/techniques/gradient-atmosphere.md +1 -0
- package/expression/techniques/grain-and-texture.md +1 -0
- package/expression/techniques/webgl-hero.md +1 -0
- package/guides/decision-tree.md +2 -0
- package/guides/webpage-generation-workflow.md +2 -0
- package/log.md +13 -0
- package/motion/accessible-motion.md +2 -0
- package/motion/interruption-and-retargeting.md +2 -0
- package/motion/rendering-and-performance.md +1 -0
- package/motion/techniques/kinetic-type.md +1 -0
- package/motion/techniques/scroll-choreography.md +2 -0
- package/package.json +14 -8
- package/patterns/centering/center.md +1 -0
- package/patterns/centering/super-center.md +2 -0
- package/patterns/containment/box.md +1 -0
- package/patterns/containment/clamped-card.md +1 -0
- package/patterns/containment/content-limiter.md +2 -0
- package/patterns/containment/fluid-styles.md +1 -0
- package/patterns/grid-repetition/card-grid.md +2 -0
- package/patterns/grid-repetition/columns.md +1 -0
- package/patterns/grid-repetition/dense-grid.md +1 -0
- package/patterns/grid-repetition/grid-wrapper.md +1 -0
- package/patterns/grid-repetition/masonry-approx.md +1 -0
- package/patterns/grid-repetition/page-grid.md +1 -0
- package/patterns/grid-repetition/ram-grid.md +2 -0
- package/patterns/grid-repetition/twelve-span-grid.md +1 -0
- package/patterns/in-line-grouping/badge-list.md +1 -0
- package/patterns/in-line-grouping/breadcrumb.md +1 -0
- package/patterns/in-line-grouping/cluster.md +1 -0
- package/patterns/in-line-grouping/deconstructed-pancake.md +1 -0
- package/patterns/in-line-grouping/pagination.md +1 -0
- package/patterns/in-line-grouping/reel.md +2 -0
- package/patterns/in-line-grouping/split-nav.md +1 -0
- package/patterns/in-line-grouping/tab-strip.md +1 -0
- package/patterns/in-line-grouping/wrap-row.md +1 -0
- package/patterns/media-fit/frame.md +2 -0
- package/patterns/media-fit/icon-frame.md +1 -0
- package/patterns/overlay-exception/imposter.md +1 -0
- package/patterns/overlay-exception/overlay-stack.md +1 -0
- package/patterns/split-sidebar/list-detail.md +1 -0
- package/patterns/split-sidebar/main-with-rail.md +1 -0
- package/patterns/split-sidebar/media-object.md +1 -0
- package/patterns/split-sidebar/sidebar.md +2 -0
- package/patterns/split-sidebar/split-screen.md +1 -0
- package/patterns/split-sidebar/sticky-aside.md +2 -0
- package/patterns/split-sidebar/supporting-pane.md +1 -0
- package/patterns/split-sidebar/switcher.md +2 -0
- package/patterns/stacking/feed.md +1 -0
- package/patterns/stacking/line-up.md +2 -0
- package/patterns/stacking/stack.md +1 -0
- package/patterns/stacking/step-nav.md +1 -0
- package/patterns/viewport-shell/cover.md +1 -0
- package/patterns/viewport-shell/fixed-sidenav-shell.md +2 -0
- package/patterns/viewport-shell/holy-grail.md +2 -0
- package/patterns/viewport-shell/panel-layout.md +1 -0
- package/patterns/viewport-shell/scroll-body-shell.md +2 -0
- package/patterns/viewport-shell/sticky-footer.md +2 -0
- package/patterns/viewport-shell/sticky-header.md +2 -0
- package/platform-guides/preferences-and-accessibility.md +1 -0
- package/recipes/article-page.md +1 -0
- package/recipes/command-surface.md +1 -0
- package/recipes/dashboard.md +1 -0
- package/recipes/form-flow.md +1 -0
- package/recipes/homepage.md +2 -0
- package/recipes/list-detail.md +1 -0
- package/recipes/saas-settings.md +1 -0
- package/scripts/agent-native/v2/material-cli-adapter.mjs +7 -5
- package/scripts/agent-native/v2/material-queries.mjs +21 -0
- package/scripts/agent-native/v2/material-search-v3.mjs +409 -0
- package/scripts/compiler/README.md +16 -16
- package/scripts/compiler/mcp.mjs +38 -2
- package/scripts/legacy-sg-material-mcp.mjs +9 -0
- package/scripts/legacy-sg-material.mjs +4 -0
- package/scripts/pattern-data.mjs +409 -0
- package/scripts/sg-entry.mjs +2 -2
- package/scripts/sg-material-mcp.mjs +1 -1
- package/scripts/stylegallery-cli.mjs +130 -0
- package/skills/stylegallery-search/SKILL.md +34 -0
|
@@ -4,6 +4,7 @@ name: feed
|
|
|
4
4
|
title: feed
|
|
5
5
|
category: Stacking
|
|
6
6
|
description: Stack repeated content items with stable rhythm.
|
|
7
|
+
aliases: ["content feed","activity feed","피드"]
|
|
7
8
|
primary_spatial_problem: Stack repeated content items with stable rhythm.
|
|
8
9
|
secondary_spatial_problems: none
|
|
9
10
|
layout_axis: block
|
|
@@ -4,6 +4,8 @@ name: line-up
|
|
|
4
4
|
title: line-up
|
|
5
5
|
category: Stacking
|
|
6
6
|
description: Keep card footer actions aligned at the bottom.
|
|
7
|
+
aliases: ["equal height cards","buttons aligned at bottom","card footer alignment","카드 버튼 하단 정렬"]
|
|
8
|
+
questions: ["How do I align card footer actions at the bottom?"]
|
|
7
9
|
primary_spatial_problem: Keep card footer actions aligned at the bottom.
|
|
8
10
|
secondary_spatial_problems: none
|
|
9
11
|
layout_axis: block
|
|
@@ -4,6 +4,7 @@ name: stack
|
|
|
4
4
|
title: stack
|
|
5
5
|
category: Stacking
|
|
6
6
|
description: Create consistent vertical rhythm between direct children.
|
|
7
|
+
aliases: ["vertical rhythm","vertical spacing between children","스택","세로 간격"]
|
|
7
8
|
primary_spatial_problem: Create consistent vertical rhythm between direct children.
|
|
8
9
|
secondary_spatial_problems: none
|
|
9
10
|
layout_axis: block
|
|
@@ -4,6 +4,7 @@ name: step-nav
|
|
|
4
4
|
title: step-nav
|
|
5
5
|
category: Stacking
|
|
6
6
|
description: Present sequential steps with consistent vertical rhythm.
|
|
7
|
+
aliases: ["stepper","steps list","단계 표시","스텝 내비게이션"]
|
|
7
8
|
primary_spatial_problem: Present sequential steps with consistent vertical rhythm.
|
|
8
9
|
secondary_spatial_problems: none
|
|
9
10
|
layout_axis: block
|
|
@@ -4,6 +4,7 @@ name: cover
|
|
|
4
4
|
title: cover
|
|
5
5
|
category: Viewport / Shell
|
|
6
6
|
description: Keep a central region balanced between optional header and footer.
|
|
7
|
+
aliases: ["full height hero","vertically centered hero","커버 레이아웃","전체 높이 히어로"]
|
|
7
8
|
primary_spatial_problem: Keep a central region balanced between optional header and footer.
|
|
8
9
|
secondary_spatial_problems: none
|
|
9
10
|
layout_axis: block
|
|
@@ -4,6 +4,8 @@ name: fixed-sidenav-shell
|
|
|
4
4
|
title: fixed-sidenav-shell
|
|
5
5
|
category: Viewport / Shell
|
|
6
6
|
description: Keep side navigation stable while main content scrolls.
|
|
7
|
+
aliases: ["fixed side navigation","sidenav shell","고정 사이드 내비게이션","사이드바 레이아웃","고정 사이드바"]
|
|
8
|
+
questions: ["How do I keep side navigation fixed while the main content scrolls?"]
|
|
7
9
|
primary_spatial_problem: Keep side navigation stable while main content scrolls.
|
|
8
10
|
secondary_spatial_problems: none
|
|
9
11
|
layout_axis: both
|
|
@@ -4,6 +4,8 @@ name: holy-grail
|
|
|
4
4
|
title: holy-grail
|
|
5
5
|
category: Viewport / Shell
|
|
6
6
|
description: Place header, footer, sidebars, and main content in a resilient shell.
|
|
7
|
+
aliases: ["header footer two sidebars","three column page layout","홀리 그레일 레이아웃","3단 레이아웃"]
|
|
8
|
+
questions: ["How do I lay out a header, footer, two sidebars, and main content?"]
|
|
7
9
|
primary_spatial_problem: Place header, footer, sidebars, and main content in a resilient shell.
|
|
8
10
|
secondary_spatial_problems: none
|
|
9
11
|
layout_axis: both
|
|
@@ -4,6 +4,7 @@ name: panel-layout
|
|
|
4
4
|
title: panel-layout
|
|
5
5
|
category: Viewport / Shell
|
|
6
6
|
description: Create predictable main and utility panels.
|
|
7
|
+
aliases: ["main and utility panels","패널 레이아웃"]
|
|
7
8
|
primary_spatial_problem: Create predictable main and utility panels.
|
|
8
9
|
secondary_spatial_problems: none
|
|
9
10
|
layout_axis: inline
|
|
@@ -4,6 +4,8 @@ name: scroll-body-shell
|
|
|
4
4
|
title: scroll-body-shell
|
|
5
5
|
category: Viewport / Shell
|
|
6
6
|
description: Keep shell regions fixed while only the body scrolls.
|
|
7
|
+
aliases: ["fixed header and footer with scrolling body","app shell","스크롤 영역","본문만 스크롤"]
|
|
8
|
+
questions: ["How do I make only the body scroll between a fixed header and footer?"]
|
|
7
9
|
primary_spatial_problem: Keep shell regions fixed while only the body scrolls.
|
|
8
10
|
secondary_spatial_problems: none
|
|
9
11
|
layout_axis: block
|
|
@@ -4,6 +4,8 @@ name: sticky-footer
|
|
|
4
4
|
title: sticky-footer
|
|
5
5
|
category: Viewport / Shell
|
|
6
6
|
description: Keep footer at the bottom when content is short.
|
|
7
|
+
aliases: ["footer at bottom","push footer down","푸터 고정","하단 푸터","푸터를 화면 아래에 붙이기"]
|
|
8
|
+
questions: ["How do I keep the footer at the bottom when content is short?"]
|
|
7
9
|
primary_spatial_problem: Keep footer at the bottom when content is short.
|
|
8
10
|
secondary_spatial_problems: none
|
|
9
11
|
layout_axis: block
|
|
@@ -4,6 +4,8 @@ name: sticky-header
|
|
|
4
4
|
title: sticky-header
|
|
5
5
|
category: Viewport / Shell
|
|
6
6
|
description: Keep a header visible above a scrolling content region.
|
|
7
|
+
aliases: ["pinned top bar","sticky top navigation","헤더 고정","고정 헤더","상단 고정 메뉴"]
|
|
8
|
+
questions: ["How do I keep the header visible while the page scrolls?"]
|
|
7
9
|
primary_spatial_problem: Keep a header visible above a scrolling content region.
|
|
8
10
|
secondary_spatial_problems: none
|
|
9
11
|
layout_axis: block
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
type: Domain Guide
|
|
3
3
|
title: Preferences And Accessibility Across Platforms
|
|
4
4
|
description: Compare text scaling, motion, contrast, transparency, and accessibility semantics without collapsing distinct user preferences.
|
|
5
|
+
aliases: ["text scaling","reduced motion","high contrast","글자 크기","고대비"]
|
|
5
6
|
domain: platform-guides
|
|
6
7
|
lifecycle: experimental
|
|
7
8
|
provenance_kind: local
|
package/recipes/article-page.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
type: Layout Recipe
|
|
3
3
|
title: Article Page
|
|
4
4
|
description: Compose readable prose with optional supporting aside content.
|
|
5
|
+
aliases: ["blog post layout","long-form article","블로그 글 레이아웃","아티클 페이지"]
|
|
5
6
|
screen_type: Article page
|
|
6
7
|
primary_user_task: Read a long document while referencing supporting material.
|
|
7
8
|
spatial_model: Constrained reading column with nearby supplemental content.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
type: Layout Recipe
|
|
3
3
|
title: Command Surface
|
|
4
4
|
description: Compose a tool-heavy shell with stable command and content regions.
|
|
5
|
+
aliases: ["editor shell","tool-heavy app shell","toolbar app","툴 화면","에디터 레이아웃"]
|
|
5
6
|
screen_type: Command surface
|
|
6
7
|
primary_user_task: Repeatedly execute commands while inspecting or editing content.
|
|
7
8
|
spatial_model: Stable shell regions with wrapping or scrolling command rows.
|
package/recipes/dashboard.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
type: Layout Recipe
|
|
3
3
|
title: Dashboard
|
|
4
4
|
description: Compose repeated panels and action clusters on a page grid.
|
|
5
|
+
aliases: ["admin dashboard","panel dashboard","대시보드","대시보드 레이아웃"]
|
|
5
6
|
screen_type: Dashboard
|
|
6
7
|
primary_user_task: Scan and compare repeated status, metric, or workflow panels.
|
|
7
8
|
spatial_model: Page grid containing repeated local grids and action groups.
|
package/recipes/form-flow.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
type: Layout Recipe
|
|
3
3
|
title: Form Flow
|
|
4
4
|
description: Compose sequential form content with constrained width and reachable actions.
|
|
5
|
+
aliases: ["form layout","checkout form","폼 레이아웃","입력 폼"]
|
|
5
6
|
screen_type: Form flow
|
|
6
7
|
primary_user_task: Complete a sequence of related inputs.
|
|
7
8
|
spatial_model: Single primary column with grouped fields and action placement.
|
package/recipes/homepage.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
type: Layout Recipe
|
|
3
3
|
title: Homepage
|
|
4
4
|
description: Compose raw content into a homepage or ordinary webpage before product-level visual styling.
|
|
5
|
+
aliases: ["landing page","marketing homepage","랜딩 페이지","홈페이지 레이아웃","랜딩 페이지 만들기"]
|
|
6
|
+
questions: ["How do I compose raw content into a homepage layout?"]
|
|
5
7
|
screen_type: Homepage
|
|
6
8
|
primary_user_task: Understand the offer, find the right next path, and decide whether to act.
|
|
7
9
|
spatial_model: First-viewport hook followed by stacked explanation, proof, comparison, conversion, and navigation sections.
|
package/recipes/list-detail.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
type: Layout Recipe
|
|
3
3
|
title: List Detail
|
|
4
4
|
description: Compose an explorable list beside its selected detail region.
|
|
5
|
+
aliases: ["master detail screen","inbox layout","목록 상세 화면"]
|
|
5
6
|
screen_type: List detail
|
|
6
7
|
primary_user_task: Browse a collection and inspect one selected item.
|
|
7
8
|
spatial_model: Peer list and detail regions with explicit scroll ownership.
|
package/recipes/saas-settings.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
type: Layout Recipe
|
|
3
3
|
title: SaaS Settings
|
|
4
4
|
description: Compose a fixed side navigation with a constrained settings flow.
|
|
5
|
+
aliases: ["settings page","account settings layout","설정 페이지","설정 화면 레이아웃"]
|
|
5
6
|
screen_type: SaaS settings
|
|
6
7
|
primary_user_task: Review and edit account or workspace settings.
|
|
7
8
|
spatial_model: Fixed navigation plus constrained primary content.
|
|
@@ -115,12 +115,14 @@ function packagedInventoryRunner(root) {
|
|
|
115
115
|
};
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
|
|
118
|
+
/** Repository root plus the packaged inventory runner when installed without Git metadata. */
|
|
119
|
+
export function materialRuntimeOptions() {
|
|
119
120
|
const hasGitMetadata = fs.existsSync(path.join(repositoryRoot, ".git"));
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
return { repositoryRoot, ...(hasGitMetadata ? {} : { gitRunner: packagedInventoryRunner(repositoryRoot) }) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function runtimeRegistry() {
|
|
125
|
+
return createMaterialOperationRegistry(materialRuntimeOptions());
|
|
124
126
|
}
|
|
125
127
|
|
|
126
128
|
export function executeMaterialCli(args, registry = runtimeRegistry()) {
|
|
@@ -415,6 +415,27 @@ export function materialSearch({ repositoryRoot, input, fileSystem = fs, sourceR
|
|
|
415
415
|
return deepFreeze({ ...envelope, results: selected.map(({ result }) => result), total_matches: ranked.length });
|
|
416
416
|
}
|
|
417
417
|
|
|
418
|
+
/**
|
|
419
|
+
* Read every admitted material under the same manifest, tracking, and hash
|
|
420
|
+
* checks as material-search. Ranking layers (search v3) build on this.
|
|
421
|
+
*/
|
|
422
|
+
export function materialCorpus({ repositoryRoot, fileSystem = fs, sourceReader, gitRunner }) {
|
|
423
|
+
const { root, manifest } = loadMetadata(repositoryRoot, fileSystem);
|
|
424
|
+
const beforeInventory = trackedInventory(repositoryRoot, gitRunner);
|
|
425
|
+
const documents = manifest.materials.map((record) => {
|
|
426
|
+
const bytes = readRecord({ repositoryRoot, root, record, fileSystem, sourceReader, gitRunner, inventory: beforeInventory });
|
|
427
|
+
let text;
|
|
428
|
+
try { text = utf8Fatal.decode(bytes); } catch { fail("material_source_utf8_invalid", "material source is not valid UTF-8"); }
|
|
429
|
+
return { record, identity: materialIdentityForRecord(record), text };
|
|
430
|
+
});
|
|
431
|
+
const afterInventory = trackedInventory(repositoryRoot, gitRunner);
|
|
432
|
+
for (const record of manifest.materials) {
|
|
433
|
+
assertTracked(record, afterInventory);
|
|
434
|
+
if (canonicalize(beforeInventory.get(record.repository_path)) !== canonicalize(afterInventory.get(record.repository_path))) fail("material_source_race", "material source tracking changed during read");
|
|
435
|
+
}
|
|
436
|
+
return { manifestVersionId: manifest.version_id, documents };
|
|
437
|
+
}
|
|
438
|
+
|
|
418
439
|
function validateGetInput(input) {
|
|
419
440
|
assertObject(input);
|
|
420
441
|
assertOnly(input, new Set(["reference", "offset", "length"]));
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
// Search v3: readable IDs, BM25F field ranking, section-aware read.
|
|
2
|
+
// Offline and deterministic: no model, no network, stable tie-breaks.
|
|
3
|
+
import { deepFreeze } from "../canonical-json.mjs";
|
|
4
|
+
import { MaterialQueryError, materialCorpus, tokenizeMaterialText } from "./material-queries.mjs";
|
|
5
|
+
|
|
6
|
+
export const SEARCH_SCHEMA_VERSION = "3.0";
|
|
7
|
+
export const SEARCH_DEFAULT_LIMIT = 5;
|
|
8
|
+
export const SEARCH_MAX_LIMIT = 50;
|
|
9
|
+
export const SEARCH_SNIPPET_CHARS = 500;
|
|
10
|
+
export const SEARCH_BUDGET_BYTES = 16 * 1024;
|
|
11
|
+
export const READ_MAX_CHARS = 64 * 1024;
|
|
12
|
+
const QUERY_MAX_BYTES = 4096;
|
|
13
|
+
|
|
14
|
+
// Field weight (w) and length normalization (b) for BM25F. `ko` holds Hangul
|
|
15
|
+
// 2-grams taken only from Korean aliases, questions, and titles.
|
|
16
|
+
export const SEARCH_FIELDS = deepFreeze({
|
|
17
|
+
title: { w: 4, b: 0.3 },
|
|
18
|
+
aliases: { w: 4, b: 0.3 },
|
|
19
|
+
description: { w: 2.5, b: 0.5 },
|
|
20
|
+
questions: { w: 2.5, b: 0.5 },
|
|
21
|
+
path: { w: 2, b: 0.3 },
|
|
22
|
+
headings: { w: 1.5, b: 0.6 },
|
|
23
|
+
body: { w: 1, b: 0.75 },
|
|
24
|
+
ko: { w: 1.5, b: 0.3 },
|
|
25
|
+
});
|
|
26
|
+
export const SEARCH_K1 = 1.2;
|
|
27
|
+
|
|
28
|
+
const STOPWORDS = new Set(("a an and are as at be by can do does for from how i in into is it its me my of on or should "
|
|
29
|
+
+ "so that the this to use using what when where which with you your").split(" "));
|
|
30
|
+
const HANGUL = /^[\p{Script=Hangul}]+$/u;
|
|
31
|
+
const KO_PREFIX = "\u0001ko:";
|
|
32
|
+
|
|
33
|
+
function fail(code, message) { throw new MaterialQueryError(code, message); }
|
|
34
|
+
function compare(left, right) { return left < right ? -1 : left > right ? 1 : 0; }
|
|
35
|
+
|
|
36
|
+
function stem(token) {
|
|
37
|
+
if (!/^[a-z]+$/.test(token) || token.length <= 3) return token;
|
|
38
|
+
if (token.endsWith("ies") && token.length > 4) return `${token.slice(0, -3)}y`;
|
|
39
|
+
if (/(?:ss|us|is)$/.test(token)) return token;
|
|
40
|
+
if (/(?:sh|ch|x|z)es$/.test(token)) return token.slice(0, -2);
|
|
41
|
+
if (token.endsWith("s")) return token.slice(0, -1);
|
|
42
|
+
let base = token;
|
|
43
|
+
if (token.length > 5 && token.endsWith("ing")) base = token.slice(0, -3);
|
|
44
|
+
else if (token.length > 4 && token.endsWith("ed") && !token.endsWith("eed")) base = token.slice(0, -2);
|
|
45
|
+
else return token;
|
|
46
|
+
return /([b-df-hj-km-rtv-y])\1$/.test(base) ? base.slice(0, -1) : base;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Terms for ranking: tokenizer words, stopwords removed, light English plural stemming. */
|
|
50
|
+
export function searchTerms(value) {
|
|
51
|
+
return tokenizeMaterialText(value).filter((token) => !STOPWORDS.has(token)).map(stem);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function hangulBigrams(value) {
|
|
55
|
+
const grams = [];
|
|
56
|
+
for (const token of tokenizeMaterialText(value)) {
|
|
57
|
+
if (!HANGUL.test(token)) continue;
|
|
58
|
+
const chars = [...token];
|
|
59
|
+
if (chars.length === 1) grams.push(`${KO_PREFIX}${token}`);
|
|
60
|
+
for (let index = 0; index + 1 < chars.length; index += 1) grams.push(`${KO_PREFIX}${chars[index]}${chars[index + 1]}`);
|
|
61
|
+
}
|
|
62
|
+
return grams;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseFrontmatter(text) {
|
|
66
|
+
const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(text);
|
|
67
|
+
const fields = {};
|
|
68
|
+
if (!match) return { fields, bodyStart: 0, bodyLine: 1 };
|
|
69
|
+
for (const line of match[1].split("\n")) {
|
|
70
|
+
const index = line.indexOf(":");
|
|
71
|
+
if (index <= 0 || /^\s/.test(line)) continue;
|
|
72
|
+
fields[line.slice(0, index).trim()] = line.slice(index + 1).trim();
|
|
73
|
+
}
|
|
74
|
+
return { fields, bodyStart: match[0].length, bodyLine: match[0].split("\n").length - (match[0].endsWith("\n") ? 1 : 0) + 1 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function unquote(value = "") { return value.replace(/^(["'])(.*)\1$/, "$2"); }
|
|
78
|
+
|
|
79
|
+
/** Parse a JSON flow list such as ["sticky header", "헤더 고정"]; anything else yields []. */
|
|
80
|
+
export function frontmatterList(value) {
|
|
81
|
+
if (typeof value !== "string" || !value.startsWith("[")) return [];
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(value);
|
|
84
|
+
return Array.isArray(parsed) ? parsed.filter((item) => typeof item === "string" && item.trim().length > 0) : [];
|
|
85
|
+
} catch { return []; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function headingSlug(text) {
|
|
89
|
+
return text.normalize("NFKC").toLowerCase().replace(/[`*_~[\]()]/g, "").replace(/[^\p{L}\p{N}\s-]/gu, "").trim().replace(/\s+/g, "-");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Headings outside fenced code, with stable unique slugs and 1-based line ranges. */
|
|
93
|
+
export function parseSections(text) {
|
|
94
|
+
const lines = text.split("\n");
|
|
95
|
+
const { bodyLine } = parseFrontmatter(text);
|
|
96
|
+
const sections = [];
|
|
97
|
+
const seen = new Map();
|
|
98
|
+
let fence = null;
|
|
99
|
+
for (let index = bodyLine - 1; index < lines.length; index += 1) {
|
|
100
|
+
const line = lines[index];
|
|
101
|
+
const fenceMatch = /^\s*(```|~~~)/.exec(line);
|
|
102
|
+
if (fenceMatch) {
|
|
103
|
+
if (fence === null) fence = fenceMatch[1];
|
|
104
|
+
else if (fence === fenceMatch[1]) fence = null;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (fence !== null) continue;
|
|
108
|
+
const heading = /^(#{1,6})\s+(.+?)\s*#*\s*$/.exec(line);
|
|
109
|
+
if (!heading) continue;
|
|
110
|
+
const base = headingSlug(heading[2]) || "section";
|
|
111
|
+
const count = seen.get(base) ?? 0;
|
|
112
|
+
seen.set(base, count + 1);
|
|
113
|
+
sections.push({ level: heading[1].length, title: heading[2], slug: count === 0 ? base : `${base}-${count + 1}`, line_start: index + 1 });
|
|
114
|
+
}
|
|
115
|
+
for (let index = 0; index < sections.length; index += 1) {
|
|
116
|
+
const next = sections.slice(index + 1).find((candidate) => candidate.level <= sections[index].level);
|
|
117
|
+
sections[index].line_end = next ? next.line_start - 1 : lines.length;
|
|
118
|
+
}
|
|
119
|
+
return sections;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const DOMAIN_ROOTS = ["layout", "motion", "design-engineering", "platform-guides", "design-terminology", "expression"];
|
|
123
|
+
function slugPart(value) { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); }
|
|
124
|
+
|
|
125
|
+
function longSlug(repositoryPath, domain) {
|
|
126
|
+
let rest = repositoryPath.replace(/\.md$/, "");
|
|
127
|
+
if (rest.startsWith(`${domain}/`)) rest = rest.slice(domain.length + 1);
|
|
128
|
+
rest = rest.replace(/(^|\/)index$/, "") || "index";
|
|
129
|
+
return slugPart(rest.replaceAll("/", "-"));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function shortSlug(repositoryPath, domain) {
|
|
133
|
+
const parts = repositoryPath.replace(/\.md$/, "").split("/");
|
|
134
|
+
let base = parts.at(-1);
|
|
135
|
+
if (base === "index") base = parts.length > 1 ? parts.at(-2) : "index";
|
|
136
|
+
if (base === domain && DOMAIN_ROOTS.includes(domain) && parts.length === 2) base = "index";
|
|
137
|
+
return slugPart(base);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Readable IDs `sg:<domain>/<slug>`: file basename, or the path-derived slug when basenames collide. */
|
|
141
|
+
export function readableIds(records) {
|
|
142
|
+
const groups = new Map();
|
|
143
|
+
for (const record of records) {
|
|
144
|
+
const key = `${record.domain}/${shortSlug(record.repository_path, record.domain)}`;
|
|
145
|
+
groups.set(key, [...(groups.get(key) ?? []), record]);
|
|
146
|
+
}
|
|
147
|
+
const ids = new Map();
|
|
148
|
+
for (const [key, members] of groups) {
|
|
149
|
+
for (const record of members) ids.set(record.repository_path, members.length === 1 ? `sg:${key}` : `sg:${record.domain}/${longSlug(record.repository_path, record.domain)}`);
|
|
150
|
+
}
|
|
151
|
+
const unique = new Set(ids.values());
|
|
152
|
+
if (unique.size !== ids.size) fail("search_id_collision", "readable search IDs are not unique");
|
|
153
|
+
return ids;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function counts(terms) {
|
|
157
|
+
const map = new Map();
|
|
158
|
+
for (const term of terms) map.set(term, (map.get(term) ?? 0) + 1);
|
|
159
|
+
return map;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function buildDocument(entry, id) {
|
|
163
|
+
const { record, identity, text } = entry;
|
|
164
|
+
const { fields: meta, bodyStart } = parseFrontmatter(text);
|
|
165
|
+
const body = text.slice(bodyStart);
|
|
166
|
+
const sections = parseSections(text);
|
|
167
|
+
const title = unquote(meta.title) || sections[0]?.title || record.repository_path;
|
|
168
|
+
const description = unquote(meta.description);
|
|
169
|
+
const aliases = frontmatterList(meta.aliases);
|
|
170
|
+
const questions = frontmatterList(meta.questions);
|
|
171
|
+
const fieldText = {
|
|
172
|
+
title, description, aliases: aliases.join("\n"), questions: questions.join("\n"),
|
|
173
|
+
path: record.repository_path.replace(/[/.\-_]/g, " "), headings: sections.map((section) => section.title).join("\n"), body,
|
|
174
|
+
};
|
|
175
|
+
const fields = {};
|
|
176
|
+
for (const [name, value] of Object.entries(fieldText)) {
|
|
177
|
+
const terms = searchTerms(value);
|
|
178
|
+
fields[name] = { tf: counts(terms), length: terms.length };
|
|
179
|
+
}
|
|
180
|
+
const ko = hangulBigrams([...aliases, ...questions, title].join("\n"));
|
|
181
|
+
fields.ko = { tf: counts(ko), length: ko.length };
|
|
182
|
+
return { id, record, identity, text, title, description, aliases, questions, sections, fields };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Build the ranked index once per corpus read. */
|
|
186
|
+
export function buildSearchIndex(corpus) {
|
|
187
|
+
const ids = readableIds(corpus.documents.map(({ record }) => record));
|
|
188
|
+
const documents = corpus.documents.map((entry) => buildDocument(entry, ids.get(entry.record.repository_path)))
|
|
189
|
+
.sort((left, right) => compare(left.id, right.id));
|
|
190
|
+
const averages = {};
|
|
191
|
+
for (const name of Object.keys(SEARCH_FIELDS)) {
|
|
192
|
+
averages[name] = documents.reduce((sum, document) => sum + document.fields[name].length, 0) / Math.max(1, documents.length) || 1;
|
|
193
|
+
}
|
|
194
|
+
return { manifestVersionId: corpus.manifestVersionId, documents, averages, byId: new Map(documents.map((document) => [document.id, document])) };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function queryTerms(query, queryEn) {
|
|
198
|
+
const text = [query, queryEn].filter(Boolean).join("\n");
|
|
199
|
+
return [...new Set([...searchTerms(text), ...hangulBigrams(query)])].sort();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function scoreDocument(index, document, terms, idf) {
|
|
203
|
+
let score = 0;
|
|
204
|
+
const matched = new Set();
|
|
205
|
+
let koTotal = 0;
|
|
206
|
+
let koHits = 0;
|
|
207
|
+
for (const term of terms) {
|
|
208
|
+
if (term.startsWith(KO_PREFIX)) {
|
|
209
|
+
koTotal += 1;
|
|
210
|
+
if (document.fields.ko.tf.has(term)) koHits += 1;
|
|
211
|
+
}
|
|
212
|
+
let weighted = 0;
|
|
213
|
+
for (const [name, { w, b }] of Object.entries(SEARCH_FIELDS)) {
|
|
214
|
+
const isKo = term.startsWith(KO_PREFIX);
|
|
215
|
+
if (isKo !== (name === "ko")) continue;
|
|
216
|
+
const field = document.fields[name];
|
|
217
|
+
const tf = field.tf.get(term) ?? 0;
|
|
218
|
+
if (tf === 0) continue;
|
|
219
|
+
matched.add(name);
|
|
220
|
+
weighted += (w * tf) / (1 - b + (b * field.length) / index.averages[name]);
|
|
221
|
+
}
|
|
222
|
+
if (weighted > 0) score += idf.get(term) * (weighted / (SEARCH_K1 + weighted));
|
|
223
|
+
}
|
|
224
|
+
// A stray shared syllable pair is not a match: 2-gram-only hits need half the query's 2-grams.
|
|
225
|
+
if (matched.size === 1 && matched.has("ko") && koHits * 2 < koTotal) return { score: 0, matched };
|
|
226
|
+
return { score: Math.round(score * 1e6) / 1e6, matched };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function inverseDocumentFrequency(index, terms) {
|
|
230
|
+
const total = index.documents.length;
|
|
231
|
+
return new Map(terms.map((term) => {
|
|
232
|
+
const df = index.documents.reduce((count, document) => count + Number(Object.values(document.fields).some((field) => field.tf.has(term))), 0);
|
|
233
|
+
return [term, Math.log(1 + (total - df + 0.5) / (df + 0.5))];
|
|
234
|
+
}));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function bestSnippet(document, terms) {
|
|
238
|
+
const lines = document.text.split("\n");
|
|
239
|
+
const termSet = new Set(terms.filter((term) => !term.startsWith(KO_PREFIX)));
|
|
240
|
+
const lineHits = (line) => searchTerms(line).filter((term) => termSet.has(term)).length;
|
|
241
|
+
// A section's own text stops at the next heading of any level.
|
|
242
|
+
const ownEnd = (section) => {
|
|
243
|
+
const next = document.sections.find((candidate) => candidate.line_start > section.line_start);
|
|
244
|
+
return next ? next.line_start - 1 : section.line_end;
|
|
245
|
+
};
|
|
246
|
+
let best = null;
|
|
247
|
+
for (const section of document.sections) {
|
|
248
|
+
const own = lines.slice(section.line_start, ownEnd(section));
|
|
249
|
+
const hits = lineHits(section.title) * 2 + own.reduce((sum, line) => sum + lineHits(line), 0);
|
|
250
|
+
if (hits > 0 && (!best || hits > best.hits)) best = { section, hits };
|
|
251
|
+
}
|
|
252
|
+
const section = best?.section ?? document.sections.find((candidate) => candidate.level > 1) ?? document.sections[0];
|
|
253
|
+
if (!section) return null;
|
|
254
|
+
const start = section.line_start + 1;
|
|
255
|
+
const end = ownEnd(section);
|
|
256
|
+
let first = start;
|
|
257
|
+
for (let line = start; line <= end; line += 1) if (lineHits(lines[line - 1]) > 0) { first = line; break; }
|
|
258
|
+
while (first <= end && lines[first - 1].trim() === "") first += 1;
|
|
259
|
+
let text = "";
|
|
260
|
+
let last = first - 1;
|
|
261
|
+
for (let line = first; line <= end && text.length < SEARCH_SNIPPET_CHARS; line += 1) {
|
|
262
|
+
text += `${text ? "\n" : ""}${lines[line - 1]}`;
|
|
263
|
+
last = line;
|
|
264
|
+
}
|
|
265
|
+
text = text.trimEnd();
|
|
266
|
+
if (text.length > SEARCH_SNIPPET_CHARS) text = `${[...text].slice(0, SEARCH_SNIPPET_CHARS - 1).join("")}…`;
|
|
267
|
+
const headingPath = [];
|
|
268
|
+
for (const candidate of document.sections) {
|
|
269
|
+
if (candidate.line_start > section.line_start) break;
|
|
270
|
+
while (headingPath.length && headingPath.at(-1).level >= candidate.level) headingPath.pop();
|
|
271
|
+
headingPath.push(candidate);
|
|
272
|
+
}
|
|
273
|
+
return { section, snippet: { heading_path: headingPath.map((item) => item.title), line_start: first, line_end: Math.max(first, last), text } };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validateSearchInput(input) {
|
|
277
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) fail("material_input_invalid", "input must be an object");
|
|
278
|
+
const allowed = new Set(["query", "query_en", "limit", "paths_only", "response_format"]);
|
|
279
|
+
if (Object.keys(input).some((key) => !allowed.has(key))) fail("material_input_unknown", "input contains an unsupported field");
|
|
280
|
+
for (const key of ["query", "query_en"]) {
|
|
281
|
+
const value = input[key];
|
|
282
|
+
if (value === undefined && key === "query_en") continue;
|
|
283
|
+
if (typeof value !== "string") fail("material_query_invalid", `${key} must be a string`);
|
|
284
|
+
if (Buffer.byteLength(value, "utf8") > QUERY_MAX_BYTES) fail("material_query_oversized", `${key} exceeds 4096 UTF-8 bytes`);
|
|
285
|
+
}
|
|
286
|
+
if (tokenizeMaterialText(input.query, { query: true }).length === 0) fail("material_query_empty", "query must contain a Unicode word token");
|
|
287
|
+
const limit = input.limit ?? SEARCH_DEFAULT_LIMIT;
|
|
288
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > SEARCH_MAX_LIMIT) fail("search_limit_invalid", `limit must be an integer from 1 to ${SEARCH_MAX_LIMIT}`);
|
|
289
|
+
if (input.paths_only !== undefined && typeof input.paths_only !== "boolean") fail("material_paths_only_invalid", "paths_only must be a boolean");
|
|
290
|
+
const format = input.response_format ?? "concise";
|
|
291
|
+
if (!["concise", "detailed"].includes(format)) fail("search_response_format_invalid", "response_format must be concise or detailed");
|
|
292
|
+
return { query: input.query, queryEn: input.query_en, limit, pathsOnly: input.paths_only === true, detailed: format === "detailed" };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Rank documents for a query. Pure over an index; exported for evaluation. */
|
|
296
|
+
export function rankSearchIndex(index, query, queryEn) {
|
|
297
|
+
const terms = queryTerms(query, queryEn);
|
|
298
|
+
const idf = inverseDocumentFrequency(index, terms);
|
|
299
|
+
const ranked = [];
|
|
300
|
+
for (const document of index.documents) {
|
|
301
|
+
const { score, matched } = scoreDocument(index, document, terms, idf);
|
|
302
|
+
if (score > 0) ranked.push({ document, score, matched });
|
|
303
|
+
}
|
|
304
|
+
ranked.sort((left, right) => right.score - left.score || compare(left.document.id, right.document.id));
|
|
305
|
+
return { terms, ranked };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function readCommand(document, section) {
|
|
309
|
+
return `stylegallery read ${document.id}${section && section.level > 1 ? ` --section ${section.slug}` : ""}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function searchIndex(index, input) {
|
|
313
|
+
const normalized = validateSearchInput(input);
|
|
314
|
+
const { terms, ranked } = rankSearchIndex(index, normalized.query, normalized.queryEn);
|
|
315
|
+
const selected = ranked.slice(0, normalized.limit);
|
|
316
|
+
const query = { text: normalized.query, ...(normalized.queryEn ? { query_en: normalized.queryEn } : {}) };
|
|
317
|
+
const page = { returned: selected.length, total: ranked.length, next_cursor: null };
|
|
318
|
+
if (normalized.pathsOnly) {
|
|
319
|
+
return deepFreeze({ schema_version: SEARCH_SCHEMA_VERSION, query, snapshot: index.manifestVersionId, paths: selected.map(({ document }) => document.record.repository_path), page });
|
|
320
|
+
}
|
|
321
|
+
const results = selected.map(({ document, score, matched }) => {
|
|
322
|
+
const best = bestSnippet(document, terms);
|
|
323
|
+
return {
|
|
324
|
+
id: document.id,
|
|
325
|
+
path: document.record.repository_path,
|
|
326
|
+
title: document.title,
|
|
327
|
+
...(document.description ? { description: document.description } : {}),
|
|
328
|
+
domain: document.record.domain,
|
|
329
|
+
matched_fields: [...matched].sort(),
|
|
330
|
+
snippets: best ? [best.snippet] : [],
|
|
331
|
+
next: { read: readCommand(document, best?.section) },
|
|
332
|
+
...(normalized.detailed ? { score, source_ref: document.record.stable_ref, version_id: document.record.version_id, material_ref: document.identity.stable_ref } : {}),
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
const envelope = { schema_version: SEARCH_SCHEMA_VERSION, query, snapshot: index.manifestVersionId, results, page, coverage: { truncated: false } };
|
|
336
|
+
if (normalized.detailed) envelope.ranking = { method: "BM25F", k1: SEARCH_K1, fields: SEARCH_FIELDS, terms };
|
|
337
|
+
while (Buffer.byteLength(JSON.stringify(envelope), "utf8") > SEARCH_BUDGET_BYTES && envelope.results.length > 1) {
|
|
338
|
+
envelope.results.pop();
|
|
339
|
+
envelope.page.returned = envelope.results.length;
|
|
340
|
+
envelope.coverage.truncated = true;
|
|
341
|
+
}
|
|
342
|
+
if (results.length === 0) {
|
|
343
|
+
envelope.hint = /[\p{Script=Hangul}]/u.test(normalized.query) && !normalized.queryEn
|
|
344
|
+
? "No match. Retry with query_en: an English rewrite of the question (e.g. --query-en \"sticky header\")."
|
|
345
|
+
: "No match. Try fewer or broader words, or browse a domain with stylegallery discover.";
|
|
346
|
+
}
|
|
347
|
+
return deepFreeze(envelope);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function resolveDocument(index, reference) {
|
|
351
|
+
if (typeof reference !== "string" || reference.length === 0) fail("read_reference_required", "reference is required");
|
|
352
|
+
const direct = index.byId.get(reference);
|
|
353
|
+
if (direct) return direct;
|
|
354
|
+
const clean = reference.replace(/^\.\//, "");
|
|
355
|
+
const found = index.documents.find((document) => document.record.repository_path === clean
|
|
356
|
+
|| document.record.stable_ref === reference || document.identity.stable_ref === reference);
|
|
357
|
+
if (!found) fail("read_reference_not_found", "no admitted document has this id, path, or StableRef; run stylegallery search first");
|
|
358
|
+
return found;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function validateReadInput(input) {
|
|
362
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) fail("material_input_invalid", "input must be an object");
|
|
363
|
+
const allowed = new Set(["reference", "section", "outline"]);
|
|
364
|
+
if (Object.keys(input).some((key) => !allowed.has(key))) fail("material_input_unknown", "input contains an unsupported field");
|
|
365
|
+
if (input.section !== undefined && (typeof input.section !== "string" || input.section.length === 0)) fail("read_section_invalid", "section must be a heading slug");
|
|
366
|
+
if (input.outline !== undefined && typeof input.outline !== "boolean") fail("read_outline_invalid", "outline must be a boolean");
|
|
367
|
+
if (input.section !== undefined && input.outline === true) fail("read_mode_conflict", "use either section or outline");
|
|
368
|
+
return input;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function readIndex(index, input) {
|
|
372
|
+
const normalized = validateReadInput(input);
|
|
373
|
+
const document = resolveDocument(index, normalized.reference);
|
|
374
|
+
const head = { schema_version: SEARCH_SCHEMA_VERSION, id: document.id, path: document.record.repository_path, title: document.title, snapshot: index.manifestVersionId, version_id: document.record.version_id };
|
|
375
|
+
const outline = document.sections.map(({ level, title, slug, line_start, line_end }) => ({ level, title, slug, line_start, line_end }));
|
|
376
|
+
if (normalized.outline) return deepFreeze({ ...head, outline, next: { read: outline[1] ? `stylegallery read ${document.id} --section ${outline[1].slug}` : `stylegallery read ${document.id}` } });
|
|
377
|
+
const lines = document.text.split("\n");
|
|
378
|
+
if (normalized.section !== undefined) {
|
|
379
|
+
const section = document.sections.find((candidate) => candidate.slug === normalized.section);
|
|
380
|
+
if (!section) fail("read_section_not_found", `section not found; valid slugs: ${document.sections.map((item) => item.slug).join(", ")}`);
|
|
381
|
+
return deepFreeze({ ...head, section: { title: section.title, slug: section.slug, line_start: section.line_start, line_end: section.line_end }, content: lines.slice(section.line_start - 1, section.line_end).join("\n") });
|
|
382
|
+
}
|
|
383
|
+
const truncated = document.text.length > READ_MAX_CHARS;
|
|
384
|
+
return deepFreeze({
|
|
385
|
+
...head, content: truncated ? document.text.slice(0, READ_MAX_CHARS) : document.text, truncated,
|
|
386
|
+
...(truncated ? { hint: `content truncated at ${READ_MAX_CHARS} characters; read one part with --section (see --outline)` } : {}),
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Load the admitted corpus with source checks and build the index. */
|
|
391
|
+
export function loadSearchIndex(options) {
|
|
392
|
+
return buildSearchIndex(materialCorpus(options));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const PUBLIC_CODES = new Set(["material_input_invalid", "material_input_unknown", "material_query_invalid", "material_query_oversized",
|
|
396
|
+
"material_query_empty", "search_limit_invalid", "material_paths_only_invalid", "search_response_format_invalid", "read_reference_required",
|
|
397
|
+
"read_reference_not_found", "read_section_invalid", "read_outline_invalid", "read_mode_conflict", "read_section_not_found", "search_id_collision"]);
|
|
398
|
+
|
|
399
|
+
/** Run `search` or `read` and wrap it in the ok/failures envelope shared by CLI and MCP. */
|
|
400
|
+
export function invokeSearchOperation(operation, input, options) {
|
|
401
|
+
try {
|
|
402
|
+
const index = loadSearchIndex(options);
|
|
403
|
+
const result = operation === "search" ? searchIndex(index, input) : readIndex(index, input);
|
|
404
|
+
return deepFreeze({ ok: true, operation, result });
|
|
405
|
+
} catch (error) {
|
|
406
|
+
const known = typeof error?.code === "string" && (PUBLIC_CODES.has(error.code) || error.code.startsWith("material_"));
|
|
407
|
+
return deepFreeze({ ok: false, operation, failures: [{ code: known ? error.code : `${operation}_failed`, message: known ? error.message : `${operation} failed` }] });
|
|
408
|
+
}
|
|
409
|
+
}
|