@dominikcz/greg 0.9.27
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 +397 -0
- package/bin/greg.js +241 -0
- package/bin/init.js +351 -0
- package/bin/templates/docs/getting-started.md +47 -0
- package/bin/templates/docs/index.md +11 -0
- package/bin/templates/greg.config.js +39 -0
- package/bin/templates/greg.config.ts +38 -0
- package/bin/templates/index.html +16 -0
- package/bin/templates/src/App.svelte +5 -0
- package/bin/templates/src/app.css +20 -0
- package/bin/templates/src/main.js +9 -0
- package/bin/templates/svelte.config.js +1 -0
- package/bin/templates/tsconfig.json +21 -0
- package/bin/templates/vite.config.js +23 -0
- package/docs/__partials/markdown/examples/basic.md +4 -0
- package/docs/__partials/markdown/examples/diff.md +10 -0
- package/docs/__partials/markdown/examples/focus.md +5 -0
- package/docs/__partials/markdown/examples/language-title.md +3 -0
- package/docs/__partials/markdown/examples/line-highlighting.md +5 -0
- package/docs/__partials/markdown/examples/line-numbers.md +5 -0
- package/docs/__partials/note.md +4 -0
- package/docs/guide/__shared-warning.md +4 -0
- package/docs/guide/asset-handling.md +88 -0
- package/docs/guide/deploying.md +162 -0
- package/docs/guide/getting-started.md +334 -0
- package/docs/guide/index.md +23 -0
- package/docs/guide/localization.md +290 -0
- package/docs/guide/markdown/code.md +95 -0
- package/docs/guide/markdown/components-and-mermaid.md +43 -0
- package/docs/guide/markdown/containers.md +110 -0
- package/docs/guide/markdown/header-anchors.md +34 -0
- package/docs/guide/markdown/includes.md +84 -0
- package/docs/guide/markdown/index.md +20 -0
- package/docs/guide/markdown/inline-attributes.md +21 -0
- package/docs/guide/markdown/links-and-toc.md +64 -0
- package/docs/guide/markdown/math.md +54 -0
- package/docs/guide/markdown/syntax-highlighting.md +75 -0
- package/docs/guide/routing.md +150 -0
- package/docs/guide/using-svelte.md +88 -0
- package/docs/guide/versioning.md +281 -0
- package/docs/incompatibilities.md +48 -0
- package/docs/index.md +43 -0
- package/docs/reference/badge.md +100 -0
- package/docs/reference/carbon-ads.md +46 -0
- package/docs/reference/code-group.md +126 -0
- package/docs/reference/home-page.md +232 -0
- package/docs/reference/index.md +18 -0
- package/docs/reference/markdowndocs.md +275 -0
- package/docs/reference/outline.md +79 -0
- package/docs/reference/search.md +263 -0
- package/docs/reference/steps.md +200 -0
- package/docs/reference/team-page.md +189 -0
- package/docs/reference/theme.md +150 -0
- package/fakeDocsGenerator/generate_docs.js +310 -0
- package/package.json +92 -0
- package/scripts/build-versions.js +609 -0
- package/scripts/generate-static.js +79 -0
- package/scripts/render-markdown.js +420 -0
- package/src/lib/MarkdownDocs/AiChat.svelte +936 -0
- package/src/lib/MarkdownDocs/BackToTop.svelte +68 -0
- package/src/lib/MarkdownDocs/Breadcrumb.svelte +68 -0
- package/src/lib/MarkdownDocs/DocsNavigation.svelte +149 -0
- package/src/lib/MarkdownDocs/DocsSiteHeader.svelte +758 -0
- package/src/lib/MarkdownDocs/DocsVersionSwitcher.svelte +103 -0
- package/src/lib/MarkdownDocs/MarkdownDocs.svelte +2115 -0
- package/src/lib/MarkdownDocs/MarkdownRenderer.svelte +487 -0
- package/src/lib/MarkdownDocs/Outline.svelte +238 -0
- package/src/lib/MarkdownDocs/PrevNext.svelte +115 -0
- package/src/lib/MarkdownDocs/SearchModal.svelte +1241 -0
- package/src/lib/MarkdownDocs/TreeView.svelte +32 -0
- package/src/lib/MarkdownDocs/TreeViewItem.svelte +219 -0
- package/src/lib/MarkdownDocs/VersionOutdatedNotice.svelte +72 -0
- package/src/lib/MarkdownDocs/__tests__/codeDirectives.test.js +54 -0
- package/src/lib/MarkdownDocs/__tests__/common.test.js +41 -0
- package/src/lib/MarkdownDocs/__tests__/docsExamplesLint.test.js +77 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/docs/markdown/__partial-basic.md +3 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/docs/markdown/snippet.js +9 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/includes/part.md +11 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/includes/wrapper.md +5 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/snippets/sample.js +8 -0
- package/src/lib/MarkdownDocs/__tests__/fixtures/snippets/sample.md +5 -0
- package/src/lib/MarkdownDocs/__tests__/helpers.js +67 -0
- package/src/lib/MarkdownDocs/__tests__/localeUtils.test.js +204 -0
- package/src/lib/MarkdownDocs/__tests__/markdown.test.js +704 -0
- package/src/lib/MarkdownDocs/__tests__/markdownRendererRuntime.test.js +65 -0
- package/src/lib/MarkdownDocs/__tests__/searchIndexBuilder.test.js +117 -0
- package/src/lib/MarkdownDocs/__tests__/sqliteStore.test.js +202 -0
- package/src/lib/MarkdownDocs/__tests__/useRouter.test.js +16 -0
- package/src/lib/MarkdownDocs/ai/adapters/customAdapter.js +14 -0
- package/src/lib/MarkdownDocs/ai/adapters/customAdapter.ts +43 -0
- package/src/lib/MarkdownDocs/ai/adapters/ollamaAdapter.js +81 -0
- package/src/lib/MarkdownDocs/ai/adapters/ollamaAdapter.ts +116 -0
- package/src/lib/MarkdownDocs/ai/adapters/openaiAdapter.js +92 -0
- package/src/lib/MarkdownDocs/ai/adapters/openaiAdapter.ts +137 -0
- package/src/lib/MarkdownDocs/ai/aiProvider.ts +31 -0
- package/src/lib/MarkdownDocs/ai/characters.js +52 -0
- package/src/lib/MarkdownDocs/ai/characters.ts +69 -0
- package/src/lib/MarkdownDocs/ai/chunkStore.ts +25 -0
- package/src/lib/MarkdownDocs/ai/chunker.js +85 -0
- package/src/lib/MarkdownDocs/ai/chunker.ts +135 -0
- package/src/lib/MarkdownDocs/ai/docLinker.js +26 -0
- package/src/lib/MarkdownDocs/ai/docLinker.ts +36 -0
- package/src/lib/MarkdownDocs/ai/promptBuilder.js +33 -0
- package/src/lib/MarkdownDocs/ai/promptBuilder.ts +53 -0
- package/src/lib/MarkdownDocs/ai/ragPipeline.js +54 -0
- package/src/lib/MarkdownDocs/ai/ragPipeline.ts +106 -0
- package/src/lib/MarkdownDocs/ai/stores/memoryStore.js +88 -0
- package/src/lib/MarkdownDocs/ai/stores/memoryStore.ts +112 -0
- package/src/lib/MarkdownDocs/ai/stores/sqliteStore.ts +372 -0
- package/src/lib/MarkdownDocs/ai/types.ts +71 -0
- package/src/lib/MarkdownDocs/aiServer.js +288 -0
- package/src/lib/MarkdownDocs/codeDirectives.js +191 -0
- package/src/lib/MarkdownDocs/codeFenceInfo.js +45 -0
- package/src/lib/MarkdownDocs/codeGroup.ts +46 -0
- package/src/lib/MarkdownDocs/common.ts +47 -0
- package/src/lib/MarkdownDocs/docsUtils.js +281 -0
- package/src/lib/MarkdownDocs/index.plugins.js +22 -0
- package/src/lib/MarkdownDocs/layouts/LayoutDoc.svelte +8 -0
- package/src/lib/MarkdownDocs/layouts/LayoutHome.svelte +58 -0
- package/src/lib/MarkdownDocs/layouts/LayoutPage.svelte +9 -0
- package/src/lib/MarkdownDocs/loadGregConfig.js +82 -0
- package/src/lib/MarkdownDocs/localeUtils.ts +682 -0
- package/src/lib/MarkdownDocs/markdownRendererRuntime.ts +314 -0
- package/src/lib/MarkdownDocs/mermaidThemes.js +319 -0
- package/src/lib/MarkdownDocs/navigationUtils.js +22 -0
- package/src/lib/MarkdownDocs/rehypeCodeGroup.js +326 -0
- package/src/lib/MarkdownDocs/rehypeCodeTitle.js +96 -0
- package/src/lib/MarkdownDocs/rehypeToc.js +170 -0
- package/src/lib/MarkdownDocs/remarkCodeMeta.js +22 -0
- package/src/lib/MarkdownDocs/remarkContainers.js +329 -0
- package/src/lib/MarkdownDocs/remarkCustomAnchors.js +42 -0
- package/src/lib/MarkdownDocs/remarkEscapeSvelte.js +33 -0
- package/src/lib/MarkdownDocs/remarkGlobalComponents.js +65 -0
- package/src/lib/MarkdownDocs/remarkImports.js +461 -0
- package/src/lib/MarkdownDocs/remarkImportsBrowser.js +349 -0
- package/src/lib/MarkdownDocs/remarkInlineAttrs.js +95 -0
- package/src/lib/MarkdownDocs/remarkMathToHtml.js +138 -0
- package/src/lib/MarkdownDocs/searchIndexBuilder.js +497 -0
- package/src/lib/MarkdownDocs/searchServer.js +263 -0
- package/src/lib/MarkdownDocs/treeViewTypes.ts +11 -0
- package/src/lib/MarkdownDocs/useRouter.svelte.ts +114 -0
- package/src/lib/MarkdownDocs/useSplitter.svelte.ts +33 -0
- package/src/lib/MarkdownDocs/versioningDefaults.js +20 -0
- package/src/lib/MarkdownDocs/vitePluginAiServer.js +204 -0
- package/src/lib/MarkdownDocs/vitePluginCopyDocs.js +153 -0
- package/src/lib/MarkdownDocs/vitePluginFrontmatter.js +109 -0
- package/src/lib/MarkdownDocs/vitePluginGregConfig.js +108 -0
- package/src/lib/MarkdownDocs/vitePluginSearchIndex.js +57 -0
- package/src/lib/MarkdownDocs/vitePluginSearchServer.js +190 -0
- package/src/lib/components/Badge.svelte +59 -0
- package/src/lib/components/Button.svelte +138 -0
- package/src/lib/components/CarbonAds.svelte +99 -0
- package/src/lib/components/CodeGroup.svelte +102 -0
- package/src/lib/components/Feature.svelte +209 -0
- package/src/lib/components/Features.svelte +123 -0
- package/src/lib/components/Hero.svelte +399 -0
- package/src/lib/components/Image.svelte +128 -0
- package/src/lib/components/Link.svelte +105 -0
- package/src/lib/components/SocialLink.svelte +84 -0
- package/src/lib/components/SocialLinks.svelte +33 -0
- package/src/lib/components/Steps.svelte +143 -0
- package/src/lib/components/TeamMember.svelte +273 -0
- package/src/lib/components/TeamMembers.svelte +81 -0
- package/src/lib/components/TeamPage.svelte +65 -0
- package/src/lib/components/TeamPageSection.svelte +108 -0
- package/src/lib/components/TeamPageTitle.svelte +89 -0
- package/src/lib/components/index.js +24 -0
- package/src/lib/portal/context.js +12 -0
- package/src/lib/portal/index.js +3 -0
- package/src/lib/portal/portal.svelte +14 -0
- package/src/lib/portal/slot.svelte +8 -0
- package/src/lib/scss/__code.scss +128 -0
- package/src/lib/scss/__containers.scss +99 -0
- package/src/lib/scss/__markdown.scss +447 -0
- package/src/lib/scss/__scrollbar.scss +60 -0
- package/src/lib/scss/__steps.scss +100 -0
- package/src/lib/scss/__theme.scss +238 -0
- package/src/lib/scss/__toc.scss +55 -0
- package/src/lib/scss/__utilities.scss +7 -0
- package/src/lib/scss/greg.scss +9 -0
- package/src/lib/spinner/spinner.svelte +42 -0
- package/svelte.config.js +146 -0
- package/types/index.d.ts +456 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getRuntimeRenderHandlers,
|
|
4
|
+
getThemeChangeRenderHandlers,
|
|
5
|
+
runRenderHandlers,
|
|
6
|
+
} from '../markdownRendererRuntime';
|
|
7
|
+
|
|
8
|
+
describe('markdownRendererRuntime registries', () => {
|
|
9
|
+
it('builds runtime handlers in stable order', () => {
|
|
10
|
+
const handlers = getRuntimeRenderHandlers({
|
|
11
|
+
hydrateComponents: vi.fn(),
|
|
12
|
+
initMermaid: vi.fn(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(handlers.map((h) => h.name)).toEqual([
|
|
16
|
+
'hydrate-components',
|
|
17
|
+
'init-mermaid',
|
|
18
|
+
]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('builds theme-change handlers in stable order', () => {
|
|
22
|
+
const handlers = getThemeChangeRenderHandlers({
|
|
23
|
+
rerenderMermaid: vi.fn(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
expect(handlers.map((h) => h.name)).toEqual(['rerender-mermaid']);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('runRenderHandlers', () => {
|
|
31
|
+
it('runs handlers in sequence and keeps going after an error', async () => {
|
|
32
|
+
const calls = [];
|
|
33
|
+
const root = /** @type {HTMLElement} */ ({});
|
|
34
|
+
const context = { mermaidThemeConfig: { theme: 'material' } };
|
|
35
|
+
|
|
36
|
+
const handlers = [
|
|
37
|
+
{
|
|
38
|
+
name: 'first',
|
|
39
|
+
run: vi.fn(async (_root, _ctx) => {
|
|
40
|
+
calls.push('first');
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'broken',
|
|
45
|
+
run: vi.fn(async () => {
|
|
46
|
+
calls.push('broken');
|
|
47
|
+
throw new Error('boom');
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'last',
|
|
52
|
+
run: vi.fn(async () => {
|
|
53
|
+
calls.push('last');
|
|
54
|
+
}),
|
|
55
|
+
},
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
await runRenderHandlers(root, handlers, context);
|
|
59
|
+
|
|
60
|
+
expect(calls).toEqual(['first', 'broken', 'last']);
|
|
61
|
+
expect(handlers[0].run).toHaveBeenCalledWith(root, context);
|
|
62
|
+
expect(handlers[1].run).toHaveBeenCalledWith(root, context);
|
|
63
|
+
expect(handlers[2].run).toHaveBeenCalledWith(root, context);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { buildSearchIndex, invalidateSearchIndexCache } from '../searchIndexBuilder.js';
|
|
6
|
+
|
|
7
|
+
describe('buildSearchIndex', () => {
|
|
8
|
+
const tempDirs = [];
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
invalidateSearchIndexCache();
|
|
12
|
+
for (const dir of tempDirs.splice(0)) {
|
|
13
|
+
rmSync(dir, { recursive: true, force: true });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('indexes text from markdown includes located in __partials', async () => {
|
|
18
|
+
const rootDir = mkdtempSync(join(tmpdir(), 'greg-search-index-'));
|
|
19
|
+
tempDirs.push(rootDir);
|
|
20
|
+
|
|
21
|
+
const docsDir = join(rootDir, 'docs');
|
|
22
|
+
mkdirSync(join(docsDir, '__partials'), { recursive: true });
|
|
23
|
+
|
|
24
|
+
writeFileSync(
|
|
25
|
+
join(docsDir, 'index.md'),
|
|
26
|
+
[
|
|
27
|
+
'# Home',
|
|
28
|
+
'',
|
|
29
|
+
'Intro section.',
|
|
30
|
+
'',
|
|
31
|
+
'<!--@include: /__partials/note.md-->',
|
|
32
|
+
'',
|
|
33
|
+
'Outro section.',
|
|
34
|
+
].join('\n'),
|
|
35
|
+
'utf8',
|
|
36
|
+
);
|
|
37
|
+
writeFileSync(
|
|
38
|
+
join(docsDir, '__partials', 'note.md'),
|
|
39
|
+
'Searchable phrase from included partial.',
|
|
40
|
+
'utf8',
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
const index = await buildSearchIndex(docsDir, '/docs');
|
|
44
|
+
const homeDoc = index.find(entry => entry.id === '/docs');
|
|
45
|
+
|
|
46
|
+
expect(homeDoc).toBeTruthy();
|
|
47
|
+
expect(homeDoc.sections.some(section => section.content.includes('Searchable phrase from included partial.'))).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('indexes text from markdown includes loaded via @/ alias', async () => {
|
|
51
|
+
const rootDir = mkdtempSync(join(tmpdir(), 'greg-search-index-'));
|
|
52
|
+
tempDirs.push(rootDir);
|
|
53
|
+
|
|
54
|
+
const docsDir = join(rootDir, 'docs');
|
|
55
|
+
mkdirSync(docsDir, { recursive: true });
|
|
56
|
+
mkdirSync(join(rootDir, 'snippets'), { recursive: true });
|
|
57
|
+
|
|
58
|
+
writeFileSync(
|
|
59
|
+
join(docsDir, 'index.md'),
|
|
60
|
+
[
|
|
61
|
+
'# Home',
|
|
62
|
+
'',
|
|
63
|
+
'Intro section.',
|
|
64
|
+
'',
|
|
65
|
+
'<!--@include: @/snippets/shared.md-->',
|
|
66
|
+
].join('\n'),
|
|
67
|
+
'utf8',
|
|
68
|
+
);
|
|
69
|
+
writeFileSync(
|
|
70
|
+
join(rootDir, 'snippets', 'shared.md'),
|
|
71
|
+
'Alias include searchable sentence.',
|
|
72
|
+
'utf8',
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const index = await buildSearchIndex(docsDir, '/docs');
|
|
76
|
+
const homeDoc = index.find(entry => entry.id === '/docs');
|
|
77
|
+
|
|
78
|
+
expect(homeDoc).toBeTruthy();
|
|
79
|
+
expect(homeDoc.sections.some(section => section.content.includes('Alias include searchable sentence.'))).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('merges index from multiple docs directories', async () => {
|
|
83
|
+
const rootDir = mkdtempSync(join(tmpdir(), 'greg-search-index-multi-'));
|
|
84
|
+
tempDirs.push(rootDir);
|
|
85
|
+
|
|
86
|
+
const docsDir1 = join(rootDir, 'docs');
|
|
87
|
+
const docsDir2 = join(rootDir, 'extra-docs');
|
|
88
|
+
mkdirSync(docsDir1, { recursive: true });
|
|
89
|
+
mkdirSync(docsDir2, { recursive: true });
|
|
90
|
+
|
|
91
|
+
writeFileSync(join(docsDir1, 'guide.md'), '# Guide\n\nContent from first dir.', 'utf8');
|
|
92
|
+
writeFileSync(join(docsDir2, 'api.md'), '# API\n\nContent from second dir.', 'utf8');
|
|
93
|
+
|
|
94
|
+
const index = await buildSearchIndex([docsDir1, docsDir2], '/docs');
|
|
95
|
+
|
|
96
|
+
expect(index.length).toBe(2);
|
|
97
|
+
expect(index.find(e => e.id === '/docs/guide')).toBeTruthy();
|
|
98
|
+
expect(index.find(e => e.id === '/docs/api')).toBeTruthy();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('returns same result as single-dir call when given single-element array', async () => {
|
|
102
|
+
const rootDir = mkdtempSync(join(tmpdir(), 'greg-search-index-single-'));
|
|
103
|
+
tempDirs.push(rootDir);
|
|
104
|
+
|
|
105
|
+
const docsDir = join(rootDir, 'docs');
|
|
106
|
+
mkdirSync(docsDir, { recursive: true });
|
|
107
|
+
writeFileSync(join(docsDir, 'page.md'), '# Page\n\nSome content.', 'utf8');
|
|
108
|
+
|
|
109
|
+
const [stringResult, arrayResult] = await Promise.all([
|
|
110
|
+
buildSearchIndex(docsDir, '/docs'),
|
|
111
|
+
(invalidateSearchIndexCache(), buildSearchIndex([docsDir], '/docs')),
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
expect(arrayResult.length).toBe(stringResult.length);
|
|
115
|
+
expect(arrayResult[0].id).toBe(stringResult[0].id);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { SqliteStore } from '../ai/stores/sqliteStore';
|
|
3
|
+
|
|
4
|
+
/** Helper: build a small set of doc chunks for testing. */
|
|
5
|
+
function sampleChunks() {
|
|
6
|
+
return [
|
|
7
|
+
{
|
|
8
|
+
pageId: '/docs/guide/routing',
|
|
9
|
+
pageTitle: 'Routing',
|
|
10
|
+
sectionHeading: 'Dynamic Routes',
|
|
11
|
+
sectionAnchor: 'dynamic-routes',
|
|
12
|
+
content: 'Greg supports dynamic routes using square bracket syntax in file names.',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
pageId: '/docs/guide/deploying',
|
|
16
|
+
pageTitle: 'Deploying',
|
|
17
|
+
sectionHeading: 'Static hosting',
|
|
18
|
+
sectionAnchor: 'static-hosting',
|
|
19
|
+
content: 'You can deploy greg docs to any static hosting provider like Netlify or Vercel.',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
pageId: '/docs/guide/localization',
|
|
23
|
+
pageTitle: 'Localization',
|
|
24
|
+
sectionHeading: 'Adding a language',
|
|
25
|
+
sectionAnchor: 'adding-a-language',
|
|
26
|
+
content: 'To add a new language, create a folder under docs with the locale code.',
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
pageId: '/docs/reference/search',
|
|
30
|
+
pageTitle: 'Search',
|
|
31
|
+
sectionHeading: 'Server search',
|
|
32
|
+
sectionAnchor: 'server-search',
|
|
33
|
+
content: 'Server-side search uses BM25 ranking for fast full-text retrieval.',
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
pageId: '/pl/docs/guide/routing',
|
|
37
|
+
pageTitle: 'Routing',
|
|
38
|
+
sectionHeading: 'Dynamiczne trasy',
|
|
39
|
+
sectionAnchor: 'dynamiczne-trasy',
|
|
40
|
+
content: 'Greg obsługuje dynamiczne trasy za pomocą składni nawiasów kwadratowych.',
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe('SqliteStore', () => {
|
|
46
|
+
/** @type {SqliteStore | null} */
|
|
47
|
+
let store = null;
|
|
48
|
+
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
if (store) {
|
|
51
|
+
store.close();
|
|
52
|
+
store = null;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('indexes chunks and reports correct size', async () => {
|
|
57
|
+
store = new SqliteStore({ dbPath: ':memory:' });
|
|
58
|
+
const chunks = sampleChunks();
|
|
59
|
+
await store.index(chunks);
|
|
60
|
+
expect(store.size()).toBe(chunks.length);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('returns empty array when searching empty store', async () => {
|
|
64
|
+
store = new SqliteStore({ dbPath: ':memory:' });
|
|
65
|
+
const results = await store.search('routing');
|
|
66
|
+
expect(results).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('finds relevant chunks via BM25 full-text search', async () => {
|
|
70
|
+
store = new SqliteStore({ dbPath: ':memory:' });
|
|
71
|
+
await store.index(sampleChunks());
|
|
72
|
+
|
|
73
|
+
const results = await store.search('dynamic routes');
|
|
74
|
+
expect(results.length).toBeGreaterThan(0);
|
|
75
|
+
expect(results[0].pageId).toBe('/docs/guide/routing');
|
|
76
|
+
expect(results[0].score).toBeGreaterThan(0);
|
|
77
|
+
expect(results[0].score).toBeLessThanOrEqual(1);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns results with all DocChunk fields', async () => {
|
|
81
|
+
store = new SqliteStore({ dbPath: ':memory:' });
|
|
82
|
+
await store.index(sampleChunks());
|
|
83
|
+
|
|
84
|
+
const results = await store.search('deploy');
|
|
85
|
+
expect(results.length).toBeGreaterThan(0);
|
|
86
|
+
const first = results[0];
|
|
87
|
+
expect(first).toHaveProperty('pageId');
|
|
88
|
+
expect(first).toHaveProperty('pageTitle');
|
|
89
|
+
expect(first).toHaveProperty('sectionHeading');
|
|
90
|
+
expect(first).toHaveProperty('sectionAnchor');
|
|
91
|
+
expect(first).toHaveProperty('content');
|
|
92
|
+
expect(first).toHaveProperty('score');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('respects the limit parameter', async () => {
|
|
96
|
+
store = new SqliteStore({ dbPath: ':memory:' });
|
|
97
|
+
await store.index(sampleChunks());
|
|
98
|
+
|
|
99
|
+
const results = await store.search('docs', 2);
|
|
100
|
+
expect(results.length).toBeLessThanOrEqual(2);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('returns scores normalized to 0–1 with top result at 1', async () => {
|
|
104
|
+
store = new SqliteStore({ dbPath: ':memory:' });
|
|
105
|
+
await store.index(sampleChunks());
|
|
106
|
+
|
|
107
|
+
const results = await store.search('static hosting deploy');
|
|
108
|
+
expect(results.length).toBeGreaterThan(0);
|
|
109
|
+
expect(results[0].score).toBe(1);
|
|
110
|
+
for (const r of results) {
|
|
111
|
+
expect(r.score).toBeGreaterThan(0);
|
|
112
|
+
expect(r.score).toBeLessThanOrEqual(1);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('returns empty for queries with no matching terms', async () => {
|
|
117
|
+
store = new SqliteStore({ dbPath: ':memory:' });
|
|
118
|
+
await store.index(sampleChunks());
|
|
119
|
+
|
|
120
|
+
const results = await store.search('xyznonexistent');
|
|
121
|
+
expect(results).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('re-indexes correctly (replaces old data)', async () => {
|
|
125
|
+
store = new SqliteStore({ dbPath: ':memory:' });
|
|
126
|
+
await store.index(sampleChunks());
|
|
127
|
+
expect(store.size()).toBe(5);
|
|
128
|
+
|
|
129
|
+
// Re-index with fewer chunks
|
|
130
|
+
await store.index([sampleChunks()[0]]);
|
|
131
|
+
expect(store.size()).toBe(1);
|
|
132
|
+
|
|
133
|
+
const results = await store.search('deploy');
|
|
134
|
+
expect(results).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('handles queries with special characters gracefully', async () => {
|
|
138
|
+
store = new SqliteStore({ dbPath: ':memory:' });
|
|
139
|
+
await store.index(sampleChunks());
|
|
140
|
+
|
|
141
|
+
// Should not throw — special chars are escaped
|
|
142
|
+
const results = await store.search('"routing" OR "deploy"');
|
|
143
|
+
expect(Array.isArray(results)).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('searches across title, heading, and content', async () => {
|
|
147
|
+
store = new SqliteStore({ dbPath: ':memory:' });
|
|
148
|
+
await store.index(sampleChunks());
|
|
149
|
+
|
|
150
|
+
// "Localization" appears only in pageTitle
|
|
151
|
+
const byTitle = await store.search('Localization');
|
|
152
|
+
expect(byTitle.length).toBeGreaterThan(0);
|
|
153
|
+
expect(byTitle[0].pageId).toBe('/docs/guide/localization');
|
|
154
|
+
|
|
155
|
+
// "BM25" appears only in content
|
|
156
|
+
const byContent = await store.search('BM25');
|
|
157
|
+
expect(byContent.length).toBeGreaterThan(0);
|
|
158
|
+
expect(byContent[0].pageId).toBe('/docs/reference/search');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('works with non-ASCII content (Polish)', async () => {
|
|
162
|
+
store = new SqliteStore({ dbPath: ':memory:' });
|
|
163
|
+
await store.index(sampleChunks());
|
|
164
|
+
|
|
165
|
+
const results = await store.search('dynamiczne trasy');
|
|
166
|
+
expect(results.length).toBeGreaterThan(0);
|
|
167
|
+
expect(results[0].pageId).toBe('/pl/docs/guide/routing');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('SqliteStore hybrid search (with mock embeddings)', () => {
|
|
172
|
+
/** @type {SqliteStore | null} */
|
|
173
|
+
let store = null;
|
|
174
|
+
|
|
175
|
+
afterEach(() => {
|
|
176
|
+
if (store) {
|
|
177
|
+
store.close();
|
|
178
|
+
store = null;
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('falls back to BM25-only when provider has no embed()', async () => {
|
|
183
|
+
const mockProvider = {
|
|
184
|
+
name: 'mock',
|
|
185
|
+
chat: vi.fn(),
|
|
186
|
+
isAvailable: vi.fn().mockResolvedValue(true),
|
|
187
|
+
// No embed method
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
store = new SqliteStore({
|
|
191
|
+
dbPath: ':memory:',
|
|
192
|
+
provider: mockProvider,
|
|
193
|
+
embeddingDimensions: 0, // No vector search
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
await store.index(sampleChunks());
|
|
197
|
+
const results = await store.search('routing');
|
|
198
|
+
expect(results.length).toBeGreaterThan(0);
|
|
199
|
+
// Both EN and PL routing pages match — just verify it finds something relevant
|
|
200
|
+
expect(results.some(r => r.pageId.includes('routing'))).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { buildNavigationUrl, isSameNavigationTarget } from '../useRouter.svelte.ts';
|
|
3
|
+
|
|
4
|
+
describe('useRouter helpers', () => {
|
|
5
|
+
it('builds URL with normalized path and optional hash', () => {
|
|
6
|
+
expect(buildNavigationUrl('/docs/guide/', 'intro')).toBe('/docs/guide#intro');
|
|
7
|
+
expect(buildNavigationUrl('/docs/guide', '#intro')).toBe('/docs/guide#intro');
|
|
8
|
+
expect(buildNavigationUrl('/docs/guide/', '')).toBe('/docs/guide');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('treats same path with different hash as a different navigation target', () => {
|
|
12
|
+
expect(isSameNavigationTarget('/docs/guide', '', '/docs/guide', 'intro')).toBe(false);
|
|
13
|
+
expect(isSameNavigationTarget('/docs/guide', '#overview', '/docs/guide', 'intro')).toBe(false);
|
|
14
|
+
expect(isSameNavigationTarget('/docs/guide', '#intro', '/docs/guide', 'intro')).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AiProvider } from '../aiProvider.js';
|
|
2
|
+
import type { AiProviderOptions, ChatMessage } from '../types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A user-provided async function that implements the LLM call.
|
|
6
|
+
* Mirrors the `search.provider: "custom"` pattern from the search system.
|
|
7
|
+
*/
|
|
8
|
+
export type CustomProviderFn = (
|
|
9
|
+
messages: ChatMessage[],
|
|
10
|
+
options?: AiProviderOptions,
|
|
11
|
+
) => Promise<string>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Thin wrapper that turns any async function into an AiProvider.
|
|
15
|
+
*
|
|
16
|
+
* Usage in greg.config.js:
|
|
17
|
+
* search: {
|
|
18
|
+
* ai: {
|
|
19
|
+
* enabled: true,
|
|
20
|
+
* provider: 'custom',
|
|
21
|
+
* customProvider: async (messages, opts) => {
|
|
22
|
+
* // call your own LLM backend
|
|
23
|
+
* return "answer text";
|
|
24
|
+
* }
|
|
25
|
+
* }
|
|
26
|
+
* }
|
|
27
|
+
*/
|
|
28
|
+
export class CustomAdapter implements AiProvider {
|
|
29
|
+
readonly name = 'custom';
|
|
30
|
+
private readonly fn: CustomProviderFn;
|
|
31
|
+
|
|
32
|
+
constructor(fn: CustomProviderFn) {
|
|
33
|
+
this.fn = fn;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
chat(messages: ChatMessage[], options?: AiProviderOptions): Promise<string> {
|
|
37
|
+
return this.fn(messages, options);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async isAvailable(): Promise<boolean> {
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export class OllamaAdapter {
|
|
2
|
+
constructor(options = {}) {
|
|
3
|
+
this.name = 'ollama';
|
|
4
|
+
this.baseUrl = (options.baseUrl ?? 'http://localhost:11434').replace(/\/$/, '');
|
|
5
|
+
this.model = options.model ?? 'llama3.2';
|
|
6
|
+
this.embeddingModel = options.embeddingModel !== undefined
|
|
7
|
+
? (options.embeddingModel ?? null)
|
|
8
|
+
: 'nomic-embed-text';
|
|
9
|
+
this.timeout = options.timeout ?? 60000;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async chat(messages, options) {
|
|
13
|
+
const body = JSON.stringify({
|
|
14
|
+
model: options?.model ?? this.model,
|
|
15
|
+
messages,
|
|
16
|
+
stream: false,
|
|
17
|
+
options: {
|
|
18
|
+
temperature: options?.temperature ?? 0.3,
|
|
19
|
+
...(options?.maxTokens ? { num_predict: options.maxTokens } : {}),
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
25
|
+
let response;
|
|
26
|
+
try {
|
|
27
|
+
response = await fetch(`${this.baseUrl}/api/chat`, {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'Content-Type': 'application/json' },
|
|
30
|
+
body,
|
|
31
|
+
signal: controller.signal,
|
|
32
|
+
});
|
|
33
|
+
} finally {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!response.ok) {
|
|
38
|
+
const text = await response.text().catch(() => '');
|
|
39
|
+
throw new Error(`Ollama chat error ${response.status}: ${text}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const data = await response.json();
|
|
43
|
+
if (data.error) throw new Error(`Ollama error: ${data.error}`);
|
|
44
|
+
return data.message?.content ?? '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async embed(texts) {
|
|
48
|
+
if (!this.embeddingModel) {
|
|
49
|
+
throw new Error('OllamaAdapter: embeddingModel is disabled (null). Set embeddingModel to use embeddings.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const results = [];
|
|
53
|
+
for (const text of texts) {
|
|
54
|
+
const response = await fetch(`${this.baseUrl}/api/embed`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: { 'Content-Type': 'application/json' },
|
|
57
|
+
body: JSON.stringify({ model: this.embeddingModel, input: text }),
|
|
58
|
+
});
|
|
59
|
+
if (!response.ok) {
|
|
60
|
+
const err = await response.text().catch(() => '');
|
|
61
|
+
throw new Error(`Ollama embed error ${response.status}: ${err}`);
|
|
62
|
+
}
|
|
63
|
+
const data = await response.json();
|
|
64
|
+
results.push(data.embeddings?.[0] ?? []);
|
|
65
|
+
}
|
|
66
|
+
return results;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async isAvailable() {
|
|
70
|
+
try {
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
setTimeout(() => controller.abort(), 3000);
|
|
73
|
+
const response = await fetch(`${this.baseUrl}/api/tags`, {
|
|
74
|
+
signal: controller.signal,
|
|
75
|
+
});
|
|
76
|
+
return response.ok;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { AiProvider } from '../aiProvider.js';
|
|
2
|
+
import type { AiProviderOptions, ChatMessage } from '../types.js';
|
|
3
|
+
|
|
4
|
+
export type OllamaAdapterOptions = {
|
|
5
|
+
/** Base URL of the Ollama service. Default: http://localhost:11434 */
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
/** Chat model to use. Default: llama3.2 */
|
|
8
|
+
model?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Embedding model to use for dense retrieval.
|
|
11
|
+
* Set to null to disable embeddings (BM25-only mode).
|
|
12
|
+
* Default: nomic-embed-text
|
|
13
|
+
*/
|
|
14
|
+
embeddingModel?: string | null;
|
|
15
|
+
/** Request timeout in milliseconds. Default: 60_000 */
|
|
16
|
+
timeout?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Adapter for Ollama — a local LLM runtime.
|
|
21
|
+
* https://ollama.com
|
|
22
|
+
*
|
|
23
|
+
* Requires Ollama to be running locally. Pull a model before use:
|
|
24
|
+
* ollama pull llama3.2
|
|
25
|
+
* ollama pull nomic-embed-text (for semantic search)
|
|
26
|
+
*/
|
|
27
|
+
export class OllamaAdapter implements AiProvider {
|
|
28
|
+
readonly name = 'ollama';
|
|
29
|
+
private readonly baseUrl: string;
|
|
30
|
+
private readonly model: string;
|
|
31
|
+
private readonly embeddingModel: string | null;
|
|
32
|
+
private readonly timeout: number;
|
|
33
|
+
|
|
34
|
+
constructor(options: OllamaAdapterOptions = {}) {
|
|
35
|
+
this.baseUrl = (options.baseUrl ?? 'http://localhost:11434').replace(/\/$/, '');
|
|
36
|
+
this.model = options.model ?? 'llama3.2';
|
|
37
|
+
this.embeddingModel = options.embeddingModel !== undefined
|
|
38
|
+
? (options.embeddingModel ?? null)
|
|
39
|
+
: 'nomic-embed-text';
|
|
40
|
+
this.timeout = options.timeout ?? 60_000;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async chat(messages: ChatMessage[], options?: AiProviderOptions): Promise<string> {
|
|
44
|
+
const body = JSON.stringify({
|
|
45
|
+
model: options?.model ?? this.model,
|
|
46
|
+
messages,
|
|
47
|
+
stream: false,
|
|
48
|
+
options: {
|
|
49
|
+
temperature: options?.temperature ?? 0.3,
|
|
50
|
+
...(options?.maxTokens ? { num_predict: options.maxTokens } : {}),
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
const timer = setTimeout(() => controller.abort(), this.timeout);
|
|
56
|
+
let response: Response;
|
|
57
|
+
try {
|
|
58
|
+
response = await fetch(`${this.baseUrl}/api/chat`, {
|
|
59
|
+
method: 'POST',
|
|
60
|
+
headers: { 'Content-Type': 'application/json' },
|
|
61
|
+
body,
|
|
62
|
+
signal: controller.signal,
|
|
63
|
+
});
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
const text = await response.text().catch(() => '');
|
|
70
|
+
throw new Error(`Ollama chat error ${response.status}: ${text}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const data = await response.json() as {
|
|
74
|
+
message?: { content?: string };
|
|
75
|
+
error?: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (data.error) throw new Error(`Ollama error: ${data.error}`);
|
|
79
|
+
return data.message?.content ?? '';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async embed(texts: string[]): Promise<number[][]> {
|
|
83
|
+
if (!this.embeddingModel) {
|
|
84
|
+
throw new Error('OllamaAdapter: embeddingModel is disabled (null). Set embeddingModel to use embeddings.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const results: number[][] = [];
|
|
88
|
+
for (const text of texts) {
|
|
89
|
+
const response = await fetch(`${this.baseUrl}/api/embed`, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: { 'Content-Type': 'application/json' },
|
|
92
|
+
body: JSON.stringify({ model: this.embeddingModel, input: text }),
|
|
93
|
+
});
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
const err = await response.text().catch(() => '');
|
|
96
|
+
throw new Error(`Ollama embed error ${response.status}: ${err}`);
|
|
97
|
+
}
|
|
98
|
+
const data = await response.json() as { embeddings?: number[][] };
|
|
99
|
+
results.push(data.embeddings?.[0] ?? []);
|
|
100
|
+
}
|
|
101
|
+
return results;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async isAvailable(): Promise<boolean> {
|
|
105
|
+
try {
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
setTimeout(() => controller.abort(), 3_000);
|
|
108
|
+
const response = await fetch(`${this.baseUrl}/api/tags`, {
|
|
109
|
+
signal: controller.signal,
|
|
110
|
+
});
|
|
111
|
+
return response.ok;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|