@jxsuite/search 0.2.6 → 0.3.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.
package/README.md CHANGED
@@ -1,29 +1,15 @@
1
1
  # `@jxsuite/search`
2
2
 
3
- > Full-text site search built at compile time from content collections. No server, no third-party
4
- > service.
3
+ > Full-text site search built at compile time from content collections. No server, no third-party service.
5
4
 
6
5
  ## Overview
7
6
 
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`** is 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).
7
+ `@jxsuite/search` indexes a site's content collections during `jx build` and emits a single JSON index file next to the pages. In the browser, a headless MiniSearch client fetches that file once and answers queries synchronously. Two classes, declared in `jx-extension.json`:
8
+
9
+ - **`SearchIndex`**: owner of the project.json `search` section. `projectData` normalizes the section into `_project.search`; `emit` (specs/extensions.md §8.4) builds the index from the already-loaded content collections and hands it to the host to write.
10
+ - **`Search`** is a state class that **lowers** to core defs (specs/extensions.md §8.3): its static `lower` rewrites a `timing: "client"` def into a core `$prototype: "Function"` computed, so the only extension code reaching the browser is the client bundle.
11
+
12
+ The schema fragment `schemas/project.fragment.schema.json` (`$id` `https://jxsuite.com/schema/ext/search/project/v1`) is the source of truth for the section's shape and defaults: it requires `collections` with at least one entry, requires `basePath` per collection, and rejects unknown keys. `engine` is an enum whose only member today is `"minisearch"`. Docs: [/docs/framework/site/search](https://jxsuite.com/docs/framework/site/search) (authoring) and [/docs/extending/extensions/search](https://jxsuite.com/docs/extending/extensions/search) (the extension itself).
27
13
 
28
14
  ## Enable it
29
15
 
@@ -42,41 +28,23 @@ collection, and rejects unknown keys. `engine` is an enum whose only member toda
42
28
  }
43
29
  ```
44
30
 
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.
31
+ `@jxsuite/parser` is what loads the content collections this indexes. Defaults per collection: `fields` `["title", "heading", "text"]`, `sections` `true`, `sectionDepth` `3`; `output` defaults to `/search-index.json`. `basePath` is normalized to leading **and** trailing slashes, so `"blog"` becomes `"/blog/"`. This is the whole configuration surface. There is no CLI of its own.
49
32
 
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.
33
+ One default does not come from the fragment: `boost` falls back to `{}` in `normalizeSearchConfig` (`src/shared.ts`), and the fragment declares no default for it. The schema is the source of truth for the section's shape and for every other default.
53
34
 
54
35
  ## Build-time emission (`SearchIndex`)
55
36
 
56
- `emit` reads `ctx.sections.content` (the collections the parser already loaded, keyed by name) and
57
- 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:
37
+ `emit` reads `ctx.sections.content` (the collections the parser already loaded, keyed by name) and never re-reads source files. It returns exactly one record, `{ path: <output>, content }`, which the compiler writes under `outDir` (step 6e of the site build). The content is one envelope:
59
38
 
60
39
  ```json
61
40
  { "version": 1, "engine": "minisearch", "fields": [], "boost": {}, "documents": [] }
62
41
  ```
63
42
 
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.
43
+ `fields` is the union of every configured collection's `fields` and `boost` their merged boost maps, baked in so the client needs no configuration of its own. Both cover only the collections that actually loaded: a name matching no content collection is skipped before its fields and boosts are merged, so a typo silently narrows the index rather than failing the build.
68
44
 
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"`).
45
+ Each content entry yields a **page document** (`heading: ""`, the entry's full extracted text, id `<collection>:<entry-id>`) and, when `sections` is on, one **section document** per heading, id `<collection>:<entry-id>#<anchor>` and URL carrying the same `#anchor`. `title` falls back to the entry id when frontmatter has no string `title`; `description` falls back to `""`. Entry URLs honour `build.trailingSlash` (default `"always"`).
74
46
 
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, while under `prefix-always` every locale carries one. The
78
- document id becomes `<collection>:<locale>:<entry-id>`, and the document carries a canonical
79
- `locale` tag. A project with no `i18n` emits documents with no `locale` field at all.
47
+ Localized collections are indexed per locale. Each entry is indexed at the URL its own locale routes to: under the default `prefix-except-default` routing the default locale stays unprefixed and only the others carry a `/<locale>` prefix, while under `prefix-always` every locale carries one. The document id becomes `<collection>:<locale>:<entry-id>`, and the document carries a canonical `locale` tag. A project with no `i18n` emits documents with no `locale` field at all.
80
48
 
81
49
  ## The `Search` state class
82
50
 
@@ -89,33 +57,15 @@ document id becomes `<collection>:<locale>:<entry-id>`, and the document carries
89
57
  }
