@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 +183 -0
- package/dist/search-state.js +2 -2
- package/package.json +2 -2
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.
|
package/dist/search-state.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jxsuite/search",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
39
|
+
"@jxsuite/schema": "^1.8.1",
|
|
40
40
|
"minisearch": "^7.2.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|