@astryxdesign/cli 0.7.2 → 0.7.3-canary.06872b4
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 +99 -66
- package/api/build/_adapter.d.mts +58 -0
- package/api/build/_adapter.mjs +115 -3
- package/api/build/blocks/blocks.d.mts +42 -0
- package/api/build/blocks/blocks.mjs +169 -0
- package/api/build/blocks/blocks.test.mjs +108 -0
- package/api/build/blocks/blocks.weights.json +1 -0
- package/api/build/build.mjs +25 -6
- package/api/build/kit/answer.d.mts +14 -0
- package/api/build/kit/answer.mjs +85 -0
- package/api/build/kit/answer.test.mjs +38 -0
- package/api/build/kit/kind.d.mts +25 -0
- package/api/build/kit/kind.mjs +170 -0
- package/api/build/kit/kit.d.mts +2 -4
- package/api/build/kit/kit.mjs +8 -398
- package/api/build/kit/rank.d.mts +3 -32
- package/api/build/kit/rank.mjs +25 -342
- package/api/build/kit/rank.test.mjs +2 -1
- package/api/build/kit/signals.d.mts +60 -0
- package/api/build/kit/signals.mjs +284 -0
- package/api/build/kit/start.d.mts +32 -0
- package/api/build/kit/start.mjs +163 -0
- package/api/build/kit/words.d.mts +72 -0
- package/api/build/kit/words.mjs +202 -0
- package/api/build/tree.d.mts +20 -0
- package/api/build/tree.mjs +52 -0
- package/api/build/tree.test.mjs +177 -0
- package/api/build/tree.weights.json +1 -0
- package/api/component/component.mjs +16 -2
- package/api/component/component.test.mjs +15 -0
- package/api/discover/_adapter.mjs +36 -9
- package/api/discover/_adapter.test.mjs +42 -0
- package/api/docs/_adapter.mjs +17 -5
- package/api/docs/docs.test.mjs +7 -1
- package/api/doctor/doctor.mjs +86 -12
- package/api/doctor/doctor.test.mjs +28 -3
- package/api/init/init.doc.mjs +20 -6
- package/api/init/init.test.mjs +18 -2
- package/api/init/init.type.d.mts +3 -3
- package/api/init/init.type.mjs +3 -3
- package/api/init/remove/remove.mjs +17 -4
- package/api/init/remove/remove.test.mjs +114 -2
- package/api/init/run/run.mjs +69 -6
- package/api/integration/authoring-checks.mjs +83 -9
- package/api/integration/pack-check.mjs +123 -4
- package/api/integration/pack-check.test.mjs +87 -0
- package/api/integration/validate-integration-case-disk.test.mjs +111 -0
- package/api/integration/validate-integration-gaps.test.mjs +1964 -0
- package/api/integration/validate-integration.d.mts +11 -2
- package/api/integration/validate-integration.mjs +226 -32
- package/api/integration/validate-integration.type.d.mts +58 -1
- package/api/integration/validate-integration.type.mjs +45 -2
- package/api/integration/validateIntegration.doc.mjs +1 -1
- package/api/search/ORDERING.md +28 -0
- package/api/search/_adapter.d.mts +66 -2
- package/api/search/_adapter.mjs +118 -10
- package/api/search/_candidate-score.d.mts +40 -0
- package/api/search/_candidate-score.mjs +238 -0
- package/api/search/_cards.json +6098 -0
- package/api/search/_compound.d.mts +50 -0
- package/api/search/_compound.mjs +170 -0
- package/api/search/_doc-searcher.d.mts +4 -0
- package/api/search/_doc-searcher.mjs +23 -0
- package/api/search/_doc-searcher.test.mjs +36 -0
- package/api/search/_doc-searcher.weights.json +1 -0
- package/api/search/_doc-text.d.mts +30 -0
- package/api/search/_doc-text.mjs +87 -0
- package/api/search/_entries.d.mts +52 -0
- package/api/search/_entries.mjs +158 -0
- package/api/search/_features.d.mts +35 -0
- package/api/search/_features.mjs +155 -0
- package/api/search/_gather-components.d.mts +26 -0
- package/api/search/_gather-components.mjs +284 -0
- package/api/search/_gather-docs.d.mts +20 -0
- package/api/search/_gather-docs.mjs +254 -0
- package/api/search/_gather-hooks.d.mts +12 -0
- package/api/search/_gather-hooks.mjs +52 -0
- package/api/search/_gather-templates.d.mts +25 -0
- package/api/search/_gather-templates.mjs +107 -0
- package/api/search/_lexical.d.mts +135 -0
- package/api/search/_lexical.mjs +351 -0
- package/api/search/_main-searcher.d.mts +19 -0
- package/api/search/_main-searcher.mjs +115 -0
- package/api/search/_main-searcher.test.mjs +106 -0
- package/api/search/_main-searcher.weights.json +1 -0
- package/api/search/_module-doc.d.mts +32 -0
- package/api/search/_module-doc.mjs +78 -0
- package/api/search/_order.d.mts +32 -0
- package/api/search/_order.mjs +162 -0
- package/api/search/_order.test.mjs +72 -0
- package/api/search/_priority.d.mts +50 -0
- package/api/search/_priority.mjs +139 -0
- package/api/search/_query-words.d.mts +25 -0
- package/api/search/_query-words.mjs +92 -0
- package/api/search/_query.d.mts +65 -0
- package/api/search/_query.mjs +176 -0
- package/api/search/_result.d.mts +97 -0
- package/api/search/_result.mjs +150 -0
- package/api/search/_score.d.mts +60 -0
- package/api/search/_score.mjs +283 -0
- package/api/search/_synonyms.d.mts +21 -0
- package/api/search/_synonyms.mjs +67 -0
- package/api/search/_template-searcher.d.mts +4 -0
- package/api/search/_template-searcher.mjs +23 -0
- package/api/search/_template-searcher.test.mjs +40 -0
- package/api/search/_template-searcher.weights.json +1 -0
- package/api/search/_titles.d.mts +33 -0
- package/api/search/_titles.mjs +65 -0
- package/api/search/_words.d.mts +64 -0
- package/api/search/_words.mjs +157 -0
- package/api/search/search.d.mts +22 -305
- package/api/search/search.doc.mjs +7 -5
- package/api/search/search.mjs +53 -2483
- package/api/search/search.type.d.mts +6 -1
- package/api/search/search.type.mjs +6 -1
- package/api/search/tree.d.mts +15 -0
- package/api/search/tree.mjs +40 -0
- package/api/search/tree.test.mjs +66 -0
- package/api/search/tree.weights.json +1 -0
- package/api/theme/build/build.packed-old-core.test.mjs +1 -0
- package/api/upgrade/_adapter.d.mts +57 -9
- package/api/upgrade/_adapter.mjs +198 -28
- package/api/upgrade/registry/registry.d.mts +10 -4
- package/api/upgrade/registry/registry.mjs +33 -16
- package/api/upgrade/registry/registry.test.mjs +85 -0
- package/api/upgrade/run/run.d.mts +4 -2
- package/api/upgrade/run/run.mjs +13 -2
- package/api/upgrade/run/run.test.mjs +417 -3
- package/api/upgrade/upgrade.doc.mjs +10 -5
- package/api/upgrade/upgrade.mjs +13 -2
- package/api/upgrade/upgrade.test.mjs +194 -0
- package/api/upgrade/upgrade.type.d.mts +5 -3
- package/api/upgrade/upgrade.type.mjs +8 -6
- package/assets/codemods/transforms/v0.7.0/__tests__/validate-data-token-ownership.test.mjs +11 -0
- package/assets/codemods/transforms/v0.7.0/validate-data-token-ownership.mjs +3 -3
- package/assets/docs/is-my-code-good.doc.mjs +287 -0
- package/assets/docs/tree/api.doc.mjs +6 -0
- package/assets/docs/tree/host-entry.doc.mjs +128 -0
- package/assets/docs/tree/template-grading-rubric.doc.mjs +16 -37
- package/assets/docs/tree/tokens-data.doc.mjs +2 -2
- package/assets/docs/working-with-ai.doc.mjs +6 -2
- package/assets/templates/themes/butter/butterTheme.ts +4 -2
- package/assets/templates/themes/stone/stoneTheme.ts +4 -2
- package/assets/templates/themes/y2k/y2kTheme.ts +4 -2
- package/authoring/debug/parse.d.mts +1 -1
- package/clients/cli/bin/astryx.mjs +7 -78
- package/clients/cli/commands/doctor.mjs +30 -0
- package/clients/cli/commands/doctor.test.mjs +65 -0
- package/clients/cli/commands/init.behavior.test.mjs +230 -1
- package/clients/cli/commands/init.doc.mjs +28 -9
- package/clients/cli/commands/init.flag-help.test.mjs +1 -0
- package/clients/cli/commands/init.mjs +2 -2
- package/clients/cli/commands/outside-project.test.mjs +2 -1
- package/clients/cli/commands/setup-nudge.test.mjs +61 -0
- package/clients/cli/commands/upgrade.doc.mjs +4 -2
- package/clients/cli/commands/upgrade.mjs +9 -2
- package/clients/cli/commands/upgrade.subfolder-path.test.mjs +97 -0
- package/clients/cli/index.mjs +19 -1
- package/clients/cli/run.mjs +108 -0
- package/foundation/agent-docs/agent-doc-state.d.mts +20 -7
- package/foundation/agent-docs/agent-doc-state.mjs +35 -8
- package/foundation/agent-docs/agent-docs.d.mts +100 -12
- package/foundation/agent-docs/agent-docs.mjs +1010 -99
- package/foundation/agent-docs/agent-docs.path-safety.test.mjs +1239 -17
- package/foundation/agent-docs/agent-docs.test.mjs +678 -35
- package/foundation/config/project.mjs +20 -22
- package/foundation/config/project.test.mjs +56 -0
- package/foundation/discovery/component-discovery.d.mts +37 -0
- package/foundation/discovery/component-discovery.mjs +80 -5
- package/foundation/discovery/component-loader.d.mts +1 -1
- package/foundation/discovery/docs-discovery.mjs +14 -1
- package/foundation/discovery/module-exports.d.mts +41 -0
- package/foundation/discovery/module-exports.mjs +253 -0
- package/foundation/discovery/template-adapter.mjs +18 -1
- package/foundation/discovery/template-needs.mjs +1 -44
- package/foundation/discovery/theme-discovery.mjs +147 -3
- package/foundation/doc-compiler/doc-loads.test.mjs +8 -0
- package/foundation/doc-compiler/read.mjs +59 -1
- package/foundation/doc-compiler/read.test.mjs +11 -2
- package/foundation/fs/paths.d.mts +70 -8
- package/foundation/fs/paths.mjs +556 -38
- package/foundation/fs/paths.test.mjs +430 -54
- package/foundation/integrations/autolink.d.mts +2 -1
- package/foundation/integrations/autolink.mjs +4 -13
- package/foundation/integrations/computer-integrations.d.mts +6 -3
- package/foundation/integrations/computer-integrations.mjs +38 -9
- package/foundation/integrations/contribution-inventory.d.mts +15 -20
- package/foundation/integrations/contribution-inventory.mjs +41 -5
- package/foundation/integrations/integrations.d.mts +27 -1
- package/foundation/integrations/integrations.mjs +61 -2
- package/foundation/integrations/validate-contributions.d.mts +115 -0
- package/foundation/integrations/validate-contributions.mjs +1168 -13
- package/foundation/release/handoff.d.mts +23 -4
- package/foundation/release/handoff.mjs +175 -8
- package/foundation/release/run-context.mjs +14 -0
- package/foundation/response/response-types.doc.mjs +1 -1
- package/foundation/route/route.d.mts +285 -0
- package/foundation/route/route.mjs +324 -0
- package/foundation/route/route.test.mjs +153 -0
- package/foundation/runtime/hosted-runtime.test.mjs +403 -0
- package/foundation/runtime/locked-versions.d.mts +32 -0
- package/foundation/runtime/locked-versions.mjs +65 -0
- package/foundation/runtime/package-root.d.mts +91 -0
- package/foundation/runtime/package-root.mjs +174 -0
- package/foundation/runtime/package-set.d.mts +62 -0
- package/foundation/runtime/package-set.mjs +130 -0
- package/foundation/runtime/package-set.test.mjs +432 -0
- package/host/door-conformance.test.mjs +274 -0
- package/host/index.d.ts +116 -0
- package/host/index.mjs +295 -0
- package/host/index.test.mjs +302 -0
- package/package.json +14 -9
package/api/search/search.mjs
CHANGED
|
@@ -3,2193 +3,38 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @file Programmatic API for the unified `search` command.
|
|
5
5
|
*
|
|
6
|
-
* Returns the same typed envelope { type, data } that `
|
|
6
|
+
* Returns the same typed envelope { type, data } that `astryx --json search`
|
|
7
7
|
* outputs. The CLI command handler is a thin wrapper around this function.
|
|
8
8
|
*
|
|
9
|
-
* `search(query)` is the single "I'm looking for X" entry point across
|
|
10
|
-
* content domains
|
|
11
|
-
* and themes.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* Scoring is keyword + fuzzy ranking (NOT semantic / embeddings — that is a
|
|
16
|
-
* deliberate future follow-up). It reuses the same signal weighting as the
|
|
17
|
-
* component fuzzy resolver in lib/string-utils.mjs:
|
|
18
|
-
*
|
|
19
|
-
* 100 exact name match
|
|
20
|
-
* 95 name is the term's plural or stem form ("buttons" -> Button)
|
|
21
|
-
* 90 exact keyword match
|
|
22
|
-
* 88 keyword is the term's plural or stem form
|
|
23
|
-
* 80 name Levenshtein distance 1 (one-word lookups, words of 5+ letters)
|
|
24
|
-
* 70 keyword word prefix / distance 1 (distance: one-word lookups, 5+ letters)
|
|
25
|
-
* 60 name word prefix (>=4 chars, >=50% coverage)
|
|
26
|
-
* 60 exact weak-keyword match
|
|
27
|
-
* 50 description / prose mentions the term
|
|
28
|
-
* 45 usage guidance mentions the term
|
|
29
|
-
* 40 name Levenshtein distance 2 (one-word lookups, 8+ letters)
|
|
30
|
-
* 40 weak-keyword word prefix
|
|
31
|
-
* 30 keyword Levenshtein distance 2 (one-word lookups, 8+ letters)
|
|
32
|
-
* 20 name Levenshtein distance 3 (one-word lookups, 11+ letters)
|
|
33
|
-
*
|
|
34
|
-
* Name + keyword signals always outweigh description/prose, so an exact match
|
|
35
|
-
* sorts above an incidental mention.
|
|
36
|
-
*
|
|
37
|
-
* A term matches inside a name or keyword only at the start of one of its
|
|
38
|
-
* words: "dash" finds "dashboard" and "input" finds "TextInput", but "file"
|
|
39
|
-
* does not find "profile". Edit distance is typo tolerance, so it applies only
|
|
40
|
-
* to a one-word lookup, where a typo is the likely explanation, and only to
|
|
41
|
-
* words long enough that one edit rarely makes another real word. In a
|
|
42
|
-
* sentence, a near miss is usually a different word: "site" is not "side",
|
|
43
|
-
* "cable" is not "table".
|
|
44
|
-
*
|
|
45
|
-
* A multi-word query has a reserved top tier (see {@link scoreQuery}): the
|
|
46
|
-
* whole query as a candidate's name or keyword (190-200), then the whole query
|
|
47
|
-
* as a phrase inside a doc's title or one of its headings (170), then a whole
|
|
48
|
-
* title of two words or more inside the query (160-169), then a candidate that
|
|
49
|
-
* matches every word of the query, at least one of them by name or keyword
|
|
50
|
-
* (151-159). Below those sits everything else: a partial match, or every word
|
|
51
|
-
* matched only in prose or through the components a page renders. A section titled "Light/Dark Mode" answers `dark
|
|
52
|
-
* mode` better than any doc that merely names `mode` in code, however exactly;
|
|
53
|
-
* "Dark mode" answers `how do I add dark mode`; and a guide whose title and
|
|
54
|
-
* description hold both words of `troubleshoot integration` answers it better
|
|
55
|
-
* than a doc named `integration`.
|
|
56
|
-
*
|
|
57
|
-
* Between domains, ranking adds one rule ahead of the score: domain priority
|
|
58
|
-
* (see {@link domainPriority}). A component, hook, template, or theme whose
|
|
59
|
-
* name or keyword a query word hits ranks ahead of every doc the reader did not
|
|
60
|
-
* ask for by name, title, or a declared phrase, so `font size` finds `Text`
|
|
61
|
-
* before a token page whose heading holds the phrase. A doc the query names,
|
|
62
|
-
* whose title the query holds, or that declares the whole query as a keyword
|
|
63
|
-
* keeps its place.
|
|
64
|
-
*
|
|
65
|
-
* Description and guidance are separate tiers on purpose. A component's own
|
|
66
|
-
* one-line description saying "notification" is a claim about what it IS; the
|
|
67
|
-
* same word inside another component's best-practice advice is a passing
|
|
68
|
-
* mention. Scored equally, `Toast` — "a brief, non-blocking notification" —
|
|
69
|
-
* ties with `Card`, `Dialog` and `Item`, which merely mention notifications in
|
|
70
|
-
* their guidance, and ties break alphabetically, so Toast falls off the end of
|
|
71
|
-
* its own best query.
|
|
72
|
-
*
|
|
73
|
-
* `keywords` carry AUTHORED intent — a block's `componentsUsed`, a page's
|
|
74
|
-
* `category` words. `weakKeywords` are DERIVED: the components a page template
|
|
75
|
-
* happens to render, scraped out of its JSX. Derived signal is deliberately
|
|
76
|
-
* capped below the confident-match gate on its own, because breadth is not
|
|
77
|
-
* relevance. At full keyword strength every rendered component is an
|
|
78
|
-
* independent 90-point shot with no penalty for how many a template has, so
|
|
79
|
-
* the broadest pages (a theme showcase rendering one of everything) win
|
|
80
|
-
* queries they have nothing to do with.
|
|
9
|
+
* `search(query)` is the single "I'm looking for X" entry point across all
|
|
10
|
+
* content domains: components, hooks, docs topics, templates (page and
|
|
11
|
+
* block), and themes. The adapter gathers every candidate (_adapter.mjs),
|
|
12
|
+
* and search's tree (tree.mjs) picks the searcher that answers.
|
|
13
|
+
* `searchCore(query)` is the lexical ranker alone (_lexical.mjs); `build`
|
|
14
|
+
* reads it, so build's output does not move when search's tree does.
|
|
81
15
|
*/
|
|
82
16
|
|
|
83
17
|
import {withListingCache} from '../../foundation/fs/listing-cache.mjs';
|
|
84
|
-
import {
|
|
85
|
-
import {
|
|
86
|
-
import {
|
|
87
|
-
import {
|
|
88
|
-
import {
|
|
89
|
-
discoverComponents,
|
|
90
|
-
discoverValidIntegrationComponents,
|
|
91
|
-
docStem,
|
|
92
|
-
familyMember,
|
|
93
|
-
findComponentReadme,
|
|
94
|
-
forEachIntegrationDoc,
|
|
95
|
-
integrationMemberEntry,
|
|
96
|
-
integrationRecordHold,
|
|
97
|
-
isIntegrationMember,
|
|
98
|
-
resolveImportPath,
|
|
99
|
-
resolveIntegrationImportPath,
|
|
100
|
-
CORE_PACKAGE,
|
|
101
|
-
} from '../../foundation/discovery/component-discovery.mjs';
|
|
102
|
-
import {
|
|
103
|
-
discoverHooks,
|
|
104
|
-
findHookDoc,
|
|
105
|
-
} from '../../foundation/discovery/hook-discovery.mjs';
|
|
106
|
-
import {
|
|
107
|
-
loadComponentReplacements,
|
|
108
|
-
loadIntegrationsSafely,
|
|
109
|
-
resolveLegacyExternalDoc,
|
|
110
|
-
} from '../component/_adapter.mjs';
|
|
111
|
-
import {levenshteinDistance} from '../../foundation/text/string-utils.mjs';
|
|
112
|
-
import {discoverTemplates, extractComponents} from '../template/template.mjs';
|
|
113
|
-
import {templateLookupIds} from '../../foundation/discovery/template-adapter.mjs';
|
|
114
|
-
import {listAvailableThemes} from '../theme/_adapter.mjs';
|
|
115
|
-
import {
|
|
116
|
-
guideEntry,
|
|
117
|
-
loadDocsCatalog,
|
|
118
|
-
lowerTopic,
|
|
119
|
-
projectTree,
|
|
120
|
-
holdsOwnName,
|
|
121
|
-
} from '../docs/_adapter.mjs';
|
|
122
|
-
import {unlinkText} from '../../foundation/doc-compiler/links.mjs';
|
|
123
|
-
import {nodeView} from '../docs/node/node.mjs';
|
|
124
|
-
import {
|
|
125
|
-
sectionKey,
|
|
126
|
-
sectionSummary,
|
|
127
|
-
} from '../../foundation/discovery/docs-section-key.mjs';
|
|
128
|
-
import {AstryxError, coreDirOrThrow} from '../error.mjs';
|
|
129
|
-
import {ERROR_CODES} from '../../foundation/response/error-codes.mjs';
|
|
130
|
-
import {setResultCoverage} from './coverage.mjs';
|
|
18
|
+
import {gatherCandidates, loadSearchData} from './_adapter.mjs';
|
|
19
|
+
import {rankLexical} from './_lexical.mjs';
|
|
20
|
+
import {checkSearchArgs} from './_result.mjs';
|
|
21
|
+
import {runTree} from '../../foundation/route/route.mjs';
|
|
22
|
+
import {SEARCH_TREE} from './tree.mjs';
|
|
131
23
|
import {linkSearchResponse} from '../component/_adapter.mjs';
|
|
132
24
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
* @property {string} [description]
|
|
147
|
-
* @property {string[]} [prose]
|
|
148
|
-
* @property {string[]} [guidance]
|
|
149
|
-
* @property {string[]} [titles] - A doc's title and the headings inside it:
|
|
150
|
-
* the lines a reader scans to pick it. The whole query standing in one of
|
|
151
|
-
* them, or one of them standing whole in the query, is a top-tier match.
|
|
152
|
-
* @property {string} [_import]
|
|
153
|
-
* @property {string} [_title]
|
|
154
|
-
* @property {string} [_topic] - A doc result's topic or docs-tree route.
|
|
155
|
-
* @property {string} [_section] - A doc result's section key, when it is one section.
|
|
156
|
-
* @property {string} [_command] - The command that reads exactly this doc part.
|
|
157
|
-
* @property {string} [_parent] - The command that opens the level above a doc
|
|
158
|
-
* part: its topic's section list, or the docs-tree namespace it sits in.
|
|
159
|
-
* @property {string} [_package] - The npm package that owns the candidate:
|
|
160
|
-
* Core's package for its components, hooks, and templates, the integration's
|
|
161
|
-
* for what it contributed, and for a doc part the package that wrote it.
|
|
162
|
-
* @property {string} [_displayName]
|
|
163
|
-
* @property {'page'|'block'} [_kind]
|
|
164
|
-
* @property {string} [_resultName]
|
|
165
|
-
* @property {string} [_commandName]
|
|
166
|
-
* @property {'file'|'doc'} [_hold] - How an integration component holds its
|
|
167
|
-
* name, for the owner rule.
|
|
168
|
-
* @property {string} [_docPath] - An integration component's doc.
|
|
169
|
-
*/
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Synonym / intent map: product-language terms an agent is likely to type,
|
|
173
|
-
* expanded to the catalog's vocabulary so oblique queries still rank. Keys and
|
|
174
|
-
* values are matched bidirectionally (typing any value also pulls in the key
|
|
175
|
-
* and its siblings). Lowercase, single words or short phrases. Exported for
|
|
176
|
-
* `build`, whose page ranker expands a query with the same vocabulary.
|
|
177
|
-
*/
|
|
178
|
-
export const SYNONYMS = {
|
|
179
|
-
dashboard: [
|
|
180
|
-
'overview',
|
|
181
|
-
'analytics',
|
|
182
|
-
'kpi',
|
|
183
|
-
'kpis',
|
|
184
|
-
'metrics',
|
|
185
|
-
'stats',
|
|
186
|
-
'reporting',
|
|
187
|
-
'insights',
|
|
188
|
-
'control',
|
|
189
|
-
],
|
|
190
|
-
login: ['signin', 'auth', 'authentication', 'sso', 'credentials', 'account'],
|
|
191
|
-
signup: ['register', 'registration', 'onboarding'],
|
|
192
|
-
payment: ['checkout', 'billing', 'card', 'pay', 'purchase', 'order'],
|
|
193
|
-
pricing: ['plans', 'plan', 'tiers', 'tier', 'subscription', 'subscriptions'],
|
|
194
|
-
chat: ['messaging', 'message', 'messages', 'conversation', 'inbox', 'dm'],
|
|
195
|
-
settings: ['preferences', 'config', 'configuration', 'account'],
|
|
196
|
-
calendar: ['schedule', 'scheduling', 'events', 'event', 'month', 'agenda'],
|
|
197
|
-
table: ['list', 'rows', 'records', 'grid', 'spreadsheet', 'datatable'],
|
|
198
|
-
gallery: ['photos', 'photo', 'images', 'image', 'pictures'],
|
|
199
|
-
hero: ['banner', 'splash', 'headline', 'landing'],
|
|
200
|
-
form: ['fields', 'input', 'inputs', 'survey'],
|
|
201
|
-
profile: ['bio', 'avatar', 'user'],
|
|
202
|
-
documentation: ['docs', 'reference', 'guide', 'api'],
|
|
203
|
-
navigation: ['nav', 'menu', 'sidebar'],
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
// Flatten into a token -> Set(expansions) lookup (bidirectional).
|
|
207
|
-
const SYNONYM_INDEX = (() => {
|
|
208
|
-
/** @type {Map<string, Set<string>>} */
|
|
209
|
-
const idx = new Map();
|
|
210
|
-
/**
|
|
211
|
-
* @param {string} a
|
|
212
|
-
* @param {string} b
|
|
213
|
-
*/
|
|
214
|
-
const add = (a, b) => {
|
|
215
|
-
let set = idx.get(a);
|
|
216
|
-
if (!set) {
|
|
217
|
-
set = new Set();
|
|
218
|
-
idx.set(a, set);
|
|
219
|
-
}
|
|
220
|
-
set.add(b);
|
|
221
|
-
};
|
|
222
|
-
for (const [key, vals] of Object.entries(SYNONYMS)) {
|
|
223
|
-
for (const v of vals) {
|
|
224
|
-
add(key, v);
|
|
225
|
-
add(v, key);
|
|
226
|
-
for (const v2 of vals) if (v2 !== v) add(v, v2);
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return idx;
|
|
230
|
-
})();
|
|
231
|
-
|
|
232
|
-
/**
|
|
233
|
-
* Light stemmer: strips common English suffixes so "charts"/"charting" and
|
|
234
|
-
* "chart" share a root. Deliberately crude (no Porter) — good enough to bridge
|
|
235
|
-
* plural/gerund gaps without a dependency.
|
|
236
|
-
* @param {string} w
|
|
237
|
-
* @returns {string}
|
|
238
|
-
*/
|
|
239
|
-
export function stem(w) {
|
|
240
|
-
let s = w;
|
|
241
|
-
for (const suf of ['ing', 'ed', 'ies', 'es', 's']) {
|
|
242
|
-
if (s.length > suf.length + 2 && s.endsWith(suf)) {
|
|
243
|
-
s = suf === 'ies' ? s.slice(0, -3) + 'y' : s.slice(0, -suf.length);
|
|
244
|
-
break;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
return s;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* The forms of a word that count as the same word: itself, its stem, and its
|
|
252
|
-
* singular when it ends in a plural suffix — so "tables" is "table",
|
|
253
|
-
* "statuses" is "status", and "filtering" is "filter".
|
|
254
|
-
* @param {string} w - Lowercase word.
|
|
255
|
-
* @returns {Set<string>}
|
|
256
|
-
*/
|
|
257
|
-
function wordForms(w) {
|
|
258
|
-
const forms = new Set([w, stem(w)]);
|
|
259
|
-
if (w.length > 3 && w.endsWith('s')) forms.add(w.slice(0, -1));
|
|
260
|
-
if (w.length > 4 && w.endsWith('es')) forms.add(w.slice(0, -2));
|
|
261
|
-
if (w.length > 4 && w.endsWith('ies')) forms.add(w.slice(0, -3) + 'y');
|
|
262
|
-
// A gerund keeps its verb: "spacing" lost a final e ("space"), and
|
|
263
|
-
// "dragging" doubled a consonant ("drag"). A past participle does not: it
|
|
264
|
-
// usually describes a state ("archived notes"), not the action a catalog
|
|
265
|
-
// word names.
|
|
266
|
-
if (w.length >= 6 && w.endsWith('ing')) {
|
|
267
|
-
const base = w.slice(0, -3);
|
|
268
|
-
forms.add(base);
|
|
269
|
-
forms.add(`${base}e`);
|
|
270
|
-
if (/([b-df-hj-np-tv-z])\1$/.test(base)) forms.add(base.slice(0, -1));
|
|
271
|
-
}
|
|
272
|
-
return forms;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* The singulars a lowercase word may have when it ends in a plural suffix:
|
|
277
|
-
* "tokens" is "token", "boxes" is "box", "entries" is "entry".
|
|
278
|
-
* @param {string} w
|
|
279
|
-
* @returns {string[]}
|
|
280
|
-
*/
|
|
281
|
-
function singulars(w) {
|
|
282
|
-
/** @type {string[]} */
|
|
283
|
-
const out = [];
|
|
284
|
-
if (w.length > 3 && w.endsWith('s')) out.push(w.slice(0, -1));
|
|
285
|
-
if (w.length > 4 && w.endsWith('es')) out.push(w.slice(0, -2));
|
|
286
|
-
if (w.length > 4 && w.endsWith('ies')) out.push(w.slice(0, -3) + 'y');
|
|
287
|
-
return out;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Whether two lowercase words are the same word, up to plural and stem form.
|
|
292
|
-
* @param {string} a
|
|
293
|
-
* @param {string} b
|
|
294
|
-
* @returns {boolean}
|
|
295
|
-
*/
|
|
296
|
-
export function sameWord(a, b) {
|
|
297
|
-
if (a === b) return true;
|
|
298
|
-
const forms = wordForms(a);
|
|
299
|
-
for (const f of wordForms(b)) if (forms.has(f)) return true;
|
|
300
|
-
return false;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* The lowercase words of a name or keyword: split at non-alphanumerics and at
|
|
305
|
-
* camelCase boundaries, so "TextInput" is ["text", "input"] and
|
|
306
|
-
* "Dashboard - Analytics" is ["dashboard", "analytics"].
|
|
307
|
-
* @param {string} text
|
|
308
|
-
* @returns {string[]}
|
|
309
|
-
*/
|
|
310
|
-
function wordsOf(text) {
|
|
311
|
-
return String(text)
|
|
312
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
313
|
-
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
|
|
314
|
-
.toLowerCase()
|
|
315
|
-
.split(/[^a-z0-9]+/)
|
|
316
|
-
.filter(Boolean);
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
/**
|
|
320
|
-
* Whether a term is found inside a name or keyword: it is one of its words, or
|
|
321
|
-
* the start of one (a truncation), and covers at least half of the whole
|
|
322
|
-
* string. Four letters minimum, so a short term never matches by accident.
|
|
323
|
-
* @param {string} term - Lowercase term.
|
|
324
|
-
* @param {string} text - The name or keyword as authored.
|
|
325
|
-
* @returns {boolean}
|
|
326
|
-
*/
|
|
327
|
-
function startsAWordOf(term, text) {
|
|
328
|
-
if (term.length < 4) return false;
|
|
329
|
-
if (term.length / String(text).length < 0.5) return false;
|
|
330
|
-
// The term or its singular may start the word: "tokens" starts
|
|
331
|
-
// "tokenizer" through "token". A singular needs five letters, so "stats"
|
|
332
|
-
// does not start "status".
|
|
333
|
-
const starts = [term, ...singulars(term).filter(f => f.length >= 5)];
|
|
334
|
-
return wordsOf(text).some(
|
|
335
|
-
w => starts.some(f => w.startsWith(f)) || sameWord(term, w),
|
|
336
|
-
);
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
/**
|
|
340
|
-
* A query with its camelCase and PascalCase identifiers spelled as words, so
|
|
341
|
-
* `SidebarToggle` reads "sidebar toggle" and `useTablePaginationState` "use
|
|
342
|
-
* table pagination state". Plain words, and identifiers joined with `_` or `-`, are
|
|
343
|
-
* unchanged: a joined token that matches nothing stays unmatched.
|
|
344
|
-
* @param {string} query - The query as typed, before lowercasing.
|
|
345
|
-
* @returns {string} Lowercased.
|
|
346
|
-
*/
|
|
347
|
-
export function spellIdentifiers(query) {
|
|
348
|
-
return String(query)
|
|
349
|
-
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
350
|
-
.replace(/([A-Z])([A-Z][a-z])/g, '$1 $2')
|
|
351
|
-
.toLowerCase();
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* A name or keyword with its separators removed, lowercased: `Side Nav`,
|
|
356
|
-
* `side-nav`, and `SideNav` all join to `sidenav`.
|
|
357
|
-
* @param {string} text
|
|
358
|
-
* @returns {string}
|
|
359
|
-
*/
|
|
360
|
-
function joined(text) {
|
|
361
|
-
return String(text)
|
|
362
|
-
.toLowerCase()
|
|
363
|
-
.replace(/[^a-z0-9]+/g, '');
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* The strongest hit a run of two or more adjacent query words has when it is
|
|
368
|
-
* read as one joined word, and the query words it covers. Readers type a
|
|
369
|
-
* compound name as separate words (`table header cell` is TableHeaderCell,
|
|
370
|
-
* `number field` the keyword `numberfield`), and no single word of the run
|
|
371
|
-
* matches on its own.
|
|
372
|
-
*
|
|
373
|
-
* - 100: the run joins to a component, hook, or template name, or its plural
|
|
374
|
-
* (`table header cells` is TableHeaderCell), or a hook name without its
|
|
375
|
-
* `use` prefix (`table sticky columns` is useTableStickyColumns).
|
|
376
|
-
* - 90: the run joins to a one-word keyword (`number field` is
|
|
377
|
-
* `numberfield`).
|
|
378
|
-
* - 95: the run joins to most of a name, cut short (`dropdown menu radio` is
|
|
379
|
-
* DropdownMenuRadioItem); 92: it is a name's leading words, covering at least half
|
|
380
|
-
* of it (`top nav mega` is TopNavMegaMenu); 70: it is two or more adjacent
|
|
381
|
-
* words of a name that cover at least half of it (`header cell` in
|
|
382
|
-
* TableHeaderCell), the same coverage a word prefix needs. These three
|
|
383
|
-
* are for components and hooks only.
|
|
384
|
-
*
|
|
385
|
-
* A doc's name is a route or key, so docs are left out.
|
|
386
|
-
* @param {string[]} words - The query's words, stopwords included.
|
|
387
|
-
* @param {Candidate} candidate
|
|
388
|
-
* @returns {{score: number, reason: string, covered: Set<string>, alias?: 'partial' | 'keyword', aliasWords?: number} | null}
|
|
389
|
-
* `covered` holds the run's words and every stretch of them joined. `alias`
|
|
390
|
-
* marks a hit on part of a name or on a keyword, not on the whole name.
|
|
391
|
-
*/
|
|
392
|
-
export function compoundHit(words, candidate) {
|
|
393
|
-
if (candidate.domain === 'doc' || words.length < 2) return null;
|
|
394
|
-
const names = [candidate.name, ...(candidate.aliases ?? [])]
|
|
395
|
-
.filter(Boolean)
|
|
396
|
-
.map(name => ({name, nameJoined: joined(name), nameWords: wordsOf(name)}));
|
|
397
|
-
// Part of a name counts only for a component or hook. A template's name
|
|
398
|
-
// starts with the component it shows (`ProgressBarShowcase`), so part of it
|
|
399
|
-
// names the component, not the template.
|
|
400
|
-
const partial =
|
|
401
|
-
candidate.domain === 'component' || candidate.domain === 'hook';
|
|
402
|
-
// One-word lowercase keywords: a keyword an author declares for a reader
|
|
403
|
-
// to type, joined (`numberfield`). A capitalized one names another entry (a
|
|
404
|
-
// block's `componentsUsed`), which matches by its own name, and a keyword of
|
|
405
|
-
// several words is a phrase, matched as one.
|
|
406
|
-
const keywords = (candidate.keywords ?? []).filter(k =>
|
|
407
|
-
/^[a-z0-9]+$/.test(String(k)),
|
|
408
|
-
);
|
|
409
|
-
/** @type {{score: number, reason: string, covered: Set<string>, alias?: 'partial' | 'keyword', aliasWords?: number} | null} */
|
|
410
|
-
let best = null;
|
|
411
|
-
for (const {run, word} of queryRuns(words)) {
|
|
412
|
-
let score = 0;
|
|
413
|
-
let reason = '';
|
|
414
|
-
/** @type {'partial' | 'keyword' | null} */
|
|
415
|
-
let kind = null;
|
|
416
|
-
for (const {name, nameJoined, nameWords} of names) {
|
|
417
|
-
if (
|
|
418
|
-
nameJoined === word ||
|
|
419
|
-
pluralOf(word, nameJoined) ||
|
|
420
|
-
(candidate.domain === 'hook' && nameJoined === `use${word}`)
|
|
421
|
-
) {
|
|
422
|
-
if (score < 100)
|
|
423
|
-
[score, reason, kind] = [100, `name ${name} spelled`, null];
|
|
424
|
-
} else if (!partial) {
|
|
425
|
-
continue;
|
|
426
|
-
} else if (
|
|
427
|
-
nameJoined.startsWith(word) &&
|
|
428
|
-
word.length / nameJoined.length >= 0.75
|
|
429
|
-
) {
|
|
430
|
-
// Most of the name, cut short: `dropdown menu radio` is
|
|
431
|
-
// DropdownMenuRadioItem.
|
|
432
|
-
if (score < 95)
|
|
433
|
-
[score, reason, kind] = [95, `name ${name} spelled`, 'partial'];
|
|
434
|
-
} else if (
|
|
435
|
-
word.length / nameJoined.length >= 0.5 &&
|
|
436
|
-
run.every(
|
|
437
|
-
(w, j) => nameWords[j + (nameWords[0] === 'use' ? 1 : 0)] === w,
|
|
438
|
-
)
|
|
439
|
-
) {
|
|
440
|
-
// The name's leading words: `top nav mega` is TopNavMegaMenu.
|
|
441
|
-
if (score < 92)
|
|
442
|
-
[score, reason, kind] = [
|
|
443
|
-
92,
|
|
444
|
-
`name ${name} starts "${run.join(' ')}"`,
|
|
445
|
-
'partial',
|
|
446
|
-
];
|
|
447
|
-
} else if (
|
|
448
|
-
word.length / nameJoined.length >= 0.5 &&
|
|
449
|
-
nameWords.some((_, k) => run.every((w, j) => nameWords[k + j] === w))
|
|
450
|
-
) {
|
|
451
|
-
if (score < 70)
|
|
452
|
-
[score, reason, kind] = [
|
|
453
|
-
70,
|
|
454
|
-
`name ${name} holds "${run.join(' ')}"`,
|
|
455
|
-
'partial',
|
|
456
|
-
];
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
if (score < 90 && keywords.includes(word)) {
|
|
460
|
-
[score, reason, kind] = [
|
|
461
|
-
90,
|
|
462
|
-
`keyword "${run.join(' ')}" spelled`,
|
|
463
|
-
'keyword',
|
|
464
|
-
];
|
|
465
|
-
}
|
|
466
|
-
if (score > (best?.score ?? 0)) {
|
|
467
|
-
// Each word of the run, and each identifier typed as several of its
|
|
468
|
-
// words joined (`dropdownMenu` in `dropdownMenu checkbox`).
|
|
469
|
-
const covered = new Set();
|
|
470
|
-
for (let a = 0; a < run.length; a++) {
|
|
471
|
-
for (let b = a + 1; b <= run.length; b++) {
|
|
472
|
-
covered.add(run.slice(a, b).join(''));
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
best = {
|
|
476
|
-
score,
|
|
477
|
-
reason,
|
|
478
|
-
covered,
|
|
479
|
-
...(kind ? {alias: kind, aliasWords: run.length} : {}),
|
|
480
|
-
};
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
return best;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
/**
|
|
487
|
-
* The longest run of adjacent query words read as one name. Core's longest
|
|
488
|
-
* names run to six words; a longer run is a sentence, not a name, and reading
|
|
489
|
-
* every run of a long build idea costs time on every candidate.
|
|
490
|
-
*/
|
|
491
|
-
const MAX_RUN_WORDS = 6;
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Each run of two to {@link MAX_RUN_WORDS} adjacent query words, longest
|
|
495
|
-
* first, with the run joined into one word. Computed once per query.
|
|
496
|
-
* @type {WeakMap<string[], {run: string[], word: string}[]>}
|
|
497
|
-
*/
|
|
498
|
-
const runsOf = new WeakMap();
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* @param {string[]} words
|
|
502
|
-
* @returns {{run: string[], word: string}[]}
|
|
503
|
-
*/
|
|
504
|
-
function queryRuns(words) {
|
|
505
|
-
let runs = runsOf.get(words);
|
|
506
|
-
if (runs) return runs;
|
|
507
|
-
runs = [];
|
|
508
|
-
for (let len = Math.min(words.length, MAX_RUN_WORDS); len >= 2; len--) {
|
|
509
|
-
for (let i = 0; i + len <= words.length; i++) {
|
|
510
|
-
const run = words.slice(i, i + len);
|
|
511
|
-
runs.push({run, word: run.join('')});
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
runsOf.set(words, runs);
|
|
515
|
-
return runs;
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Each candidate's compound hit from its last scoreQuery, so domainPriority
|
|
520
|
-
* reads it without walking the query's runs again.
|
|
521
|
-
* @type {WeakMap<object, ReturnType<typeof compoundHit>>}
|
|
522
|
-
*/
|
|
523
|
-
const compoundOf = new WeakMap();
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* The fewest letters both words need before an edit distance counts as a
|
|
527
|
-
* typo, by distance. Below them, one edit usually makes a different word.
|
|
528
|
-
*/
|
|
529
|
-
const TYPO_MIN_LENGTH = {1: 5, 2: 8, 3: 11};
|
|
530
|
-
|
|
531
|
-
/**
|
|
532
|
-
* @param {string} a
|
|
533
|
-
* @param {string} b
|
|
534
|
-
* @param {number} dist
|
|
535
|
-
*/
|
|
536
|
-
const isTypo = (a, b, dist) =>
|
|
537
|
-
dist > 0 &&
|
|
538
|
-
dist <= 3 &&
|
|
539
|
-
Math.min(a.length, b.length) >=
|
|
540
|
-
TYPO_MIN_LENGTH[/** @type {1 | 2 | 3} */ (dist)];
|
|
541
|
-
|
|
542
|
-
/** Valid domain filters for `--type`. */
|
|
543
|
-
export const SEARCH_DOMAINS = ['component', 'hook', 'doc', 'template', 'theme'];
|
|
544
|
-
|
|
545
|
-
/**
|
|
546
|
-
* The domains a search reads without @astryxdesign/core. An open search outside
|
|
547
|
-
* an app covers these alone.
|
|
548
|
-
*/
|
|
549
|
-
const CORELESS_DOMAINS = ['doc', 'theme'];
|
|
550
|
-
|
|
551
|
-
/**
|
|
552
|
-
* Filler words stripped from multi-word queries so natural-language phrasing
|
|
553
|
-
* ("a page where you can see business stats") ranks on its content words.
|
|
554
|
-
* Exported for `build`, whose page ranker strips the same words.
|
|
555
|
-
*/
|
|
556
|
-
export const STOPWORDS = new Set([
|
|
557
|
-
'a',
|
|
558
|
-
'an',
|
|
559
|
-
'the',
|
|
560
|
-
'of',
|
|
561
|
-
'for',
|
|
562
|
-
'to',
|
|
563
|
-
'with',
|
|
564
|
-
'and',
|
|
565
|
-
'or',
|
|
566
|
-
'in',
|
|
567
|
-
'on',
|
|
568
|
-
'at',
|
|
569
|
-
'by',
|
|
570
|
-
'that',
|
|
571
|
-
'this',
|
|
572
|
-
'my',
|
|
573
|
-
'your',
|
|
574
|
-
'our',
|
|
575
|
-
'their',
|
|
576
|
-
'is',
|
|
577
|
-
'are',
|
|
578
|
-
'be',
|
|
579
|
-
'it',
|
|
580
|
-
'its',
|
|
581
|
-
'as',
|
|
582
|
-
'from',
|
|
583
|
-
'page',
|
|
584
|
-
'screen',
|
|
585
|
-
'app',
|
|
586
|
-
'application',
|
|
587
|
-
'view',
|
|
588
|
-
'where',
|
|
589
|
-
'you',
|
|
590
|
-
'can',
|
|
591
|
-
'some',
|
|
592
|
-
'like',
|
|
593
|
-
'just',
|
|
594
|
-
'basically',
|
|
595
|
-
'kinda',
|
|
596
|
-
'want',
|
|
597
|
-
'wants',
|
|
598
|
-
'need',
|
|
599
|
-
'needs',
|
|
600
|
-
'something',
|
|
601
|
-
'thing',
|
|
602
|
-
'things',
|
|
603
|
-
'build',
|
|
604
|
-
'make',
|
|
605
|
-
'create',
|
|
606
|
-
'i',
|
|
607
|
-
'me',
|
|
608
|
-
'we',
|
|
609
|
-
'us',
|
|
610
|
-
'so',
|
|
611
|
-
'up',
|
|
612
|
-
'out',
|
|
613
|
-
'over',
|
|
614
|
-
'side',
|
|
615
|
-
'one',
|
|
616
|
-
'big',
|
|
617
|
-
]);
|
|
618
|
-
|
|
619
|
-
/**
|
|
620
|
-
* Split a query into meaningful content tokens (lowercased, stopwords + very
|
|
621
|
-
* short words removed). Empty for single-word queries (callers fall back to
|
|
622
|
-
* whole-phrase scoring).
|
|
623
|
-
* @param {string} term - Already-lowercased query.
|
|
624
|
-
* @returns {string[]}
|
|
625
|
-
*/
|
|
626
|
-
export function tokenizeQuery(term) {
|
|
627
|
-
return (
|
|
628
|
-
term
|
|
629
|
-
.split(/\s+/)
|
|
630
|
-
// Strip only leading/trailing punctuation; keep joined identifiers intact
|
|
631
|
-
// (e.g. "foo_bar" stays one token) so gibberish stays gibberish.
|
|
632
|
-
.map(t => t.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''))
|
|
633
|
-
.filter(t => t.length >= 2 && !STOPWORDS.has(t))
|
|
634
|
-
);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
/**
|
|
638
|
-
* Score a candidate against a query, handling multi-word natural language.
|
|
639
|
-
* Tries the whole phrase (so exact/near matches still win) AND a per-token
|
|
640
|
-
* pass (so "data table with filters" matches `table-page` via table+filter),
|
|
641
|
-
* and returns whichever is stronger.
|
|
642
|
-
*
|
|
643
|
-
* @param {string} term - Lowercased full query.
|
|
644
|
-
* @param {string[]} tokens - Content tokens from tokenizeQuery(term).
|
|
645
|
-
* @param {object} candidate
|
|
646
|
-
* @returns {{score: number, reason: string} | null}
|
|
647
|
-
*/
|
|
648
|
-
/**
|
|
649
|
-
* Minimum per-token score (in the multi-word pass) to count as a real match.
|
|
650
|
-
* 50 = a genuine name/keyword/description hit; below that is loose Levenshtein
|
|
651
|
-
* fuzz that would otherwise turn gibberish queries into noise.
|
|
652
|
-
*
|
|
653
|
-
* Guidance (45) is deliberately BELOW this floor, so it never counts as one of
|
|
654
|
-
* the matched concepts in a multi-word query. Measured: letting it count moved
|
|
655
|
-
* `nested menu` from SideNav to List and `explain why a field is required` from
|
|
656
|
-
* Field to TextInput — in both cases a component whose guidance happens to
|
|
657
|
-
* mention the other word displaced the one that IS the answer. Breadth is not
|
|
658
|
-
* relevance, the same reason `weakKeywords` are capped. Guidance still decides
|
|
659
|
-
* single-word queries and still breaks ties, which is where it earns its place.
|
|
660
|
-
*/
|
|
661
|
-
const MIN_TOKEN_SCORE = 50;
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* Best score for a token against a candidate, fanning out through synonyms
|
|
665
|
-
* (synonym hits are discounted so a direct hit always wins).
|
|
666
|
-
* @param {string} tok
|
|
667
|
-
* @param {Candidate} candidate
|
|
668
|
-
* @param {{fuzzy?: boolean}} [opts]
|
|
669
|
-
* @returns {{score: number, reason: string} | null}
|
|
670
|
-
*/
|
|
671
|
-
function bestForToken(tok, candidate, opts = {}) {
|
|
672
|
-
let best = scoreCandidate(tok, candidate, opts);
|
|
673
|
-
const syns = SYNONYM_INDEX.get(tok);
|
|
674
|
-
if (syns) {
|
|
675
|
-
for (const s of syns) {
|
|
676
|
-
const h = scoreCandidate(s, candidate, opts);
|
|
677
|
-
if (h) {
|
|
678
|
-
const score = Math.round(h.score * 0.85);
|
|
679
|
-
if (!best || score > best.score)
|
|
680
|
-
best = {score, reason: `${h.reason} (~${tok})`};
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
return best;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
/**
|
|
688
|
-
* The score of a whole-query phrase inside a doc's title or heading: a keyword
|
|
689
|
-
* substring hit (70) promoted by the same 100 as the exact tier. Below an
|
|
690
|
-
* exact name or keyword (190-200), above the token-sum path (~151 at most).
|
|
691
|
-
*/
|
|
692
|
-
const TITLE_PHRASE_SCORE = 170;
|
|
693
|
-
|
|
694
|
-
/**
|
|
695
|
-
* The score of a whole title inside a longer query, before its coverage bonus:
|
|
696
|
-
* one step below {@link TITLE_PHRASE_SCORE}. The bonus (one per query term the
|
|
697
|
-
* candidate matches, at most 9) orders the sections that share a common title,
|
|
698
|
-
* so "best practices for spacing" puts Spacing's Best Practices first.
|
|
699
|
-
*/
|
|
700
|
-
const TITLE_IN_QUERY_SCORE = 160;
|
|
701
|
-
|
|
702
|
-
/**
|
|
703
|
-
* The score of a candidate that matches every content word of a multi-word
|
|
704
|
-
* query, before a bonus of up to 8 for how strong its strongest match is: just
|
|
705
|
-
* above anything that matches only some of the words. The token-sum path tops
|
|
706
|
-
* out near 150 for a partial match (a 100 on one word, the per-word bonus, and
|
|
707
|
-
* the coverage term), so an AND-match with one keyword-strength hit (see
|
|
708
|
-
* {@link STRONG_TOKEN_SCORE}) always outranks an OR-match, and stays below the
|
|
709
|
-
* title tiers.
|
|
710
|
-
*/
|
|
711
|
-
const FULL_COVERAGE_SCORE = 151;
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* The strongest single-word hit an every-word match needs to take that tier: a
|
|
715
|
-
* keyword substring. Two passing mentions in prose, or the components a page
|
|
716
|
-
* happens to render, are breadth, not relevance; they stay on the token sum,
|
|
717
|
-
* below an exact name or keyword hit on one of the words.
|
|
718
|
-
*/
|
|
719
|
-
const STRONG_TOKEN_SCORE = 70;
|
|
720
|
-
|
|
721
|
-
/**
|
|
722
|
-
* A match's domain priority: 0 (none), 1, or 2 (a doc the whole query names).
|
|
723
|
-
* A component, hook, template, or theme with priority ranks ahead of any doc
|
|
724
|
-
* without it, whatever the two scores are. Docs among themselves rank by
|
|
725
|
-
* priority, then score; everything else ranks among itself by score.
|
|
726
|
-
*
|
|
727
|
-
* A component, hook, template, or theme has priority (1) when one of the
|
|
728
|
-
* query's words, or the whole query, hits its name or an authored keyword at
|
|
729
|
-
* {@link STRONG_TOKEN_SCORE} or above, or when it matches every word.
|
|
730
|
-
*
|
|
731
|
-
* A doc has priority when the reader asked for that doc:
|
|
732
|
-
* - 2: the whole query is the topic's name, its last route segment, or its
|
|
733
|
-
* own title, as words and whatever the plural (`font setup` is
|
|
734
|
-
* typography/font-setup, `side panel` is "Side panels");
|
|
735
|
-
* - 1: a word of the query is the topic's name (`illustration` in a longer
|
|
736
|
-
* question is the illustrations guide);
|
|
737
|
-
* - 1: a query of two or more words is one of the topic's own declared
|
|
738
|
-
* keywords, whatever the plural (`code review` on a guide that declares it);
|
|
739
|
-
* - 1: the query holds one of the doc's titles whole (`resizable side panels`
|
|
740
|
-
* names "Side panels"; `light dark mode button` names "Light/Dark Mode");
|
|
741
|
-
* - 1: the doc matches every word of the query and one of them is a word of
|
|
742
|
-
* its title (`switch to a dark theme` and "Use a theme").
|
|
743
|
-
* A section answers to its topic's name.
|
|
744
|
-
*
|
|
745
|
-
* A doc that matched only by a heading holding the query, by one word, or by
|
|
746
|
-
* words spread through its text has no priority. Docs are split into many
|
|
747
|
-
* small topics, each with its own headings, so a common phrase such as
|
|
748
|
-
* `font size` hits a heading exactly (170) while the component the reader is
|
|
749
|
-
* after matches one word by keyword (`Text`, 98). Ranked on text alone, every
|
|
750
|
-
* split adds another doc above the component. Broad reference pages match every word of many queries
|
|
751
|
-
* in their text the same way. The score stays the text-match strength the
|
|
752
|
-
* result reports, so callers that gate on it (`build`) see the same numbers.
|
|
753
|
-
*
|
|
754
|
-
* @param {string} term - Lowercased search term.
|
|
755
|
-
* @param {string[]} tokens - Content tokens from tokenizeQuery(term).
|
|
756
|
-
* @param {Candidate} candidate
|
|
757
|
-
* @param {{score: number, matched: number, total: number}} hit - The
|
|
758
|
-
* candidate's scoreQuery result.
|
|
759
|
-
* @returns {0 | 1 | 2}
|
|
760
|
-
*/
|
|
761
|
-
export function domainPriority(
|
|
762
|
-
term,
|
|
763
|
-
tokens,
|
|
764
|
-
candidate,
|
|
765
|
-
hit,
|
|
766
|
-
queryWords = phraseWords(term),
|
|
767
|
-
) {
|
|
768
|
-
const words = [term, ...tokens];
|
|
769
|
-
if (candidate.domain === 'doc') {
|
|
770
|
-
const topic = String(candidate._topic ?? candidate.name).toLowerCase();
|
|
771
|
-
// The whole query, read as words, is the topic's route, its last
|
|
772
|
-
// segment, or its own title, whatever the plural: `side panel` is
|
|
773
|
-
// layout/side-panels, and `header and footer` is "Headers and footers".
|
|
774
|
-
// A section's title is a heading inside a topic, not the topic's name.
|
|
775
|
-
const named = [topic, topic.slice(topic.lastIndexOf('/') + 1)];
|
|
776
|
-
if (candidate._section == null && candidate.titles?.[0]) {
|
|
777
|
-
named.push(candidate.titles[0]);
|
|
778
|
-
}
|
|
779
|
-
if (named.some(n => samePhrase(term, n))) {
|
|
780
|
-
return 2;
|
|
781
|
-
}
|
|
782
|
-
// A query of two or more words that is one of the doc's own declared
|
|
783
|
-
// keywords, read as words whatever the plural, is the author naming the
|
|
784
|
-
// doc for that phrase: `code review` on a guide that declares it. Only
|
|
785
|
-
// what the author declared counts: the code terms and table cells a
|
|
786
|
-
// doc's other keywords add (`missing_field`) name what it mentions, not
|
|
787
|
-
// what it is for. One word is left out too, and a section declares
|
|
788
|
-
// nothing.
|
|
789
|
-
if (
|
|
790
|
-
phraseWords(term).length >= 2 &&
|
|
791
|
-
candidate._section == null &&
|
|
792
|
-
(candidate._declared ?? []).some(k => samePhrase(term, String(k)))
|
|
793
|
-
) {
|
|
794
|
-
return 1;
|
|
795
|
-
}
|
|
796
|
-
if (
|
|
797
|
-
words.some(
|
|
798
|
-
w =>
|
|
799
|
-
(scoreCandidate(w, {name: topic}, {fuzzy: false})?.score ?? 0) >= 95,
|
|
800
|
-
)
|
|
801
|
-
) {
|
|
802
|
-
return 1;
|
|
803
|
-
}
|
|
804
|
-
// A whole title inside the query is the only path to 160-169.
|
|
805
|
-
if (hit.score >= TITLE_IN_QUERY_SCORE && hit.score < TITLE_PHRASE_SCORE) {
|
|
806
|
-
return 1;
|
|
807
|
-
}
|
|
808
|
-
// Every word matched, and one of them names the doc in its title.
|
|
809
|
-
if (
|
|
810
|
-
hit.score >= FULL_COVERAGE_SCORE &&
|
|
811
|
-
hit.score < TITLE_IN_QUERY_SCORE &&
|
|
812
|
-
hit.matched === hit.total
|
|
813
|
-
) {
|
|
814
|
-
const titleWords = phraseWords(candidate.titles?.[0] ?? '');
|
|
815
|
-
if (
|
|
816
|
-
(tokens.length ? tokens : [term]).some(t =>
|
|
817
|
-
titleWords.some(w => samePhraseWord(t, w) || sameWord(t, w)),
|
|
818
|
-
)
|
|
819
|
-
) {
|
|
820
|
-
return 1;
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
return 0;
|
|
824
|
-
}
|
|
825
|
-
if (hit.score >= FULL_COVERAGE_SCORE) return 1;
|
|
826
|
-
const compound = compoundOf.has(candidate)
|
|
827
|
-
? compoundOf.get(candidate)
|
|
828
|
-
: compoundHit(queryWords, candidate);
|
|
829
|
-
if ((compound?.score ?? 0) >= STRONG_TOKEN_SCORE) {
|
|
830
|
-
return 1;
|
|
831
|
-
}
|
|
832
|
-
const fuzzy = tokens.length <= 1;
|
|
833
|
-
return words.some(
|
|
834
|
-
w =>
|
|
835
|
-
(bestForToken(w, candidate, {fuzzy})?.score ?? 0) >= STRONG_TOKEN_SCORE,
|
|
836
|
-
)
|
|
837
|
-
? 1
|
|
838
|
-
: 0;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
/**
|
|
842
|
-
* The words of a title or query, lowercased, without punctuation or code ticks.
|
|
843
|
-
* @param {string} text
|
|
844
|
-
* @returns {string[]}
|
|
845
|
-
*/
|
|
846
|
-
function phraseWords(text) {
|
|
847
|
-
return (
|
|
848
|
-
unlinkText(text)
|
|
849
|
-
.toLowerCase()
|
|
850
|
-
.match(/[a-z0-9]+/g) ?? []
|
|
851
|
-
);
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
/**
|
|
855
|
-
* Whether two words are the same word, allowing a plural on either side, so
|
|
856
|
-
* `data attributes selector` still reads "Data attribute selectors".
|
|
857
|
-
* @param {string} a
|
|
858
|
-
* @param {string} b
|
|
859
|
-
*/
|
|
860
|
-
function samePhraseWord(a, b) {
|
|
861
|
-
return (
|
|
862
|
-
a === b ||
|
|
863
|
-
`${a}s` === b ||
|
|
864
|
-
`${b}s` === a ||
|
|
865
|
-
`${a}es` === b ||
|
|
866
|
-
`${b}es` === a
|
|
867
|
-
);
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
/**
|
|
871
|
-
* Whether two phrases are the same words, in order, ignoring case,
|
|
872
|
-
* punctuation, and a plural on any word: `side panel` is "Side panels", and
|
|
873
|
-
* `headers and footers` is `header-and-footer`.
|
|
874
|
-
* @param {string} a
|
|
875
|
-
* @param {string} b
|
|
876
|
-
* @returns {boolean}
|
|
877
|
-
*/
|
|
878
|
-
function samePhrase(a, b) {
|
|
879
|
-
const x = phraseWords(a);
|
|
880
|
-
const y = phraseWords(b);
|
|
881
|
-
return (
|
|
882
|
-
x.length > 0 &&
|
|
883
|
-
x.length === y.length &&
|
|
884
|
-
x.every((w, k) => samePhraseWord(w, y[k]) || sameWord(w, y[k]))
|
|
885
|
-
);
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
/**
|
|
889
|
-
* Whether `plural` is the plural of `word`: `integrations` of `integration`,
|
|
890
|
-
* `boxes` of `box`. `es` only follows s, x, z, ch, or sh, so `notes` is not a
|
|
891
|
-
* plural of `not`.
|
|
892
|
-
* @param {string} plural
|
|
893
|
-
* @param {string} word
|
|
894
|
-
*/
|
|
895
|
-
function pluralOf(plural, word) {
|
|
896
|
-
if (word.length < 3) return false;
|
|
897
|
-
if (plural === `${word}s`) return true;
|
|
898
|
-
return /(?:s|x|z|ch|sh)$/.test(word) && plural === `${word}es`;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
/**
|
|
902
|
-
* The first title or heading that holds every word of the query, in order and
|
|
903
|
-
* side by side, or null.
|
|
904
|
-
* @param {string} term - Lowercased full query.
|
|
905
|
-
* @param {string[] | undefined} titles
|
|
906
|
-
* @returns {string | null}
|
|
907
|
-
*/
|
|
908
|
-
export function headingWithPhrase(term, titles) {
|
|
909
|
-
const query = phraseWords(term);
|
|
910
|
-
if (query.length < 2 || !titles) return null;
|
|
911
|
-
for (const title of titles) {
|
|
912
|
-
const words = phraseWords(String(title ?? ''));
|
|
913
|
-
for (let i = 0; i + query.length <= words.length; i++) {
|
|
914
|
-
if (query.every((word, j) => samePhraseWord(words[i + j], word)))
|
|
915
|
-
return title;
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
return null;
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
/**
|
|
922
|
-
* The first title or heading of two words or more that the query holds whole,
|
|
923
|
-
* in order and side by side, or null. A question such as "how do I add dark
|
|
924
|
-
* mode" names the "Dark mode" section outright, around words no title has.
|
|
925
|
-
* @param {string} term - Lowercased full query.
|
|
926
|
-
* @param {string[] | undefined} titles
|
|
927
|
-
* @returns {string | null}
|
|
928
|
-
*/
|
|
929
|
-
export function titleInQuery(term, titles) {
|
|
930
|
-
const query = phraseWords(term);
|
|
931
|
-
if (!titles) return null;
|
|
932
|
-
for (const title of titles) {
|
|
933
|
-
const words = phraseWords(String(title ?? ''));
|
|
934
|
-
if (words.length < 2 || words.length > query.length) continue;
|
|
935
|
-
for (let i = 0; i + words.length <= query.length; i++) {
|
|
936
|
-
if (words.every((word, j) => samePhraseWord(query[i + j], word)))
|
|
937
|
-
return title;
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
return null;
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
/**
|
|
944
|
-
* @param {string} term - Lowercased full query.
|
|
945
|
-
* @param {string[]} tokens - Content tokens from tokenizeQuery(term).
|
|
946
|
-
* @param {Candidate} candidate
|
|
947
|
-
* @param {string[]} [words] - The query's words for compound names (see
|
|
948
|
-
* compoundHit); empty when the query names an entry outright.
|
|
949
|
-
* @param {{plain?: boolean}} [opts] - `plain`: score without matches on part
|
|
950
|
-
* of a name or a joined keyword.
|
|
951
|
-
* @returns {{score: number, reason: string, matched: number, total: number, alias?: 'partial' | 'keyword', aliasWords?: number} | null}
|
|
952
|
-
* `matched`/`total` are the query concepts this candidate answered, out of
|
|
953
|
-
* the concepts the query had. Callers that must distinguish "matched one word
|
|
954
|
-
* of three" from "matched all three" — `build`, which gates its pages group
|
|
955
|
-
* on coverage — cannot recover that from the score, because a single strong
|
|
956
|
-
* hit and a broad weak one land on the same number.
|
|
957
|
-
*/
|
|
958
|
-
export function scoreQuery(
|
|
959
|
-
term,
|
|
960
|
-
tokens,
|
|
961
|
-
candidate,
|
|
962
|
-
words = phraseWords(term),
|
|
963
|
-
{plain = false} = {},
|
|
964
|
-
) {
|
|
965
|
-
const total = Math.max(tokens.length, 1);
|
|
966
|
-
// A whole-phrase hit answered the whole query by definition.
|
|
967
|
-
const asFull = (/** @type {{score: number, reason: string}} */ hit) => ({
|
|
968
|
-
...hit,
|
|
969
|
-
matched: total,
|
|
970
|
-
total,
|
|
971
|
-
});
|
|
972
|
-
// Typo tolerance is for one-word lookups. In a multi-word query a near miss
|
|
973
|
-
// is usually a different word, not a typo. One typed identifier is one
|
|
974
|
-
// word, whatever words it spells.
|
|
975
|
-
const fuzzy = tokens.length <= 1 || !/\s/.test(term);
|
|
976
|
-
const full = scoreCandidate(term, candidate, {
|
|
977
|
-
fuzzy,
|
|
978
|
-
joins: !plain && words.length > 0,
|
|
979
|
-
});
|
|
980
|
-
// A query of several words keeps its phrase tiers below even when stopwords
|
|
981
|
-
// leave one content word: "make an integration" is still the phrase an
|
|
982
|
-
// author declares as a keyword, and "build an integration" still names a
|
|
983
|
-
// title outright, though each tokenizes to `integration` alone.
|
|
984
|
-
const phrase = phraseWords(term).length >= 2 || words.length >= 2;
|
|
985
|
-
let compound = compoundHit(words, candidate);
|
|
986
|
-
if (plain) {
|
|
987
|
-
if (compound?.alias) compound = null;
|
|
988
|
-
} else {
|
|
989
|
-
compoundOf.set(candidate, compound);
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
/** 0–1 content tokens: whole-phrase fuzzy matching (typo tolerance for
|
|
993
|
-
* single words), but if stopwords left exactly one DIFFERENT token (e.g.
|
|
994
|
-
* "pricing page" → "pricing"), score that token too and take the stronger. */
|
|
995
|
-
const fewTokens = () => {
|
|
996
|
-
const single =
|
|
997
|
-
tokens.length === 1 ? bestForToken(tokens[0], candidate, {fuzzy}) : null;
|
|
998
|
-
if (full && (!single || full.score >= single.score)) return asFull(full);
|
|
999
|
-
return single ? asFull(single) : null;
|
|
1000
|
-
};
|
|
1001
|
-
if (tokens.length <= 1 && !phrase) return fewTokens();
|
|
1002
|
-
|
|
1003
|
-
// The full (untokenized) query matching a candidate's name or a declared
|
|
1004
|
-
// keyword VERBATIM — full.score 90 or 100, the only two scoreCandidate
|
|
1005
|
-
// outcomes at or above that mark — is a deliberate, explicit label the
|
|
1006
|
-
// author chose for exactly this multi-word concept. Promote it to a
|
|
1007
|
-
// reserved top tier, safely above the token-sum path's ceiling below
|
|
1008
|
-
// (~151: 100 avg + 36 bonus + 15 coverage), so it always outranks a
|
|
1009
|
-
// candidate that merely happens to contain several of the query's
|
|
1010
|
-
// individual words. Without this, "table of contents" never surfaces
|
|
1011
|
-
// Outline (which declares that exact phrase as a keyword) because dozens
|
|
1012
|
-
// of Table-related templates each match "table" and "contents" separately
|
|
1013
|
-
// and accumulate a higher raw score (#5239).
|
|
1014
|
-
if (full && full.score >= 90) {
|
|
1015
|
-
return asFull({
|
|
1016
|
-
score: full.score + 100,
|
|
1017
|
-
reason: full.reason,
|
|
1018
|
-
...(full.alias ? {alias: full.alias, aliasWords: words.length} : {}),
|
|
1019
|
-
});
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
// The whole query standing as a phrase in a doc's title or one of its
|
|
1023
|
-
// headings is the next tier down, and still above the token-sum path. The
|
|
1024
|
-
// reader named what the section is about, in order: `dark mode` is the
|
|
1025
|
-
// "Light/Dark Mode" section. Without this, the title scores a keyword
|
|
1026
|
-
// substring (70) and loses to a doc that happens to name `mode` exactly in
|
|
1027
|
-
// a code tick (90 on one token, 98 with coverage), so API enum docs outrank
|
|
1028
|
-
// the guide section.
|
|
1029
|
-
const heading = headingWithPhrase(term, candidate.titles);
|
|
1030
|
-
if (heading != null) {
|
|
1031
|
-
return asFull({
|
|
1032
|
-
score: TITLE_PHRASE_SCORE,
|
|
1033
|
-
reason: `title "${heading}" holds the whole query`,
|
|
1034
|
-
});
|
|
1035
|
-
}
|
|
1036
|
-
if (tokens.length <= 1) return fewTokens();
|
|
1037
|
-
|
|
1038
|
-
// Multi-word natural language: score each content token, counting only
|
|
1039
|
-
// strong hits, then reward coverage so candidates matching more terms win.
|
|
1040
|
-
let strongest = 0;
|
|
1041
|
-
let matched = 0;
|
|
1042
|
-
let tokenSum = 0;
|
|
1043
|
-
/** @type {string[]} */
|
|
1044
|
-
const hitTerms = [];
|
|
1045
|
-
// Adjacent words that join into a name or keyword answer each word they
|
|
1046
|
-
// join, as strongly as the joined word matches (see compoundHit).
|
|
1047
|
-
// Whether a counted word matched only through part of a name or a keyword
|
|
1048
|
-
// read joined, not through the entry's own name or a word of it.
|
|
1049
|
-
/** @type {'partial' | 'keyword' | false} */
|
|
1050
|
-
let alias = false;
|
|
1051
|
-
let aliasWords = 0;
|
|
1052
|
-
for (const tok of tokens) {
|
|
1053
|
-
let h = bestForToken(tok, candidate, {fuzzy});
|
|
1054
|
-
if (
|
|
1055
|
-
compound?.covered.has(joined(tok)) &&
|
|
1056
|
-
compound.score > (h?.score ?? 0)
|
|
1057
|
-
) {
|
|
1058
|
-
h = compound;
|
|
1059
|
-
if (compound.alias && compound.score >= MIN_TOKEN_SCORE) {
|
|
1060
|
-
alias = alias === 'partial' ? alias : compound.alias;
|
|
1061
|
-
aliasWords = compound.aliasWords ?? 0;
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
if (h && h.score >= MIN_TOKEN_SCORE) {
|
|
1065
|
-
if (h.score > strongest) strongest = h.score;
|
|
1066
|
-
matched++;
|
|
1067
|
-
hitTerms.push(tok);
|
|
1068
|
-
tokenSum += h.score;
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
// The reverse of the title tier, a step lower: the query holds a whole title
|
|
1072
|
-
// of two words or more, so the reader asked a question around the section's
|
|
1073
|
-
// name ("how do I add dark mode"). Coverage breaks ties between sections
|
|
1074
|
-
// that share a title such as "Best Practices".
|
|
1075
|
-
const named = titleInQuery(term, candidate.titles);
|
|
1076
|
-
if (named != null) {
|
|
1077
|
-
return {
|
|
1078
|
-
score: TITLE_IN_QUERY_SCORE + Math.min(matched, 9),
|
|
1079
|
-
reason: `the query names the title "${named}"`,
|
|
1080
|
-
matched,
|
|
1081
|
-
total,
|
|
1082
|
-
};
|
|
1083
|
-
}
|
|
1084
|
-
if (matched === 0) return full ? asFull(full) : null;
|
|
1085
|
-
|
|
1086
|
-
const reason = `matches ${matched}/${tokens.length} terms: ${hitTerms.join(', ')}`;
|
|
1087
|
-
|
|
1088
|
-
// Every word matched is its own tier. Summed per word, a doc that matches
|
|
1089
|
-
// both words of `troubleshoot integration` in its title and description
|
|
1090
|
-
// (50 + bonus + coverage = 77) lost to thirty docs that each match
|
|
1091
|
-
// `integration` alone, by name or in a code tick (98-108). The reader asked for
|
|
1092
|
-
// both; a candidate that has both comes first, ordered among its peers by
|
|
1093
|
-
// how strong its matches are. It needs one keyword-strength hit:
|
|
1094
|
-
// every word mentioned in prose, or rendered by a page, is breadth, and
|
|
1095
|
-
// stays on the token sum below an exact hit on one word.
|
|
1096
|
-
//
|
|
1097
|
-
// The TOTAL quality of matches orders candidates within this tier, not
|
|
1098
|
-
// just the strongest single hit. A doc matching both words by keyword
|
|
1099
|
-
// (90 + 90 = 180) outranks one matching keyword + prose (90 + 50 = 140).
|
|
1100
|
-
// Before this, every all-word match whose strongest hit was a keyword (90)
|
|
1101
|
-
// scored 157, burying the better match among dozens of ties.
|
|
1102
|
-
if (matched === tokens.length && strongest >= STRONG_TOKEN_SCORE) {
|
|
1103
|
-
return {
|
|
1104
|
-
score:
|
|
1105
|
-
FULL_COVERAGE_SCORE +
|
|
1106
|
-
Math.min(
|
|
1107
|
-
Math.floor((tokenSum - matched * MIN_TOKEN_SCORE) / (matched * 5)),
|
|
1108
|
-
8,
|
|
1109
|
-
),
|
|
1110
|
-
reason,
|
|
1111
|
-
matched,
|
|
1112
|
-
total,
|
|
1113
|
-
...(alias ? {alias, aliasWords} : {}),
|
|
1114
|
-
};
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
// Base the score on the STRONGEST concept that matched, plus a bonus per
|
|
1118
|
-
// additional matched concept and a coverage term.
|
|
1119
|
-
//
|
|
1120
|
-
// Deliberately not the mean, and deliberately not divided by total query
|
|
1121
|
-
// length. Dividing by total length penalizes verbose / low-fidelity prompts.
|
|
1122
|
-
// Dividing by the number of MATCHED tokens (what this used to do) made the
|
|
1123
|
-
// score non-monotonic: a second, weaker hit could drag the mean down by more
|
|
1124
|
-
// than the coverage bonus added it back, so matching fewer terms well beat
|
|
1125
|
-
// matching more terms partially. Concretely, `build "file browser"` scored
|
|
1126
|
-
// two form wizards matching only "file" at 98, above the actual file browser
|
|
1127
|
-
// matching both terms at 97 — and 98 clears the PAGE_DIRECT gate, so the
|
|
1128
|
-
// wrong template was returned as a confident match.
|
|
1129
|
-
//
|
|
1130
|
-
// Taking the max keeps both properties: a verbose prompt is still scored on
|
|
1131
|
-
// the concepts it did hit, and matching a superset of another candidate's
|
|
1132
|
-
// terms can never score lower, since every term of the expression is
|
|
1133
|
-
// non-decreasing in the set of matched tokens.
|
|
1134
|
-
const coverage = matched / tokens.length;
|
|
1135
|
-
const tokenScore = Math.round(
|
|
1136
|
-
strongest + Math.min(matched - 1, 3) * 12 + coverage * 15,
|
|
1137
|
-
);
|
|
1138
|
-
if (full && full.score >= tokenScore) return asFull(full);
|
|
1139
|
-
return {
|
|
1140
|
-
score: tokenScore,
|
|
1141
|
-
reason,
|
|
1142
|
-
matched,
|
|
1143
|
-
total,
|
|
1144
|
-
...(alias ? {alias, aliasWords} : {}),
|
|
1145
|
-
};
|
|
1146
|
-
}
|
|
1147
|
-
|
|
1148
|
-
/**
|
|
1149
|
-
* Score a single candidate against the search term across name, keywords,
|
|
1150
|
-
* and prose signals. Returns the best (highest) score plus a human reason,
|
|
1151
|
-
* or null if nothing matched above the floor.
|
|
1152
|
-
*
|
|
1153
|
-
* @param {string} term - Lowercased search term.
|
|
1154
|
-
* @param {object} candidate
|
|
1155
|
-
* @param {string} candidate.name - Primary identifier (component/hook name, topic, template name).
|
|
1156
|
-
* @param {string[]} [candidate.aliases] - Other names, scored like the name (a theme's display name).
|
|
1157
|
-
* @param {string} [candidate.domain] - A component, hook, or template name
|
|
1158
|
-
* also matches typed as words: `command palette` is CommandPalette.
|
|
1159
|
-
* @param {string[]} [candidate.keywords] - Authored intent (componentsUsed, category words).
|
|
1160
|
-
* @param {string[]} [candidate.weakKeywords] - Derived signal (components a page renders).
|
|
1161
|
-
* @param {string} [candidate.description]
|
|
1162
|
-
* @param {string[]} [candidate.prose] - Extra free-text blobs (doc section text, best practices).
|
|
1163
|
-
* @param {string[]} [candidate.guidance] - Usage guidance (features, best practices) — scored a tier below description.
|
|
1164
|
-
* @param {{fuzzy?: boolean, joins?: boolean}} [opts] - `fuzzy`: allow edit-distance (typo) matches. Default true; multi-word queries pass false. `joins`: read a spaced query joined into a name or keyword (default true); off when the query names an entry outright.
|
|
1165
|
-
* @returns {{score: number, reason: string, alias?: 'partial' | 'keyword'} | null}
|
|
1166
|
-
* `alias` marks a hit that read the query's words joined.
|
|
1167
|
-
*/
|
|
1168
|
-
export function scoreCandidate(
|
|
1169
|
-
term,
|
|
1170
|
-
{
|
|
1171
|
-
name,
|
|
1172
|
-
aliases = [],
|
|
1173
|
-
domain,
|
|
1174
|
-
keywords = [],
|
|
1175
|
-
weakKeywords = [],
|
|
1176
|
-
description = '',
|
|
1177
|
-
prose = [],
|
|
1178
|
-
guidance = [],
|
|
1179
|
-
},
|
|
1180
|
-
{fuzzy = true, joins = true} = {},
|
|
1181
|
-
) {
|
|
1182
|
-
let best = 0;
|
|
1183
|
-
let reason = '';
|
|
1184
|
-
/** @type {'partial' | 'keyword' | false} */
|
|
1185
|
-
let alias = false;
|
|
1186
|
-
/**
|
|
1187
|
-
* @param {number} score
|
|
1188
|
-
* @param {string} why
|
|
1189
|
-
* @param {'partial' | 'keyword' | false} [byAlias] - the hit reads the
|
|
1190
|
-
* query's words joined, into part of a name or into a keyword
|
|
1191
|
-
*/
|
|
1192
|
-
const consider = (score, why, byAlias = false) => {
|
|
1193
|
-
if (score > best) {
|
|
1194
|
-
best = score;
|
|
1195
|
-
reason = why;
|
|
1196
|
-
alias = byAlias;
|
|
1197
|
-
}
|
|
1198
|
-
};
|
|
1199
|
-
|
|
1200
|
-
// ── Name signals ────────────────────────────────────────────────
|
|
1201
|
-
// An alias is a name too: a theme answers to its display name as well as
|
|
1202
|
-
// its slug.
|
|
1203
|
-
for (const candidateName of [name, ...aliases.filter(Boolean)]) {
|
|
1204
|
-
const nameLower = candidateName.toLowerCase();
|
|
1205
|
-
// A placed guide's name is its route, and the route's last segment is its
|
|
1206
|
-
// name too, as a flat topic's is: `codemods` is cli/integrations/codemods.
|
|
1207
|
-
const leafLower = nameLower.slice(nameLower.lastIndexOf('/') + 1);
|
|
1208
|
-
|
|
1209
|
-
// A plural of the name is the name: `integration` is the `integrations`
|
|
1210
|
-
// guides, `tab` the `tabs` doc.
|
|
1211
|
-
// A component, hook, or template name typed as words is its name:
|
|
1212
|
-
// `command palette` is CommandPalette. A doc's name is a route or key,
|
|
1213
|
-
// matched as written.
|
|
1214
|
-
const spelled =
|
|
1215
|
-
domain !== 'doc' &&
|
|
1216
|
-
!/[\s_-]/.test(nameLower) &&
|
|
1217
|
-
nameLower === term.replace(/\s+/g, '');
|
|
1218
|
-
if (nameLower === term || leafLower === term || spelled) {
|
|
1219
|
-
consider(100, 'exact name');
|
|
1220
|
-
} else if (
|
|
1221
|
-
joins &&
|
|
1222
|
-
(domain === 'component' || domain === 'hook') &&
|
|
1223
|
-
/\s/.test(term) &&
|
|
1224
|
-
nameLower.startsWith(term.replace(/\s+/g, '')) &&
|
|
1225
|
-
term.replace(/\s+/g, '').length / nameLower.length >= 0.75
|
|
1226
|
-
) {
|
|
1227
|
-
// Most of the name typed as words, cut short: `complex select` is
|
|
1228
|
-
// ComplexSelector, ahead of a one-word keyword elsewhere.
|
|
1229
|
-
consider(95, `name "${candidateName}" spelled`, 'partial');
|
|
1230
|
-
} else if (pluralOf(nameLower, term) || pluralOf(term, nameLower)) {
|
|
1231
|
-
// One point under the exact spelling, so the doc named `tokens` still
|
|
1232
|
-
// outranks the Token component for `tokens`.
|
|
1233
|
-
consider(99, 'plural of the name');
|
|
1234
|
-
} else {
|
|
1235
|
-
if (sameWord(term, nameLower)) consider(95, `name "${candidateName}"`);
|
|
1236
|
-
// The term is a word of the name, or starts one: "input" in TextInput.
|
|
1237
|
-
else if (startsAWordOf(term, candidateName)) {
|
|
1238
|
-
consider(60, `name contains "${term}"`);
|
|
1239
|
-
}
|
|
1240
|
-
if (fuzzy) {
|
|
1241
|
-
const dist = levenshteinDistance(term, nameLower);
|
|
1242
|
-
if (isTypo(term, nameLower, dist)) {
|
|
1243
|
-
consider(
|
|
1244
|
-
dist === 1 ? 80 : dist === 2 ? 40 : 20,
|
|
1245
|
-
`similar name (distance ${dist})`,
|
|
1246
|
-
);
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
// ── Keyword signals ─────────────────────────────────────────────
|
|
1253
|
-
const termJoined = joined(term);
|
|
1254
|
-
for (const kw of keywords) {
|
|
1255
|
-
const kwLower = String(kw).toLowerCase();
|
|
1256
|
-
// A one-word keyword typed as separate words is the keyword: `number
|
|
1257
|
-
// field` is `numberfield`. Only a
|
|
1258
|
-
// lowercase keyword, one an author declares for a reader to type: a
|
|
1259
|
-
// capitalized one names another entry (a block's `componentsUsed`). A
|
|
1260
|
-
// doc's keywords include code terms, matched as written.
|
|
1261
|
-
if (kwLower === term) {
|
|
1262
|
-
consider(90, `keyword "${kw}"`);
|
|
1263
|
-
continue;
|
|
1264
|
-
}
|
|
1265
|
-
if (
|
|
1266
|
-
joins &&
|
|
1267
|
-
domain !== 'doc' &&
|
|
1268
|
-
/\s/.test(term) &&
|
|
1269
|
-
String(kw) === termJoined
|
|
1270
|
-
) {
|
|
1271
|
-
consider(90, `keyword "${kw}"`, 'keyword');
|
|
1272
|
-
continue;
|
|
1273
|
-
}
|
|
1274
|
-
if (sameWord(term, kwLower)) {
|
|
1275
|
-
consider(88, `keyword "${kw}"`);
|
|
1276
|
-
continue;
|
|
1277
|
-
}
|
|
1278
|
-
if (startsAWordOf(term, kw)) consider(70, `keyword "${kw}"`);
|
|
1279
|
-
if (fuzzy) {
|
|
1280
|
-
const dist = levenshteinDistance(term, kwLower);
|
|
1281
|
-
if (isTypo(term, kwLower, dist) && dist <= 2) {
|
|
1282
|
-
consider(dist === 1 ? 70 : 30, `keyword "${kw}" (distance ${dist})`);
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
// ── Weak keyword signals (derived, not authored) ─────────────────
|
|
1288
|
-
// Capped at 60 so one incidental component match cannot clear the
|
|
1289
|
-
// confident-match gate on its own; it still counts toward multi-term
|
|
1290
|
-
// coverage, which is where "this page renders that" is real evidence.
|
|
1291
|
-
// No Levenshtein tier — fuzzy matching a derived signal is pure noise.
|
|
1292
|
-
for (const kw of weakKeywords) {
|
|
1293
|
-
const kwLower = String(kw).toLowerCase();
|
|
1294
|
-
if (kwLower === term || sameWord(term, kwLower)) {
|
|
1295
|
-
consider(60, `renders ${kw}`);
|
|
1296
|
-
continue;
|
|
1297
|
-
}
|
|
1298
|
-
if (startsAWordOf(term, kw)) consider(40, `renders ${kw}`);
|
|
1299
|
-
}
|
|
1300
|
-
|
|
1301
|
-
// ── Prose / description / guidance signals (stem-tolerant whole word) ──
|
|
1302
|
-
// Match the term's stem as a whole word, tolerating plural/gerund suffixes
|
|
1303
|
-
// so "chart" matches "charts" and "filter" matches "filtering".
|
|
1304
|
-
if (term.length >= 3) {
|
|
1305
|
-
// Any form of the term, with any common suffix: `spacing` finds
|
|
1306
|
-
// "space" and "spaces", `tables` finds "table".
|
|
1307
|
-
const roots = [...new Set([stem(term), ...wordForms(term)])]
|
|
1308
|
-
.filter(f => f.length >= 3)
|
|
1309
|
-
.map(f => f.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
|
1310
|
-
const re = new RegExp(`\\b(?:${roots.join('|')})(s|es|ing|ed|ies)?\\b`);
|
|
1311
|
-
if (description && re.test(description.toLowerCase())) {
|
|
1312
|
-
consider(50, `description mentions "${term}"`);
|
|
1313
|
-
} else {
|
|
1314
|
-
let matchedProse = false;
|
|
1315
|
-
for (const blob of prose) {
|
|
1316
|
-
if (blob && re.test(String(blob).toLowerCase())) {
|
|
1317
|
-
consider(50, `docs mention "${term}"`);
|
|
1318
|
-
matchedProse = true;
|
|
1319
|
-
break;
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
// A tier below prose: guidance is what a component says about USING it,
|
|
1323
|
-
// so the term appearing there is weaker evidence than the component's own
|
|
1324
|
-
// summary. Only consulted when nothing stronger matched.
|
|
1325
|
-
if (!matchedProse) {
|
|
1326
|
-
for (const blob of guidance) {
|
|
1327
|
-
if (blob && re.test(String(blob).toLowerCase())) {
|
|
1328
|
-
consider(45, `guidance mentions "${term}"`);
|
|
1329
|
-
break;
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
return best > 0 ? {score: best, reason, ...(alias ? {alias} : {})} : null;
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
/**
|
|
1340
|
-
* A component or hook doc, compiled, or null when it cannot be read.
|
|
1341
|
-
* @param {string} docPath
|
|
1342
|
-
* @param {string} [exportName]
|
|
1343
|
-
* @param {'components' | 'hooks'} [root]
|
|
1344
|
-
* @returns {Promise<any>}
|
|
1345
|
-
*/
|
|
1346
|
-
async function loadModuleDoc(
|
|
1347
|
-
docPath,
|
|
1348
|
-
exportName = 'docs',
|
|
1349
|
-
root = 'components',
|
|
1350
|
-
) {
|
|
1351
|
-
try {
|
|
1352
|
-
// Support both the stamped default export and the legacy named export.
|
|
1353
|
-
return (
|
|
1354
|
-
(await readDocView(docPath, {
|
|
1355
|
-
root,
|
|
1356
|
-
loader: 'native',
|
|
1357
|
-
exports: ['default', exportName],
|
|
1358
|
-
})) ?? null
|
|
1359
|
-
);
|
|
1360
|
-
} catch {
|
|
1361
|
-
return null;
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
/**
|
|
1366
|
-
* Build component candidates from core's own tree: name + keywords +
|
|
1367
|
-
* usage/description from the component's .doc.mjs.
|
|
1368
|
-
* @param {string} coreDir
|
|
1369
|
-
* @returns {Promise<Candidate[]>}
|
|
1370
|
-
*/
|
|
1371
|
-
/**
|
|
1372
|
-
* Usage guidance from a component doc: its feature list and its best-practice
|
|
1373
|
-
* advice, flattened to plain strings.
|
|
1374
|
-
*
|
|
1375
|
-
* This is where a reader's vocabulary usually lives. `Banner` calls itself "a
|
|
1376
|
-
* persistent message" and only its guidance names "form errors, system
|
|
1377
|
-
* updates, maintenance notices" — so a search for the words people actually
|
|
1378
|
-
* type finds nothing without it.
|
|
1379
|
-
*
|
|
1380
|
-
* @param {any} doc
|
|
1381
|
-
* @returns {string[]}
|
|
1382
|
-
*/
|
|
1383
|
-
function guidanceFrom(doc) {
|
|
1384
|
-
if (!doc) return [];
|
|
1385
|
-
const features = Array.isArray(doc.features) ? doc.features : [];
|
|
1386
|
-
const practices = Array.isArray(doc.usage?.bestPractices)
|
|
1387
|
-
? doc.usage.bestPractices
|
|
1388
|
-
: [];
|
|
1389
|
-
return [...features, ...practices]
|
|
1390
|
-
.map(entry =>
|
|
1391
|
-
typeof entry === 'string'
|
|
1392
|
-
? entry
|
|
1393
|
-
: [
|
|
1394
|
-
entry?.title,
|
|
1395
|
-
entry?.text,
|
|
1396
|
-
entry?.description,
|
|
1397
|
-
entry?.do,
|
|
1398
|
-
entry?.dont,
|
|
1399
|
-
]
|
|
1400
|
-
.filter(Boolean)
|
|
1401
|
-
.join(' '),
|
|
1402
|
-
)
|
|
1403
|
-
.filter(Boolean);
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
/**
|
|
1407
|
-
* Build component candidates from core's own tree: name + keywords +
|
|
1408
|
-
* usage/description from the component's .doc.mjs.
|
|
1409
|
-
* @param {string} coreDir
|
|
1410
|
-
* @returns {Promise<Candidate[]>}
|
|
1411
|
-
*/
|
|
1412
|
-
async function gatherCoreComponents(coreDir) {
|
|
1413
|
-
const grouped = discoverComponents(coreDir);
|
|
1414
|
-
const names = Object.values(grouped).flat();
|
|
1415
|
-
/** @type {Candidate[]} */
|
|
1416
|
-
const candidates = [];
|
|
1417
|
-
for (const comp of names) {
|
|
1418
|
-
const readme = findComponentReadme(coreDir, comp);
|
|
1419
|
-
/** @type {string[]} */
|
|
1420
|
-
let keywords = [];
|
|
1421
|
-
let description = '';
|
|
1422
|
-
/** @type {string[]} */
|
|
1423
|
-
let guidance = [];
|
|
1424
|
-
if (readme && readme.endsWith('.doc.mjs')) {
|
|
1425
|
-
const loaded = await loadModuleDoc(readme);
|
|
1426
|
-
// A member documented in its family's doc (VStack in Stack's doc) is
|
|
1427
|
-
// indexed from its own entry: the family's keywords and guidance
|
|
1428
|
-
// describe the family, not the member.
|
|
1429
|
-
const doc = familyMember(loaded, comp) ?? loaded;
|
|
1430
|
-
if (doc) {
|
|
1431
|
-
keywords = Array.isArray(doc.keywords) ? doc.keywords : [];
|
|
1432
|
-
description = doc.usage?.description || doc.description || '';
|
|
1433
|
-
guidance = guidanceFrom(doc);
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
candidates.push({
|
|
1437
|
-
domain: 'component',
|
|
1438
|
-
name: comp,
|
|
1439
|
-
keywords,
|
|
1440
|
-
description,
|
|
1441
|
-
guidance,
|
|
1442
|
-
_package: CORE_PACKAGE,
|
|
1443
|
-
_import: resolveImportPath(coreDir, comp),
|
|
1444
|
-
});
|
|
1445
|
-
}
|
|
1446
|
-
return candidates;
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
/**
|
|
1450
|
-
* Build component candidates contributed by the project's configured
|
|
1451
|
-
* integrations (astryx.config's `integrations`): name + keywords +
|
|
1452
|
-
* usage/description from each component's .doc.mjs, same as core. Without
|
|
1453
|
-
* this, an integration component is invisible to `search`/`build` even
|
|
1454
|
-
* though `component --list`/`component <Name>` already resolve it — the two
|
|
1455
|
-
* discovery paths silently disagreed. Also returns, per name, the docs that
|
|
1456
|
-
* hold it by their file name and the loadable docs that only record it.
|
|
1457
|
-
* @param {import('../../foundation/integrations/integrations.mjs').LoadedIntegration[]} loadedIntegrations
|
|
1458
|
-
* @returns {Promise<{candidates: Candidate[], fileNamed: Map<string, Set<string>>, documented: Map<string, Set<string>>}>}
|
|
1459
|
-
*/
|
|
1460
|
-
async function gatherIntegrationComponents(loadedIntegrations) {
|
|
1461
|
-
/** @type {Candidate[]} */
|
|
1462
|
-
const candidates = [];
|
|
1463
|
-
// What the owner rule reads: the docs that hold a name by their file name,
|
|
1464
|
-
// loadable or not and hidden ones too, and the loadable docs that only
|
|
1465
|
-
// record it.
|
|
1466
|
-
/** @type {Map<string, Set<string>>} */
|
|
1467
|
-
const fileNamed = new Map();
|
|
1468
|
-
/** @type {Map<string, Set<string>>} */
|
|
1469
|
-
const documented = new Map();
|
|
1470
|
-
/** @param {Map<string, Set<string>>} map @param {string} name @param {string} docPath */
|
|
1471
|
-
const hold = (map, name, docPath) =>
|
|
1472
|
-
map.set(name, (map.get(name) ?? new Set()).add(docPath));
|
|
1473
|
-
for (const integration of loadedIntegrations) {
|
|
1474
|
-
const {components, discovered} =
|
|
1475
|
-
await discoverValidIntegrationComponents(integration);
|
|
1476
|
-
const renamed = new Set(
|
|
1477
|
-
discovered
|
|
1478
|
-
.filter(rec => integrationRecordHold(rec) === 'doc')
|
|
1479
|
-
.filter(rec => !isIntegrationMember(rec))
|
|
1480
|
-
.map(rec => rec.docPath),
|
|
1481
|
-
);
|
|
1482
|
-
forEachIntegrationDoc(integration.components, (docPath, stem) => {
|
|
1483
|
-
if (!renamed.has(docPath)) hold(fileNamed, stem, docPath);
|
|
1484
|
-
});
|
|
1485
|
-
for (const rec of components) {
|
|
1486
|
-
const loaded = await loadModuleDoc(rec.docPath);
|
|
1487
|
-
// A family member is indexed from its own entry: the family's keywords
|
|
1488
|
-
// and guidance describe the family.
|
|
1489
|
-
const member = isIntegrationMember(rec);
|
|
1490
|
-
const doc = member ? integrationMemberEntry(loaded, rec) : loaded;
|
|
1491
|
-
// A doc that names another export of its file still answers to the
|
|
1492
|
-
// file's name, as `component <file name>` does.
|
|
1493
|
-
const fileName = docStem(rec.docPath);
|
|
1494
|
-
if (integrationRecordHold(rec) === 'doc') {
|
|
1495
|
-
hold(documented, rec.name, rec.docPath);
|
|
1496
|
-
}
|
|
1497
|
-
if (renamed.has(rec.docPath) && !member) {
|
|
1498
|
-
hold(documented, fileName, rec.docPath);
|
|
1499
|
-
}
|
|
1500
|
-
candidates.push({
|
|
1501
|
-
domain: 'component',
|
|
1502
|
-
name: rec.name,
|
|
1503
|
-
aliases: !member && fileName !== rec.name ? [fileName] : [],
|
|
1504
|
-
_hold: integrationRecordHold(rec),
|
|
1505
|
-
_docPath: rec.docPath,
|
|
1506
|
-
keywords: doc && Array.isArray(doc.keywords) ? doc.keywords : [],
|
|
1507
|
-
description: doc ? doc.usage?.description || doc.description || '' : '',
|
|
1508
|
-
guidance: guidanceFrom(doc),
|
|
1509
|
-
_package: rec.package ?? integration.name,
|
|
1510
|
-
// Exactly what `component` reports: a doc may state its own specifier
|
|
1511
|
-
// (one entry point exporting several components), and only when it
|
|
1512
|
-
// does not do we resolve the subpath against the owning package's
|
|
1513
|
-
// exports — read off the integration, which the loader already parsed.
|
|
1514
|
-
// Reporting the bare package name here handed out a path that does not
|
|
1515
|
-
// resolve, and disagreed with what `component <Name>` said about the
|
|
1516
|
-
// very same component.
|
|
1517
|
-
_import: resolveIntegrationImportPath(
|
|
1518
|
-
{
|
|
1519
|
-
exportsMap: integration.__packageExports,
|
|
1520
|
-
packageDir: integration.__packageDir,
|
|
1521
|
-
docPath: rec.docPath,
|
|
1522
|
-
packageName: rec.package,
|
|
1523
|
-
},
|
|
1524
|
-
rec.name,
|
|
1525
|
-
loaded?.import,
|
|
1526
|
-
),
|
|
1527
|
-
});
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
return {candidates, fileNamed, documented};
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
/**
|
|
1534
|
-
* Build component candidates: core's own tree plus every configured
|
|
1535
|
-
* integration's components.
|
|
1536
|
-
* @param {string} coreDir
|
|
1537
|
-
* @param {string} cwd
|
|
1538
|
-
* @returns {Promise<Candidate[]>}
|
|
1539
|
-
*/
|
|
1540
|
-
async function gatherComponents(coreDir, cwd) {
|
|
1541
|
-
const loadedIntegrations = await loadIntegrationsSafely(cwd);
|
|
1542
|
-
const [core, gathered, replacements] = await Promise.all([
|
|
1543
|
-
gatherCoreComponents(coreDir),
|
|
1544
|
-
gatherIntegrationComponents(loadedIntegrations),
|
|
1545
|
-
loadComponentReplacements(coreDir, loadedIntegrations),
|
|
1546
|
-
]);
|
|
1547
|
-
const {candidates: integrations, fileNamed, documented} = gathered;
|
|
1548
|
-
// An active replacement answers to the Core name it replaces, so a search
|
|
1549
|
-
// for that name finds the replacement and not the Core original
|
|
1550
|
-
// (spec:AST-035 FR11).
|
|
1551
|
-
/** @type {Map<string, string>} */
|
|
1552
|
-
const targetOf = new Map(
|
|
1553
|
-
replacements.active.map(active => [
|
|
1554
|
-
`${active.package}\0${active.name}`,
|
|
1555
|
-
active.target,
|
|
1556
|
-
]),
|
|
1557
|
-
);
|
|
1558
|
-
// The owner rule, as component detail applies it: a candidate `component
|
|
1559
|
-
// <name>` does not open names its package and follows the owner, and a
|
|
1560
|
-
// file-name alias that opens another owner is dropped.
|
|
1561
|
-
const coreNames = new Set(core.map(candidate => candidate.name));
|
|
1562
|
-
/** @param {string} name @param {Candidate} candidate */
|
|
1563
|
-
const opens = (name, candidate) =>
|
|
1564
|
-
!coreNames.has(name) &&
|
|
1565
|
-
findComponentReadme(coreDir, name) == null &&
|
|
1566
|
-
[...(fileNamed.get(name) ?? [])].every(doc => doc === candidate._docPath) &&
|
|
1567
|
-
[...(documented.get(name) ?? [])].every(doc => doc === candidate._docPath);
|
|
1568
|
-
// A legacy docs package keeps an exact name over an export name a doc records.
|
|
1569
|
-
/** @type {ReturnType<typeof discoverExternalPackages> | undefined} */
|
|
1570
|
-
let externals;
|
|
1571
|
-
/** @param {string} name */
|
|
1572
|
-
const legacyDocuments = name =>
|
|
1573
|
-
(externals ??= discoverExternalPackages(cwd)).some(
|
|
1574
|
-
ext => resolveLegacyExternalDoc(ext, name) != null,
|
|
1575
|
-
);
|
|
1576
|
-
const ruled = integrations.map(candidate => {
|
|
1577
|
-
const aliases = (candidate.aliases ?? []).filter(alias =>
|
|
1578
|
-
opens(alias, candidate),
|
|
1579
|
-
);
|
|
1580
|
-
return candidate._hold === 'doc' &&
|
|
1581
|
-
(!opens(candidate.name, candidate) || legacyDocuments(candidate.name))
|
|
1582
|
-
? {
|
|
1583
|
-
...candidate,
|
|
1584
|
-
aliases,
|
|
1585
|
-
_command: `astryx component ${candidate.name} --package ${candidate._package}`,
|
|
1586
|
-
}
|
|
1587
|
-
: {...candidate, aliases};
|
|
1588
|
-
});
|
|
1589
|
-
return [
|
|
1590
|
-
...core.filter(candidate => !replacements.forTarget(candidate.name)),
|
|
1591
|
-
...[
|
|
1592
|
-
...ruled.filter(candidate => !candidate._command),
|
|
1593
|
-
...ruled.filter(candidate => candidate._command),
|
|
1594
|
-
].map(candidate => {
|
|
1595
|
-
const target = targetOf.get(`${candidate._package}\0${candidate.name}`);
|
|
1596
|
-
if (target != null) {
|
|
1597
|
-
return {...candidate, aliases: [...(candidate.aliases ?? []), target]};
|
|
1598
|
-
}
|
|
1599
|
-
// Another package's component named after a replaced Core component is
|
|
1600
|
-
// shadowed for the bare name (FR14): its command names its package.
|
|
1601
|
-
const shadowedBy = replacements.forTarget(candidate.name);
|
|
1602
|
-
return shadowedBy && shadowedBy.package !== candidate._package
|
|
1603
|
-
? {
|
|
1604
|
-
...candidate,
|
|
1605
|
-
_command: `astryx component ${candidate.name} --package ${candidate._package}`,
|
|
1606
|
-
}
|
|
1607
|
-
: candidate;
|
|
1608
|
-
}),
|
|
1609
|
-
];
|
|
1610
|
-
}
|
|
1611
|
-
|
|
1612
|
-
/**
|
|
1613
|
-
* The components each search response was scored against, by response:
|
|
1614
|
-
* `build` reads them to tell a part of a page from a page without gathering
|
|
1615
|
-
* them again. Module-private, so they never enter search's JSON.
|
|
1616
|
-
* @type {WeakMap<object, {name: string, keywords: string[]}[]>}
|
|
1617
|
-
*/
|
|
1618
|
-
const searchedComponentsOf = new WeakMap();
|
|
1619
|
-
|
|
1620
|
-
/**
|
|
1621
|
-
* The components (name and keywords) a search response was scored against, or
|
|
1622
|
-
* null when that search was narrowed away from components.
|
|
1623
|
-
* @param {object} response
|
|
1624
|
-
* @returns {{name: string, keywords: string[]}[] | null}
|
|
1625
|
-
*/
|
|
1626
|
-
export function searchedComponents(response) {
|
|
1627
|
-
return searchedComponentsOf.get(response) ?? null;
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
/**
|
|
1631
|
-
* Every component the project can use, Core's and its integrations', with the
|
|
1632
|
-
* keywords its own doc declares: the discovery and doc reads search's own
|
|
1633
|
-
* candidates use, without the import paths and prose a result carries. `build`
|
|
1634
|
-
* reads it to tell a part of a page from a page when its search was narrowed
|
|
1635
|
-
* away from components.
|
|
1636
|
-
* @param {string} coreDir
|
|
1637
|
-
* @param {string} cwd
|
|
1638
|
-
* @returns {Promise<{name: string, keywords: string[]}[]>}
|
|
1639
|
-
*/
|
|
1640
|
-
export async function componentKeywords(coreDir, cwd) {
|
|
1641
|
-
/** @param {any} doc */
|
|
1642
|
-
const keywordsOf = doc => (Array.isArray(doc?.keywords) ? doc.keywords : []);
|
|
1643
|
-
const core = Object.values(discoverComponents(coreDir))
|
|
1644
|
-
.flat()
|
|
1645
|
-
.map(async name => {
|
|
1646
|
-
const readme = findComponentReadme(coreDir, name);
|
|
1647
|
-
const loaded =
|
|
1648
|
-
readme && readme.endsWith('.doc.mjs')
|
|
1649
|
-
? await loadModuleDoc(readme)
|
|
1650
|
-
: null;
|
|
1651
|
-
return {name, keywords: keywordsOf(familyMember(loaded, name) ?? loaded)};
|
|
1652
|
-
});
|
|
1653
|
-
const loadedIntegrations = await loadIntegrationsSafely(cwd);
|
|
1654
|
-
const replacements = await loadComponentReplacements(
|
|
1655
|
-
coreDir,
|
|
1656
|
-
loadedIntegrations,
|
|
1657
|
-
);
|
|
1658
|
-
const integrations = loadedIntegrations.map(async integration => {
|
|
1659
|
-
const {components} = await discoverValidIntegrationComponents(integration);
|
|
1660
|
-
return Promise.all(
|
|
1661
|
-
components.map(async rec => {
|
|
1662
|
-
const loaded = await loadModuleDoc(rec.docPath);
|
|
1663
|
-
return {
|
|
1664
|
-
name: rec.name,
|
|
1665
|
-
keywords: keywordsOf(integrationMemberEntry(loaded, rec) ?? loaded),
|
|
1666
|
-
};
|
|
1667
|
-
}),
|
|
1668
|
-
);
|
|
1669
|
-
});
|
|
1670
|
-
return [
|
|
1671
|
-
...(await Promise.all(core)).filter(
|
|
1672
|
-
component => !replacements.forTarget(component.name),
|
|
1673
|
-
),
|
|
1674
|
-
...(await Promise.all(integrations)).flat(),
|
|
1675
|
-
];
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
/**
|
|
1679
|
-
* Build hook candidates: name + keywords + usage/description from the hook's
|
|
1680
|
-
* .doc.mjs.
|
|
1681
|
-
* @param {string} coreDir
|
|
1682
|
-
* @returns {Promise<Candidate[]>}
|
|
1683
|
-
*/
|
|
1684
|
-
async function gatherHooks(coreDir) {
|
|
1685
|
-
const grouped = discoverHooks(coreDir);
|
|
1686
|
-
const names = Object.values(grouped).flat();
|
|
1687
|
-
/** @type {Candidate[]} */
|
|
1688
|
-
const candidates = [];
|
|
1689
|
-
for (const hookName of names) {
|
|
1690
|
-
const docPath = findHookDoc(coreDir, hookName);
|
|
1691
|
-
/** @type {string[]} */
|
|
1692
|
-
let keywords = [];
|
|
1693
|
-
let description = '';
|
|
1694
|
-
let importPath = '@astryxdesign/core/hooks';
|
|
1695
|
-
if (docPath) {
|
|
1696
|
-
const doc = await loadModuleDoc(docPath, 'docs', 'hooks');
|
|
1697
|
-
if (doc) {
|
|
1698
|
-
keywords = Array.isArray(doc.keywords) ? doc.keywords : [];
|
|
1699
|
-
description = doc.usage?.description || doc.description || '';
|
|
1700
|
-
importPath = doc.importPath || importPath;
|
|
1701
|
-
}
|
|
1702
|
-
}
|
|
1703
|
-
candidates.push({
|
|
1704
|
-
domain: 'hook',
|
|
1705
|
-
name: hookName,
|
|
1706
|
-
keywords,
|
|
1707
|
-
description,
|
|
1708
|
-
_package: CORE_PACKAGE,
|
|
1709
|
-
_import: importPath,
|
|
1710
|
-
});
|
|
1711
|
-
}
|
|
1712
|
-
return candidates;
|
|
1713
|
-
}
|
|
1714
|
-
|
|
1715
|
-
/**
|
|
1716
|
-
* Build doc candidates at the grain a reader reads them: each section of a
|
|
1717
|
-
* topic, whose command reads just that section; each topic as a whole, whose
|
|
1718
|
-
* command lists its sections; and each docs-tree node by its route. The tree's
|
|
1719
|
-
* guides split into sections like topics, and its typed docs also match by
|
|
1720
|
-
* their own name, so `assertResponse` finds `cli/api/functions/assert-response`.
|
|
1721
|
-
*
|
|
1722
|
-
* Reads the project's catalog rather than the CLI's own docs directory, so a
|
|
1723
|
-
* topic an integration contributed (or replaced) is searchable exactly like a
|
|
1724
|
-
* built-in one — otherwise the replacement is served by `astryx docs` but
|
|
1725
|
-
* invisible to the command whose job is finding it.
|
|
1726
|
-
* @param {string} cwd
|
|
1727
|
-
* @returns {Promise<Candidate[]>}
|
|
1728
|
-
*/
|
|
1729
|
-
async function gatherDocs(cwd) {
|
|
1730
|
-
/** @type {Candidate[]} */
|
|
1731
|
-
const candidates = [];
|
|
1732
|
-
let catalog;
|
|
1733
|
-
try {
|
|
1734
|
-
catalog = await loadDocsCatalog(cwd);
|
|
1735
|
-
} catch {
|
|
1736
|
-
return candidates;
|
|
1737
|
-
}
|
|
1738
|
-
let tree = null;
|
|
1739
|
-
try {
|
|
1740
|
-
tree = await projectTree(catalog);
|
|
1741
|
-
} catch {
|
|
1742
|
-
// `astryx doctor` reports a tree that fails to build; search still
|
|
1743
|
-
// indexes the topics.
|
|
1744
|
-
}
|
|
1745
|
-
for (const entry of catalog.entries()) {
|
|
1746
|
-
// A topic whose name opens another doc (spec:AST-046 FR11) is not
|
|
1747
|
-
// offered: every hit's command must open the hit.
|
|
1748
|
-
if (tree && !holdsOwnName(tree, catalog, entry)) continue;
|
|
1749
|
-
let lowered = null;
|
|
1750
|
-
try {
|
|
1751
|
-
lowered = await lowerTopic(catalog, entry);
|
|
1752
|
-
} catch {
|
|
1753
|
-
// A topic that cannot be loaded is reported by the commands that own
|
|
1754
|
-
// integration issues; search just cannot index it.
|
|
1755
|
-
}
|
|
1756
|
-
const doc = lowered?.doc ?? null;
|
|
1757
|
-
// A flat topic lives in the Unorganized level; its hits say so, and name
|
|
1758
|
-
// the package each section came from.
|
|
1759
|
-
const home = tree?.get(entry.name);
|
|
1760
|
-
const placed = home?.ref?.flatTopic === entry.name ? home : null;
|
|
1761
|
-
/** @type {Map<string, string>} */
|
|
1762
|
-
const packages = new Map([
|
|
1763
|
-
[entry.providerId ?? entry.package, entry.package],
|
|
1764
|
-
...entry.extensions.map(
|
|
1765
|
-
ext =>
|
|
1766
|
-
/** @type {[string, string]} */ ([
|
|
1767
|
-
ext.providerId ?? ext.package,
|
|
1768
|
-
ext.package,
|
|
1769
|
-
]),
|
|
1770
|
-
),
|
|
1771
|
-
]);
|
|
1772
|
-
candidates.push(
|
|
1773
|
-
...topicCandidates(
|
|
1774
|
-
entry.name,
|
|
1775
|
-
doc,
|
|
1776
|
-
entry.title,
|
|
1777
|
-
'',
|
|
1778
|
-
placed && tree
|
|
1779
|
-
? [
|
|
1780
|
-
...tree.ancestors(placed).map(a => a.title),
|
|
1781
|
-
doc?.title || entry.title || entry.name,
|
|
1782
|
-
].join(' › ')
|
|
1783
|
-
: undefined,
|
|
1784
|
-
placed ? `astryx docs ${placed.parent}` : undefined,
|
|
1785
|
-
entry.package,
|
|
1786
|
-
key =>
|
|
1787
|
-
packages.get(lowered?.sectionProviders?.[key] ?? '') ?? entry.package,
|
|
1788
|
-
),
|
|
1789
|
-
);
|
|
1790
|
-
}
|
|
1791
|
-
if (tree == null) return candidates;
|
|
1792
|
-
for (const node of tree.nodes.values()) {
|
|
1793
|
-
// A flat topic is indexed above, as a topic.
|
|
1794
|
-
if (node.ref?.flatTopic) continue;
|
|
1795
|
-
// A tree hit names where it lives: its ancestors' titles, then its own.
|
|
1796
|
-
const path = [...tree.ancestors(node).map(a => a.title), node.title];
|
|
1797
|
-
if (node.kind === 'generic') {
|
|
1798
|
-
let doc = null;
|
|
1799
|
-
try {
|
|
1800
|
-
doc = (await lowerTopic(catalog, guideEntry(node))).doc;
|
|
1801
|
-
} catch {
|
|
1802
|
-
// As above: the owning commands report it.
|
|
1803
|
-
}
|
|
1804
|
-
candidates.push(
|
|
1805
|
-
...topicCandidates(
|
|
1806
|
-
node.route,
|
|
1807
|
-
doc,
|
|
1808
|
-
node.title,
|
|
1809
|
-
node.summary,
|
|
1810
|
-
path.join(' › '),
|
|
1811
|
-
node.parent == null ? undefined : `astryx docs ${node.parent}`,
|
|
1812
|
-
node.provider,
|
|
1813
|
-
),
|
|
1814
|
-
);
|
|
1815
|
-
continue;
|
|
1816
|
-
}
|
|
1817
|
-
const selfDoc = /** @type {any} */ (node.ref)?.selfDoc;
|
|
1818
|
-
// A typed doc's content is what `astryx docs <route>` prints. The first
|
|
1819
|
-
// column of its tables names what the doc defines (an error code, an
|
|
1820
|
-
// option, a parameter), so each is a keyword the doc answers to.
|
|
1821
|
-
/** @type {any[]} */
|
|
1822
|
-
const content = (await nodeView(catalog, tree, node)).content ?? [];
|
|
1823
|
-
/** @type {string[]} */
|
|
1824
|
-
const defined = [];
|
|
1825
|
-
for (const block of content) {
|
|
1826
|
-
if (block.type !== 'table' || !Array.isArray(block.rows)) continue;
|
|
1827
|
-
for (const row of block.rows)
|
|
1828
|
-
if (row[0] != null) defined.push(plain(row[0]));
|
|
1829
|
-
}
|
|
1830
|
-
candidates.push({
|
|
1831
|
-
domain: 'doc',
|
|
1832
|
-
name: node.name,
|
|
1833
|
-
keywords: [
|
|
1834
|
-
node.route.slice(node.route.lastIndexOf('/') + 1),
|
|
1835
|
-
...(Array.isArray(selfDoc?.keywords) ? selfDoc.keywords : []),
|
|
1836
|
-
// A namespace doc's own keywords, which it declares for search.
|
|
1837
|
-
...(Array.isArray(node.keywords) ? node.keywords : []),
|
|
1838
|
-
...defined,
|
|
1839
|
-
...codeTerms({content}),
|
|
1840
|
-
],
|
|
1841
|
-
_declared: [
|
|
1842
|
-
...(Array.isArray(selfDoc?.keywords) ? selfDoc.keywords : []),
|
|
1843
|
-
...(Array.isArray(node.keywords) ? node.keywords : []),
|
|
1844
|
-
],
|
|
1845
|
-
description: node.summary || '',
|
|
1846
|
-
prose: sectionProse({title: node.title, content}),
|
|
1847
|
-
titles: [node.title],
|
|
1848
|
-
_topic: node.route,
|
|
1849
|
-
_title: path.join(' › '),
|
|
1850
|
-
_command: `astryx docs ${node.route}`,
|
|
1851
|
-
_parent:
|
|
1852
|
-
node.parent == null ? 'astryx docs' : `astryx docs ${node.parent}`,
|
|
1853
|
-
_package: node.provider,
|
|
1854
|
-
});
|
|
1855
|
-
}
|
|
1856
|
-
return candidates;
|
|
1857
|
-
}
|
|
1858
|
-
|
|
1859
|
-
/**
|
|
1860
|
-
* The words one section says: its prose, headings, and list items.
|
|
1861
|
-
* @param {any} section
|
|
1862
|
-
* @returns {string[]}
|
|
1863
|
-
*/
|
|
1864
|
-
function sectionProse(section) {
|
|
1865
|
-
/** @type {string[]} */
|
|
1866
|
-
const prose = [];
|
|
1867
|
-
if (section?.title) prose.push(section.title);
|
|
1868
|
-
for (const block of section?.content || []) {
|
|
1869
|
-
if ((block.type === 'prose' || block.type === 'heading') && block.text) {
|
|
1870
|
-
prose.push(block.text);
|
|
1871
|
-
} else if (block.type === 'list' && Array.isArray(block.items)) {
|
|
1872
|
-
for (const item of block.items) {
|
|
1873
|
-
const text = typeof item === 'string' ? item : item?.text;
|
|
1874
|
-
if (typeof text === 'string') prose.push(text);
|
|
1875
|
-
}
|
|
1876
|
-
} else if (block.type === 'table' && Array.isArray(block.rows)) {
|
|
1877
|
-
for (const row of block.rows) prose.push(row.map(plain).join(' '));
|
|
1878
|
-
} else if (block.type === 'code' && typeof block.code === 'string') {
|
|
1879
|
-
prose.push([block.label, block.code].filter(Boolean).join(' '));
|
|
1880
|
-
}
|
|
1881
|
-
}
|
|
1882
|
-
return prose;
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
/**
|
|
1886
|
-
* The identifiers a doc part names in code ticks (`token-ref`,
|
|
1887
|
-
* `ERR_UNKNOWN_SECTION`). Each is a keyword: a reader who types one exactly
|
|
1888
|
-
* wants the part that defines or explains it.
|
|
1889
|
-
* @param {any} part - a section, or `{content}` of a typed doc
|
|
1890
|
-
* @returns {string[]}
|
|
1891
|
-
*/
|
|
1892
|
-
function codeTerms(part) {
|
|
1893
|
-
/** @type {Set<string>} */
|
|
1894
|
-
const terms = new Set();
|
|
1895
|
-
/** @param {unknown} text */
|
|
1896
|
-
const scan = text => {
|
|
1897
|
-
for (const m of String(text ?? '').matchAll(/`([^`\s]{2,40})`/g)) {
|
|
1898
|
-
terms.add(m[1]);
|
|
1899
|
-
}
|
|
1900
|
-
};
|
|
1901
|
-
for (const block of part?.content || []) {
|
|
1902
|
-
if (block.type === 'prose') scan(block.text);
|
|
1903
|
-
else if (block.type === 'list' && Array.isArray(block.items)) {
|
|
1904
|
-
for (const item of block.items) {
|
|
1905
|
-
scan(typeof item === 'string' ? item : item?.text);
|
|
1906
|
-
}
|
|
1907
|
-
} else if (block.type === 'table' && Array.isArray(block.rows)) {
|
|
1908
|
-
for (const row of block.rows) for (const cell of row) scan(cell);
|
|
1909
|
-
}
|
|
1910
|
-
}
|
|
1911
|
-
return [...terms];
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
/**
|
|
1915
|
-
* The headings inside a section. Each names a subsection, so a query that
|
|
1916
|
-
* names one should find the section as surely as one that names its title.
|
|
1917
|
-
* @param {any} section
|
|
1918
|
-
* @returns {string[]}
|
|
1919
|
-
*/
|
|
1920
|
-
function headings(section) {
|
|
1921
|
-
return (section?.content || [])
|
|
1922
|
-
.filter(
|
|
1923
|
-
(/** @type {any} */ block) => block.type === 'heading' && block.text,
|
|
1924
|
-
)
|
|
1925
|
-
.map((/** @type {any} */ block) => String(block.text));
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
/**
|
|
1929
|
-
* A table cell as plain words, without its code ticks.
|
|
1930
|
-
* @param {unknown} cell
|
|
1931
|
-
* @returns {string}
|
|
1932
|
-
*/
|
|
1933
|
-
function plain(cell) {
|
|
1934
|
-
return unlinkText(String(cell ?? '')).replaceAll('`', '');
|
|
1935
|
-
}
|
|
1936
|
-
|
|
1937
|
-
/**
|
|
1938
|
-
* The candidates one topic yields: the topic itself, and one per section when
|
|
1939
|
-
* it has more than one. A topic's command lists its sections, and a section's
|
|
1940
|
-
* command reads only that section, so a hit never costs a whole-topic read.
|
|
1941
|
-
* @param {string} name - the topic name, or a placed guide's route
|
|
1942
|
-
* @param {any} doc - the lowered topic, or null when it did not load
|
|
1943
|
-
* @param {string} [title]
|
|
1944
|
-
* @param {string} [summary]
|
|
1945
|
-
* @param {string} [path] - where the topic lives in the docs tree, as titles
|
|
1946
|
-
* joined by ` › `; a flat topic is its own title
|
|
1947
|
-
* @param {string} [parent] - the command that opens the level above the
|
|
1948
|
-
* topic: its namespace, or the Unorganized level for a flat topic
|
|
1949
|
-
* @param {string} [pkg] - the npm package that authored the topic
|
|
1950
|
-
* @param {(key: string) => string} [sectionPackage] - the npm package a
|
|
1951
|
-
* section came from: an extension's section names the extension's package
|
|
1952
|
-
* @returns {Candidate[]}
|
|
1953
|
-
*/
|
|
1954
|
-
function topicCandidates(
|
|
1955
|
-
name,
|
|
1956
|
-
doc,
|
|
1957
|
-
title,
|
|
1958
|
-
summary = '',
|
|
1959
|
-
path,
|
|
1960
|
-
parent,
|
|
1961
|
-
pkg,
|
|
1962
|
-
sectionPackage,
|
|
1963
|
-
) {
|
|
1964
|
-
/** @type {any[]} */
|
|
1965
|
-
const sections = doc?.sections ?? [];
|
|
1966
|
-
const docTitle = path || doc?.title || title || name;
|
|
1967
|
-
const split = sections.length > 1;
|
|
1968
|
-
// A placed guide also answers to its last route segment's words:
|
|
1969
|
-
// `quick start` is cli/integrations/quick-start.
|
|
1970
|
-
const leaf = name.slice(name.lastIndexOf('/') + 1);
|
|
1971
|
-
/** @type {Candidate[]} */
|
|
1972
|
-
const out = [
|
|
1973
|
-
{
|
|
1974
|
-
domain: 'doc',
|
|
1975
|
-
name,
|
|
1976
|
-
keywords: [
|
|
1977
|
-
...(leaf !== name ? [leaf.replaceAll('-', ' ')] : []),
|
|
1978
|
-
...(doc?.title || title ? [doc?.title || title] : []),
|
|
1979
|
-
...(Array.isArray(doc?.keywords) ? doc.keywords : []),
|
|
1980
|
-
],
|
|
1981
|
-
_declared: Array.isArray(doc?.keywords) ? doc.keywords : [],
|
|
1982
|
-
description: doc?.description || summary,
|
|
1983
|
-
prose: split
|
|
1984
|
-
? sections.map(section => section.title).filter(Boolean)
|
|
1985
|
-
: sections.flatMap(sectionProse),
|
|
1986
|
-
titles: [
|
|
1987
|
-
doc?.title || title || name,
|
|
1988
|
-
// A topic read whole answers for the headings inside it.
|
|
1989
|
-
...(split ? [] : sections.flatMap(s => [s.title, ...headings(s)])),
|
|
1990
|
-
].filter(Boolean),
|
|
1991
|
-
_topic: name,
|
|
1992
|
-
_title: docTitle,
|
|
1993
|
-
_command: split ? `astryx docs ${name} --index` : `astryx docs ${name}`,
|
|
1994
|
-
...(parent ? {_parent: parent} : {}),
|
|
1995
|
-
...(pkg ? {_package: pkg} : {}),
|
|
1996
|
-
},
|
|
1997
|
-
];
|
|
1998
|
-
if (!split) return out;
|
|
1999
|
-
for (const section of sections) {
|
|
2000
|
-
const key = sectionKey(section);
|
|
2001
|
-
out.push({
|
|
2002
|
-
domain: 'doc',
|
|
2003
|
-
name: key,
|
|
2004
|
-
keywords: [
|
|
2005
|
-
...(section.title ? [section.title] : []),
|
|
2006
|
-
...headings(section),
|
|
2007
|
-
...codeTerms(section),
|
|
2008
|
-
],
|
|
2009
|
-
description: sectionSummary(section),
|
|
2010
|
-
prose: sectionProse(section),
|
|
2011
|
-
titles: [section.title, ...headings(section)].filter(Boolean),
|
|
2012
|
-
_topic: name,
|
|
2013
|
-
_section: key,
|
|
2014
|
-
_title: `${docTitle} › ${section.title}`,
|
|
2015
|
-
_command: `astryx docs ${name} ${key}`,
|
|
2016
|
-
_parent: `astryx docs ${name} --index`,
|
|
2017
|
-
...((sectionPackage?.(key) ?? pkg)
|
|
2018
|
-
? {_package: sectionPackage?.(key) ?? pkg}
|
|
2019
|
-
: {}),
|
|
2020
|
-
});
|
|
2021
|
-
}
|
|
2022
|
-
return out;
|
|
2023
|
-
}
|
|
2024
|
-
|
|
2025
|
-
/**
|
|
2026
|
-
* Build template candidates (page + block) from the template discovery API.
|
|
2027
|
-
* @param {string} cwd
|
|
2028
|
-
* @returns {Promise<Candidate[]>}
|
|
2029
|
-
*/
|
|
2030
|
-
async function gatherTemplates(cwd) {
|
|
2031
|
-
let templates;
|
|
2032
|
-
try {
|
|
2033
|
-
templates = await discoverTemplates(cwd);
|
|
2034
|
-
} catch {
|
|
2035
|
-
return [];
|
|
2036
|
-
}
|
|
2037
|
-
return templates.map(t => {
|
|
2038
|
-
// A replacement's target is its canonical unqualified lookup id. Keep the
|
|
2039
|
-
// integration-owned id as a keyword and response label, but score and print
|
|
2040
|
-
// commands against the id that `template()` resolves back to this entry.
|
|
2041
|
-
// This matters for replacement chains: one replacement's own id can be the
|
|
2042
|
-
// target of another, so using that shadowed id as the command would select
|
|
2043
|
-
// the other template.
|
|
2044
|
-
const commandName = t.replaces ?? t.dirName;
|
|
2045
|
-
// Blocks ship an authored componentsUsed; page templates don't, so derive
|
|
2046
|
-
// them from the source. Category words (e.g. "Dashboard - Analytics") are
|
|
2047
|
-
// strong intent signal for pages, which otherwise only index on name +
|
|
2048
|
-
// description.
|
|
2049
|
-
//
|
|
2050
|
-
// Authored signal and derived signal are kept apart: componentsUsed and
|
|
2051
|
-
// category words are a deliberate statement of what the template is for,
|
|
2052
|
-
// while scraped JSX tags only say what it happens to render. See the
|
|
2053
|
-
// scoring table at the top of this file for why the derived set is capped.
|
|
2054
|
-
const keywords = Array.isArray(t.componentsUsed)
|
|
2055
|
-
? [...t.componentsUsed]
|
|
2056
|
-
: [];
|
|
2057
|
-
keywords.push(...templateLookupIds(t).filter(id => id !== commandName));
|
|
2058
|
-
/** @type {string[]} */
|
|
2059
|
-
let weakKeywords = [];
|
|
2060
|
-
if (t.type === 'page') {
|
|
2061
|
-
if (t.filePath) {
|
|
2062
|
-
try {
|
|
2063
|
-
weakKeywords = extractComponents(t.filePath);
|
|
2064
|
-
} catch {
|
|
2065
|
-
// Best-effort: skip keyword enrichment if the source can't be read.
|
|
2066
|
-
}
|
|
2067
|
-
}
|
|
2068
|
-
if (t.category)
|
|
2069
|
-
keywords.push(...t.category.split(/[^A-Za-z0-9]+/).filter(Boolean));
|
|
2070
|
-
}
|
|
2071
|
-
return {
|
|
2072
|
-
domain: 'template',
|
|
2073
|
-
name: commandName,
|
|
2074
|
-
keywords,
|
|
2075
|
-
weakKeywords,
|
|
2076
|
-
description: t.description || '',
|
|
2077
|
-
// A template's own keywords name the many ideas it serves, so they score
|
|
2078
|
-
// as its prose does, not as a name: one of them matching one word of a
|
|
2079
|
-
// short idea is not a direct match.
|
|
2080
|
-
prose: t.keywords ?? [],
|
|
2081
|
-
_displayName: t.name,
|
|
2082
|
-
_package: t.package ?? CORE_PACKAGE,
|
|
2083
|
-
_kind: t.type, // 'page' | 'block'
|
|
2084
|
-
_resultName: t.dirName,
|
|
2085
|
-
_commandName: commandName,
|
|
2086
|
-
};
|
|
2087
|
-
});
|
|
2088
|
-
}
|
|
2089
|
-
|
|
2090
|
-
/**
|
|
2091
|
-
* Build theme candidates from bundled and integration-provided themes. Without
|
|
2092
|
-
* this, an integration's themes are invisible to `search` even though
|
|
2093
|
-
* `theme list` and `discover` already resolve them.
|
|
2094
|
-
*
|
|
2095
|
-
* A theme's slug and display name are its names, and its description is prose
|
|
2096
|
-
* (`spec:AST-050/FR14`). A theme declares no keywords, so a word it shares with
|
|
2097
|
-
* the query only through its description is a description mention: read as a
|
|
2098
|
-
* keyword, every word of the description would outrank the components and docs
|
|
2099
|
-
* that declare that word.
|
|
2100
|
-
* @param {string} cwd
|
|
2101
|
-
* @returns {Promise<Candidate[]>}
|
|
2102
|
-
*/
|
|
2103
|
-
async function gatherThemes(cwd) {
|
|
2104
|
-
let themes;
|
|
2105
|
-
try {
|
|
2106
|
-
themes = await listAvailableThemes(cwd);
|
|
2107
|
-
} catch {
|
|
2108
|
-
return [];
|
|
2109
|
-
}
|
|
2110
|
-
return themes.map(t => ({
|
|
2111
|
-
domain: 'theme',
|
|
2112
|
-
name: t.slug,
|
|
2113
|
-
aliases: t.displayName ? [t.displayName] : [],
|
|
2114
|
-
description: t.description || '',
|
|
2115
|
-
_displayName: t.displayName,
|
|
2116
|
-
_package: t.package,
|
|
2117
|
-
}));
|
|
2118
|
-
}
|
|
2119
|
-
|
|
2120
|
-
/**
|
|
2121
|
-
* Map a scored candidate to its public, actionable result shape. Each result
|
|
2122
|
-
* carries enough to act on it: the domain, name, a one-line description, and
|
|
2123
|
-
* the follow-up command (and import path where relevant).
|
|
2124
|
-
*
|
|
2125
|
-
* @param {Candidate} c - candidate
|
|
2126
|
-
* @param {number} score
|
|
2127
|
-
* @param {string} reason
|
|
2128
|
-
* @param {number} matchedTerms - Query concepts this result answered.
|
|
2129
|
-
* @param {number} queryTerms - Query concepts there were to answer.
|
|
2130
|
-
* @param {boolean} [outside] - The search runs outside a project, where a
|
|
2131
|
-
* theme cannot be added to an app, so its command forks the source instead
|
|
2132
|
-
* (spec AST-081 FR3).
|
|
2133
|
-
*/
|
|
2134
|
-
function toResult(c, score, reason, matchedTerms, queryTerms, outside = false) {
|
|
2135
|
-
const base = {
|
|
2136
|
-
domain: c.domain,
|
|
2137
|
-
name: c._resultName ?? c.name,
|
|
2138
|
-
package: c._package,
|
|
2139
|
-
score,
|
|
2140
|
-
reason,
|
|
2141
|
-
description: c.description || '',
|
|
2142
|
-
};
|
|
2143
|
-
let result;
|
|
2144
|
-
switch (c.domain) {
|
|
2145
|
-
case 'component':
|
|
2146
|
-
result = {
|
|
2147
|
-
...base,
|
|
2148
|
-
import: c._import,
|
|
2149
|
-
command: c._command ?? `astryx component ${c.name}`,
|
|
2150
|
-
};
|
|
2151
|
-
break;
|
|
2152
|
-
case 'hook':
|
|
2153
|
-
result = {
|
|
2154
|
-
...base,
|
|
2155
|
-
import: c._import,
|
|
2156
|
-
command: `astryx hook ${c.name}`,
|
|
2157
|
-
};
|
|
2158
|
-
break;
|
|
2159
|
-
case 'doc':
|
|
2160
|
-
// A doc result names its topic or route, plus the section when the
|
|
2161
|
-
// hit is one section; its command reads exactly that part.
|
|
2162
|
-
result = {
|
|
2163
|
-
...base,
|
|
2164
|
-
name: c._topic ?? c.name,
|
|
2165
|
-
...(c._section ? {section: c._section} : {}),
|
|
2166
|
-
title: c._title,
|
|
2167
|
-
command: c._command ?? `astryx docs ${c.name}`,
|
|
2168
|
-
...(c._parent ? {parent: c._parent} : {}),
|
|
2169
|
-
};
|
|
2170
|
-
break;
|
|
2171
|
-
case 'template':
|
|
2172
|
-
result = {
|
|
2173
|
-
...base,
|
|
2174
|
-
displayName: c._displayName,
|
|
2175
|
-
kind: c._kind,
|
|
2176
|
-
command: `astryx template ${c._commandName ?? c.name} --type ${c._kind}`,
|
|
2177
|
-
};
|
|
2178
|
-
break;
|
|
2179
|
-
case 'theme':
|
|
2180
|
-
result = {
|
|
2181
|
-
...base,
|
|
2182
|
-
displayName: c._displayName,
|
|
2183
|
-
command: outside
|
|
2184
|
-
? `astryx theme eject ${c.name}`
|
|
2185
|
-
: `astryx theme add ${c.name}`,
|
|
2186
|
-
};
|
|
2187
|
-
break;
|
|
2188
|
-
default:
|
|
2189
|
-
result = base;
|
|
2190
|
-
}
|
|
2191
|
-
return setResultCoverage(result, matchedTerms, queryTerms);
|
|
2192
|
-
}
|
|
25
|
+
// The domains a search covers (the command handler lists them), and what
|
|
26
|
+
// build reads from search: its word vocabulary, the components a search
|
|
27
|
+
// read, and the keywords of Core's components.
|
|
28
|
+
export {SEARCH_DOMAINS, STOPWORDS, tokenizeQuery} from './_query.mjs';
|
|
29
|
+
export {headingWithPhrase, titleInQuery} from './_titles.mjs';
|
|
30
|
+
export {scoreCandidate} from './_candidate-score.mjs';
|
|
31
|
+
export {scoreQuery} from './_score.mjs';
|
|
32
|
+
export {domainPriority} from './_priority.mjs';
|
|
33
|
+
export {compoundHit} from './_compound.mjs';
|
|
34
|
+
export {SYNONYMS} from './_synonyms.mjs';
|
|
35
|
+
export {sameWord, spellIdentifiers, stem} from './_words.mjs';
|
|
36
|
+
export {searchedComponents} from './_result.mjs';
|
|
37
|
+
export {componentKeywords} from './_adapter.mjs';
|
|
2193
38
|
|
|
2194
39
|
/**
|
|
2195
40
|
* Unified ranked search across components, hooks, docs, templates, and themes.
|
|
@@ -2202,313 +47,38 @@ function toResult(c, score, reason, matchedTerms, queryTerms, outside = false) {
|
|
|
2202
47
|
* @returns {Promise<import('./search.type.mjs').SearchResponse>}
|
|
2203
48
|
*/
|
|
2204
49
|
export async function search(query, options = {}) {
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
50
|
+
checkSearchArgs(query, options);
|
|
51
|
+
return withListingCache(
|
|
52
|
+
async () =>
|
|
53
|
+
/** @type {import('./search.type.mjs').SearchResponse} */ (
|
|
54
|
+
await linkSearchResponse(
|
|
55
|
+
/** @type {import('./search.type.mjs').SearchResponse} */ (
|
|
56
|
+
await runTree(
|
|
57
|
+
SEARCH_TREE,
|
|
58
|
+
{query, options},
|
|
59
|
+
{...(await gatherCandidates(options)), data: loadSearchData()},
|
|
60
|
+
)
|
|
61
|
+
),
|
|
62
|
+
options.cwd ?? process.cwd(),
|
|
63
|
+
)
|
|
64
|
+
),
|
|
65
|
+
);
|
|
2208
66
|
}
|
|
2209
67
|
|
|
2210
68
|
/**
|
|
69
|
+
* The lexical ranker alone: keyword and fuzzy scoring of every candidate.
|
|
70
|
+
* `build` ranks from it.
|
|
71
|
+
*
|
|
2211
72
|
* @param {string} query
|
|
2212
|
-
* @param {
|
|
2213
|
-
* @returns {
|
|
73
|
+
* @param {{cwd?: string, type?: 'component'|'hook'|'doc'|'template'|'theme', limit?: number}} [options]
|
|
74
|
+
* @returns {Promise<import('./search.type.mjs').SearchResponse>}
|
|
2214
75
|
*/
|
|
2215
|
-
async function
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
ERROR_CODES.ERR_INVALID_ARGUMENT,
|
|
2223
|
-
);
|
|
2224
|
-
}
|
|
2225
|
-
|
|
2226
|
-
if (type && !SEARCH_DOMAINS.includes(type)) {
|
|
2227
|
-
throw new AstryxError(
|
|
2228
|
-
`Unknown --type "${type}"`,
|
|
2229
|
-
SEARCH_DOMAINS.map(d => ({name: d, reason: 'valid type'})),
|
|
2230
|
-
ERROR_CODES.ERR_INVALID_ARGUMENT,
|
|
2231
|
-
);
|
|
2232
|
-
}
|
|
2233
|
-
|
|
2234
|
-
// Validate limit here (not just in the CLI) so direct API callers get the same
|
|
2235
|
-
// contract: a non-positive or non-integer limit is an error, never a silent
|
|
2236
|
-
// "return everything". (Previously `limit <= 0` fell through to the full set.)
|
|
2237
|
-
if (limit != null && (!Number.isInteger(limit) || limit <= 0)) {
|
|
2238
|
-
throw new AstryxError(
|
|
2239
|
-
`Invalid limit "${limit}". Must be a positive integer.`,
|
|
2240
|
-
undefined,
|
|
2241
|
-
ERROR_CODES.ERR_INVALID_ARGUMENT,
|
|
2242
|
-
);
|
|
2243
|
-
}
|
|
2244
|
-
|
|
2245
|
-
const term = String(query).trim().toLowerCase();
|
|
2246
|
-
// `astryx docs` reads docs without @astryxdesign/core, and `astryx theme
|
|
2247
|
-
// list` reads themes without it (bundled themes need no project), so a
|
|
2248
|
-
// search of either must too. Every other domain reads core: asked for by
|
|
2249
|
-
// name, it is an error without core; an open search then covers the docs
|
|
2250
|
-
// and themes alone.
|
|
2251
|
-
const needsCore = !type || !CORELESS_DOMAINS.includes(type);
|
|
2252
|
-
// A domain that reads Core gets it as `component` does: the project's, else
|
|
2253
|
-
// the answering release's own, installed into the per-user cache on first
|
|
2254
|
-
// need (spec AST-081 FR3). Asked for by name, a Core that cannot be had is
|
|
2255
|
-
// the error; an open search answers from the rest and says what it skipped
|
|
2256
|
-
// and why, never silently.
|
|
2257
|
-
/** @type {string | null} */
|
|
2258
|
-
let coreDir = null;
|
|
2259
|
-
/** @type {string | null} */
|
|
2260
|
-
let coreSkipped = null;
|
|
2261
|
-
if (needsCore && type) {
|
|
2262
|
-
coreDir = coreDirOrThrow(requireSearchCoreDir, cwd);
|
|
2263
|
-
} else if (needsCore) {
|
|
2264
|
-
try {
|
|
2265
|
-
coreDir = requireSearchCoreDir(cwd);
|
|
2266
|
-
} catch (error) {
|
|
2267
|
-
coreSkipped = /** @type {Error} */ (error).message;
|
|
2268
|
-
}
|
|
2269
|
-
}
|
|
2270
|
-
|
|
2271
|
-
// Gather candidates from each requested domain in parallel.
|
|
2272
|
-
/** @param {string} d */
|
|
2273
|
-
const wants = d =>
|
|
2274
|
-
(!type && (coreDir != null || CORELESS_DOMAINS.includes(d))) || type === d;
|
|
2275
|
-
const [components, hooks, docTopics, templates, themes] = await Promise.all([
|
|
2276
|
-
wants('component')
|
|
2277
|
-
? gatherComponents(/** @type {string} */ (coreDir), cwd)
|
|
2278
|
-
: [],
|
|
2279
|
-
wants('hook') ? gatherHooks(/** @type {string} */ (coreDir)) : [],
|
|
2280
|
-
wants('doc') ? gatherDocs(cwd) : [],
|
|
2281
|
-
wants('template') ? gatherTemplates(cwd) : [],
|
|
2282
|
-
wants('theme') ? gatherThemes(cwd) : [],
|
|
2283
|
-
]);
|
|
2284
|
-
|
|
2285
|
-
const all = [...components, ...hooks, ...docTopics, ...templates, ...themes];
|
|
2286
|
-
|
|
2287
|
-
// A query that is one identifier counts as the words it spells
|
|
2288
|
-
// (`SidebarToggle`), and still matches whole as typed. In a query of several
|
|
2289
|
-
// words, each identifier stays one term (`ChatComposer ChatLayout` names two
|
|
2290
|
-
// components), and its words still join into names (see compoundHit). Both
|
|
2291
|
-
// only help a query that names no entry outright: when the query is an
|
|
2292
|
-
// entry's name, typed whole or as its words, that entry answers it, and
|
|
2293
|
-
// neither reading may add near siblings beside it.
|
|
2294
|
-
const spelled = spellIdentifiers(String(query).trim());
|
|
2295
|
-
const exact = all.some(candidate =>
|
|
2296
|
-
[candidate.name, ...(candidate.aliases ?? [])].some(name => {
|
|
2297
|
-
const lower = String(name).toLowerCase();
|
|
2298
|
-
return (
|
|
2299
|
-
lower === term ||
|
|
2300
|
-
(candidate.domain !== 'doc' &&
|
|
2301
|
-
!/[\s_-]/.test(lower) &&
|
|
2302
|
-
lower === term.replace(/\s+/g, ''))
|
|
2303
|
-
);
|
|
2304
|
-
}),
|
|
2305
|
-
);
|
|
2306
|
-
// One identifier is read as its words only when, read whole, it matches
|
|
2307
|
-
// nothing: a prop or hook name a doc already mentions keeps the
|
|
2308
|
-
// entries that mention it.
|
|
2309
|
-
const split =
|
|
2310
|
-
!exact &&
|
|
2311
|
-
!/\s/.test(term) &&
|
|
2312
|
-
spelled !== term &&
|
|
2313
|
-
!all.some(candidate => scoreCandidate(term, candidate));
|
|
2314
|
-
const tokens = tokenizeQuery(split ? spelled : term);
|
|
2315
|
-
/** @type {string[]} */
|
|
2316
|
-
const words = exact
|
|
2317
|
-
? []
|
|
2318
|
-
: phraseWords(split || /\s/.test(term) ? spelled : term);
|
|
2319
|
-
// The entries the query names: a component, hook, or template whose whole
|
|
2320
|
-
// name is adjacent query words joined (`icon button in a card header` names
|
|
2321
|
-
// IconButton), or one identifier typed as such. A name inside a longer named
|
|
2322
|
-
// run does not count (`icon` there is part of IconButton).
|
|
2323
|
-
const queryWords = phraseWords(spelled);
|
|
2324
|
-
const typedIdentifiers = new Set(
|
|
2325
|
-
(String(query).match(/[A-Za-z0-9]+/g) ?? [])
|
|
2326
|
-
.filter(w => /[A-Z]/.test(w))
|
|
2327
|
-
.map(w => w.toLowerCase()),
|
|
2328
|
-
);
|
|
2329
|
-
/** @type {Map<string, [number, number]>} each run joined, longest first */
|
|
2330
|
-
const spans = new Map();
|
|
2331
|
-
for (let len = Math.min(queryWords.length, MAX_RUN_WORDS); len >= 1; len--) {
|
|
2332
|
-
for (let a = 0; a + len <= queryWords.length; a++) {
|
|
2333
|
-
// One plain word names too much (`list`, `field`); a named entry is a
|
|
2334
|
-
// run of joined words, or an identifier typed as one.
|
|
2335
|
-
if (len === 1 && !typedIdentifiers.has(queryWords[a])) continue;
|
|
2336
|
-
const run = queryWords.slice(a, a + len).join('');
|
|
2337
|
-
if (!spans.has(run)) spans.set(run, [a, a + len]);
|
|
2338
|
-
}
|
|
2339
|
-
}
|
|
2340
|
-
/**
|
|
2341
|
-
* Where a query word is the candidate's whole name, or null: such an entry
|
|
2342
|
-
* keeps the rank its name earns, whatever else it matched.
|
|
2343
|
-
* @param {Candidate} candidate
|
|
2344
|
-
* @returns {[number, number] | null}
|
|
2345
|
-
*/
|
|
2346
|
-
const wordNamed = candidate => {
|
|
2347
|
-
if (candidate.domain === 'doc') return null;
|
|
2348
|
-
for (const name of [candidate.name, ...(candidate.aliases ?? [])]) {
|
|
2349
|
-
const n = joined(name);
|
|
2350
|
-
const at = queryWords.findIndex(w => w === n || w === `${n}s`);
|
|
2351
|
-
if (at >= 0) return [at, at + 1];
|
|
2352
|
-
}
|
|
2353
|
-
return null;
|
|
2354
|
-
};
|
|
2355
|
-
/** @param {Candidate} candidate @returns {[number, number] | null} */
|
|
2356
|
-
const namedSpan = candidate => {
|
|
2357
|
-
if (candidate.domain === 'doc') return null;
|
|
2358
|
-
/** @type {[number, number] | null} */
|
|
2359
|
-
let best = null;
|
|
2360
|
-
for (const name of [candidate.name, ...(candidate.aliases ?? [])]) {
|
|
2361
|
-
const n = joined(name);
|
|
2362
|
-
const span =
|
|
2363
|
-
spans.get(n) ??
|
|
2364
|
-
spans.get(`${n}s`) ??
|
|
2365
|
-
(/(?:s|x|z|ch|sh)$/.test(n) ? spans.get(`${n}es`) : undefined);
|
|
2366
|
-
if (span && (!best || span[1] - span[0] > best[1] - best[0])) {
|
|
2367
|
-
best = span;
|
|
2368
|
-
}
|
|
2369
|
-
}
|
|
2370
|
-
return best;
|
|
2371
|
-
};
|
|
2372
|
-
|
|
2373
|
-
// Score every candidate on its own merits. The consumer groups results by
|
|
2374
|
-
// role (page / block / component) and takes the top of each, so there's no
|
|
2375
|
-
// cross-role competition to engineer — a target page only needs to be the
|
|
2376
|
-
// strongest PAGE, not outrank every component.
|
|
2377
|
-
/** @type {{result: any, priority: 0 | 1 | 2, key: number, alias: 'partial' | 'keyword' | false, aliasWords: number, candidate: Candidate, span: [number, number] | null, word: [number, number] | null}[]} */
|
|
2378
|
-
const ranked = [];
|
|
2379
|
-
const outside = themes.length > 0 && isOutsideProject(cwd);
|
|
2380
|
-
for (const candidate of all) {
|
|
2381
|
-
const hit = scoreQuery(term, tokens, candidate, words);
|
|
2382
|
-
if (hit)
|
|
2383
|
-
ranked.push({
|
|
2384
|
-
result: toResult(
|
|
2385
|
-
candidate,
|
|
2386
|
-
hit.score,
|
|
2387
|
-
hit.reason,
|
|
2388
|
-
hit.matched,
|
|
2389
|
-
hit.total,
|
|
2390
|
-
outside,
|
|
2391
|
-
),
|
|
2392
|
-
priority: domainPriority(term, tokens, candidate, hit, words),
|
|
2393
|
-
key: hit.score,
|
|
2394
|
-
alias: hit.alias ?? false,
|
|
2395
|
-
aliasWords: hit.aliasWords ?? 0,
|
|
2396
|
-
candidate,
|
|
2397
|
-
span: namedSpan(candidate),
|
|
2398
|
-
word: wordNamed(candidate),
|
|
2399
|
-
});
|
|
2400
|
-
}
|
|
2401
|
-
// A match on a keyword read joined never outranks an entry the query names,
|
|
2402
|
-
// and neither does a match on part of another name unless it spans more
|
|
2403
|
-
// query words than any named run: `table header with sticky columns` keeps
|
|
2404
|
-
// TableHeader above useTableStickyColumns, which only holds the words
|
|
2405
|
-
// `sticky columns`. Only the order changes; each result keeps the score its
|
|
2406
|
-
// text earned.
|
|
2407
|
-
/** @param {[number, number]} inner @param {[number, number]} outer */
|
|
2408
|
-
const inside = (inner, outer) =>
|
|
2409
|
-
outer[0] <= inner[0] &&
|
|
2410
|
-
outer[1] >= inner[1] &&
|
|
2411
|
-
outer[1] - outer[0] > inner[1] - inner[0];
|
|
2412
|
-
const named = ranked.filter(r => {
|
|
2413
|
-
const span = r.span;
|
|
2414
|
-
return (
|
|
2415
|
-
span != null &&
|
|
2416
|
-
!ranked.some(o => o !== r && o.span != null && inside(span, o.span))
|
|
2417
|
-
);
|
|
2418
|
-
});
|
|
2419
|
-
/** @param {{key: number}[]} rows */
|
|
2420
|
-
const floorOf = rows =>
|
|
2421
|
-
rows.length ? Math.min(...rows.map(r => r.key)) : Infinity;
|
|
2422
|
-
const namedFloor = floorOf(named);
|
|
2423
|
-
const longestNamed = Math.max(
|
|
2424
|
-
0,
|
|
2425
|
-
...named.map(r => (r.span ? r.span[1] - r.span[0] : 0)),
|
|
76
|
+
export async function searchCore(query, options = {}) {
|
|
77
|
+
checkSearchArgs(query, options);
|
|
78
|
+
return withListingCache(async () =>
|
|
79
|
+
linkSearchResponse(
|
|
80
|
+
rankLexical(query, options, await gatherCandidates(options)),
|
|
81
|
+
options.cwd ?? process.cwd(),
|
|
82
|
+
),
|
|
2426
83
|
);
|
|
2427
|
-
for (const r of ranked) {
|
|
2428
|
-
if (named.includes(r)) continue;
|
|
2429
|
-
if (!r.alias || r.word) continue;
|
|
2430
|
-
// A match on part of a longer name that spans more words than any named
|
|
2431
|
-
// run reads more of the query than the named entry does (`dropdown menu
|
|
2432
|
-
// radio` and DropdownMenuRadioItem, against DropdownMenu), so it is not
|
|
2433
|
-
// held back. A joined keyword always is.
|
|
2434
|
-
if (r.alias === 'partial' && r.aliasWords > longestNamed) continue;
|
|
2435
|
-
// It keeps what it earns without those matches.
|
|
2436
|
-
const own =
|
|
2437
|
-
scoreQuery(term, tokens, r.candidate, words, {plain: true})?.score ?? 0;
|
|
2438
|
-
r.key = Math.max(own, Math.min(r.key, namedFloor - 0.5));
|
|
2439
|
-
}
|
|
2440
|
-
|
|
2441
|
-
// Docs and everything else are each sorted by score desc, then domain
|
|
2442
|
-
// (stable order), then name. The two are merged: at each step the stronger
|
|
2443
|
-
// head goes next, except that a head with domain priority goes ahead of a
|
|
2444
|
-
// doc head without it (see domainPriority). Each side keeps its own order.
|
|
2445
|
-
/** @type {Record<string, number>} */
|
|
2446
|
-
const domainOrder = {component: 0, hook: 1, doc: 2, template: 3, theme: 4};
|
|
2447
|
-
/**
|
|
2448
|
-
* @param {{result: any, key: number}} x
|
|
2449
|
-
* @param {{result: any, key: number}} y
|
|
2450
|
-
*/
|
|
2451
|
-
const byScore = ({result: a, key: ka}, {result: b, key: kb}) =>
|
|
2452
|
-
kb - ka ||
|
|
2453
|
-
(domainOrder[a.domain] ?? 9) - (domainOrder[b.domain] ?? 9) ||
|
|
2454
|
-
a.name.localeCompare(b.name);
|
|
2455
|
-
const others = ranked.filter(r => r.result.domain !== 'doc').sort(byScore);
|
|
2456
|
-
// Against other domains, a doc the reader asked for competes ahead of docs
|
|
2457
|
-
// they did not, so it is not held behind a broad page that outscores it.
|
|
2458
|
-
// Docs alone keep their text-match order, with a doc the whole query names
|
|
2459
|
-
// first.
|
|
2460
|
-
const docs = ranked
|
|
2461
|
-
.filter(r => r.result.domain === 'doc')
|
|
2462
|
-
.sort((x, y) =>
|
|
2463
|
-
others.length > 0
|
|
2464
|
-
? y.priority - x.priority || byScore(x, y)
|
|
2465
|
-
: Number(y.priority === 2) - Number(x.priority === 2) || byScore(x, y),
|
|
2466
|
-
);
|
|
2467
|
-
const scored = [];
|
|
2468
|
-
let i = 0;
|
|
2469
|
-
let j = 0;
|
|
2470
|
-
while (i < others.length || j < docs.length) {
|
|
2471
|
-
const other = others[i];
|
|
2472
|
-
const doc = docs[j];
|
|
2473
|
-
const otherFirst =
|
|
2474
|
-
!doc ||
|
|
2475
|
-
(other != null &&
|
|
2476
|
-
((other.priority && !doc.priority) || byScore(other, doc) < 0));
|
|
2477
|
-
scored.push(otherFirst ? others[i++].result : docs[j++].result);
|
|
2478
|
-
}
|
|
2479
|
-
|
|
2480
|
-
// `results` is bounded by `limit` so a caller (and the recorded run that
|
|
2481
|
-
// quotes it) never carries an unbounded payload. `matchCount` is the number
|
|
2482
|
-
// of matches that bound was applied TO — reporting `results.length` there
|
|
2483
|
-
// would report the cap back as if it were the answer, so a query matching
|
|
2484
|
-
// 57 things and one matching exactly 20 would be indistinguishable.
|
|
2485
|
-
const limited = scored.slice(0, limit);
|
|
2486
|
-
|
|
2487
|
-
/** @type {import('./search.type.mjs').SearchResponse} */
|
|
2488
|
-
const response = {
|
|
2489
|
-
type: 'search',
|
|
2490
|
-
data: {
|
|
2491
|
-
query: String(query).trim(),
|
|
2492
|
-
matchCount: scored.length,
|
|
2493
|
-
// toResult gives every domain its command and domain fields.
|
|
2494
|
-
results: /** @type {import('./search.type.mjs').SearchResultEntry[]} */ (
|
|
2495
|
-
limited
|
|
2496
|
-
),
|
|
2497
|
-
},
|
|
2498
|
-
};
|
|
2499
|
-
if (coreSkipped) {
|
|
2500
|
-
response.meta = {
|
|
2501
|
-
partial: {
|
|
2502
|
-
skipped: ['component', 'hook', 'template'],
|
|
2503
|
-
reason: coreSkipped,
|
|
2504
|
-
},
|
|
2505
|
-
};
|
|
2506
|
-
}
|
|
2507
|
-
if (wants('component')) {
|
|
2508
|
-
searchedComponentsOf.set(
|
|
2509
|
-
response,
|
|
2510
|
-
components.map(c => ({name: c.name, keywords: c.keywords ?? []})),
|
|
2511
|
-
);
|
|
2512
|
-
}
|
|
2513
|
-
return linkSearchResponse(response, cwd);
|
|
2514
84
|
}
|