@jxsuite/search 0.2.0 → 0.2.3

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/README.md ADDED
@@ -0,0 +1,183 @@
1
+ # `@jxsuite/search`
2
+
3
+ > Full-text site search built at compile time from content collections — no server, no third-party
4
+ > service.
5
+
6
+ ## Overview
7
+
8
+ `@jxsuite/search` indexes a site's content collections during `jx build` and emits a single JSON
9
+ index file next to the pages. In the browser, a headless MiniSearch client fetches that file once
10
+ and answers queries synchronously. Two classes, declared in `jx-extension.json`:
11
+
12
+ - **`SearchIndex`** — owner of the project.json `search` section. `projectData` normalizes the
13
+ section into `_project.search`; `emit` (specs/extensions.md §8.4) builds the index from the
14
+ already-loaded content collections and hands it to the host to write.
15
+ - **`Search`** — a state class that **lowers** to core defs (specs/extensions.md §8.3): its static
16
+ `lower` rewrites a `timing: "client"` def into a core `$prototype: "Function"` computed, so the
17
+ only extension code reaching the browser is the client bundle.
18
+
19
+ The schema fragment `schemas/project.fragment.schema.json`
20
+ (`$id` `https://jxsuite.com/schema/ext/search/project/v1`) is the source of truth for the section's
21
+ shape and defaults: it requires `collections` with at least one entry, requires `basePath` per
22
+ collection, and rejects unknown keys. `engine` is an enum whose only member today is
23
+ `"minisearch"`. Docs: [/docs/framework/site/search](https://jxsuite.com/docs/framework/site/search)
24
+ (authoring) and
25
+ [/docs/extending/extensions/search](https://jxsuite.com/docs/extending/extensions/search)
26
+ (the extension itself).
27
+
28
+ ## Enable it
29
+
30
+ ```json
31
+ {
32
+ "extensions": ["@jxsuite/parser", "@jxsuite/search"],
33
+ "search": {
34
+ "output": "/search-index.json",
35
+ "collections": {
36
+ "docs": {
37
+ "basePath": "/docs/",
38
+ "boost": { "title": 4, "heading": 2 }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ `@jxsuite/parser` is what loads the content collections this indexes. Defaults per collection:
46
+ `fields` `["title", "heading", "text"]`, `sections` `true`, `sectionDepth` `3`; `output` defaults
47
+ to `/search-index.json`. `basePath` is normalized to leading **and** trailing slashes, so `"blog"`
48
+ becomes `"/blog/"`. This is the whole configuration surface — there is no CLI of its own.
49
+
50
+ One default does not come from the fragment: `boost` falls back to `{}` in `normalizeSearchConfig`
51
+ (`src/shared.ts`), and the fragment declares no default for it. The schema is the source of truth
52
+ for the section's shape and for every other default.
53
+
54
+ ## Build-time emission (`SearchIndex`)
55
+
56
+ `emit` reads `ctx.sections.content` — the collections the parser already loaded, keyed by name —
57
+ and never re-reads source files. It returns exactly one record, `{ path: <output>, content }`,
58
+ which the compiler writes under `outDir` (step 6e of the site build). The content is one envelope:
59
+
60
+ ```json
61
+ { "version": 1, "engine": "minisearch", "fields": [], "boost": {}, "documents": [] }
62
+ ```
63
+
64
+ `fields` is the union of every configured collection's `fields` and `boost` their merged boost
65
+ maps — baked in so the client needs no configuration of its own. Both cover only the collections
66
+ that actually loaded: a name matching no content collection is skipped before its fields and boosts
67
+ are merged, so a typo silently narrows the index rather than failing the build.
68
+
69
+ Each content entry yields a **page document** (`heading: ""`, the entry's full extracted text, id
70
+ `<collection>:<entry-id>`) and, when `sections` is on, one **section document** per heading, id
71
+ `<collection>:<entry-id>#<anchor>` and URL carrying the same `#anchor`. `title` falls back to the
72
+ entry id when frontmatter has no string `title`; `description` falls back to `""`. Entry URLs
73
+ honour `build.trailingSlash` (default `"always"`).
74
+
75
+ Localized collections are indexed per locale: each entry is indexed at the URL its own locale routes
76
+ to — under the default `prefix-except-default` routing the default locale stays unprefixed and only
77
+ the others carry a `/<locale>` prefix; under `prefix-always` every locale carries one — the document
78
+ id becomes `<collection>:<locale>:<entry-id>`, and the document carries a canonical `locale` tag. A
79
+ project with no `i18n` emits documents with no `locale` field at all.
80
+
81
+ ## The `Search` state class
82
+
83
+ ```json
84
+ {
85
+ "state": {
86
+ "q": "",
87
+ "results": { "$prototype": "Search", "timing": "client", "query": { "$ref": "#/state/q" } }
88
+ }
89
+ }
90
+ ```
91
+
92
+ `query` takes a literal string or a `$ref` into page state (`#/state/ui/q` compiles to
93
+ `state.ui.q`; a `$ref` outside `#/state/` degrades to `""`). Other parameters: `limit` (8), `group`
94
+ (true), `index` (index-URL override), `locale`. The index URL resolves def `index` →
95
+ `search.output` → `/search-index.json`.
96
+
97
+ `lower` emits `{ $bundle: ["npm:@jxsuite/search/client"], $prototype: "Function", timing: "client",
98
+ body }`. The body lazy-imports the client once per page onto `globalThis.__jxSearch`, preloads the
99
+ index, flips `state.__jxSearchReady` — reading that flag is what makes the computed re-run when the
100
+ client finishes loading — and returns results only once `isReady()`. The bundle URL is the
101
+ deterministic, hash-free `/assets/jxsuite-search-client.js`, derived from
102
+ `sidecarAssetPath("npm:@jxsuite/search/client")` in `@jxsuite/schema/asset-paths`, the same mapping
103
+ the compiler's sidecar bundler uses, so neither side depends on the other.
104
+
105
+ ## The client (`@jxsuite/search/client`)
106
+
107
+ MiniSearch inside (`fuzzy: 0.2`, `prefix: true`), no UI. Core API: `preload(indexUrl?)`,
108
+ `isReady()`, and a synchronous `query(q, { limit, group, pageCap, locale })` — `limit` 8, `group`
109
+ true, `pageCap` 3 (flat mode). That `limit` default governs a direct `query()` call only: the
110
+ `$src` helper `runSearch` below always passes a fixed `UI_LIMIT` of 20. Grouped mode returns
111
+ `SearchResultGroup[]`: a page with its matching sections, sorted by best score. Flat mode returns
112
+ presentation-ready `SearchResult[]` carrying
113
+ `crumbs` (title-cased slug ancestors, plus the page title on a section row), `titleTokens` and
114
+ `excerptTokens` — arrays of `{ t, m }` runs where `m: true` marks a match, so renderers highlight
115
+ without injecting HTML.
116
+
117
+ Compiled components do not go through lowering, so inside a component you use the `$src` state
118
+ conventions instead of the `Search` prototype (`sites/jxsuite.com/components/site-search.json` is
119
+ the working example):
120
+
121
+ ```json
122
+ {
123
+ "state": {
124
+ "searchInit": {
125
+ "$prototype": "Function",
126
+ "$src": "npm:@jxsuite/search/client",
127
+ "parameters": ["state"]
128
+ },
129
+ "runSearch": {
130
+ "$prototype": "Function",
131
+ "$src": "npm:@jxsuite/search/client",
132
+ "parameters": ["state", "e"]
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ `searchInit(state)` returns `true` immediately, preloads (honouring `state.searchIndexUrl`), then
139
+ sets `state.searchReady` and re-runs a pending `state.searchQuery`. `runSearch(state, event?)`
140
+ reads the query from `event.target.value` (else `state.searchQuery`) and publishes
141
+ `state.searchResults`, `state.searchCount` and `state.searchActive`. `$src` parameters bind **by
142
+ name, not position** (spec.md §5.3): a parameter literally named `state` gets reactive state, any
143
+ other name gets the event.
144
+
145
+ ## Surprises
146
+
147
+ - **A collection name that is not a loaded content collection warns and is skipped** — the build
148
+ still succeeds and the envelope still emits, possibly with zero documents. A typo is therefore a
149
+ silently empty search box.
150
+ - **A heading with no `id` never starts a section document.** Heading ids come from the parser
151
+ (specs/parser.md §3.2) and are what make the `#anchor` land on the rendered page; a heading
152
+ without one folds into the enclosing section, as does anything under a heading deeper than
153
+ `sectionDepth`. Text before the first heading belongs to no section — the page document covers it.
154
+ - **`emit` only runs when the project declares a non-empty `search` section** — the compiler skips
155
+ section-owner emitters whose value is `null` or `{}` (specs/extensions.md §8.4, "Gating").
156
+ - **The `public/` copy runs after `emit`**, so a file in `public/` at the same path shadows the
157
+ emitted index — the same semantics as `sitemap.xml`.
158
+ - **Omitting `locale` is not the same as `locale: null`.** Omitted means the page's own language,
159
+ read from `<html lang>`; `null` searches every language. Both `lower` and `resolve` drop the key
160
+ rather than serialize `undefined`. A document with no `locale` answers every search, and region
161
+ is ignored when tags differ only there (`fr` matches `fr-CA`).
162
+ - **`Search.resolve()` returns `[]` outside the browser**, with a warning — search is a client-side
163
+ interaction, so compiler-timing bakes and dev-server resolve proxying degrade rather than fail.
164
+ - **The index is one file per site and the client is a module-level singleton** — the first
165
+ `preload` wins. Multiple collections share the one index; results carry their `collection` name.
166
+ A failed load clears the memo so a later call can retry.
167
+ - **First-party (`@jxsuite/*`) schema fragments are read from the host, not the project's
168
+ `node_modules`** — schema composition routes every `@jxsuite/*` `.json` ref through host
169
+ resolution and throws when the host cannot resolve it, so installing this package into a project
170
+ alone does not make its `search` section validate. A third-party extension's fragment still
171
+ resolves project-first.
172
+ - **Class names are global across extensions.** `Search` and `SearchIndex` occupy the
173
+ `$prototype`-visible namespace, not a package-scoped one.
174
+ - **`bun run build` bundles only `search-index.ts` and `search-state.ts` into `dist/`.** The client
175
+ is deliberately not built here — the site build bundles it from source through the sidecar
176
+ bundler.
177
+
178
+ ## Versioning
179
+
180
+ Published to npm as `@jxsuite/search` — TypeScript source, like every `@jxsuite` package, following
181
+ the monorepo's release train. Its runtime dependencies are `@jxsuite/schema` and `minisearch`;
182
+ extensions may depend on core packages but never the reverse — no core package lists this one as a
183
+ runtime dependency or imports it from `src/`, and `scripts/check-dep-rules.ts` enforces that.
@@ -83,6 +83,6 @@ class Search {
83
83
  }
84
84
  }
85
85
  export {
86
- Search,
87
- CLIENT_SPECIFIER
86
+ CLIENT_SPECIFIER,
87
+ Search
88
88
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/search",
3
- "version": "0.2.0",
3
+ "version": "0.2.3",
4
4
  "description": "Static site search for Jx: build-time index emission from content collections and a headless MiniSearch browser client",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,7 +36,7 @@
36
36
  "upgrade": "bunx npm-check-updates -u && bun install"
37
37
  },
38
38
  "dependencies": {
39
- "@jxsuite/schema": "^1.6.0",
39
+ "@jxsuite/schema": "^1.8.1",
40
40
  "minisearch": "^7.2.0"
41
41
  },
42
42
  "devDependencies": {