90
58
  ```
91
59
 
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`.
60
+ `query` takes a literal string or a `$ref` into page state (`#/state/ui/q` compiles to `state.ui.q`; a `$ref` outside `#/state/` degrades to `""`). Other parameters: `limit` (8), `group` (true), `index` (index-URL override), `locale`. The index URL resolves def `index` → `search.output` → `/search-index.json`.
96
61
 
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`, and returns results only once `isReady()`. Reading that flag
100
- is what makes the computed re-run when the client finishes loading. 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.
62
+ `lower` emits `{ $bundle: ["npm:@jxsuite/search/client"], $prototype: "Function", timing: "client", body }`. The body lazy-imports the client once per page onto `globalThis.__jxSearch`, preloads the index, flips `state.__jxSearchReady`, and returns results only once `isReady()`. Reading that flag is what makes the computed re-run when the client finishes loading. The bundle URL is the deterministic, hash-free `/assets/jxsuite-search-client.js`, derived from `sidecarAssetPath("npm:@jxsuite/search/client")` in `@jxsuite/schema/asset-paths`, the same mapping the compiler's sidecar bundler uses, so neither side depends on the other.
104
63
 
105
64
  ## The client (`@jxsuite/search/client`)
106
65
 
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 })` with defaults `limit`
109
- 8, `group` true, `pageCap` 3 (flat mode). That `limit` default governs a direct `query()` call
110
- only: the `$src` helper `runSearch` below always passes a fixed `UI_LIMIT` of 20. Grouped mode
111
- returns `SearchResultGroup[]`: a page with its matching sections, sorted by best score. Flat mode
112
- returns presentation-ready `SearchResult[]` carrying `crumbs` (title-cased slug ancestors, plus the
113
- page title on a section row), `titleTokens` and `excerptTokens`. Those two are arrays of
114
- `{ t, m }` runs where `m: true` marks a match, so renderers highlight without injecting HTML.
66
+ MiniSearch inside (`fuzzy: 0.2`, `prefix: true`), no UI. Core API: `preload(indexUrl?)`, `isReady()`, and a synchronous `query(q, { limit, group, pageCap, locale })` with defaults `limit` 8, `group` true, `pageCap` 3 (flat mode). That `limit` default governs a direct `query()` call only: the `$src` helper `runSearch` below always passes a fixed `UI_LIMIT` of 20. Grouped mode returns `SearchResultGroup[]`: a page with its matching sections, sorted by best score. Flat mode returns presentation-ready `SearchResult[]` carrying `crumbs` (title-cased slug ancestors, plus the page title on a section row), `titleTokens` and `excerptTokens`. Those two are arrays of `{ t, m }` runs where `m: true` marks a match, so renderers highlight without injecting HTML.
115
67
 
116
- Compiled components do not go through lowering, so inside a component you use the `$src` state
117
- conventions instead of the `Search` prototype (`sites/jxsuite.com/components/site-search.json` is
118
- the working example):
68
+ Compiled components do not go through lowering, so inside a component you use the `$src` state conventions instead of the `Search` prototype (`sites/jxsuite.com/components/site-search.json` is the working example):
119
69
 
120
70
  ```json
121
71
  {
@@ -134,49 +84,21 @@ the working example):
134
84
  }
135
85
  ```
136
86
 
