@life-and-dev/mdsite 0.0.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 +228 -0
- package/bin/mdsite.js +3 -0
- package/dist/commands/commands.test.d.ts +1 -0
- package/dist/commands/commands.test.js +302 -0
- package/dist/commands/commands.test.js.map +1 -0
- package/dist/commands/generate.d.ts +1 -0
- package/dist/commands/generate.js +20 -0
- package/dist/commands/generate.js.map +1 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +16 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/prepare.d.ts +1 -0
- package/dist/commands/prepare.js +111 -0
- package/dist/commands/prepare.js.map +1 -0
- package/dist/commands/prepare.test.d.ts +1 -0
- package/dist/commands/prepare.test.js +131 -0
- package/dist/commands/prepare.test.js.map +1 -0
- package/dist/commands/preview.d.ts +5 -0
- package/dist/commands/preview.js +62 -0
- package/dist/commands/preview.js.map +1 -0
- package/dist/commands/start.d.ts +5 -0
- package/dist/commands/start.js +50 -0
- package/dist/commands/start.js.map +1 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +26 -0
- package/dist/commands/stop.js.map +1 -0
- package/dist/commands/workflows.test.d.ts +1 -0
- package/dist/commands/workflows.test.js +183 -0
- package/dist/commands/workflows.test.js.map +1 -0
- package/dist/config/default-mdsite-config.d.ts +2 -0
- package/dist/config/default-mdsite-config.js +82 -0
- package/dist/config/default-mdsite-config.js.map +1 -0
- package/dist/config/default-mdsite-config.test.d.ts +1 -0
- package/dist/config/default-mdsite-config.test.js +42 -0
- package/dist/config/default-mdsite-config.test.js.map +1 -0
- package/dist/config/mdsite-config.d.ts +42 -0
- package/dist/config/mdsite-config.js +74 -0
- package/dist/config/mdsite-config.js.map +1 -0
- package/dist/config/mdsite-config.test.d.ts +1 -0
- package/dist/config/mdsite-config.test.js +115 -0
- package/dist/config/mdsite-config.test.js.map +1 -0
- package/dist/config/menu.d.ts +3 -0
- package/dist/config/menu.js +58 -0
- package/dist/config/menu.js.map +1 -0
- package/dist/config/menu.test.d.ts +1 -0
- package/dist/config/menu.test.js +53 -0
- package/dist/config/menu.test.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +99 -0
- package/dist/index.js.map +1 -0
- package/dist/index.test.d.ts +1 -0
- package/dist/index.test.js +133 -0
- package/dist/index.test.js.map +1 -0
- package/dist/process/child-process.d.ts +9 -0
- package/dist/process/child-process.js +142 -0
- package/dist/process/child-process.js.map +1 -0
- package/dist/process/child-process.test.d.ts +1 -0
- package/dist/process/child-process.test.js +131 -0
- package/dist/process/child-process.test.js.map +1 -0
- package/dist/process/runtime-state.d.ts +16 -0
- package/dist/process/runtime-state.js +42 -0
- package/dist/process/runtime-state.js.map +1 -0
- package/dist/process/runtime-state.test.d.ts +1 -0
- package/dist/process/runtime-state.test.js +57 -0
- package/dist/process/runtime-state.test.js.map +1 -0
- package/dist/renderer/mdsite-nuxt.d.ts +22 -0
- package/dist/renderer/mdsite-nuxt.js +194 -0
- package/dist/renderer/mdsite-nuxt.js.map +1 -0
- package/dist/renderer/mdsite-nuxt.test.d.ts +1 -0
- package/dist/renderer/mdsite-nuxt.test.js +226 -0
- package/dist/renderer/mdsite-nuxt.test.js.map +1 -0
- package/mdsite-nuxt/app/app.vue +7 -0
- package/mdsite-nuxt/app/assets/css/bible-tooltips.css +87 -0
- package/mdsite-nuxt/app/assets/css/markdown.css +195 -0
- package/mdsite-nuxt/app/assets/css/print.css +133 -0
- package/mdsite-nuxt/app/components/AppBar.vue +138 -0
- package/mdsite-nuxt/app/components/AppFooter.vue +88 -0
- package/mdsite-nuxt/app/components/AppNavigation.vue +162 -0
- package/mdsite-nuxt/app/components/AppTableOfContents.vue +149 -0
- package/mdsite-nuxt/app/components/BreadcrumbNav.vue +139 -0
- package/mdsite-nuxt/app/components/NavigationTree.vue +169 -0
- package/mdsite-nuxt/app/components/SearchBox.vue +212 -0
- package/mdsite-nuxt/app/components/TocItem.vue +75 -0
- package/mdsite-nuxt/app/components/TreeNode.vue +264 -0
- package/mdsite-nuxt/app/components/content/MarkdownAlert.vue +42 -0
- package/mdsite-nuxt/app/components/content/Mermaid.vue +103 -0
- package/mdsite-nuxt/app/components/content/ProseA.vue +57 -0
- package/mdsite-nuxt/app/components/content/ProseBlockquote.vue +14 -0
- package/mdsite-nuxt/app/components/content/ProsePre.vue +49 -0
- package/mdsite-nuxt/app/components/content/ProseTable.vue +143 -0
- package/mdsite-nuxt/app/composables/useAppTheme.ts +89 -0
- package/mdsite-nuxt/app/composables/useBibleTooltips.ts +21 -0
- package/mdsite-nuxt/app/composables/useBreadcrumbs.ts +70 -0
- package/mdsite-nuxt/app/composables/useContentPostProcessing.ts +89 -0
- package/mdsite-nuxt/app/composables/useNavigationTree.ts +174 -0
- package/mdsite-nuxt/app/composables/useSearchIndex.ts +47 -0
- package/mdsite-nuxt/app/composables/useSearchRelevance.test.ts +255 -0
- package/mdsite-nuxt/app/composables/useSearchRelevance.ts +207 -0
- package/mdsite-nuxt/app/composables/useSidebarState.ts +62 -0
- package/mdsite-nuxt/app/composables/useSiteConfig.ts +37 -0
- package/mdsite-nuxt/app/composables/useSourceEdit.ts +69 -0
- package/mdsite-nuxt/app/composables/useTableOfContents.ts +141 -0
- package/mdsite-nuxt/app/composables/useTableParser.ts +106 -0
- package/mdsite-nuxt/app/composables/useTooltipConfig.ts +20 -0
- package/mdsite-nuxt/app/config/themes.ts +17 -0
- package/mdsite-nuxt/app/layouts/default.vue +376 -0
- package/mdsite-nuxt/app/pages/[...slug].vue +56 -0
- package/mdsite-nuxt/app/pages/index.vue +54 -0
- package/mdsite-nuxt/app/plugins/bible-tooltips.client.ts +401 -0
- package/mdsite-nuxt/app/plugins/bible-tooltips.test.ts +284 -0
- package/mdsite-nuxt/app/types/content.d.ts +10 -0
- package/mdsite-nuxt/app/types/table.ts +57 -0
- package/mdsite-nuxt/app/utils/bible-book-names.ts +37 -0
- package/mdsite-nuxt/app/utils/bible-verse-utils.test.ts +148 -0
- package/mdsite-nuxt/app/utils/bible-verse-utils.ts +236 -0
- package/mdsite-nuxt/content.config.ts +18 -0
- package/mdsite-nuxt/example.config.yml +67 -0
- package/mdsite-nuxt/nuxt.config.ts +247 -0
- package/mdsite-nuxt/package-lock.json +15922 -0
- package/mdsite-nuxt/package.json +50 -0
- package/mdsite-nuxt/playwright.config.js +47 -0
- package/mdsite-nuxt/public/robots.txt +2 -0
- package/mdsite-nuxt/scripts/generate-favicons.ts +192 -0
- package/mdsite-nuxt/scripts/generate-indices.ts +508 -0
- package/mdsite-nuxt/scripts/renderer-hooks.test.ts +277 -0
- package/mdsite-nuxt/scripts/renderer-hooks.ts +141 -0
- package/mdsite-nuxt/scripts/start.test.ts +164 -0
- package/mdsite-nuxt/scripts/start.ts +55 -0
- package/mdsite-nuxt/scripts/sync-content.ts +504 -0
- package/mdsite-nuxt/tsconfig.json +18 -0
- package/mdsite-nuxt/utils/mdsite-config.ts +240 -0
- package/mdsite-nuxt/vitest.config.ts +7 -0
- package/package.json +36 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import type { MinimalTreeNode } from '../../scripts/generate-indices'
|
|
2
|
+
|
|
3
|
+
export interface TreeNode {
|
|
4
|
+
id: string
|
|
5
|
+
title: string
|
|
6
|
+
path: string
|
|
7
|
+
order: number
|
|
8
|
+
children: TreeNode[]
|
|
9
|
+
parent?: TreeNode
|
|
10
|
+
externalUrl?: string
|
|
11
|
+
isExternal?: boolean
|
|
12
|
+
isSeparator?: boolean
|
|
13
|
+
isHeader?: boolean
|
|
14
|
+
isPrimary?: boolean // Marks primary menu items (string/array syntax, not aliases)
|
|
15
|
+
description?: string
|
|
16
|
+
keywords?: string[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generate cache key that changes every hour
|
|
21
|
+
* This ensures menu changes are picked up within 1 hour
|
|
22
|
+
*/
|
|
23
|
+
function getCacheKey(): string {
|
|
24
|
+
const now = new Date()
|
|
25
|
+
const hourTimestamp = Math.floor(now.getTime() / (1000 * 60 * 60)) // Changes every hour
|
|
26
|
+
return `navigation-tree-${hourTimestamp}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build hierarchical navigation tree from @nuxt/content collection
|
|
31
|
+
*/
|
|
32
|
+
export function useNavigationTree() {
|
|
33
|
+
// Use useState for server-side rendering and client hydration
|
|
34
|
+
// Cache key changes hourly to pick up menu structure changes
|
|
35
|
+
const tree = useState<TreeNode | null>(getCacheKey(), () => null)
|
|
36
|
+
// Use useState for loading state to prevent duplicate fetches across components
|
|
37
|
+
const isLoading = useState<boolean>(`${getCacheKey()}-loading`, () => false)
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load navigation tree from pre-built JSON file
|
|
41
|
+
* Uses cached tree if available to avoid rebuilding on every navigation
|
|
42
|
+
* Prevents concurrent fetches when multiple components mount simultaneously
|
|
43
|
+
*/
|
|
44
|
+
async function loadTree() {
|
|
45
|
+
// Return if already loaded OR currently loading (prevents race condition)
|
|
46
|
+
if (tree.value !== null || isLoading.value) {
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
isLoading.value = true
|
|
51
|
+
try {
|
|
52
|
+
// Fetch pre-built navigation JSON
|
|
53
|
+
const minimalNodes = await $fetch<MinimalTreeNode[]>('/_navigation.json')
|
|
54
|
+
|
|
55
|
+
// Convert to TreeNode format with parent references
|
|
56
|
+
tree.value = buildTreeFromMinimalNodes(minimalNodes)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('Error loading navigation tree:', error)
|
|
59
|
+
tree.value = null
|
|
60
|
+
} finally {
|
|
61
|
+
isLoading.value = false
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Find node by path in tree
|
|
67
|
+
*/
|
|
68
|
+
function findNodeByPath(path: string, node: TreeNode | null = tree.value): TreeNode | null {
|
|
69
|
+
if (!node) return null
|
|
70
|
+
if (node.path === path) return node
|
|
71
|
+
|
|
72
|
+
for (const child of node.children) {
|
|
73
|
+
const found = findNodeByPath(path, child)
|
|
74
|
+
if (found) return found
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Find primary node by path in tree (only returns nodes with isPrimary=true)
|
|
82
|
+
*/
|
|
83
|
+
function findPrimaryNodeByPath(path: string, node: TreeNode | null = tree.value): TreeNode | null {
|
|
84
|
+
if (!node) return null
|
|
85
|
+
if (node.path === path && node.isPrimary) return node
|
|
86
|
+
|
|
87
|
+
for (const child of node.children) {
|
|
88
|
+
const found = findPrimaryNodeByPath(path, child)
|
|
89
|
+
if (found) return found
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Get all ancestor paths for a given path
|
|
97
|
+
*/
|
|
98
|
+
function getAncestorPaths(path: string): string[] {
|
|
99
|
+
const segments = path.split('/').filter(Boolean)
|
|
100
|
+
const ancestors: string[] = ['/']
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < segments.length - 1; i++) {
|
|
103
|
+
ancestors.push('/' + segments.slice(0, i + 1).join('/'))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return ancestors
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get sibling nodes at the same level
|
|
111
|
+
*/
|
|
112
|
+
function getSiblings(nodePath: string): TreeNode[] {
|
|
113
|
+
const node = findNodeByPath(nodePath)
|
|
114
|
+
if (!node || !node.parent) return []
|
|
115
|
+
return node.parent.children.filter(child => child.path !== nodePath)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
tree,
|
|
120
|
+
isLoading,
|
|
121
|
+
loadTree,
|
|
122
|
+
findNodeByPath,
|
|
123
|
+
findPrimaryNodeByPath,
|
|
124
|
+
getAncestorPaths,
|
|
125
|
+
getSiblings
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Convert MinimalTreeNode array to TreeNode with parent references
|
|
131
|
+
*/
|
|
132
|
+
function buildTreeFromMinimalNodes(minimalNodes: MinimalTreeNode[]): TreeNode {
|
|
133
|
+
const { siteName } = useSiteConfig()
|
|
134
|
+
// Create root node
|
|
135
|
+
const root: TreeNode = {
|
|
136
|
+
id: 'root',
|
|
137
|
+
title: siteName || 'Home',
|
|
138
|
+
path: '/',
|
|
139
|
+
order: -1,
|
|
140
|
+
children: []
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Recursively convert minimal nodes to TreeNode format
|
|
144
|
+
function convertNode(minimalNode: MinimalTreeNode, parent: TreeNode, order: number): TreeNode {
|
|
145
|
+
const node: TreeNode = {
|
|
146
|
+
id: minimalNode.id,
|
|
147
|
+
title: minimalNode.title,
|
|
148
|
+
path: minimalNode.path,
|
|
149
|
+
order,
|
|
150
|
+
children: [],
|
|
151
|
+
parent,
|
|
152
|
+
description: minimalNode.description,
|
|
153
|
+
isPrimary: minimalNode.isPrimary,
|
|
154
|
+
isExternal: minimalNode.type === 'external',
|
|
155
|
+
isSeparator: minimalNode.type === 'separator',
|
|
156
|
+
isHeader: minimalNode.type === 'header',
|
|
157
|
+
externalUrl: minimalNode.type === 'external' ? minimalNode.path : undefined
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Recursively convert children
|
|
161
|
+
if (minimalNode.children) {
|
|
162
|
+
node.children = minimalNode.children.map((child, index) =>
|
|
163
|
+
convertNode(child, node, index)
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return node
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Convert all top-level nodes
|
|
171
|
+
root.children = minimalNodes.map((node, index) => convertNode(node, root, index))
|
|
172
|
+
|
|
173
|
+
return root
|
|
174
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { SearchIndexEntry } from '../../scripts/generate-indices'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fetch and cache search index from pre-built JSON file
|
|
5
|
+
* Uses useState to prevent duplicate fetches across component instances
|
|
6
|
+
*/
|
|
7
|
+
export function useSearchIndex() {
|
|
8
|
+
// Cache search index using useState (shared across all components)
|
|
9
|
+
const searchIndex = useState<SearchIndexEntry[] | null>('search-index', () => null)
|
|
10
|
+
const isLoading = useState<boolean>('search-index-loading', () => false)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Load search index from pre-built JSON file
|
|
14
|
+
* Cached result prevents duplicate fetches
|
|
15
|
+
*/
|
|
16
|
+
async function loadSearchIndex(): Promise<SearchIndexEntry[]> {
|
|
17
|
+
// Return cached index if available
|
|
18
|
+
if (searchIndex.value !== null) {
|
|
19
|
+
return searchIndex.value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Return empty array if already loading (prevents race condition)
|
|
23
|
+
if (isLoading.value) {
|
|
24
|
+
return []
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
isLoading.value = true
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const index = await $fetch<SearchIndexEntry[]>('/_search-index.json')
|
|
31
|
+
searchIndex.value = index
|
|
32
|
+
return index
|
|
33
|
+
} catch (error) {
|
|
34
|
+
console.error('Error loading search index:', error)
|
|
35
|
+
searchIndex.value = []
|
|
36
|
+
return []
|
|
37
|
+
} finally {
|
|
38
|
+
isLoading.value = false
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
searchIndex,
|
|
44
|
+
isLoading,
|
|
45
|
+
loadSearchIndex
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for search relevance scoring
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect } from 'vitest'
|
|
6
|
+
import { calculateRelevanceScore, sortByRelevance, FIELD_WEIGHTS, MATCH_QUALITY } from './useSearchRelevance'
|
|
7
|
+
import type { SearchableFields } from './useSearchRelevance'
|
|
8
|
+
|
|
9
|
+
describe('useSearchRelevance', () => {
|
|
10
|
+
describe('calculateRelevanceScore', () => {
|
|
11
|
+
it('should score title matches higher than description matches', () => {
|
|
12
|
+
const titleMatch: SearchableFields = {
|
|
13
|
+
title: 'Church',
|
|
14
|
+
description: 'About the kingdom',
|
|
15
|
+
path: '/church',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const descriptionMatch: SearchableFields = {
|
|
19
|
+
title: 'Kingdom',
|
|
20
|
+
description: 'About the church',
|
|
21
|
+
path: '/kingdom',
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const titleScore = calculateRelevanceScore(titleMatch, 'church')
|
|
25
|
+
const descScore = calculateRelevanceScore(descriptionMatch, 'church')
|
|
26
|
+
|
|
27
|
+
expect(titleScore).toBeGreaterThan(descScore)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should score exact matches higher than partial matches', () => {
|
|
31
|
+
const exactMatch: SearchableFields = {
|
|
32
|
+
title: 'Church',
|
|
33
|
+
path: '/church',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const partialMatch: SearchableFields = {
|
|
37
|
+
title: 'Church History',
|
|
38
|
+
path: '/church-history',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const exactScore = calculateRelevanceScore(exactMatch, 'church')
|
|
42
|
+
const partialScore = calculateRelevanceScore(partialMatch, 'church')
|
|
43
|
+
|
|
44
|
+
expect(exactScore).toBeGreaterThan(partialScore)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('should score startsWith matches higher than contains matches', () => {
|
|
48
|
+
const startsWithMatch: SearchableFields = {
|
|
49
|
+
title: 'Dark Ages',
|
|
50
|
+
path: '/dark-ages',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const containsMatch: SearchableFields = {
|
|
54
|
+
title: 'The Dark Period',
|
|
55
|
+
path: '/dark-period',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const startsScore = calculateRelevanceScore(startsWithMatch, 'dark')
|
|
59
|
+
const containsScore = calculateRelevanceScore(containsMatch, 'dark')
|
|
60
|
+
|
|
61
|
+
expect(startsScore).toBeGreaterThan(containsScore)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('should score keyword matches appropriately', () => {
|
|
65
|
+
const keywordMatch: SearchableFields = {
|
|
66
|
+
title: 'History',
|
|
67
|
+
keywords: ['Constantine', 'Rome', 'Empire'],
|
|
68
|
+
path: '/history',
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const score = calculateRelevanceScore(keywordMatch, 'constantine')
|
|
72
|
+
expect(score).toBeGreaterThan(0)
|
|
73
|
+
expect(score).toBeLessThan(FIELD_WEIGHTS.title * MATCH_QUALITY.exact)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should add bonus for multi-field matches', () => {
|
|
77
|
+
const singleFieldMatch: SearchableFields = {
|
|
78
|
+
title: 'Church',
|
|
79
|
+
description: 'About the kingdom',
|
|
80
|
+
path: '/church',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const multiFieldMatch: SearchableFields = {
|
|
84
|
+
title: 'Church',
|
|
85
|
+
description: 'About the church',
|
|
86
|
+
path: '/church',
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const singleScore = calculateRelevanceScore(singleFieldMatch, 'church')
|
|
90
|
+
const multiScore = calculateRelevanceScore(multiFieldMatch, 'church')
|
|
91
|
+
|
|
92
|
+
expect(multiScore).toBeGreaterThan(singleScore)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should penalize deep paths', () => {
|
|
96
|
+
const shallowPage: SearchableFields = {
|
|
97
|
+
title: 'Church',
|
|
98
|
+
path: '/church',
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const deepPage: SearchableFields = {
|
|
102
|
+
title: 'Church',
|
|
103
|
+
path: '/kingdom/church/history/constantine',
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const shallowScore = calculateRelevanceScore(shallowPage, 'church')
|
|
107
|
+
const deepScore = calculateRelevanceScore(deepPage, 'church')
|
|
108
|
+
|
|
109
|
+
expect(shallowScore).toBeGreaterThan(deepScore)
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('should handle case-insensitive matching', () => {
|
|
113
|
+
const page: SearchableFields = {
|
|
114
|
+
title: 'CHURCH',
|
|
115
|
+
path: '/church',
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const lowerScore = calculateRelevanceScore(page, 'church')
|
|
119
|
+
const upperScore = calculateRelevanceScore(page, 'CHURCH')
|
|
120
|
+
const mixedScore = calculateRelevanceScore(page, 'ChUrCh')
|
|
121
|
+
|
|
122
|
+
expect(lowerScore).toEqual(upperScore)
|
|
123
|
+
expect(upperScore).toEqual(mixedScore)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('should return 0 for non-matching pages', () => {
|
|
127
|
+
const page: SearchableFields = {
|
|
128
|
+
title: 'Kingdom',
|
|
129
|
+
description: 'About the kingdom',
|
|
130
|
+
path: '/kingdom',
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const score = calculateRelevanceScore(page, 'church')
|
|
134
|
+
expect(score).toEqual(0)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('should handle pages with missing fields', () => {
|
|
138
|
+
const minimalPage: SearchableFields = {
|
|
139
|
+
title: 'Church',
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const score = calculateRelevanceScore(minimalPage, 'church')
|
|
143
|
+
expect(score).toBeGreaterThan(0)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should score excerpt matches lowest', () => {
|
|
147
|
+
const excerptMatch: SearchableFields = {
|
|
148
|
+
title: 'Kingdom',
|
|
149
|
+
excerpt: 'church',
|
|
150
|
+
path: '/kingdom',
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const titleMatch: SearchableFields = {
|
|
154
|
+
title: 'Church',
|
|
155
|
+
path: '/church',
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const excerptScore = calculateRelevanceScore(excerptMatch, 'church')
|
|
159
|
+
const titleScore = calculateRelevanceScore(titleMatch, 'church')
|
|
160
|
+
|
|
161
|
+
expect(titleScore).toBeGreaterThan(excerptScore)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('sortByRelevance', () => {
|
|
166
|
+
it('should sort results by score descending', () => {
|
|
167
|
+
const pages: SearchableFields[] = [
|
|
168
|
+
{ title: 'History of the Church', path: '/history' },
|
|
169
|
+
{ title: 'Church', path: '/church' },
|
|
170
|
+
{ title: 'Kingdom', description: 'About the church', path: '/kingdom' },
|
|
171
|
+
]
|
|
172
|
+
|
|
173
|
+
const sorted = sortByRelevance(pages, 'church')
|
|
174
|
+
|
|
175
|
+
expect(sorted.length).toBe(3)
|
|
176
|
+
// Exact title match should be first
|
|
177
|
+
expect(sorted[0]?.title).toBe('Church')
|
|
178
|
+
// Title startsWith match should be second
|
|
179
|
+
expect(sorted[1]?.title).toBe('History of the Church')
|
|
180
|
+
// Description match should be last
|
|
181
|
+
expect(sorted[2]?.title).toBe('Kingdom')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('should use alphabetical tie-breaking', () => {
|
|
185
|
+
const pages: SearchableFields[] = [
|
|
186
|
+
{ title: 'Zebra Church', path: '/zebra' },
|
|
187
|
+
{ title: 'Alpha Church', path: '/alpha' },
|
|
188
|
+
{ title: 'Beta Church', path: '/beta' },
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
const sorted = sortByRelevance(pages, 'church')
|
|
192
|
+
|
|
193
|
+
expect(sorted.length).toBe(3)
|
|
194
|
+
// All have same score, should be alphabetical
|
|
195
|
+
expect(sorted[0]?.title).toBe('Alpha Church')
|
|
196
|
+
expect(sorted[1]?.title).toBe('Beta Church')
|
|
197
|
+
expect(sorted[2]?.title).toBe('Zebra Church')
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('should handle empty results', () => {
|
|
201
|
+
const pages: SearchableFields[] = []
|
|
202
|
+
const sorted = sortByRelevance(pages, 'church')
|
|
203
|
+
expect(sorted).toEqual([])
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('should handle custom title getter', () => {
|
|
207
|
+
interface CustomPage extends SearchableFields {
|
|
208
|
+
customTitle: string
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const pages: CustomPage[] = [
|
|
212
|
+
{ customTitle: 'Zebra', title: 'A', path: '/zebra' },
|
|
213
|
+
{ customTitle: 'Alpha', title: 'Z', path: '/alpha' },
|
|
214
|
+
]
|
|
215
|
+
|
|
216
|
+
const sorted = sortByRelevance(
|
|
217
|
+
pages,
|
|
218
|
+
'page',
|
|
219
|
+
(page) => (page as CustomPage).customTitle
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
expect(sorted.length).toBe(2)
|
|
223
|
+
// Should use customTitle for alphabetical sorting
|
|
224
|
+
expect(sorted[0]?.customTitle).toBe('Alpha')
|
|
225
|
+
expect(sorted[1]?.customTitle).toBe('Zebra')
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('should prioritize relevance over alphabetical order', () => {
|
|
229
|
+
const pages: SearchableFields[] = [
|
|
230
|
+
{ title: 'Zebra Church Article', path: '/zebra' },
|
|
231
|
+
{ title: 'Church', path: '/church' },
|
|
232
|
+
]
|
|
233
|
+
|
|
234
|
+
const sorted = sortByRelevance(pages, 'church')
|
|
235
|
+
|
|
236
|
+
expect(sorted.length).toBe(2)
|
|
237
|
+
// Exact match should win despite being alphabetically later
|
|
238
|
+
expect(sorted[0]?.title).toBe('Church')
|
|
239
|
+
expect(sorted[1]?.title).toBe('Zebra Church Article')
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
describe('field weight constants', () => {
|
|
244
|
+
it('should have correct field weight hierarchy', () => {
|
|
245
|
+
expect(FIELD_WEIGHTS.title).toBeGreaterThan(FIELD_WEIGHTS.keywords)
|
|
246
|
+
expect(FIELD_WEIGHTS.keywords).toBeGreaterThan(FIELD_WEIGHTS.description)
|
|
247
|
+
expect(FIELD_WEIGHTS.description).toBeGreaterThan(FIELD_WEIGHTS.excerpt)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('should have correct match quality multipliers', () => {
|
|
251
|
+
expect(MATCH_QUALITY.exact).toBeGreaterThan(MATCH_QUALITY.startsWith)
|
|
252
|
+
expect(MATCH_QUALITY.startsWith).toBeGreaterThan(MATCH_QUALITY.contains)
|
|
253
|
+
})
|
|
254
|
+
})
|
|
255
|
+
})
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search relevance scoring system
|
|
3
|
+
*
|
|
4
|
+
* Calculates relevance scores for search results based on:
|
|
5
|
+
* - Field weights (title > keywords > description > excerpt)
|
|
6
|
+
* - Match quality (exact > startsWith > contains)
|
|
7
|
+
* - Position bonus (matches at start of field)
|
|
8
|
+
* - Path depth penalty (prefer top-level pages)
|
|
9
|
+
* - Multi-field match bonus
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// Field weight constants
|
|
13
|
+
export const FIELD_WEIGHTS = {
|
|
14
|
+
title: 10,
|
|
15
|
+
keywords: 7,
|
|
16
|
+
description: 3,
|
|
17
|
+
excerpt: 1,
|
|
18
|
+
} as const
|
|
19
|
+
|
|
20
|
+
// Match quality multipliers
|
|
21
|
+
export const MATCH_QUALITY = {
|
|
22
|
+
exact: 3,
|
|
23
|
+
startsWith: 2,
|
|
24
|
+
contains: 1,
|
|
25
|
+
} as const
|
|
26
|
+
|
|
27
|
+
// Scoring bonuses/penalties
|
|
28
|
+
const POSITION_BONUS = 2 // Bonus for match at start of field
|
|
29
|
+
const MULTI_FIELD_BONUS = 3 // Bonus when query matches multiple fields
|
|
30
|
+
const DEPTH_PENALTIES = {
|
|
31
|
+
root: 0, // /page
|
|
32
|
+
shallow: -1, // /church/history (1-2 levels)
|
|
33
|
+
deep: -2, // /church/history/subsection (3+ levels)
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
export interface SearchableFields {
|
|
37
|
+
title?: string
|
|
38
|
+
description?: string
|
|
39
|
+
excerpt?: string
|
|
40
|
+
keywords?: string[]
|
|
41
|
+
path?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface FieldMatchResult {
|
|
45
|
+
field: keyof typeof FIELD_WEIGHTS
|
|
46
|
+
score: number
|
|
47
|
+
matchType: keyof typeof MATCH_QUALITY
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Determine match quality for a field value
|
|
52
|
+
*/
|
|
53
|
+
function getMatchQuality(fieldValue: string, query: string): keyof typeof MATCH_QUALITY | null {
|
|
54
|
+
const fieldLower = fieldValue.toLowerCase()
|
|
55
|
+
const queryLower = query.toLowerCase()
|
|
56
|
+
|
|
57
|
+
if (fieldLower === queryLower) {
|
|
58
|
+
return 'exact'
|
|
59
|
+
}
|
|
60
|
+
if (fieldLower.startsWith(queryLower)) {
|
|
61
|
+
return 'startsWith'
|
|
62
|
+
}
|
|
63
|
+
if (fieldLower.includes(queryLower)) {
|
|
64
|
+
return 'contains'
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Calculate score for a single field match
|
|
72
|
+
*/
|
|
73
|
+
function calculateFieldScore(
|
|
74
|
+
fieldValue: string,
|
|
75
|
+
query: string,
|
|
76
|
+
fieldName: keyof typeof FIELD_WEIGHTS
|
|
77
|
+
): number {
|
|
78
|
+
const matchType = getMatchQuality(fieldValue, query)
|
|
79
|
+
if (!matchType) return 0
|
|
80
|
+
|
|
81
|
+
const baseWeight = FIELD_WEIGHTS[fieldName]
|
|
82
|
+
const qualityMultiplier = MATCH_QUALITY[matchType]
|
|
83
|
+
|
|
84
|
+
// Calculate base score with quality multiplier
|
|
85
|
+
let score = baseWeight * qualityMultiplier
|
|
86
|
+
|
|
87
|
+
// Add position bonus if match is at start
|
|
88
|
+
const fieldLower = fieldValue.toLowerCase()
|
|
89
|
+
const queryLower = query.toLowerCase()
|
|
90
|
+
if (fieldLower.startsWith(queryLower)) {
|
|
91
|
+
score += POSITION_BONUS
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return score
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Calculate depth penalty based on path structure
|
|
99
|
+
*/
|
|
100
|
+
function calculateDepthPenalty(path: string): number {
|
|
101
|
+
const segments = path.split('/').filter(Boolean)
|
|
102
|
+
const depth = segments.length
|
|
103
|
+
|
|
104
|
+
if (depth <= 1) return DEPTH_PENALTIES.root
|
|
105
|
+
if (depth <= 2) return DEPTH_PENALTIES.shallow
|
|
106
|
+
return DEPTH_PENALTIES.deep
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Calculate relevance score for a page
|
|
111
|
+
*/
|
|
112
|
+
export function calculateRelevanceScore(page: SearchableFields, query: string): number {
|
|
113
|
+
const queryLower = query.toLowerCase()
|
|
114
|
+
const matches: FieldMatchResult[] = []
|
|
115
|
+
let totalScore = 0
|
|
116
|
+
|
|
117
|
+
// Score title match
|
|
118
|
+
if (page.title) {
|
|
119
|
+
const score = calculateFieldScore(page.title, query, 'title')
|
|
120
|
+
if (score > 0) {
|
|
121
|
+
matches.push({ field: 'title', score, matchType: getMatchQuality(page.title, query)! })
|
|
122
|
+
totalScore += score
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Score description match
|
|
127
|
+
if (page.description) {
|
|
128
|
+
const score = calculateFieldScore(page.description, query, 'description')
|
|
129
|
+
if (score > 0) {
|
|
130
|
+
matches.push({ field: 'description', score, matchType: getMatchQuality(page.description, query)! })
|
|
131
|
+
totalScore += score
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Score excerpt match
|
|
136
|
+
if (page.excerpt) {
|
|
137
|
+
const score = calculateFieldScore(page.excerpt, query, 'excerpt')
|
|
138
|
+
if (score > 0) {
|
|
139
|
+
matches.push({ field: 'excerpt', score, matchType: getMatchQuality(page.excerpt, query)! })
|
|
140
|
+
totalScore += score
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Score keywords match (take highest scoring keyword)
|
|
145
|
+
if (page.keywords && page.keywords.length > 0) {
|
|
146
|
+
let maxKeywordScore = 0
|
|
147
|
+
for (const keyword of page.keywords) {
|
|
148
|
+
const score = calculateFieldScore(keyword, query, 'keywords')
|
|
149
|
+
if (score > maxKeywordScore) {
|
|
150
|
+
maxKeywordScore = score
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (maxKeywordScore > 0) {
|
|
154
|
+
matches.push({ field: 'keywords', score: maxKeywordScore, matchType: 'contains' })
|
|
155
|
+
totalScore += maxKeywordScore
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Add multi-field bonus if query matches in 2+ fields
|
|
160
|
+
if (matches.length >= 2) {
|
|
161
|
+
totalScore += MULTI_FIELD_BONUS
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Apply depth penalty
|
|
165
|
+
if (page.path) {
|
|
166
|
+
totalScore += calculateDepthPenalty(page.path)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return Math.max(0, totalScore) // Ensure non-negative
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Sort search results by relevance score
|
|
174
|
+
*/
|
|
175
|
+
export function sortByRelevance<T extends SearchableFields>(
|
|
176
|
+
results: T[],
|
|
177
|
+
query: string,
|
|
178
|
+
getTitleFn: (item: T) => string = (item) => item.title || ''
|
|
179
|
+
): T[] {
|
|
180
|
+
// Calculate scores for all results
|
|
181
|
+
const scored = results.map(item => ({
|
|
182
|
+
item,
|
|
183
|
+
score: calculateRelevanceScore(item, query),
|
|
184
|
+
}))
|
|
185
|
+
|
|
186
|
+
// Sort by score (descending), then alphabetically by title
|
|
187
|
+
scored.sort((a, b) => {
|
|
188
|
+
if (b.score !== a.score) {
|
|
189
|
+
return b.score - a.score
|
|
190
|
+
}
|
|
191
|
+
return getTitleFn(a.item).localeCompare(getTitleFn(b.item))
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
return scored.map(s => s.item)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Composable for search relevance scoring
|
|
199
|
+
*/
|
|
200
|
+
export function useSearchRelevance() {
|
|
201
|
+
return {
|
|
202
|
+
calculateRelevanceScore,
|
|
203
|
+
sortByRelevance,
|
|
204
|
+
FIELD_WEIGHTS,
|
|
205
|
+
MATCH_QUALITY,
|
|
206
|
+
}
|
|
207
|
+
}
|