@crombolo/render 0.13.1 → 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 +2 -2
- package/src/context.ts +17 -3
- package/src/excerpt.ts +118 -0
- package/src/index.ts +3 -0
- package/src/renderer.ts +4 -1
- package/src/search.ts +61 -0
- package/src/sign-in.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/render",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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(
|
|
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
|
+
'&': '&',
|
|
104
|
+
'<': '<',
|
|
105
|
+
'>': '>',
|
|
106
|
+
'"': '"',
|
|
107
|
+
"'": ''',
|
|
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
|
+
}
|