137
- `searchInit(state)` returns `true` immediately, preloads (honouring `state.searchIndexUrl`), then
138
- sets `state.searchReady` and re-runs a pending `state.searchQuery`. `runSearch(state, event?)`
139
- reads the query from `event.target.value` (else `state.searchQuery`) and publishes
140
- `state.searchResults`, `state.searchCount` and `state.searchActive`. `$src` parameters bind **by
141
- name, not position** (spec.md §5.3): a parameter literally named `state` gets reactive state, any
142
- other name gets the event.
87
+ `searchInit(state)` returns `true` immediately, preloads (honouring `state.searchIndexUrl`), then sets `state.searchReady` and re-runs a pending `state.searchQuery`. `runSearch(state, event?)` reads the query from `event.target.value` (else `state.searchQuery`) and publishes `state.searchResults`, `state.searchCount` and `state.searchActive`. `$src` parameters bind **by name, not position** (spec.md §5.3): a parameter literally named `state` gets reactive state, any other name gets the event.
143
88
 
144
89
  ## Surprises
145
90
 
146
- - **A collection name that is not a loaded content collection warns and is skipped**. The build
147
- still succeeds and the envelope still emits, possibly with zero documents. A typo is therefore a
148
- silently empty search box.
149
- - **A heading with no `id` never starts a section document.** Heading ids come from the parser
150
- (specs/parser.md §3.2) and are what make the `#anchor` land on the rendered page; a heading
151
- without one folds into the enclosing section, as does anything under a heading deeper than
152
- `sectionDepth`. Text before the first heading belongs to no section; the page document covers it.
153
- - **`emit` only runs when the project declares a non-empty `search` section**, because the compiler
154
- skips section-owner emitters whose value is `null` or `{}` (specs/extensions.md §8.4, "Gating").
155
- - **The `public/` copy runs after `emit`**, so a file in `public/` at the same path shadows the
156
- emitted index (the same semantics as `sitemap.xml`).
157
- - **Omitting `locale` is not the same as `locale: null`.** Omitted means the page's own language,
158
- read from `<html lang>`; `null` searches every language. Both `lower` and `resolve` drop the key
159
- rather than serialize `undefined`. A document with no `locale` answers every search, and region
160
- is ignored when tags differ only there (`fr` matches `fr-CA`).
161
- - **`Search.resolve()` returns `[]` outside the browser**, with a warning. Search is a client-side
162
- interaction, so compiler-timing bakes and dev-server resolve proxying degrade rather than fail.
163
- - **The index is one file per site and the client is a module-level singleton**, so the first
164
- `preload` wins. Multiple collections share the one index; results carry their `collection` name.
165
- A failed load clears the memo so a later call can retry.
166
- - **First-party (`@jxsuite/*`) schema fragments are read from the host, not the project's
167
- `node_modules`**: schema composition routes every `@jxsuite/*` `.json` ref through host
168
- resolution and throws when the host cannot resolve it, so installing this package into a project
169
- alone does not make its `search` section validate. A third-party extension's fragment still
170
- resolves project-first.
171
- - **Class names are global across extensions.** `Search` and `SearchIndex` occupy the
172
- `$prototype`-visible namespace, not a package-scoped one.
173
- - **`bun run build` bundles only `search-index.ts` and `search-state.ts` into `dist/`.** The client
174
- is deliberately not built here, because the site build bundles it from source through the sidecar
175
- bundler.
91
+ - **A collection name that is not a loaded content collection warns and is skipped**. The build still succeeds and the envelope still emits, possibly with zero documents. A typo is therefore a silently empty search box.
92
+ - **A heading with no `id` never starts a section document.** Heading ids come from the parser (specs/parser.md §3.2) and are what make the `#anchor` land on the rendered page; a heading without one folds into the enclosing section, as does anything under a heading deeper than `sectionDepth`. Text before the first heading belongs to no section; the page document covers it.
93
+ - **`emit` only runs when the project declares a non-empty `search` section**, because the compiler skips section-owner emitters whose value is `null` or `{}` (specs/extensions.md §8.4, "Gating").
94
+ - **The `public/` copy runs after `emit`**, so a file in `public/` at the same path shadows the emitted index (the same semantics as `sitemap.xml`).
95
+ - **Omitting `locale` is not the same as `locale: null`.** Omitted means the page's own language, read from `<html lang>`; `null` searches every language. Both `lower` and `resolve` drop the key rather than serialize `undefined`. A document with no `locale` answers every search, and region is ignored when tags differ only there (`fr` matches `fr-CA`).
96
+ - **`Search.resolve()` returns `[]` outside the browser**, with a warning. Search is a client-side interaction, so compiler-timing bakes and dev-server resolve proxying degrade rather than fail.
97
+ - **The index is one file per site and the client is a module-level singleton**, so the first `preload` wins. Multiple collections share the one index; results carry their `collection` name. A failed load clears the memo so a later call can retry.
98
+ - **First-party (`@jxsuite/*`) schema fragments are read from the host, not the project's `node_modules`**: schema composition routes every `@jxsuite/*` `.json` ref through host resolution and throws when the host cannot resolve it, so installing this package into a project alone does not make its `search` section validate. A third-party extension's fragment still resolves project-first.
99
+ - **Class names are global across extensions.** `Search` and `SearchIndex` occupy the `$prototype`-visible namespace, not a package-scoped one.
100
+ - **`bun run build` bundles only `search-index.ts` and `search-state.ts` into `dist/`.** The client is deliberately not built here, because the site build bundles it from source through the sidecar bundler.
176
101
 
