Skip to content

Commit 8053ae7

Browse files
authored
feat(search): add minHeading/maxHeading options to queryCollectionSearchSections (#3636)
1 parent 2938e42 commit 8053ae7

File tree

5 files changed

+72
-24
lines changed

5 files changed

+72
-24
lines changed

docs/content/docs/4.utils/4.query-collection-search-sections.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ The `queryCollectionSearchSections` utility is available in both Vue and Nitro.
2323
## Type
2424

2525
```ts
26-
function queryCollectionSearchSections(collection: keyof Collections, opts?: { ignoredTags: string[] }): ChainablePromise<T, Section[]>
26+
function queryCollectionSearchSections(collection: keyof Collections, opts?: { ignoredTags?: string[], minHeading?: string, maxHeading?: string }): ChainablePromise<T, Section[]>
2727

2828
interface ChainablePromise<T extends keyof PageCollections, R> extends Promise<R> {
2929
where(field: keyof PageCollections[T] | string, operator: SQLOperator, value?: unknown): ChainablePromise<T, R>
@@ -43,6 +43,8 @@ Generate searchable sections from the specified collection.
4343
- `collection`: The key of the defined collection in `content.config.ts`.
4444
- `options`: (Optional) An object with the following properties:
4545
- `ignoredTags`: An array of tag names to ignore when generating sections. Default is an empty array.
46+
- `minHeading`: Minimum heading level to split on (e.g., `'h2'`). Default is `'h1'`.
47+
- `maxHeading`: Maximum heading level to split on (e.g., `'h3'`). Default is `'h6'`.
4648
- Returns: A Promise that resolves to an array of searchable sections. Each section is an object with the following properties:
4749
- `id`: A unique identifier for the section.
4850
- `title`: The title of the section (usually the heading text).
@@ -58,7 +60,9 @@ Here's an example of how to use `queryCollectionSearchSections` to create search
5860
<script>
5961
const { data: surround } = await useAsyncData('foo-surround', () => {
6062
return queryCollectionSearchSections('docs', {
61-
ignoredTags: ['code']
63+
ignoredTags: ['code'],
64+
minHeading: 'h2',
65+
maxHeading: 'h3',
6266
})
6367
})
6468
</script>

src/runtime/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function queryCollectionItemSurroundings<T extends keyof PageCollections>
2727
return chainablePromise(collection, qb => generateItemSurround(qb, path, opts))
2828
}
2929

30-
export function queryCollectionSearchSections(collection: keyof Collections, opts?: { ignoredTags: string[] }) {
30+
export function queryCollectionSearchSections(collection: keyof Collections, opts?: { ignoredTags?: string[], separators?: string[] }) {
3131
return chainablePromise(collection, qb => generateSearchSections(qb, opts))
3232
}
3333

src/runtime/internal/search.ts

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ type Section = {
1818
}
1919

2020
const HEADING = /^h([1-6])$/
21-
const isHeading = (tag: string) => HEADING.test(tag)
21+
const headingLevel = (tag: string) => Number(tag.match(HEADING)?.[1] ?? 0)
2222

2323
interface SectionablePage {
2424
path: string
@@ -27,18 +27,20 @@ interface SectionablePage {
2727
body: MDCRoot | MinimarkTree
2828
}
2929

30-
export async function generateSearchSections<T extends PageCollectionItemBase>(queryBuilder: CollectionQueryBuilder<T>, opts?: { ignoredTags?: string[], extraFields?: Array<keyof T> }) {
31-
const { ignoredTags = [], extraFields = [] } = opts || {}
30+
export async function generateSearchSections<T extends PageCollectionItemBase>(queryBuilder: CollectionQueryBuilder<T>, opts?: { ignoredTags?: string[], extraFields?: Array<keyof T>, minHeading?: `h${1 | 2 | 3 | 4 | 5 | 6}`, maxHeading?: `h${1 | 2 | 3 | 4 | 5 | 6}` }) {
31+
const { ignoredTags = [], extraFields = [], minHeading = 'h1', maxHeading = 'h6' } = opts || {}
32+
const minLevel = headingLevel(minHeading)
33+
const maxLevel = headingLevel(maxHeading)
3234

3335
const documents = await queryBuilder
3436
.where('extension', '=', 'md')
3537
.select('path', 'body', 'description', 'title', ...(extraFields || []))
3638
.all()
3739

38-
return documents.flatMap(doc => splitPageIntoSections(doc, { ignoredTags, extraFields: extraFields as string[] }))
40+
return documents.flatMap(doc => splitPageIntoSections(doc, { ignoredTags, extraFields: extraFields as string[], minLevel, maxLevel }))
3941
}
4042

41-
function splitPageIntoSections(page: SectionablePage, { ignoredTags, extraFields }: { ignoredTags: string[], extraFields: Array<string> }) {
43+
function splitPageIntoSections(page: SectionablePage, { ignoredTags, extraFields, minLevel, maxLevel }: { ignoredTags: string[], extraFields: Array<string>, minLevel: number, maxLevel: number }) {
4244
const body = (!page.body || page.body?.type === 'root') ? page.body : toHast(page.body as unknown as MinimarkTree) as MDCRoot
4345
const path = (page.path ?? '')
4446
const extraFieldsData = pick(extraFields)(page as unknown as Record<string, unknown>)
@@ -57,27 +59,22 @@ function splitPageIntoSections(page: SectionablePage, { ignoredTags, extraFields
5759
return sections
5860
}
5961

60-
// No section
6162
let section = 1
6263
let previousHeadingLevel = 0
6364
const titles = [page.title ?? '']
6465
for (const item of body.children) {
6566
const tag = (item as MDCElement).tag || ''
66-
if (isHeading(tag)) {
67-
const currentHeadingLevel: number = Number(tag.match(HEADING)?.[1] ?? 0)
68-
67+
const level = headingLevel(tag)
68+
if (level >= minLevel && level <= maxLevel) {
6969
const title = extractTextFromAst(item).trim()
7070

71-
if (currentHeadingLevel === 1) {
72-
// Reset the titles
71+
if (level === 1) {
7372
titles.splice(0, titles.length)
7473
}
75-
else if (currentHeadingLevel < previousHeadingLevel) {
76-
// Go up tree, remove every title after the current level
77-
titles.splice(currentHeadingLevel - 1, titles.length - 1)
74+
else if (level < previousHeadingLevel) {
75+
titles.splice(level - 1, titles.length - 1)
7876
}
79-
else if (currentHeadingLevel === previousHeadingLevel) {
80-
// Same level, remove the last title (add title later to avoid to it in titles)
77+
else if (level === previousHeadingLevel) {
8178
titles.pop()
8279
}
8380

@@ -87,13 +84,11 @@ function splitPageIntoSections(page: SectionablePage, { ignoredTags, extraFields
8784
title,
8885
titles: [...titles],
8986
content: '',
90-
level: currentHeadingLevel,
87+
level,
9188
})
9289

9390
titles.push(title)
94-
95-
// Swap to a new section
96-
previousHeadingLevel = currentHeadingLevel
91+
previousHeadingLevel = level
9792
section += 1
9893
}
9994
else {

src/runtime/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function queryCollectionItemSurroundings<T extends keyof PageCollections>
2525
return chainablePromise(event, collection, qb => generateItemSurround(qb, path, opts))
2626
}
2727

28-
export function queryCollectionSearchSections(event: H3Event, collection: keyof Collections, opts?: { ignoredTags: string[] }) {
28+
export function queryCollectionSearchSections(event: H3Event, collection: keyof Collections, opts?: { ignoredTags?: string[], separators?: string[] }) {
2929
return chainablePromise(event, collection, qb => generateSearchSections(qb, opts))
3030
}
3131

test/unit/generateSearchSections.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,55 @@ describe('generateSearchSections', () => {
177177
},
178178
])
179179
})
180+
181+
it('should use default heading range (h1-h6) when not specified', async () => {
182+
const mockQueryBuilder = createMockQueryBuilder([{
183+
path: '/test',
184+
title: 'Test Page',
185+
description: '',
186+
body: {
187+
type: 'root',
188+
children: [
189+
{ type: 'element', tag: 'h2', props: { id: 's1' }, children: [{ type: 'text', value: 'Section 1' }] },
190+
{ type: 'element', tag: 'p', children: [{ type: 'text', value: 'Content 1' }] },
191+
],
192+
},
193+
}])
194+
195+
const sectionsDefault = await generateSearchSections(mockQueryBuilder)
196+
const sectionsExplicit = await generateSearchSections(mockQueryBuilder, { minHeading: 'h1', maxHeading: 'h6' })
197+
198+
expect(sectionsDefault).toEqual(sectionsExplicit)
199+
})
200+
201+
it('should filter headings by minHeading/maxHeading', async () => {
202+
const mockQueryBuilder = createMockQueryBuilder([{
203+
path: '/test',
204+
title: 'Test Page',
205+
description: '',
206+
body: {
207+
type: 'root',
208+
children: [
209+
{ type: 'element', tag: 'h1', props: { id: 'h1' }, children: [{ type: 'text', value: 'H1' }] },
210+
{ type: 'element', tag: 'p', children: [{ type: 'text', value: 'P1' }] },
211+
{ type: 'element', tag: 'h2', props: { id: 'h2' }, children: [{ type: 'text', value: 'H2' }] },
212+
{ type: 'element', tag: 'p', children: [{ type: 'text', value: 'P2' }] },
213+
{ type: 'element', tag: 'h3', props: { id: 'h3' }, children: [{ type: 'text', value: 'H3' }] },
214+
{ type: 'element', tag: 'p', children: [{ type: 'text', value: 'P3' }] },
215+
{ type: 'element', tag: 'h4', props: { id: 'h4' }, children: [{ type: 'text', value: 'H4' }] },
216+
{ type: 'element', tag: 'p', children: [{ type: 'text', value: 'P4' }] },
217+
],
218+
},
219+
}])
220+
221+
const sections = await generateSearchSections(mockQueryBuilder, { minHeading: 'h2', maxHeading: 'h3' })
222+
223+
expect(sections).toEqual([
224+
{ id: '/test', title: 'Test Page', titles: [], content: 'H1 P1', level: 1 },
225+
{ id: '/test#h2', title: 'H2', titles: ['Test Page'], content: 'P2', level: 2 },
226+
{ id: '/test#h3', title: 'H3', titles: ['Test Page', 'H2'], content: 'P3 H4 P4', level: 3 },
227+
])
228+
})
180229
})
181230

182231
function createMockQueryBuilder(result: unknown[]) {

0 commit comments

Comments
 (0)