@hollware/core 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -290,6 +290,13 @@ interface SiteDefinition {
290
290
  readonly styleGuide?: string;
291
291
  /** The public origin of the site, such as `https://example.com`: entries' URLs start with it. */
292
292
  readonly url?: string;
293
+ /**
294
+ * The origins whose pages may read the published content of the site from the browser, over
295
+ * the read API with a read token (`hw_read_`). None by default.
296
+ */
297
+ readonly cors?: {
298
+ readonly origins: readonly string[];
299
+ };
293
300
  /** Who hears about publications (ADR-025). */
294
301
  readonly events?: {
295
302
  /** Each gets every event, signed with HMAC-SHA256 by the Worker secret named in `secret`. */
@@ -346,6 +353,10 @@ interface ResolvedSite {
346
353
  readonly collections: ReadonlyMap<string, ResolvedCollection>;
347
354
  /** The public origin of the site, or `null`. */
348
355
  readonly url: string | null;
356
+ /** The origins that may call the read API from a page, each as `URL.origin` gives it. */
357
+ readonly cors: {
358
+ readonly origins: readonly string[];
359
+ };
349
360
  readonly events: ResolvedEvents;
350
361
  }
351
362
  /** A webhook of a site; `secret` names the Worker secret that signs its deliveries. */
@@ -361,7 +372,7 @@ interface ResolvedEvents {
361
372
  readonly key: string;
362
373
  } | null;
363
374
  }
364
- /** The config once resolved, at the start of the Worker. v0.1 holds exactly one site. */
375
+ /** The config once resolved, at the start of the Worker: one site or more (ADR-027). */
365
376
  interface ResolvedConfig {
366
377
  readonly sites: ReadonlyMap<string, ResolvedSite>;
367
378
  readonly auth: ResolvedAuth;
@@ -428,7 +439,7 @@ export declare const DEFAULT_MAX_MARKDOWN_BYTES = 524288;
428
439
  */
429
440
  export declare const MAX_MARKDOWN_BYTES_CEILING = 786432;
430
441
  declare namespace schemas_d_exports {
431
- export { acknowledgedWarning, collectionDescription, collectionName, componentDescription, conflict, contractDescription, document, entry, entryId, entryStatus, entrySummary, format, locale, markdown, phase, publication, review, reviewId, reviewStatus, revisionId, severity, siteDescription, slug, verdict, version, violation };
442
+ export { acknowledgedWarning, collectionDescription, collectionName, componentDescription, conflict, contractDescription, document, entry, entryId, entryStatus, entrySummary, format, locale, markdown, phase, publication, review, reviewId, reviewStatus, revisionId, searchMode, severity, siteDescription, slug, verdict, version, violation };
432
443
  }
433
444
  /**
434
445
  * The pieces the operation schemas share. Their descriptions become the JSON Schema that agents
@@ -692,6 +703,12 @@ declare const format: z.ZodEnum<{
692
703
  html: "html";
693
704
  markdown: "markdown";
694
705
  }>;
706
+ /** How a search is made: by its words, by meaning, or both merged (ADR-030). */
707
+ declare const searchMode: z.ZodEnum<{
708
+ fulltext: "fulltext";
709
+ hybrid: "hybrid";
710
+ semantic: "semantic";
711
+ }>;
695
712
  declare const componentDescription: z.ZodObject<{
696
713
  name: z.ZodString;
697
714
  description: z.ZodNullable<z.ZodString>;
@@ -954,7 +971,7 @@ export declare function describeResolvedSite(site: ResolvedSite, options?: {
954
971
  /**
955
972
  * Checks the config of the deployment and fills in its defaults, or throws a `ConfigError` with
956
973
  * every problem it found, so that a broken `hollware.config.ts` stops the Worker at startup with
957
- * a message that says what to change. v0.1 serves exactly one site per deployment.
974
+ * a message that says what to change. A deployment serves one site or more.
958
975
  *
959
976
  * The parameter is `unknown` because the config comes from user code that may be plain
960
977
  * JavaScript; a `HollwareConfig` is accepted as is, so this is a `ResolveConfig`. Notes on the
@@ -1037,6 +1054,7 @@ export declare class HollwareError extends Error {
1037
1054
  export declare const ID_PREFIXES: {
1038
1055
  readonly challenge: 'chl';
1039
1056
  readonly delivery: 'dlv';
1057
+ readonly embedding: 'emb';
1040
1058
  readonly entry: 'ent';
1041
1059
  readonly event: 'evt';
1042
1060
  readonly invitation: 'inv';
@@ -1227,6 +1245,48 @@ export declare const listEntries: OperationSpec<"list_entries", z.ZodObject<{
1227
1245
  }, z.core.$strip>>;
1228
1246
  nextCursor: z.ZodNullable<z.ZodString>;
1229
1247
  }, z.core.$strip>>;
1248
+ export declare const search: OperationSpec<"search", z.ZodObject<{
1249
+ query: z.ZodString;
1250
+ collection: z.ZodOptional<z.ZodString>;
1251
+ locale: z.ZodOptional<z.ZodString>;
1252
+ limit: z.ZodDefault<z.ZodNumber>;
1253
+ mode: z.ZodOptional<z.ZodEnum<{
1254
+ fulltext: "fulltext";
1255
+ hybrid: "hybrid";
1256
+ semantic: "semantic";
1257
+ }>>;
1258
+ }, z.core.$strict>, z.ZodObject<{
1259
+ outcome: z.ZodEnum<{
1260
+ ok: "ok";
1261
+ }>;
1262
+ mode: z.ZodEnum<{
1263
+ fulltext: "fulltext";
1264
+ hybrid: "hybrid";
1265
+ semantic: "semantic";
1266
+ }>;
1267
+ results: z.ZodArray<z.ZodObject<{
1268
+ entry: z.ZodObject<{
1269
+ id: z.ZodString;
1270
+ collection: z.ZodString;
1271
+ locale: z.ZodString;
1272
+ slug: z.ZodString;
1273
+ translationGroup: z.ZodNullable<z.ZodString>;
1274
+ status: z.ZodEnum<{
1275
+ draft: "draft";
1276
+ published: "published";
1277
+ scheduled: "scheduled";
1278
+ }>;
1279
+ version: z.ZodNumber;
1280
+ frontmatter: z.ZodRecord<z.ZodString, z.ZodUnknown>;
1281
+ tags: z.ZodArray<z.ZodString>;
1282
+ hasUnpublishedChanges: z.ZodBoolean;
1283
+ publishedAt: z.ZodNullable<z.ZodString>;
1284
+ updatedAt: z.ZodString;
1285
+ }, z.core.$strip>;
1286
+ snippet: z.ZodString;
1287
+ score: z.ZodNumber;
1288
+ }, z.core.$strip>>;
1289
+ }, z.core.$strip>>;
1230
1290
  export declare const getEntry: OperationSpec<"get_entry", z.ZodObject<{
1231
1291
  collection: z.ZodString;
1232
1292
  locale: z.ZodString;
@@ -1237,6 +1297,7 @@ export declare const getEntry: OperationSpec<"get_entry", z.ZodObject<{
1237
1297
  markdown: "markdown";
1238
1298
  }>>>;
1239
1299
  published: z.ZodDefault<z.ZodBoolean>;
1300
+ related: z.ZodDefault<z.ZodBoolean>;
1240
1301
  }, z.core.$strict>, z.ZodObject<{
1241
1302
  outcome: z.ZodEnum<{
1242
1303
  not_found: "not_found";
@@ -1336,6 +1397,24 @@ export declare const getEntry: OperationSpec<"get_entry", z.ZodObject<{
1336
1397
  }, z.core.$strip>>;
1337
1398
  html: z.ZodOptional<z.ZodString>;
1338
1399
  }, z.core.$strip>>;
1400
+ related: z.ZodOptional<z.ZodArray<z.ZodObject<{
1401
+ id: z.ZodString;
1402
+ collection: z.ZodString;
1403
+ locale: z.ZodString;
1404
+ slug: z.ZodString;
1405
+ translationGroup: z.ZodNullable<z.ZodString>;
1406
+ status: z.ZodEnum<{
1407
+ draft: "draft";
1408
+ published: "published";
1409
+ scheduled: "scheduled";
1410
+ }>;
1411
+ version: z.ZodNumber;
1412
+ frontmatter: z.ZodRecord<z.ZodString, z.ZodUnknown>;
1413
+ tags: z.ZodArray<z.ZodString>;
1414
+ hasUnpublishedChanges: z.ZodBoolean;
1415
+ publishedAt: z.ZodNullable<z.ZodString>;
1416
+ updatedAt: z.ZodString;
1417
+ }, z.core.$strip>>>;
1339
1418
  }, z.core.$strip>>;
1340
1419
  export declare const getTranslations: OperationSpec<"get_translations", z.ZodObject<{
1341
1420
  collection: z.ZodString;
@@ -1802,6 +1881,38 @@ export declare const requestChanges: OperationSpec<"request_changes", z.ZodObjec
1802
1881
  note: z.ZodNullable<z.ZodString>;
1803
1882
  }, z.core.$strip>>;
1804
1883
  }, z.core.$strip>>;
1884
+ export declare const contentStats: OperationSpec<"content_stats", z.ZodObject<{
1885
+ days: z.ZodDefault<z.ZodNumber>;
1886
+ limit: z.ZodDefault<z.ZodNumber>;
1887
+ }, z.core.$strict>, z.ZodObject<{
1888
+ outcome: z.ZodEnum<{
1889
+ ok: "ok";
1890
+ unavailable: "unavailable";
1891
+ }>;
1892
+ since: z.ZodOptional<z.ZodString>;
1893
+ pages: z.ZodOptional<z.ZodArray<z.ZodObject<{
1894
+ path: z.ZodString;
1895
+ views: z.ZodNumber;
1896
+ entry: z.ZodNullable<z.ZodObject<{
1897
+ id: z.ZodString;
1898
+ collection: z.ZodString;
1899
+ locale: z.ZodString;
1900
+ slug: z.ZodString;
1901
+ translationGroup: z.ZodNullable<z.ZodString>;
1902
+ status: z.ZodEnum<{
1903
+ draft: "draft";
1904
+ published: "published";
1905
+ scheduled: "scheduled";
1906
+ }>;
1907
+ version: z.ZodNumber;
1908
+ frontmatter: z.ZodRecord<z.ZodString, z.ZodUnknown>;
1909
+ tags: z.ZodArray<z.ZodString>;
1910
+ hasUnpublishedChanges: z.ZodBoolean;
1911
+ publishedAt: z.ZodNullable<z.ZodString>;
1912
+ updatedAt: z.ZodString;
1913
+ }, z.core.$strip>>;
1914
+ }, z.core.$strip>>>;
1915
+ }, z.core.$strip>>;
1805
1916
  /** Every operation, sorted by name: the order of `tools/list`. */
1806
1917
  export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.ZodObject<{
1807
1918
  id: z.ZodString;
@@ -1882,6 +1993,37 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
1882
1993
  }, z.core.$strip>>;
1883
1994
  contractsHash: z.ZodString;
1884
1995
  }, z.core.$strip>>;
1996
+ }, z.core.$strip>>, OperationSpec<"content_stats", z.ZodObject<{
1997
+ days: z.ZodDefault<z.ZodNumber>;
1998
+ limit: z.ZodDefault<z.ZodNumber>;
1999
+ }, z.core.$strict>, z.ZodObject<{
2000
+ outcome: z.ZodEnum<{
2001
+ ok: "ok";
2002
+ unavailable: "unavailable";
2003
+ }>;
2004
+ since: z.ZodOptional<z.ZodString>;
2005
+ pages: z.ZodOptional<z.ZodArray<z.ZodObject<{
2006
+ path: z.ZodString;
2007
+ views: z.ZodNumber;
2008
+ entry: z.ZodNullable<z.ZodObject<{
2009
+ id: z.ZodString;
2010
+ collection: z.ZodString;
2011
+ locale: z.ZodString;
2012
+ slug: z.ZodString;
2013
+ translationGroup: z.ZodNullable<z.ZodString>;
2014
+ status: z.ZodEnum<{
2015
+ draft: "draft";
2016
+ published: "published";
2017
+ scheduled: "scheduled";
2018
+ }>;
2019
+ version: z.ZodNumber;
2020
+ frontmatter: z.ZodRecord<z.ZodString, z.ZodUnknown>;
2021
+ tags: z.ZodArray<z.ZodString>;
2022
+ hasUnpublishedChanges: z.ZodBoolean;
2023
+ publishedAt: z.ZodNullable<z.ZodString>;
2024
+ updatedAt: z.ZodString;
2025
+ }, z.core.$strip>>;
2026
+ }, z.core.$strip>>>;
1885
2027
  }, z.core.$strip>>, OperationSpec<"create_draft", z.ZodObject<{
1886
2028
  collection: z.ZodString;
1887
2029
  locale: z.ZodString;
@@ -1988,6 +2130,7 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
1988
2130
  markdown: "markdown";
1989
2131
  }>>>;
1990
2132
  published: z.ZodDefault<z.ZodBoolean>;
2133
+ related: z.ZodDefault<z.ZodBoolean>;
1991
2134
  }, z.core.$strict>, z.ZodObject<{
1992
2135
  outcome: z.ZodEnum<{
1993
2136
  not_found: "not_found";
@@ -2087,6 +2230,24 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
2087
2230
  }, z.core.$strip>>;
2088
2231
  html: z.ZodOptional<z.ZodString>;
2089
2232
  }, z.core.$strip>>;
2233
+ related: z.ZodOptional<z.ZodArray<z.ZodObject<{
2234
+ id: z.ZodString;
2235
+ collection: z.ZodString;
2236
+ locale: z.ZodString;
2237
+ slug: z.ZodString;
2238
+ translationGroup: z.ZodNullable<z.ZodString>;
2239
+ status: z.ZodEnum<{
2240
+ draft: "draft";
2241
+ published: "published";
2242
+ scheduled: "scheduled";
2243
+ }>;
2244
+ version: z.ZodNumber;
2245
+ frontmatter: z.ZodRecord<z.ZodString, z.ZodUnknown>;
2246
+ tags: z.ZodArray<z.ZodString>;
2247
+ hasUnpublishedChanges: z.ZodBoolean;
2248
+ publishedAt: z.ZodNullable<z.ZodString>;
2249
+ updatedAt: z.ZodString;
2250
+ }, z.core.$strip>>>;
2090
2251
  }, z.core.$strip>>, OperationSpec<"get_translations", z.ZodObject<{
2091
2252
  collection: z.ZodString;
2092
2253
  translationGroup: z.ZodString;
@@ -2312,6 +2473,47 @@ export declare const OPERATIONS: readonly [OperationSpec<"approve_review", z.Zod
2312
2473
  decidedAt: z.ZodNullable<z.ZodString>;
2313
2474
  note: z.ZodNullable<z.ZodString>;
2314
2475
  }, z.core.$strip>>;
2476
+ }, z.core.$strip>>, OperationSpec<"search", z.ZodObject<{
2477
+ query: z.ZodString;
2478
+ collection: z.ZodOptional<z.ZodString>;
2479
+ locale: z.ZodOptional<z.ZodString>;
2480
+ limit: z.ZodDefault<z.ZodNumber>;
2481
+ mode: z.ZodOptional<z.ZodEnum<{
2482
+ fulltext: "fulltext";
2483
+ hybrid: "hybrid";
2484
+ semantic: "semantic";
2485
+ }>>;
2486
+ }, z.core.$strict>, z.ZodObject<{
2487
+ outcome: z.ZodEnum<{
2488
+ ok: "ok";
2489
+ }>;
2490
+ mode: z.ZodEnum<{
2491
+ fulltext: "fulltext";
2492
+ hybrid: "hybrid";
2493
+ semantic: "semantic";
2494
+ }>;
2495
+ results: z.ZodArray<z.ZodObject<{
2496
+ entry: z.ZodObject<{
2497
+ id: z.ZodString;
2498
+ collection: z.ZodString;
2499
+ locale: z.ZodString;
2500
+ slug: z.ZodString;
2501
+ translationGroup: z.ZodNullable<z.ZodString>;
2502
+ status: z.ZodEnum<{
2503
+ draft: "draft";
2504
+ published: "published";
2505
+ scheduled: "scheduled";
2506
+ }>;
2507
+ version: z.ZodNumber;
2508
+ frontmatter: z.ZodRecord<z.ZodString, z.ZodUnknown>;
2509
+ tags: z.ZodArray<z.ZodString>;
2510
+ hasUnpublishedChanges: z.ZodBoolean;
2511
+ publishedAt: z.ZodNullable<z.ZodString>;
2512
+ updatedAt: z.ZodString;
2513
+ }, z.core.$strip>;
2514
+ snippet: z.ZodString;
2515
+ score: z.ZodNumber;
2516
+ }, z.core.$strip>>;
2315
2517
  }, z.core.$strip>>, OperationSpec<"unpublish", z.ZodObject<{
2316
2518
  id: z.ZodString;
2317
2519
  }, z.core.$strict>, z.ZodObject<{
@@ -2456,6 +2658,11 @@ type OperationByName<Name extends OperationName> = Extract<Operation, {
2456
2658
  * row of a token has an id of its own (`tok_…`, see `ids.ts`), its scopes and its expiry.
2457
2659
  */
2458
2660
  export declare const PERSONAL_TOKEN_PREFIX = "hw_pat_";
2661
+ /**
2662
+ * Read tokens: `hw_read_` and the same 52 characters. A read token reads the published content of
2663
+ * one site over the read API (`GET /v1/…`) and nothing else, so a site may ship one to a browser.
2664
+ */
2665
+ export declare const READ_TOKEN_PREFIX = "hw_read_";
2459
2666
  /** Days until a new personal token expires, unless whoever creates it asks for another expiry. */
2460
2667
  export declare const PERSONAL_TOKEN_TTL_DAYS = 90;
2461
2668
  /**
@@ -2464,8 +2671,12 @@ export declare const PERSONAL_TOKEN_TTL_DAYS = 90;
2464
2671
  * tests pass their own bytes.
2465
2672
  */
2466
2673
  export declare function generatePersonalToken(random?: Uint8Array): string;
2674
+ /** A new read token, made as a personal token is. */
2675
+ export declare function generateReadToken(random?: Uint8Array): string;
2467
2676
  /** Whether a string has the shape of a personal token. It says nothing about whether one exists. */
2468
2677
  export declare function isPersonalToken(value: string): boolean;
2678
+ /** Whether a string has the shape of a read token. It says nothing about whether one exists. */
2679
+ export declare function isReadToken(value: string): boolean;
2469
2680
  /** The SHA-256 of a token, in lowercase hex: what the `tokens` table stores and looks up. */
2470
2681
  export declare function hashPersonalToken(token: string): Promise<string>;
2471
2682
  //#endregion
@@ -2496,7 +2707,38 @@ export interface WebhookCheck {
2496
2707
  */
2497
2708
  export declare function verifyWebhook(check: WebhookCheck): Promise<boolean>;
2498
2709
  //#endregion
2710
+ //#region src/search/chunks.d.ts
2711
+ /**
2712
+ * The pieces of an entry that semantic search embeds (v0.5): one summary, and one piece for each
2713
+ * section the body has under an `h2`. Vectors are derived data, like the Comark document: a change
2714
+ * of this version makes every vector one to make again.
2715
+ */
2716
+ export declare const CHUNKER_VERSION = "1";
2717
+ interface Chunk {
2718
+ /** The place of the piece in the entry: 0 is the summary, then the sections in order. */
2719
+ readonly index: number;
2720
+ readonly kind: 'summary' | 'section';
2721
+ /** The heading of a section; `null` for the summary. */
2722
+ readonly heading: string | null;
2723
+ readonly text: string;
2724
+ }
2725
+ /** How long a piece may grow, in characters, before it is cut at a word. */
2726
+ export declare const CHUNK_LIMITS: {
2727
+ readonly summary: 1200;
2728
+ readonly section: 2000;
2729
+ };
2730
+ /**
2731
+ * The pieces of an entry: the summary (its title, its description and the text before the first
2732
+ * `h2`), then each section (its heading and its text up to the next `h2`). An `h1` adds nothing:
2733
+ * it repeats the title. A section with no text is left out.
2734
+ */
2735
+ export declare function chunksOf(entry: {
2736
+ readonly title: string;
2737
+ readonly description?: string;
2738
+ readonly document: EntryDocument;
2739
+ }): Chunk[];
2740
+ //#endregion
2499
2741
  //#region src/index.d.ts
2500
2742
  export declare const name = "@hollware/core";
2501
2743
  //#endregion
2502
- export { type AcknowledgedWarning, type AuthDefinition, type BuiltInContractId, type BuiltInOptions, type CollectionDefinition, type ComponentDefinition, type ComponentUse, type ConfigProblem, type Contract, type ContractContext, type ContractDescription, type ContractFinding, type ContractText, type CustomContractOptions, type DescribedSchema, type DisallowedMarker, type Effect, type EntryDocument, type EntryFacts, type EntryLookup, type EntryRef, type ErrorCode, type ExtractedContent, type Heading, type HollwareConfig, type IdPrefix, type ImageRef, type LinkRef, type LinkTarget, type Operation, type OperationByName, type OperationName, type OperationSpec, type OutcomeInfo, type OutcomeOf, type Phase, type ProcessMarkdown, type ProcessMarkdownOptions, type ProcessResult, type ProcessedMarkdown, type RenderHtml, type ResolveConfig, type ResolvedAuth, type ResolvedCollection, type ResolvedConfig, type ResolvedSite, type RunContractsInput, type SchemaOutput, type Scope, type Severity, type SiteDefinition, type SiteDescription, type Verdict, type Violation, schemas_d_exports as schemas };
2744
+ export { type AcknowledgedWarning, type AuthDefinition, type BuiltInContractId, type BuiltInOptions, type Chunk, type CollectionDefinition, type ComponentDefinition, type ComponentUse, type ConfigProblem, type Contract, type ContractContext, type ContractDescription, type ContractFinding, type ContractText, type CustomContractOptions, type DescribedSchema, type DisallowedMarker, type Effect, type EntryDocument, type EntryFacts, type EntryLookup, type EntryRef, type ErrorCode, type ExtractedContent, type Heading, type HollwareConfig, type IdPrefix, type ImageRef, type LinkRef, type LinkTarget, type Operation, type OperationByName, type OperationName, type OperationSpec, type OutcomeInfo, type OutcomeOf, type Phase, type ProcessMarkdown, type ProcessMarkdownOptions, type ProcessResult, type ProcessedMarkdown, type RenderHtml, type ResolveConfig, type ResolvedAuth, type ResolvedCollection, type ResolvedConfig, type ResolvedSite, type RunContractsInput, type SchemaOutput, type Scope, type Severity, type SiteDefinition, type SiteDescription, type Verdict, type Violation, schemas_d_exports as schemas };
package/dist/index.mjs CHANGED
@@ -1396,6 +1396,20 @@ function readSiteUrl(path, raw, problems) {
1396
1396
  }
1397
1397
  return url.origin;
1398
1398
  }
1399
+ /** The origins of a site's pages that may read its published content from the browser. */
1400
+ function readCors(path, raw, problems) {
1401
+ if (raw === void 0) return { origins: [] };
1402
+ if (!isRecord$1(raw) || !Array.isArray(raw["origins"])) {
1403
+ problems.add(path, wrongType(raw, "an object with origins"), "Declare it as { origins: [\"https://example.com\"] }, or remove it.");
1404
+ return { origins: [] };
1405
+ }
1406
+ const origins = [];
1407
+ for (const [index, origin] of raw["origins"].entries()) {
1408
+ const read = readSiteUrl(itemPath(keyPath(path, "origins"), index), origin, problems);
1409
+ if (read !== null && !origins.includes(read)) origins.push(read);
1410
+ }
1411
+ return { origins };
1412
+ }
1399
1413
  function readBindingName(path, raw, problems) {
1400
1414
  if (typeof raw === "string" && BINDING_NAME.test(raw)) return raw;
1401
1415
  problems.add(path, wrongType(raw, "the name of a Worker secret"), "Name the secret that holds the value, such as \"WEBHOOK_SECRET\", and set it with wrangler secret put.");
@@ -1573,6 +1587,7 @@ function readSite(key, raw, problems) {
1573
1587
  styleGuide,
1574
1588
  collections,
1575
1589
  url,
1590
+ cors: readCors(keyPath(path, "cors"), raw["cors"], problems),
1576
1591
  events: readEvents(keyPath(path, "events"), raw["events"], url, problems)
1577
1592
  };
1578
1593
  }
@@ -1583,8 +1598,7 @@ function readSites(raw, problems) {
1583
1598
  return sites;
1584
1599
  }
1585
1600
  const keys = Object.keys(raw);
1586
- if (keys.length === 0) problems.add("sites", "has no site, and v0.1 serves exactly one site per deployment", SITE_FIX);
1587
- else if (keys.length > 1) problems.add("sites", `has ${keys.length} sites (${keys.map(quote).join(", ")}), and v0.1 serves exactly one site per deployment`, "Keep exactly one site in sites.");
1601
+ if (keys.length === 0) problems.add("sites", "has no site, and a deployment serves at least one", SITE_FIX);
1588
1602
  for (const key of keys) {
1589
1603
  const site = readSite(key, raw[key], problems);
1590
1604
  if (site !== null) sites.set(key, site);
@@ -1609,7 +1623,7 @@ function readAuth(raw, problems) {
1609
1623
  /**
1610
1624
  * Checks the config of the deployment and fills in its defaults, or throws a `ConfigError` with
1611
1625
  * every problem it found, so that a broken `hollware.config.ts` stops the Worker at startup with
1612
- * a message that says what to change. v0.1 serves exactly one site per deployment.
1626
+ * a message that says what to change. A deployment serves one site or more.
1613
1627
  *
1614
1628
  * The parameter is `unknown` because the config comes from user code that may be plain
1615
1629
  * JavaScript; a `HollwareConfig` is accepted as is, so this is a `ResolveConfig`. Notes on the
@@ -1688,6 +1702,7 @@ const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
1688
1702
  const ID_PREFIXES = {
1689
1703
  challenge: "chl",
1690
1704
  delivery: "dlv",
1705
+ embedding: "emb",
1691
1706
  entry: "ent",
1692
1707
  event: "evt",
1693
1708
  invitation: "inv",
@@ -3038,6 +3053,7 @@ var schemas_exports = /* @__PURE__ */ __exportAll({
3038
3053
  reviewId: () => reviewId,
3039
3054
  reviewStatus: () => reviewStatus,
3040
3055
  revisionId: () => revisionId,
3056
+ searchMode: () => searchMode,
3041
3057
  severity: () => severity,
3042
3058
  siteDescription: () => siteDescription,
3043
3059
  slug: () => slug,
@@ -3148,6 +3164,12 @@ const format = z.enum([
3148
3164
  "document",
3149
3165
  "html"
3150
3166
  ]);
3167
+ /** How a search is made: by its words, by meaning, or both merged (ADR-030). */
3168
+ const searchMode = z.enum([
3169
+ "fulltext",
3170
+ "semantic",
3171
+ "hybrid"
3172
+ ]);
3151
3173
  const componentDescription = z.object({
3152
3174
  name: z.string(),
3153
3175
  description: z.string().nullable(),
@@ -3226,6 +3248,30 @@ const listEntries = defineOperation({
3226
3248
  effect: "read",
3227
3249
  outcomes: { ok: { isError: false } }
3228
3250
  });
3251
+ const search = defineOperation({
3252
+ name: "search",
3253
+ title: "Search entries",
3254
+ description: "Searches the published entries of the site, best match first. Full text finds the entries that hold every word of query, in their title, tags and text; semantic finds them by meaning, in any language; hybrid merges both. Narrow it to a collection or a locale. Drafts are not searched: read them with list_entries.",
3255
+ input: z.strictObject({
3256
+ query: z.string().trim().min(1).max(200).describe("The words to find, or a question; punctuation is ignored by full text."),
3257
+ collection: collectionName.optional(),
3258
+ locale: locale.optional(),
3259
+ limit: z.number().int().min(1).max(50).default(10),
3260
+ mode: searchMode.optional().describe("fulltext, semantic or hybrid; hybrid when not given. Semantic and hybrid need the semantic search of the deployment: without it the search is full text, and mode in the answer says so.")
3261
+ }),
3262
+ output: z.object({
3263
+ outcome: z.enum(["ok"]),
3264
+ mode: searchMode.describe("How the search was made."),
3265
+ results: z.array(z.object({
3266
+ entry: entrySummary,
3267
+ snippet: z.string().describe("The words around the match, the words found between [ and ]; for a match by meaning, the start of the closest part."),
3268
+ score: z.number().describe("How well it matches: higher is better.")
3269
+ }))
3270
+ }),
3271
+ scopes: ["content:read"],
3272
+ effect: "read",
3273
+ outcomes: { ok: { isError: false } }
3274
+ });
3229
3275
  const getEntry = defineOperation({
3230
3276
  name: "get_entry",
3231
3277
  title: "Get an entry",
@@ -3235,11 +3281,13 @@ const getEntry = defineOperation({
3235
3281
  locale,
3236
3282
  slug,
3237
3283
  formats: z.array(format).min(1).default(["markdown"]).describe("What to return of the revision: markdown, the Comark document, or html."),
3238
- published: z.boolean().default(false).describe("Read the revision that is live instead of the latest one: not_found when the entry is not published.")
3284
+ published: z.boolean().default(false).describe("Read the revision that is live instead of the latest one: not_found when the entry is not published."),
3285
+ related: z.boolean().default(false).describe("Also return the published entries closest to this one by meaning, in its collection and locale, closest first. Needs the semantic search of the deployment; empty without it.")
3239
3286
  }),
3240
3287
  output: z.object({
3241
3288
  outcome: z.enum(["ok", "not_found"]),
3242
- entry: entry.optional()
3289
+ entry: entry.optional(),
3290
+ related: z.array(entrySummary).optional().describe("When related was asked for.")
3243
3291
  }),
3244
3292
  scopes: ["content:read"],
3245
3293
  effect: "read",
@@ -3466,9 +3514,34 @@ const requestChanges = defineOperation({
3466
3514
  not_found: { isError: true }
3467
3515
  }
3468
3516
  });
3517
+ const contentStats = defineOperation({
3518
+ name: "content_stats",
3519
+ title: "Read what readers read",
3520
+ description: "The most read pages of the sites that read this one, over the last days, most read first, each with the published entry it shows when it shows one. Use it to pick what to update. unavailable when the deployment has no analytics.",
3521
+ input: z.strictObject({
3522
+ days: z.number().int().min(1).max(90).default(30).describe("How many days back to count."),
3523
+ limit: z.number().int().min(1).max(50).default(10)
3524
+ }),
3525
+ output: z.object({
3526
+ outcome: z.enum(["ok", "unavailable"]),
3527
+ since: z.string().optional().describe("The start of the count, in ISO 8601."),
3528
+ pages: z.array(z.object({
3529
+ path: z.string(),
3530
+ views: z.number().describe("Page views, sampling accounted for."),
3531
+ entry: entrySummary.nullable().describe("The published entry at that path, or null.")
3532
+ })).optional()
3533
+ }),
3534
+ scopes: ["content:read"],
3535
+ effect: "read",
3536
+ outcomes: {
3537
+ ok: { isError: false },
3538
+ unavailable: { isError: true }
3539
+ }
3540
+ });
3469
3541
  /** Every operation, sorted by name: the order of `tools/list`. */
3470
3542
  const OPERATIONS = [
3471
3543
  approveReview,
3544
+ contentStats,
3472
3545
  createDraft,
3473
3546
  describeSite,
3474
3547
  getEntry,
@@ -3477,6 +3550,7 @@ const OPERATIONS = [
3477
3550
  listReviews,
3478
3551
  publish,
3479
3552
  requestChanges,
3553
+ search,
3480
3554
  unpublish,
3481
3555
  updateDraft,
3482
3556
  validate
@@ -3506,26 +3580,43 @@ function isScope(value) {
3506
3580
  * row of a token has an id of its own (`tok_…`, see `ids.ts`), its scopes and its expiry.
3507
3581
  */
3508
3582
  const PERSONAL_TOKEN_PREFIX = "hw_pat_";
3583
+ /**
3584
+ * Read tokens: `hw_read_` and the same 52 characters. A read token reads the published content of
3585
+ * one site over the read API (`GET /v1/…`) and nothing else, so a site may ship one to a browser.
3586
+ */
3587
+ const READ_TOKEN_PREFIX = "hw_read_";
3509
3588
  /** Days until a new personal token expires, unless whoever creates it asks for another expiry. */
3510
3589
  const PERSONAL_TOKEN_TTL_DAYS = 90;
3511
3590
  const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
3512
3591
  const SECRET_LENGTH = 52;
3513
3592
  const TOKEN_PATTERN = /^hw_pat_[0-9a-hjkmnp-tv-z]{52}$/;
3593
+ const READ_TOKEN_PATTERN = /^hw_read_[0-9a-hjkmnp-tv-z]{52}$/;
3514
3594
  /**
3515
3595
  * A new personal token. Each of the 52 random bytes gives one character (its low 5 bits, which
3516
3596
  * are uniform because 256 is a multiple of 32). `random` defaults to `crypto.getRandomValues`;
3517
3597
  * tests pass their own bytes.
3518
3598
  */
3519
3599
  function generatePersonalToken(random = crypto.getRandomValues(new Uint8Array(SECRET_LENGTH))) {
3520
- if (random.length !== SECRET_LENGTH) throw new RangeError(`a personal token takes ${SECRET_LENGTH} random bytes, got ${random.length}`);
3600
+ return PERSONAL_TOKEN_PREFIX + secretOf(random, "a personal token");
3601
+ }
3602
+ /** A new read token, made as a personal token is. */
3603
+ function generateReadToken(random = crypto.getRandomValues(new Uint8Array(SECRET_LENGTH))) {
3604
+ return READ_TOKEN_PREFIX + secretOf(random, "a read token");
3605
+ }
3606
+ function secretOf(random, what) {
3607
+ if (random.length !== SECRET_LENGTH) throw new RangeError(`${what} takes ${SECRET_LENGTH} random bytes, got ${random.length}`);
3521
3608
  let secret = "";
3522
3609
  for (const byte of random) secret += ALPHABET.charAt(byte & 31);
3523
- return PERSONAL_TOKEN_PREFIX + secret;
3610
+ return secret;
3524
3611
  }
3525
3612
  /** Whether a string has the shape of a personal token. It says nothing about whether one exists. */
3526
3613
  function isPersonalToken(value) {
3527
3614
  return TOKEN_PATTERN.test(value);
3528
3615
  }
3616
+ /** Whether a string has the shape of a read token. It says nothing about whether one exists. */
3617
+ function isReadToken(value) {
3618
+ return READ_TOKEN_PATTERN.test(value);
3619
+ }
3529
3620
  /** The SHA-256 of a token, in lowercase hex: what the `tokens` table stores and looks up. */
3530
3621
  async function hashPersonalToken(token) {
3531
3622
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token));
@@ -3576,7 +3667,82 @@ async function verifyWebhook(check) {
3576
3667
  return sameText(signature, await signWebhook(check.secret, timestamp, check.body));
3577
3668
  }
3578
3669
  //#endregion
3670
+ //#region src/search/chunks.ts
3671
+ /**
3672
+ * The pieces of an entry that semantic search embeds (v0.5): one summary, and one piece for each
3673
+ * section the body has under an `h2`. Vectors are derived data, like the Comark document: a change
3674
+ * of this version makes every vector one to make again.
3675
+ */
3676
+ const CHUNKER_VERSION = "1";
3677
+ /** How long a piece may grow, in characters, before it is cut at a word. */
3678
+ const CHUNK_LIMITS = {
3679
+ summary: 1200,
3680
+ section: 2e3
3681
+ };
3682
+ /** The text a reader sees in a node: its strings, never its tag, attributes or comments. */
3683
+ function textOf(node) {
3684
+ if (typeof node === "string") return node;
3685
+ if (!Array.isArray(node) || node[0] === null) return "";
3686
+ return node.slice(2).map((child) => textOf(child)).join("");
3687
+ }
3688
+ function tagOf(node) {
3689
+ return Array.isArray(node) && typeof node[0] === "string" ? node[0] : null;
3690
+ }
3691
+ /** The words of a text in one line, cut at the last word that fits in `limit`. */
3692
+ function fit(text, limit) {
3693
+ const flat = text.replace(/\s+/g, " ").trim();
3694
+ if (flat.length <= limit) return flat;
3695
+ const cut = flat.slice(0, limit);
3696
+ const lastSpace = cut.lastIndexOf(" ");
3697
+ return (lastSpace > 0 ? cut.slice(0, lastSpace) : cut).trim();
3698
+ }
3699
+ /**
3700
+ * The pieces of an entry: the summary (its title, its description and the text before the first
3701
+ * `h2`), then each section (its heading and its text up to the next `h2`). An `h1` adds nothing:
3702
+ * it repeats the title. A section with no text is left out.
3703
+ */
3704
+ function chunksOf(entry) {
3705
+ const intro = [];
3706
+ const sections = [];
3707
+ for (const node of entry.document.nodes) {
3708
+ const tag = tagOf(node);
3709
+ if (tag === "h1") continue;
3710
+ if (tag === "h2") {
3711
+ sections.push({
3712
+ heading: textOf(node).trim(),
3713
+ parts: []
3714
+ });
3715
+ continue;
3716
+ }
3717
+ const text = textOf(node);
3718
+ const current = sections.at(-1);
3719
+ if (current === void 0) intro.push(text);
3720
+ else current.parts.push(text);
3721
+ }
3722
+ const chunks = [{
3723
+ index: 0,
3724
+ kind: "summary",
3725
+ heading: null,
3726
+ text: fit([
3727
+ entry.title,
3728
+ entry.description ?? "",
3729
+ intro.join(" ")
3730
+ ].filter((part) => part !== "").join("\n"), CHUNK_LIMITS.summary)
3731
+ }];
3732
+ for (const section of sections) {
3733
+ const body = section.parts.join(" ").trim();
3734
+ if (body === "") continue;
3735
+ chunks.push({
3736
+ index: chunks.length,
3737
+ kind: "section",
3738
+ heading: section.heading,
3739
+ text: fit(`${section.heading}\n${body}`, CHUNK_LIMITS.section)
3740
+ });
3741
+ }
3742
+ return chunks;
3743
+ }
3744
+ //#endregion
3579
3745
  //#region src/index.ts
3580
3746
  const name = "@hollware/core";
3581
3747
  //#endregion
3582
- export { BUILT_IN_CONTRACT_IDS, COMARK_VERSION, ConfigError, DEFAULT_MAX_MARKDOWN_BYTES, ERROR_CODES, EVENT_ID_HEADER, HollwareError, ID_PREFIXES, MAX_MARKDOWN_BYTES_CEILING, OPERATIONS, PERSONAL_TOKEN_PREFIX, PERSONAL_TOKEN_TTL_DAYS, PIPELINE_VERSION, REFUSAL_CONTRACT_IDS, SCOPES, SIGNATURE_HEADER, SITE_KEY_MAX_LENGTH, SITE_KEY_PATTERN, TIMESTAMP_HEADER, approveReview, contracts, createDraft, defineCollection, defineComponent, defineConfig, defineContract, defineOperation, describeBuiltInContracts, describeContract, describeResolvedSite, describeSite, generatePersonalToken, getEntry, getTranslations, hashPersonalToken, isBuiltInContract, isId, isPersonalToken, isScope, isSiteKey, listEntries, listReviews, name, newId, processMarkdown, publish, renderHtml, requestChanges, resolveConfig, runContracts, schemas_exports as schemas, signWebhook, sizeLimitViolation, ulid, uniqueSlugViolation, uniqueTranslationViolation, unpublish, updateDraft, validate, verifyWebhook };
3748
+ export { BUILT_IN_CONTRACT_IDS, CHUNKER_VERSION, CHUNK_LIMITS, COMARK_VERSION, ConfigError, DEFAULT_MAX_MARKDOWN_BYTES, ERROR_CODES, EVENT_ID_HEADER, HollwareError, ID_PREFIXES, MAX_MARKDOWN_BYTES_CEILING, OPERATIONS, PERSONAL_TOKEN_PREFIX, PERSONAL_TOKEN_TTL_DAYS, PIPELINE_VERSION, READ_TOKEN_PREFIX, REFUSAL_CONTRACT_IDS, SCOPES, SIGNATURE_HEADER, SITE_KEY_MAX_LENGTH, SITE_KEY_PATTERN, TIMESTAMP_HEADER, approveReview, chunksOf, contentStats, contracts, createDraft, defineCollection, defineComponent, defineConfig, defineContract, defineOperation, describeBuiltInContracts, describeContract, describeResolvedSite, describeSite, generatePersonalToken, generateReadToken, getEntry, getTranslations, hashPersonalToken, isBuiltInContract, isId, isPersonalToken, isReadToken, isScope, isSiteKey, listEntries, listReviews, name, newId, processMarkdown, publish, renderHtml, requestChanges, resolveConfig, runContracts, schemas_exports as schemas, search, signWebhook, sizeLimitViolation, ulid, uniqueSlugViolation, uniqueTranslationViolation, unpublish, updateDraft, validate, verifyWebhook };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hollware/core",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "Contracts, operation specs, schemas and the Markdown pipeline of Hollware. No Cloudflare dependencies.",
5
5
  "homepage": "https://hollware.com",
6
6
  "bugs": {