177
102
  ## Versioning
178
103
 
179
- Published to npm as `@jxsuite/search`, TypeScript source like every `@jxsuite` package, following
180
- the monorepo's release train. Its runtime dependencies are `@jxsuite/schema` and `minisearch`;
181
- extensions may depend on core packages but never the reverse: no core package lists this one as a
182
- runtime dependency or imports it from `src/`, and `scripts/check-dep-rules.ts` enforces that.
104
+ Published to npm as `@jxsuite/search`, TypeScript source like every `@jxsuite` package, following the monorepo's release train. Its runtime dependencies are `@jxsuite/schema` and `minisearch`; extensions may depend on core packages but never the reverse: no core package lists this one as a runtime dependency or imports it from `src/`, and `scripts/check-dep-rules.ts` enforces that.
@@ -118,7 +118,17 @@ var SCHEMA_KEYWORDS = new Set([
118
118
  "$comment"
119
119
  ]);
120
120
  function isTagExpression(tagName) {
121
- return isJsonObject(tagName) && isJsonObject(tagName.$expression);
121
+ if (!isJsonObject(tagName) || !isJsonObject(tagName.$expression)) {
122
+ return false;
123
+ }
124
+ const expression = tagName.$expression;
125
+ if (expression.operator === "?:") {
126
+ return typeof expression.value === "string" && typeof expression.initial === "string";
127
+ }
128
+ if (expression.operator === "switch") {
129
+ return isJsonObject(expression.cases) && Object.values(expression.cases).every((tag) => typeof tag === "string") && typeof expression.default === "string";
130
+ }
131
+ return false;
122
132
  }
