@phis/contracts 0.7.2 → 0.7.4

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
@@ -60,7 +61,14 @@ erased at compile time apart from a handful of constants.
60
61
  What it covers:
61
62
 
62
63
  - **Manifest** -- `PhisAddonManifestV1`: identity, version, required Core capabilities, the routes an
63
- Add-on claims, the services it provides.
64
+ Add-on claims, the services it provides, the jobs it runs and the events it listens to or emits.
65
+ - **Jobs and events** -- a job carries a `PhisAddonJobSchedule` (`every` one of
66
+ `PHIS_ADDON_JOB_INTERVALS`, five minutes at the shortest, with `at` and `weekday` for the daily and
67
+ weekly steps) that Core keeps: each moment of a schedule is run by exactly one instance. An event an
68
+ Add-on emits is a `PhisAddonEmittedEventId`, its own id in front (`@scope/name:what.happened`),
69
+ declared under `emits` and sent through the `events` capability (`PhisEventsCapabilityV1`); a listener
70
+ receives a `PhisAddonEmittedEventSubject` with emitter, time, hop count, dedupe key and facts. Core's
71
+ own events keep their bare names. `PHIS_ADDON_EVENT_MAX_HOPS` bounds a chain of handlers emitting.
64
72
  - **Runtime** -- `PhisAddonRuntimeV1`, `PhisAddonHandler`, `PhisAddonRequestContext`. The artifact
65
73
  exports it under the name `phisAddon` (`PhisAddonRuntimeModuleV1`).
66
74
  - **Schema** -- `PhisAddonSchemaDescriptor` and its column, index and constraint descriptors. Tables are
