@jxsuite/search 0.2.5 → 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 +29 -107
- package/dist/search-index.js +21 -1
- package/package.json +2 -2
- package/src/SearchIndex.class.json +1 -1
- package/src/search-index.ts +31 -1
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
- **
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
-
|
|
154
|
-
|
|
155
|
-
-
|
|
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.
|
package/dist/search-index.js
CHANGED
|
@@ -118,7 +118,17 @@ var SCHEMA_KEYWORDS = new Set([
|
|
|
118
118
|
"$comment"
|
|
119
119
|
]);
|
|
120
120
|
function isTagExpression(tagName) {
|
|
121
|
-
|
|
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.
|
|
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.
|
|
39
|
+
"@jxsuite/schema": "^2.2.0",
|
|
40
40
|
"minisearch": "^7.2.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
package/src/search-index.ts
CHANGED
|
@@ -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
|