@crombolo/render 0.13.2 → 0.14.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/render",
3
- "version": "0.13.2",
3
+ "version": "0.14.1",
4
4
  "description": "TSX server rendering and the published-content model for crombolo.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -32,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@crombolo/core": "0.13.2",
35
+ "@crombolo/core": "0.14.1",
36
36
  "@types/react": "^19.3.0"
37
37
  }
38
38
  }
package/src/context.ts CHANGED
@@ -12,9 +12,12 @@
12
12
  import { AsyncLocalStorage } from 'node:async_hooks'
13
13
  import type { Child } from './jsx-runtime.ts'
14
14
  import type { PageProps } from './model.ts'
15
+ import type { SiteSearchPort } from './search.ts'
15
16
 
16
17
  interface RenderScope {
17
18
  page: PageProps | undefined
19
+ /** The site search the server scoped to the person asking, when it provides one. */
20
+ search?: SiteSearchPort
18
21
  contexts: ReadonlyMap<symbol, unknown>
19
22
  /** Shared by every scope a provider opens inside one render, so ids stay unique. */
20
23
  ids: { next: number }
@@ -30,8 +33,8 @@ if (!shared[KEY]) shared[KEY] = new AsyncLocalStorage<RenderScope>()
30
33
  const store = shared[KEY] as AsyncLocalStorage<RenderScope>
31
34
 
32
35
  /** Runs a render with `page` available to `usePage()`, and ids counted from zero. */
33
- export function renderScope<T>(page: PageProps, render: () => T): T {
34
- return store.run({ page, contexts: new Map(), ids: { next: 0 } }, render)
36
+ export function renderScope<T>(page: PageProps, render: () => T, search?: SiteSearchPort): T {
37
+ return store.run({ page, search, contexts: new Map(), ids: { next: 0 } }, render)
35
38
  }
36
39
 
37
40
  function within(caller: string): RenderScope {
@@ -56,6 +59,14 @@ export function usePage<Properties = any>(): PageProps<Properties> {
56
59
  return page as PageProps<Properties>
57
60
  }
58
61
 
62
+ /** The page and the site search for `search()`, which lives in `search.ts`. */
63
+ export function currentSiteSearch(): { page: PageProps; search: SiteSearchPort | undefined } {
64
+ const scope = within('search()')
65
+ if (!scope.page)
66
+ throw new Error('search() was called inside a context Provider rendered outside a page.')
67
+ return { page: scope.page, search: scope.search }
68
+ }
69
+
59
70
  /**
60
71
  * An id unique within this render, for tying a label to its input or an
61
72
  * `aria-describedby` to its text. Not for CSS selectors: the colons need escaping.
@@ -100,7 +111,10 @@ export function createContext<T>(defaultValue: T): Context<T> {
100
111
  const outer = store.getStore()
101
112
  const contexts = new Map(outer?.contexts)
102
113
  contexts.set(key, value)
103
- return store.run({ page: outer?.page, contexts, ids: outer?.ids ?? { next: 0 } }, children)
114
+ return store.run(
115
+ { page: outer?.page, search: outer?.search, contexts, ids: outer?.ids ?? { next: 0 } },
116
+ children,
117
+ )
104
118
  }
105
119
  return Object.assign(Provider, { Provider, defaultValue, key }) as Context<T>
106
120
  }
package/src/excerpt.ts ADDED
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Search excerpts: the stretch of a page's text around the words searched for,
3
+ * with each matching word marked. Words are compared folded — lowercase, no
4
+ * diacritics — exactly as the index matches them, and a word matches a query
5
+ * word it starts with. Pure, so both dialects give the same excerpt.
6
+ */
7
+
8
+ /** A page's text by weight, as the site-search index keeps it. */
9
+ export interface SearchableText {
10
+ name: string
11
+ high: readonly string[]
12
+ normal: readonly string[]
13
+ low: readonly string[]
14
+ }
15
+
16
+ /** A run of excerpt text; `match` when it is a word the query matched. */
17
+ export interface ExcerptSegment {
18
+ text: string
19
+ match: boolean
20
+ }
21
+
22
+ const WORD = /[\p{L}\p{N}]+/gu
23
+
24
+ /** A word as the index stores it: decomposed, diacritics stripped, lowercase. */
25
+ export function foldWord(word: string): string {
26
+ return word
27
+ .normalize('NFKD')
28
+ .replace(/\p{M}+/gu, '')
29
+ .toLowerCase()
30
+ }
31
+
32
+ /** The folded words of a query. */
33
+ export function queryWords(query: string): string[] {
34
+ return [...new Set((query.match(WORD) ?? []).map(foldWord).filter(Boolean))]
35
+ }
36
+
37
+ const matches = (word: string, terms: readonly string[]) => {
38
+ const folded = foldWord(word)
39
+ return terms.some((term) => folded.startsWith(term))
40
+ }
41
+
42
+ /**
43
+ * About `length` characters of the page's text around the first matching
44
+ * word, taken from its highest-weighted text that matches, else from the start
45
+ * of its text. Ends that were cut are marked with an ellipsis.
46
+ */
47
+ export function excerpt(text: SearchableText, query: string, length = 200): ExcerptSegment[] {
48
+ const terms = queryWords(query)
49
+ const fields = [text.high, text.normal, text.low]
50
+ .map((field) => field.join(' … ').replace(/\s+/g, ' ').trim())
51
+ .filter(Boolean)
52
+ if (fields.length === 0) return []
53
+ let source = fields[0] as string
54
+ let first = -1
55
+ for (const field of fields) {
56
+ const hit = [...field.matchAll(WORD)].find((m) => matches(m[0], terms))
57
+ if (hit) {
58
+ source = field
59
+ first = hit.index
60
+ break
61
+ }
62
+ }
63
+
64
+ let start = first < 0 ? 0 : Math.max(0, first - Math.floor(length / 3))
65
+ let end = Math.min(source.length, start + length)
66
+ if (end - start < length) start = Math.max(0, end - length)
67
+ // Cut at spaces, so no word is shown in part.
68
+ if (start > 0) {
69
+ const space = source.indexOf(' ', start)
70
+ if (space >= 0 && space < (first < 0 ? end : first)) start = space + 1
71
+ }
72
+ if (end < source.length) {
73
+ const space = source.lastIndexOf(' ', end)
74
+ if (space > start) end = space
75
+ }
76
+ // A cut that lands on the separator between two texts keeps one ellipsis, not two.
77
+ const window = source
78
+ .slice(start, end)
79
+ .replace(/^(?:…\s*)+/, '')
80
+ .replace(/(?:\s*…)+$/, '')
81
+
82
+ const segments: ExcerptSegment[] = []
83
+ const push = (value: string, match: boolean) => {
84
+ if (!value) return
85
+ const last = segments[segments.length - 1]
86
+ if (last && last.match === match) last.text += value
87
+ else segments.push({ text: value, match })
88
+ }
89
+ if (start > 0) push('… ', false)
90
+ let at = 0
91
+ for (const word of window.matchAll(WORD)) {
92
+ if (!matches(word[0], terms)) continue
93
+ push(window.slice(at, word.index), false)
94
+ push(word[0], true)
95
+ at = word.index + word[0].length
96
+ }
97
+ push(window.slice(at), false)
98
+ if (end < source.length) push(' …', false)
99
+ return segments
100
+ }
101
+
102
+ const ESCAPES: Record<string, string> = {
103
+ '&': '&amp;',
104
+ '<': '&lt;',
105
+ '>': '&gt;',
106
+ '"': '&quot;',
107
+ "'": '&#39;',
108
+ }
109
+
110
+ /** An excerpt as HTML: every segment escaped, the matches in `<mark>`. */
111
+ export function excerptHtml(segments: readonly ExcerptSegment[]): string {
112
+ return segments
113
+ .map((segment) => {
114
+ const text = segment.text.replace(/[&<>"']/g, (c) => ESCAPES[c] as string)
115
+ return segment.match ? `<mark>${text}</mark>` : text
116
+ })
117
+ .join('')
118
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './context.ts'
2
+ export * from './excerpt.ts'
2
3
  export * from './form-script.ts'
3
4
  export * from './forms.ts'
4
5
  export * from './html.ts'
@@ -9,7 +10,9 @@ export * from './model.ts'
9
10
  export * from './published-cache.ts'
10
11
  export * from './redirects.ts'
11
12
  export * from './renderer.ts'
13
+ export * from './search.ts'
12
14
  export * from './seo.ts'
15
+ export * from './sign-in.ts'
13
16
  export * from './sitemap.ts'
14
17
  export * from './snapshots.ts'
15
18
  export * from './url-segment.ts'
package/src/renderer.ts CHANGED
@@ -15,6 +15,7 @@ import { RawHtml } from './html.ts'
15
15
  import { type Child, renderToString } from './jsx-runtime.ts'
16
16
  import type { PageProps, PublishedContent, RequestMember } from './model.ts'
17
17
  import { type PublishedCache, translate, urlsIn } from './published-cache.ts'
18
+ import type { SiteSearchPort } from './search.ts'
18
19
  import { absoluteUrl, type SeoSiteDefaults } from './seo.ts'
19
20
  import type { ViewSnapshots } from './snapshots.ts'
20
21
 
@@ -95,6 +96,8 @@ export interface RenderRequest {
95
96
  seo?: SeoSiteDefaults
96
97
  /** Aborts when nobody is waiting for the page any more: the visitor disconnected. */
97
98
  signal?: AbortSignal
99
+ /** Site search scoped to whoever is asking, for `search()`. */
100
+ search?: SiteSearchPort
98
101
  }
99
102
 
100
103
  /**
@@ -322,7 +325,7 @@ export class Renderer {
322
325
  }
323
326
 
324
327
  // Inside the page's scope, so any component can ask for it with `usePage()`.
325
- return renderScope(props, () => this.#renderLayouts(alias, props, rendering))
328
+ return renderScope(props, () => this.#renderLayouts(alias, props, rendering), request.search)
326
329
  }
327
330
 
328
331
  async #renderLayouts(
package/src/search.ts ADDED
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Site search from a component (story 140). The server answers it from the
3
+ * published-only index, leaving out pages the signed-in member — or a visitor
4
+ * who is not signed in — may not open, and hands back each page as published
5
+ * content with an excerpt around the words searched for.
6
+ */
7
+ import { currentSiteSearch } from './context.ts'
8
+ import type { ExcerptSegment } from './excerpt.ts'
9
+ import type { PublishedContent } from './model.ts'
10
+
11
+ export interface SearchOptions {
12
+ /** The culture searched; the page's own when absent. */
13
+ culture?: string | null
14
+ /** Content type aliases to keep to; every searchable type when absent. */
15
+ types?: readonly string[]
16
+ skip?: number
17
+ take?: number
18
+ }
19
+
20
+ export interface SearchResult {
21
+ content: PublishedContent
22
+ url: string
23
+ name: string
24
+ score: number
25
+ /** The text around the words searched for; render `match` segments marked. */
26
+ excerpt: ExcerptSegment[]
27
+ }
28
+
29
+ export interface SearchResults {
30
+ /** Every match the person searching may open, not only this page of them. */
31
+ total: number
32
+ items: SearchResult[]
33
+ }
34
+
35
+ /** What the server provides a render: a search already scoped to the person asking. */
36
+ export type SiteSearchPort = (
37
+ query: string,
38
+ options: { culture: string | null; types: readonly string[]; skip: number; take: number },
39
+ ) => Promise<SearchResults>
40
+
41
+ /**
42
+ * Searches the site's published pages, best match first:
43
+ *
44
+ * ```tsx
45
+ * const results = await search(q, { take: 10, skip: (page - 1) * 10 })
46
+ * ```
47
+ *
48
+ * Every word of the query must be in a page, by the start of a word; the name
49
+ * counts most, then the properties marked `site-search = "high"`, the rest,
50
+ * and those marked `"low"`.
51
+ */
52
+ export async function search(query: string, options: SearchOptions = {}): Promise<SearchResults> {
53
+ const { page, search: port } = currentSiteSearch()
54
+ if (!port) throw new Error('Site search is not available to this render.')
55
+ return port(query, {
56
+ culture: options.culture === undefined ? page.culture : options.culture,
57
+ types: options.types ?? [],
58
+ skip: Math.max(0, Math.floor(options.skip ?? 0)),
59
+ take: Math.min(100, Math.max(1, Math.floor(options.take ?? 10))),
60
+ })
61
+ }
package/src/sign-in.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Links for signing in through a member identity provider (story 031), for a
3
+ * site's login form: `<a href={externalSignInUrl('entra', returnUrl)}>`. The
4
+ * provider is one the site's config lists in `memberIdentityProviders`, by id.
5
+ */
6
+
7
+ /** Where member sign-ins through an identity provider start. */
8
+ export const MEMBER_EXTERNAL_SIGN_IN_PATH = '/umbraco/members/external'
9
+
10
+ /** The URL that signs a member in through a provider, coming back to `returnUrl` (a path on this site). */
11
+ export function externalSignInUrl(providerId: string, returnUrl?: string): string {
12
+ const query = returnUrl ? `?returnUrl=${encodeURIComponent(returnUrl)}` : ''
13
+ return `${MEMBER_EXTERNAL_SIGN_IN_PATH}/${encodeURIComponent(providerId)}${query}`
14
+ }