@@ -190,6 +198,18 @@ The structured log vocabulary: `PhiLogService` (`phis`, `ui`, `site`, `cli`), `P
190
198
  to journald, and the log surface in phis parses them back, filtering by exactly these services and
191
199
  fields. How a line is serialised is each writer's own concern.
192
200
 
201
+ ## `/search`
202
+
203
+ Searching a Site's own Pages, split the only way it can be: the Site writes the documents, phis keeps and
204
+ searches them. A Page's words stand in Widget configs, in content phis stores without knowing which Page
205
+ shows it, and in documents fetched while the Page is drawn, so only the Site -- resolving a Page as a
206
+ visitor's request does -- sees the whole text. It sends one `PhiSearchIndexSet` per Area and language,
207
+ named by a `fingerprint` of what it was built from, so a set phis already holds is not written twice;
208
+ phis answers a query with `PhiSearchResponse`, each `PhiSearchHit` carrying its Page's path and a
209
+ snippet in pieces. `readPhiSearchQuery` and `tokenizePhiSearchQuery` are called on both ends, so a
210
+ query the Site calls too short is one phis would not have answered. Only the Public and App Areas are
211
+ indexed (`PHI_SEARCH_INDEXED_AREA_MASK`).
212
+
193
213
  ## `/server-capabilities`
194
214
 
195
215
  The snapshot `/api/v1/site/capabilities` reports: per provider (Core or an Add-on), the capabilities it
package/dist/addon.d.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  * back a constant, it is in the wrong package.
13
13
  */
14
14
  import type { PhisAddonCapabilities, PhisAddonEventHandler, PhisAddonJobHandler, PhisSiteAccess } from "./capabilities.js";
15
- import type { PhisAddonEventId, PhisCoreCapabilityId, PhisServiceKind } from "./core.js";
15
+ import type { PhisAddonEmittedEventId, PhisAddonEventId, PhisCoreCapabilityId, PhisServiceKind } from "./core.js";
16
16
  import type { PhisAddonQueryCatalog } from "./queries.js";
17
17
  import type { PhisAddonSchemaDescriptor } from "./schema.js";
18
18
  export declare const PHIS_ADDON_MANIFEST_VERSION: 1;
@@ -101,17 +101,61 @@ export type PhisAddonServiceProviderDescriptor = {
101
101
  factoryExport: string;
102
102
  requiredCoreCapabilities: PhisCoreCapabilityId[];
103
103
  };
104
+ /**
105
+ * The steps a job may run at. Every step divides the hour or the day, so the moments a schedule names
106
+ * fall on the clock -- `5m` is :00, :05, :10 -- and every instance of Core computes the same moment
107
+ * from the same clock without asking another.
108
+ *
109
+ * Nothing shorter than five minutes: a job that has to look more often than that is polling for an
110
+ * event, and an event is what it should be given. Jobs run per Site, so a short step multiplies by
111
+ * the Sites of a stage, and the services a job reads from have quotas of their own.
112
+ */
113
+ export declare const PHIS_ADDON_JOB_INTERVALS: readonly ["5m", "15m", "30m", "hourly", "daily", "weekly"];
114
+ export type PhisAddonJobInterval = (typeof PHIS_ADDON_JOB_INTERVALS)[number];
115
+ /**
116
+ * When a job wants to run. Core keeps it: the schedule is a promise, not a note for the operator.
117
+ *
118
+ * A schedule is a series of moments, and each moment is claimed by exactly one instance of Core --
119
+ * the claim is a row whose key is the moment, so the database, not a leader, decides who runs. A
120
+ * moment nobody claimed while every instance was down is run once at the next start, the latest one
121
+ * only; a job does not receive a week of missed runs after a week of downtime. A job still running
122
+ * when its next moment comes has that moment recorded as skipped rather than run beside itself, which
123
+ * is why a handler must finish well inside its own step.
124
+ */
125
+ export type PhisAddonJobSchedule = {
126
+ every: PhisAddonJobInterval;
127
+ /**
128
+ * The time of day, `HH:MM` in UTC, for `daily` and `weekly`; absent means `00:00`. Not for the shorter
129
+ * steps, which fall on the clock by themselves.
130
+ */
131
+ at?: string;
132
+ /** The ISO weekday for `weekly`, 1 (Monday) to 7 (Sunday); absent means Monday. */
133
+ weekday?: number;
134
+ };
104
135
  export type PhisAddonJobDescriptor = {
105
136
  id: string;
106
137
  handler: string;
107
138
  /**
108
- * How often this job expects to run, for the operator to read -- never a schedule Core keeps.
139
+ * When Core runs this job. Absent means it runs only when an operator starts it with `phis addon
140
+ * job`, which also runs a scheduled job out of turn.
109
141
  *
110
- * phi-server serves from several instances, so an in-process timer would fire once per instance for
111
- * one intended run. The operator schedules `phis addon job run`, which is one runner by construction,
112
- * and this field tells them what the Add-on had in mind.
142
+ * Optional like every field added after this manifest version was in the wild.
113
143
  */
114
- suggestedInterval?: "hourly" | "daily" | "weekly";
144
+ schedule?: PhisAddonJobSchedule;
145
+ };
146
+ /**
147
+ * One event this Add-on announces under its own name.
148
+ *
149
+ * Declared like a job or a route: the id, its emitter's own id in front, is part of the manifest and
150
+ * therefore of the digest, so what an installed Add-on can say to the others is readable at install.
151
+ * Emitting an undeclared event is refused. `audience` says who may listen: every Add-on of the Site
152
+ * that declares a handler for it, which is the default, because the operator installed each of them;
153
+ * or `self`, for an Add-on that uses the outbox to hand work to its own handler after the fact.
154
+ */
155
+ export type PhisAddonEmittedEventDescriptor = {
156
+ id: PhisAddonEmittedEventId;
157
+ description?: string;
158
+ audience?: "stage" | "self";
115
159
  };
116
160
  /**
117
161
  * One setting an operator may configure for this Add-on.
@@ -259,6 +303,13 @@ export type PhisAddonManifestV1 = {
259
303
  * Add-on that declares them and links nothing is told nothing.
260
304
  */
261
305
  eventHandlers?: PhisAddonEventHandlerDescriptor[];
306
+ /**
307
+ * The events this Add-on emits. Absent means none, and an emit of anything is refused.
308
+ *
309
+ * Optional like `eventHandlers`. Declaring an event is not delivering it: a row is written only for
310
+ * an Add-on of the Site that declares a handler for the id, and none at all where nobody listens.
311
+ */
312
+ emits?: PhisAddonEmittedEventDescriptor[];
262
313
  /**
263
314
  * What an operator may configure. Absent means nothing is.
264
315
  *
package/dist/addon.js CHANGED
@@ -13,3 +13,13 @@
13
13
  */
14
14
  export const PHIS_ADDON_MANIFEST_VERSION = 1;
15
15
  export const PHIS_ADDON_ABI_VERSION = 1;
16
+ /**
17
+ * The steps a job may run at. Every step divides the hour or the day, so the moments a schedule names
18
+ * fall on the clock -- `5m` is :00, :05, :10 -- and every instance of Core computes the same moment
19
+ * from the same clock without asking another.
20
+ *
21
+ * Nothing shorter than five minutes: a job that has to look more often than that is polling for an
22
+ * event, and an event is what it should be given. Jobs run per Site, so a short step multiplies by
23
+ * the Sites of a stage, and the services a job reads from have quotas of their own.
24
+ */
25
+ export const PHIS_ADDON_JOB_INTERVALS = ["5m", "15m", "30m", "hourly", "daily", "weekly"];
@@ -12,7 +12,7 @@
12
12
  * at the point where it made the mistake.
13
13
  */
14
14
  import type { PhisAssetsCapabilityV1 } from "./assets.js";
15
- import type { PhisAddonThreadEventId } from "./core.js";
15
+ import type { PhisAddonEmittedEventId, PhisAddonThreadEventId } from "./core.js";
16
16
  import type { PhisPaymentChangedEventFacts, PhisPaymentNotificationsCapabilityV1, PhisPaymentsCapabilityV1 } from "./payments.js";
17
17
  import type { PhisDataCapabilityV1 } from "./queries.js";
18
18
  import type { PhisResourceLinksCapabilityV1 } from "./resource-links.js";
@@ -109,6 +109,52 @@ export type PhisAddonCapabilities = {
109
109
  paymentNotifications?: PhisPaymentNotificationsCapabilityV1;
110
110
  /** Writing to a person about an occasion this Add-on declared; Core owns the frame and the sending. */
111
111
  notifications?: PhisNotificationsCapabilityV1;
112
+ /** Announcing an event this Add-on declared under its own name; Core owns the outbox and the delivery. */
113
+ events?: PhisEventsCapabilityV1;
114
+ };
115
+ /**
116
+ * The events capability: `@phis/server/events:v1`.
117
+ *
118
+ * An Add-on says that something happened; Core writes one outbox row per Add-on of this Site that
119
+ * declares a handler for the event, and delivers them the way it delivers its own events -- after the
120
+ * fact, in order, retried until they arrive. Emitting is only a write to the outbox, so it is cheap and
121
+ * never waits for a listener.
122
+ *
123
+ * `facts` are values about what happened, as JSON, bounded in size, and never references into another
124
+ * Add-on's tables: a listener runs with authority over its own tables only, and a fact it cannot read
125
+ * is a fact it should have been handed. `dedupeKey` makes a repeated emit safe -- a second call with
126
+ * the same key writes nothing for a listener already owed the first.
127
+ *
128
+ * Refused: an event the manifest does not declare under `emits`, a call from an unverified hook, and
129
+ * an emit from a handler already at `PHIS_ADDON_EVENT_MAX_HOPS`. Answers how many listeners were
130
+ * written for; zero means nobody on this Site listens, which is not a failure.
131
+ */
132
+ export type PhisEventsCapabilityV1 = {
133
+ emit(input: PhisAddonEmitEventInput): Promise<{
134
+ recipients: number;
135
+ }>;
136
+ };
137
+ export type PhisAddonEmitEventInput = {
138
+ event: PhisAddonEmittedEventId;
139
+ facts?: Readonly<Record<string, unknown>>;
140
+ /** Unique within the Site and the event; a second emit with the same key writes nothing new. */
141
+ dedupeKey?: string;
142
+ };
143
+ /**
144
+ * What a listener is handed about an event another Add-on -- or the same one -- emitted.
145
+ *
146
+ * The emitter's id says who, `occurredAt` when (the moment the outbox row was written), `hop` how many
147
+ * handlers the chain has passed through so far, and `facts` what the emitter said. `dedupeKey` is the
148
+ * emitter's, where it gave one; the delivery's own key, stable across retries, is `deliveryId` on the
149
+ * context, as for every event.
150
+ */
151
+ export type PhisAddonEmittedEventSubject = {
152
+ kind: "emitted";
153
+ emitter: string;
154
+ occurredAt: string;
155
+ hop: number;
156
+ dedupeKey: string | null;
157
+ facts: Readonly<Record<string, unknown>>;
112
158
  };
113
159
  /**
114
160
  * The roles capability: `@phis/server/roles:v1`.
@@ -399,8 +445,9 @@ export type PhisSettingsCapabilityV1 = {
399
445
  *
400
446
  * No request, no headers, and above all no actor: a job is the Add-on acting on its own data, not a
401
447
  * person acting through it. That is why the row-level ladder does not apply inside one, and why a job
402
- * is reachable only from `phis addon job run` and never from a route -- an Add-on that could start its
403
- * own job from a request would have built itself a way around the ladder.
448
+ * is started only by Core's scheduler at the moments its `schedule` names, or by an operator with
449
+ * `phis addon job`, and never from a route -- an Add-on that could start its own job from a request
450
+ * would have built itself a way around the ladder.
404
451
  */
405
452
  export type PhisAddonJobContext = {
406
453
  addonId: string;
@@ -434,7 +481,10 @@ export type PhisAddonEventFacts = {
434
481
  threadId: number;
435
482
  messageId: number | null;
436
483
  };
437
- } | PhisPaymentChangedEventFacts;
484
+ } | PhisPaymentChangedEventFacts | {
485
+ event: PhisAddonEmittedEventId;
486
+ subject: PhisAddonEmittedEventSubject;
487
+ };
438
488
  export type PhisAddonEventContext = PhisAddonEventFacts & {
439
489
  addonId: string;
440
490
  /** This delivery, stable across retries, so a handler can recognise one it already settled. */
package/dist/core.d.ts CHANGED
@@ -50,6 +50,8 @@ export declare const PHIS_CORE_CAPABILITIES: {
50
50
  readonly paymentNotifications: "@phis/server/payment-notifications:v1";
51
51
  /** Writing to a person about an occasion the Add-on declared, with Core owning frame and transport. */
52
52
  readonly notifications: "@phis/server/notifications:v1";
53
+ /** Announcing an event this Add-on declared under its own name, for the Add-ons of this Site that listen. */
54
+ readonly events: "@phis/server/events:v1";
53
55
  };
54
56
  export type PhisCoreCapabilityId = (typeof PHIS_CORE_CAPABILITIES)[keyof typeof PHIS_CORE_CAPABILITIES];
55
57
  /**
@@ -75,8 +77,27 @@ export declare const PHIS_ADDON_EVENTS: {
75
77
  readonly paymentChanged: "payment.changed";
76
78
  };
77
79
  export type PhisAddonThreadEventId = Exclude<(typeof PHIS_ADDON_EVENTS)[keyof typeof PHIS_ADDON_EVENTS], "payment.changed">;
78
- /** Delivered v1 event vocabulary, discriminated by its owned subject. */
79
- export type PhisAddonEventId = PhisAddonThreadEventId | PhisPaymentChangedEventFacts["event"];
80
+ /** The events Core itself emits: the thread events and the Payment event. */
81
+ export type PhisCoreEventId = PhisAddonThreadEventId | PhisPaymentChangedEventFacts["event"];
82
+ /**
83
+ * An event an Add-on emits, named under the Add-on that emits it: `@scope/name:what.happened`.
84
+ *
85
+ * The emitter's own id is the first half, so two Add-ons cannot name the same event and a reader of
86
+ * a manifest sees who announces it without looking anything up. The second half is the Add-on's own
87
+ * word for it, `^[a-z][a-z0-9.-]*$`, the shape Core's own event names have. Core's names stay bare:
88
+ * a bare name is Core's, a prefixed one is an Add-on's, and the two can never be confused.
89
+ */
90
+ export type PhisAddonEmittedEventId = `@${string}/${string}:${string}`;
91
+ /** Delivered v1 event vocabulary: Core's own events, and what an installed Add-on emits. */
92
+ export type PhisAddonEventId = PhisCoreEventId | PhisAddonEmittedEventId;
93
+ /**
94
+ * How many Add-ons an event may pass through.
95
+ *
96
+ * An Add-on that emits inside an event handler writes an event with the incoming hop plus one. A
97
+ * handler that answers every event with an event, met by another doing the same, would otherwise
98
+ * write forever; at this depth the emit is refused and logged instead of delivered.
99
+ */
100
+ export declare const PHIS_ADDON_EVENT_MAX_HOPS = 4;
80
101
  /**
81
102
  * The Core-owned service kinds an Add-on may implement.
82
103
  *
package/dist/core.js CHANGED
@@ -49,6 +49,8 @@ export const PHIS_CORE_CAPABILITIES = {
49
49
  paymentNotifications: "@phis/server/payment-notifications:v1",
50
50
  /** Writing to a person about an occasion the Add-on declared, with Core owning frame and transport. */
51
51
  notifications: "@phis/server/notifications:v1",
52
+ /** Announcing an event this Add-on declared under its own name, for the Add-ons of this Site that listen. */
53
+ events: "@phis/server/events:v1",
52
54
  };
53
55
  /**
54
56
  * What Core tells an Add-on about after the fact.
@@ -72,6 +74,14 @@ export const PHIS_ADDON_EVENTS = {
72
74
  threadLinkRemoved: "thread.link.removed",
73
75
  paymentChanged: "payment.changed",
74
76
  };
77
+ /**
78
+ * How many Add-ons an event may pass through.
79
+ *
80
+ * An Add-on that emits inside an event handler writes an event with the incoming hop plus one. A
81
+ * handler that answers every event with an event, met by another doing the same, would otherwise
82
+ * write forever; at this depth the emit is refused and logged instead of delivered.
83
+ */
84
+ export const PHIS_ADDON_EVENT_MAX_HOPS = 4;
75
85
  /**
76
86
  * The Core-owned service kinds an Add-on may implement.
77
87
  *
@@ -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.4",
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",