@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.
Files changed (133) hide show
  1. package/README.md +228 -0
  2. package/bin/mdsite.js +3 -0
  3. package/dist/commands/commands.test.d.ts +1 -0
  4. package/dist/commands/commands.test.js +302 -0
  5. package/dist/commands/commands.test.js.map +1 -0
  6. package/dist/commands/generate.d.ts +1 -0
  7. package/dist/commands/generate.js +20 -0
  8. package/dist/commands/generate.js.map +1 -0
  9. package/dist/commands/init.d.ts +1 -0
  10. package/dist/commands/init.js +16 -0
  11. package/dist/commands/init.js.map +1 -0
  12. package/dist/commands/prepare.d.ts +1 -0
  13. package/dist/commands/prepare.js +111 -0
  14. package/dist/commands/prepare.js.map +1 -0
  15. package/dist/commands/prepare.test.d.ts +1 -0
  16. package/dist/commands/prepare.test.js +131 -0
  17. package/dist/commands/prepare.test.js.map +1 -0
  18. package/dist/commands/preview.d.ts +5 -0
  19. package/dist/commands/preview.js +62 -0
  20. package/dist/commands/preview.js.map +1 -0
  21. package/dist/commands/start.d.ts +5 -0
  22. package/dist/commands/start.js +50 -0
  23. package/dist/commands/start.js.map +1 -0
  24. package/dist/commands/stop.d.ts +1 -0
  25. package/dist/commands/stop.js +26 -0
  26. package/dist/commands/stop.js.map +1 -0
  27. package/dist/commands/workflows.test.d.ts +1 -0
  28. package/dist/commands/workflows.test.js +183 -0
  29. package/dist/commands/workflows.test.js.map +1 -0
  30. package/dist/config/default-mdsite-config.d.ts +2 -0
  31. package/dist/config/default-mdsite-config.js +82 -0
  32. package/dist/config/default-mdsite-config.js.map +1 -0
  33. package/dist/config/default-mdsite-config.test.d.ts +1 -0
  34. package/dist/config/default-mdsite-config.test.js +42 -0
  35. package/dist/config/default-mdsite-config.test.js.map +1 -0
  36. package/dist/config/mdsite-config.d.ts +42 -0
  37. package/dist/config/mdsite-config.js +74 -0
  38. package/dist/config/mdsite-config.js.map +1 -0
  39. package/dist/config/mdsite-config.test.d.ts +1 -0
  40. package/dist/config/mdsite-config.test.js +115 -0
  41. package/dist/config/mdsite-config.test.js.map +1 -0
  42. package/dist/config/menu.d.ts +3 -0
  43. package/dist/config/menu.js +58 -0
  44. package/dist/config/menu.js.map +1 -0
  45. package/dist/config/menu.test.d.ts +1 -0
  46. package/dist/config/menu.test.js +53 -0
  47. package/dist/config/menu.test.js.map +1 -0
  48. package/dist/index.d.ts +2 -0
  49. package/dist/index.js +99 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.test.d.ts +1 -0
  52. package/dist/index.test.js +133 -0
  53. package/dist/index.test.js.map +1 -0
  54. package/dist/process/child-process.d.ts +9 -0
  55. package/dist/process/child-process.js +142 -0
  56. package/dist/process/child-process.js.map +1 -0
  57. package/dist/process/child-process.test.d.ts +1 -0
  58. package/dist/process/child-process.test.js +131 -0
  59. package/dist/process/child-process.test.js.map +1 -0
  60. package/dist/process/runtime-state.d.ts +16 -0
  61. package/dist/process/runtime-state.js +42 -0
  62. package/dist/process/runtime-state.js.map +1 -0
  63. package/dist/process/runtime-state.test.d.ts +1 -0
  64. package/dist/process/runtime-state.test.js +57 -0
  65. package/dist/process/runtime-state.test.js.map +1 -0
  66. package/dist/renderer/mdsite-nuxt.d.ts +22 -0
  67. package/dist/renderer/mdsite-nuxt.js +194 -0
  68. package/dist/renderer/mdsite-nuxt.js.map +1 -0
  69. package/dist/renderer/mdsite-nuxt.test.d.ts +1 -0
  70. package/dist/renderer/mdsite-nuxt.test.js +226 -0
  71. package/dist/renderer/mdsite-nuxt.test.js.map +1 -0
  72. package/mdsite-nuxt/app/app.vue +7 -0
  73. package/mdsite-nuxt/app/assets/css/bible-tooltips.css +87 -0
  74. package/mdsite-nuxt/app/assets/css/markdown.css +195 -0
  75. package/mdsite-nuxt/app/assets/css/print.css +133 -0
  76. package/mdsite-nuxt/app/components/AppBar.vue +138 -0
  77. package/mdsite-nuxt/app/components/AppFooter.vue +88 -0
  78. package/mdsite-nuxt/app/components/AppNavigation.vue +162 -0
  79. package/mdsite-nuxt/app/components/AppTableOfContents.vue +149 -0
  80. package/mdsite-nuxt/app/components/BreadcrumbNav.vue +139 -0
  81. package/mdsite-nuxt/app/components/NavigationTree.vue +169 -0
  82. package/mdsite-nuxt/app/components/SearchBox.vue +212 -0
  83. package/mdsite-nuxt/app/components/TocItem.vue +75 -0
  84. package/mdsite-nuxt/app/components/TreeNode.vue +264 -0
  85. package/mdsite-nuxt/app/components/content/MarkdownAlert.vue +42 -0
  86. package/mdsite-nuxt/app/components/content/Mermaid.vue +103 -0
  87. package/mdsite-nuxt/app/components/content/ProseA.vue +57 -0
  88. package/mdsite-nuxt/app/components/content/ProseBlockquote.vue +14 -0
  89. package/mdsite-nuxt/app/components/content/ProsePre.vue +49 -0
  90. package/mdsite-nuxt/app/components/content/ProseTable.vue +143 -0
  91. package/mdsite-nuxt/app/composables/useAppTheme.ts +89 -0
  92. package/mdsite-nuxt/app/composables/useBibleTooltips.ts +21 -0
  93. package/mdsite-nuxt/app/composables/useBreadcrumbs.ts +70 -0
  94. package/mdsite-nuxt/app/composables/useContentPostProcessing.ts +89 -0
  95. package/mdsite-nuxt/app/composables/useNavigationTree.ts +174 -0
  96. package/mdsite-nuxt/app/composables/useSearchIndex.ts +47 -0
  97. package/mdsite-nuxt/app/composables/useSearchRelevance.test.ts +255 -0
  98. package/mdsite-nuxt/app/composables/useSearchRelevance.ts +207 -0
  99. package/mdsite-nuxt/app/composables/useSidebarState.ts +62 -0
  100. package/mdsite-nuxt/app/composables/useSiteConfig.ts +37 -0
  101. package/mdsite-nuxt/app/composables/useSourceEdit.ts +69 -0
  102. package/mdsite-nuxt/app/composables/useTableOfContents.ts +141 -0
  103. package/mdsite-nuxt/app/composables/useTableParser.ts +106 -0
  104. package/mdsite-nuxt/app/composables/useTooltipConfig.ts +20 -0
  105. package/mdsite-nuxt/app/config/themes.ts +17 -0
  106. package/mdsite-nuxt/app/layouts/default.vue +376 -0
  107. package/mdsite-nuxt/app/pages/[...slug].vue +56 -0
  108. package/mdsite-nuxt/app/pages/index.vue +54 -0
  109. package/mdsite-nuxt/app/plugins/bible-tooltips.client.ts +401 -0
  110. package/mdsite-nuxt/app/plugins/bible-tooltips.test.ts +284 -0
  111. package/mdsite-nuxt/app/types/content.d.ts +10 -0
  112. package/mdsite-nuxt/app/types/table.ts +57 -0
  113. package/mdsite-nuxt/app/utils/bible-book-names.ts +37 -0
  114. package/mdsite-nuxt/app/utils/bible-verse-utils.test.ts +148 -0
  115. package/mdsite-nuxt/app/utils/bible-verse-utils.ts +236 -0
  116. package/mdsite-nuxt/content.config.ts +18 -0
  117. package/mdsite-nuxt/example.config.yml +67 -0
  118. package/mdsite-nuxt/nuxt.config.ts +247 -0
  119. package/mdsite-nuxt/package-lock.json +15922 -0
  120. package/mdsite-nuxt/package.json +50 -0
  121. package/mdsite-nuxt/playwright.config.js +47 -0
  122. package/mdsite-nuxt/public/robots.txt +2 -0
  123. package/mdsite-nuxt/scripts/generate-favicons.ts +192 -0
  124. package/mdsite-nuxt/scripts/generate-indices.ts +508 -0
  125. package/mdsite-nuxt/scripts/renderer-hooks.test.ts +277 -0
  126. package/mdsite-nuxt/scripts/renderer-hooks.ts +141 -0
  127. package/mdsite-nuxt/scripts/start.test.ts +164 -0
  128. package/mdsite-nuxt/scripts/start.ts +55 -0
  129. package/mdsite-nuxt/scripts/sync-content.ts +504 -0
  130. package/mdsite-nuxt/tsconfig.json +18 -0
  131. package/mdsite-nuxt/utils/mdsite-config.ts +240 -0
  132. package/mdsite-nuxt/vitest.config.ts +7 -0
  133. 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
+ }