@phis/contracts 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,6 +22,7 @@ and change at different moments.
22
22
  @phis/contracts/http the request headers phis and the site UI must spell the same way
23
23
  @phis/contracts/locale the locale this software is authored in
24
24
  @phis/contracts/logging the structured log vocabulary a Site writes and phis reads back
25
+ @phis/contracts/search the documents a Site writes of its Pages and the hits phis answers a query with
25
26
  @phis/contracts/server-capabilities the capability snapshot phis reports and the UI acts on
26
27
  @phis/contracts/site-groups the membership levels of a Site group, as both ends count them
27
28
  @phis/contracts/user-state the shapes a Module may keep a person's decisions in, and the key grammar
@@ -190,6 +191,18 @@ The structured log vocabulary: `PhiLogService` (`phis`, `ui`, `site`, `cli`), `P
190
191
  to journald, and the log surface in phis parses them back, filtering by exactly these services and
191
192
  fields. How a line is serialised is each writer's own concern.
192
193
 
194
+ ## `/search`
195
+
196
+ Searching a Site's own Pages, split the only way it can be: the Site writes the documents, phis keeps and
197
+ searches them. A Page's words stand in Widget configs, in content phis stores without knowing which Page
198
+ shows it, and in documents fetched while the Page is drawn, so only the Site -- resolving a Page as a
199
+ visitor's request does -- sees the whole text. It sends one `PhiSearchIndexSet` per Area and language,
200
+ named by a `fingerprint` of what it was built from, so a set phis already holds is not written twice;
201
+ phis answers a query with `PhiSearchResponse`, each `PhiSearchHit` carrying its Page's path and a
202
+ snippet in pieces. `readPhiSearchQuery` and `tokenizePhiSearchQuery` are called on both ends, so a
203
+ query the Site calls too short is one phis would not have answered. Only the Public and App Areas are
204
+ indexed (`PHI_SEARCH_INDEXED_AREA_MASK`).
205
+
193
206
  ## `/server-capabilities`
194
207
 
195
208
  The snapshot `/api/v1/site/capabilities` reports: per provider (Core or an Add-on), the capabilities it
@@ -0,0 +1,96 @@
1
+ /**
2
+ * The Areas a search index covers: a visitor's Public Site and the App a signed-in member reaches.
3
+ *
4
+ * Admin, Editor, Builder and Accounting are the operator's chrome, not Pages anybody searches, and a
5
+ * document set declared for one of them is refused rather than kept.
6
+ */
7
+ export declare const PHI_SEARCH_INDEXED_AREA_MASK: number;
8
+ /** The URL parameter the search box writes and the results Widget reads. */
9
+ export declare const PHI_SEARCH_QUERY_PARAM = "q";
10
+ /** A query shorter than this is not a search; two letters match too much to be an answer. */
11
+ export declare const PHI_SEARCH_MIN_QUERY_LENGTH = 2;
12
+ export declare const PHI_SEARCH_MAX_QUERY_LENGTH = 200;
13
+ export declare const PHI_SEARCH_DEFAULT_LIMIT = 20;
14
+ export declare const PHI_SEARCH_MAX_LIMIT = 50;
15
+ /** How many documents one set may carry; a Site with more Pages than this is a different product. */
16
+ export declare const PHI_SEARCH_MAX_DOCUMENTS = 5000;
17
+ export declare const PHI_SEARCH_MAX_PATH_LENGTH = 512;
18
+ export declare const PHI_SEARCH_MAX_TITLE_LENGTH = 512;
19
+ export declare const PHI_SEARCH_MAX_DESCRIPTION_LENGTH = 2048;
20
+ /** The text of one Page, as indexed; the words beyond it are not searched. */
21
+ export declare const PHI_SEARCH_MAX_TEXT_LENGTH = 200000;
22
+ /** One Page in one language, as the Site read it when the set was built. */
23
+ export type PhiSearchDocument = {
24
+ /** The Page's address within its Area, `/docs/install`, as a link to it is written. */
25
+ path: string;
26
+ title: string;
27
+ description: string;
28
+ /** The Page's words, in reading order, with no markup; a heading is a line like any other. */
29
+ text: string;
30
+ };
31
+ /**
32
+ * Every document of one Area in one language, replacing whatever the set held before.
33
+ *
34
+ * `fingerprint` names what the set was built from -- the live revisions, the route table, the Module
35
+ * addresses -- so phis can tell a set it already holds from a new one and several Site processes
36
+ * noticing the same publish write it once.
37
+ */
38
+ export type PhiSearchIndexSet = {
39
+ areaMask: number;
40
+ locale: string;
41
+ fingerprint: string;
42
+ documents: PhiSearchDocument[];
43
+ };
44
+ /** What phis holds of one set: enough for a Site to decide whether to build it again. */
45
+ export type PhiSearchIndexState = {
46
+ areaMask: number;
47
+ locale: string;
48
+ fingerprint: string;
49
+ documentCount: number;
50
+ /** ISO instant. */
51
+ indexedAt: string;
52
+ };
53
+ export type PhiSearchIndexWriteResult = {
54
+ /** `false` when the set phis holds already carries this fingerprint, and nothing was written. */
55
+ accepted: boolean;
56
+ state: PhiSearchIndexState;
57
+ };
58
+ /** A piece of a snippet; a `match` piece is one of the query's words as it stands in the text. */
59
+ export type PhiSearchSnippetSegment = {
60
+ text: string;
61
+ match: boolean;
62
+ };
63
+ export type PhiSearchHit = {
64
+ path: string;
65
+ areaMask: number;
66
+ title: string;
67
+ description: string;
68
+ /** The passage around the best matches, in pieces; empty when the match is in the title alone. */
69
+ snippet: PhiSearchSnippetSegment[];
70
+ /** Higher is better; comparable within one answer only. */
71
+ rank: number;
72
+ };
73
+ export type PhiSearchResponse = {
74
+ /** The query as phis read it (`readPhiSearchQuery`). */
75
+ query: string;
76
+ locale: string;
77
+ /** The Areas that were searched, as a mask. */
78
+ areaMask: number;
79
+ hits: PhiSearchHit[];
80
+ /** How many documents match in all, of which `hits` are the first. */
81
+ total: number;
82
+ };
83
+ /**
84
+ * The query as both ends read it, or `null` for no query: whitespace collapsed, bounds applied.
85
+ *
86
+ * Length is measured after collapsing, so a visitor cannot pad past the ceiling with spaces, and a query
87
+ * over the ceiling is cut rather than refused -- the first two hundred characters are a search, the rest
88
+ * was never going to narrow it.
89
+ */
90
+ export declare function readPhiSearchQuery(value: unknown): string | null;
91
+ /**
92
+ * The words of a query, as both ends count them: letters and digits of any script, lower-cased,
93
+ * everything else a separator. phis builds its text query from these; the Site marks them in what it
94
+ * shows. A word is at most 64 characters, so a pasted token does not become a search for nothing.
95
+ */
96
+ export declare function tokenizePhiSearchQuery(query: string): string[];
package/dist/search.js ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * What a Site and phis say to each other about searching the Site's own Pages.
3
+ *
4
+ * The texts a visitor searches are not phis's to know: a Page's words stand in Widget configs, in content
5
+ * phis stores without knowing which Page shows it, and in documents a Widget fetches from elsewhere while
6
+ * the Page is drawn. Only the Site, resolving a Page the way a visitor's request resolves it, sees the
7
+ * whole text. So the Site writes the documents, one per Page and language, and phis keeps them, indexes
8
+ * them and answers a query with the Pages that match -- which is the half a database does well.
9
+ *
10
+ * Both halves read the same shapes: what the Site sends (`PhiSearchIndexSet`), what phis answers
11
+ * (`PhiSearchResponse`), and the query's admission (`readPhiSearchQuery`), because a query the Site shows
12
+ * as "too short" and phis would have answered -- or the reverse -- is a visitor told two things at once.
13
+ */
14
+ import { PhiCmsVisibilityContext } from "./cms.js";
15
+ /**
16
+ * The Areas a search index covers: a visitor's Public Site and the App a signed-in member reaches.
17
+ *
18
+ * Admin, Editor, Builder and Accounting are the operator's chrome, not Pages anybody searches, and a
19
+ * document set declared for one of them is refused rather than kept.
20
+ */
21
+ export const PHI_SEARCH_INDEXED_AREA_MASK = PhiCmsVisibilityContext.PublicArea | PhiCmsVisibilityContext.AppArea;
22
+ /** The URL parameter the search box writes and the results Widget reads. */
23
+ export const PHI_SEARCH_QUERY_PARAM = "q";
24
+ /** A query shorter than this is not a search; two letters match too much to be an answer. */
25
+ export const PHI_SEARCH_MIN_QUERY_LENGTH = 2;
26
+ export const PHI_SEARCH_MAX_QUERY_LENGTH = 200;
27
+ export const PHI_SEARCH_DEFAULT_LIMIT = 20;
28
+ export const PHI_SEARCH_MAX_LIMIT = 50;
29
+ /** How many documents one set may carry; a Site with more Pages than this is a different product. */
30
+ export const PHI_SEARCH_MAX_DOCUMENTS = 5000;
31
+ export const PHI_SEARCH_MAX_PATH_LENGTH = 512;
32
+ export const PHI_SEARCH_MAX_TITLE_LENGTH = 512;
33
+ export const PHI_SEARCH_MAX_DESCRIPTION_LENGTH = 2048;
34
+ /** The text of one Page, as indexed; the words beyond it are not searched. */
35
+ export const PHI_SEARCH_MAX_TEXT_LENGTH = 200_000;
36
+ /**
37
+ * The query as both ends read it, or `null` for no query: whitespace collapsed, bounds applied.
38
+ *
39
+ * Length is measured after collapsing, so a visitor cannot pad past the ceiling with spaces, and a query
40
+ * over the ceiling is cut rather than refused -- the first two hundred characters are a search, the rest
41
+ * was never going to narrow it.
42
+ */
43
+ export function readPhiSearchQuery(value) {
44
+ if (typeof value !== "string")
45
+ return null;
46
+ const collapsed = value.replace(/\s+/gu, " ").trim();
47
+ if (collapsed.length < PHI_SEARCH_MIN_QUERY_LENGTH)
48
+ return null;
49
+ return collapsed.length > PHI_SEARCH_MAX_QUERY_LENGTH
50
+ ? collapsed.slice(0, PHI_SEARCH_MAX_QUERY_LENGTH).trimEnd()
51
+ : collapsed;
52
+ }
53
+ /**
54
+ * The words of a query, as both ends count them: letters and digits of any script, lower-cased,
55
+ * everything else a separator. phis builds its text query from these; the Site marks them in what it
56
+ * shows. A word is at most 64 characters, so a pasted token does not become a search for nothing.
57
+ */
58
+ export function tokenizePhiSearchQuery(query) {
59
+ const words = [];
60
+ for (const match of query.toLowerCase().matchAll(/[\p{L}\p{N}]+/gu)) {
61
+ const word = match[0].slice(0, 64);
62
+ if (!words.includes(word))
63
+ words.push(word);
64
+ }
65
+ return words;
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phis/contracts",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -70,6 +70,11 @@
70
70
  "import": "./dist/resource-links.js",
71
71
  "default": "./dist/resource-links.js"
72
72
  },
73
+ "./search": {
74
+ "types": "./dist/search.d.ts",
75
+ "import": "./dist/search.js",
76
+ "default": "./dist/search.js"
77
+ },
73
78
  "./server-capabilities": {
74
79
  "types": "./dist/server-capabilities.d.ts",
75
80
  "import": "./dist/server-capabilities.js",