123
133
  function tagNameCandidates(tagName) {
124
134
  if (typeof tagName === "string") {
@@ -241,6 +251,13 @@ function headingOf(node) {
241
251
  }
242
252
 
243
253
  // src/search-index.ts
254
+ function isOptedOut(collection, entryId, value) {
255
+ if (value === undefined || typeof value === "boolean") {
256
+ return value === false;
257
+ }
258
+ console.warn(`@jxsuite/search: "${collection}/${entryId}" sets search to ${JSON.stringify(value)}; ` + `only the boolean false opts a page out of the index — it stays indexed`);
259
+ return false;
260
+ }
244
261
  var SearchIndex = {
245
262
  projectData(sectionValue) {
246
263
  return normalizeSearchConfig(sectionValue);
@@ -265,6 +282,9 @@ var SearchIndex = {
265
282
  Object.assign(boost, collection.boost);
266
283
  for (const entry of entries) {
267
284
  const data = entry.data ?? {};
285
+ if (isOptedOut(name, entry.id, data.search)) {
286
+ continue;
287
+ }
268
288
  const locale = i18n === null ? undefined : canonicalizeLocale(entry._meta?.locale) ?? undefined;
269
289
  const url = entryUrl(`${localeUrlPrefix(locale, i18n)}${collection.basePath}`, entry.id, trailingSlash);
270
290
  const title = typeof data.title === "string" ? data.title : entry.id;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jxsuite/search",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
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": "bun update --latest && bun install"
37
37
  },
38
38
  "dependencies": {
39
- "@jxsuite/schema": "^2.1.0",
39
+ "@jxsuite/schema": "^2.2.0",
40
40
  "minisearch": "^7.2.0"
41
41
  },
42
42
  "devDependencies": {
@@ -15,7 +15,7 @@
15
15
 
16
16
  "$studio": {
17
17
  "settings": {
18
- "icon": "sp-icon-search",
18
+ "icon": "magnifying-glass",
19
19
  "label": "Site Search",
20
20
  "order": 70,
21
21
  "layout": "form"
@@ -5,7 +5,8 @@
5
5
  * the site's search index from the loaded content collections and returns it for the host to write
6
6
  * into the build output. Documents come in two granularities: one per content entry (full text)
7
7
  * and, when `sections` is on, one per heading section carrying a `#anchor` deep link (heading ids
8
- * are assigned by the parser — parser.md §3.2).
8
+ * are assigned by the parser — parser.md §3.2). An entry whose frontmatter says `search: false`
9
+ * contributes neither.
9
10
  *
10
11
  * @docs extending/extensions/search
11
12
  */
@@ -57,6 +58,32 @@ export interface EmitContext {
57
58
  routes?: unknown[];
58
59
  }
59
60
 
61
+ /**
62
+ * Whether an entry's frontmatter keeps it out of the index (`search: false`).
63
+ *
64
+ * The opt-out is per ENTRY, so it removes the page document and every section document with it: a
65
+ * page that should not be found by its title should not be found by its headings either. It is also
66
+ * the only lever a page has — the index is otherwise a pure function of the collection, which is
67
+ * what lets a generated reference page (a spec changelog, a standards table) leave the index
68
+ * without leaving the site or losing a word of its prose.
69
+ *
70
+ * Only the boolean `false` opts out. A string `"false"`, a `0` or a `"no"` reads like an opt-out to
71
+ * the author who wrote it and like nothing at all to a strict reader, and silently honouring the
72
+ * looser reading would make the index depend on YAML's coercion rules. So a non-boolean value is
73
+ * INDEXED — the safe failure, because a page that is present can be found and a page that is absent
74
+ * cannot say why — and reported once per entry, not once per document it yields.
75
+ */
76
+ function isOptedOut(collection: string, entryId: string, value: unknown): boolean {
77
+ if (value === undefined || typeof value === "boolean") {
78
+ return value === false;
79
+ }
80
+ console.warn(
81
+ `@jxsuite/search: "${collection}/${entryId}" sets search to ${JSON.stringify(value)}; ` +
82
+ `only the boolean false opts a page out of the index — it stays indexed`,
83
+ );
84
+ return false;
85
+ }
86
+
60
87
  /**
61
88
  * The `SearchIndex` capability surface. The extension host dispatches capability methods on the
62
89
  * export named by the descriptor title (specs/extensions.md §6.1); a plain object serves the
@@ -100,6 +127,9 @@ export const SearchIndex = {
100
127
 
101
128
  for (const entry of entries) {
102
129
  const data = (entry.data ?? {}) as Record<string, unknown>;
130
+ if (isOptedOut(name, entry.id, data.search)) {
131
+ continue;
132
+ }
103
133
  /*
104
134
  * Only when the project declares locales. An entry cannot carry one otherwise — the loader
105
135
  * stamps it while expanding `{locale}`, which needs a list to expand over — and a stray tag