@urbicon-ui/mcp-server 6.1.5 → 6.1.6

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 (48) hide show
  1. package/README.md +44 -41
  2. package/package.json +3 -1
  3. package/src/data/catalog-loader.test.ts +1 -1
  4. package/src/data/catalog-loader.ts +12 -37
  5. package/src/data/component-loader.ts +5 -40
  6. package/src/data/design-system-loader.test.ts +1 -1
  7. package/src/data/design-system-loader.ts +1 -1
  8. package/src/data/icon-loader.test.ts +25 -80
  9. package/src/data/icon-loader.ts +8 -68
  10. package/src/data/template-loader.ts +1 -1
  11. package/src/data/verb-loader.ts +29 -0
  12. package/src/eval/eval.test.ts +16 -9
  13. package/src/eval/score.ts +26 -10
  14. package/src/index.ts +7 -14
  15. package/src/prompts/design-prompts.test.ts +56 -28
  16. package/src/prompts/design-prompts.ts +135 -104
  17. package/src/server.test.ts +16 -7
  18. package/src/server.ts +4 -7
  19. package/src/tools/find-components.ts +1 -1
  20. package/src/tools/get-design-principles.ts +1 -1
  21. package/src/tools/get-recipe.ts +6 -4
  22. package/src/tools/suggest-implementation.ts +2 -3
  23. package/src/tools/validate-design.ts +17 -9
  24. package/src/data/recipe-loader.test.ts +0 -49
  25. package/src/data/recipe-loader.ts +0 -131
  26. package/src/design-linter/heuristics.ts +0 -162
  27. package/src/design-linter/index.ts +0 -14
  28. package/src/design-linter/linter.test.ts +0 -257
  29. package/src/design-linter/linter.ts +0 -62
  30. package/src/design-linter/rules.ts +0 -348
  31. package/src/design-linter/tokens.test.ts +0 -80
  32. package/src/design-linter/tokens.ts +0 -203
  33. package/src/design-linter/types.ts +0 -66
  34. package/src/design-manifest/index.ts +0 -20
  35. package/src/design-manifest/manifest.test.ts +0 -175
  36. package/src/design-manifest/manifest.ts +0 -250
  37. package/src/design-manifest/scan.test.ts +0 -51
  38. package/src/design-manifest/scan.ts +0 -74
  39. package/src/design-manifest/types.ts +0 -40
  40. package/src/design-rubric/rubric.test.ts +0 -43
  41. package/src/design-rubric/rubric.ts +0 -140
  42. package/src/tools/get-design-context.ts +0 -43
  43. package/src/tools/record-design-decision.ts +0 -99
  44. package/src/tools/sync-design-manifest.ts +0 -92
  45. package/src/utils/paths.test.ts +0 -101
  46. package/src/utils/paths.ts +0 -78
  47. package/src/utils/search.test.ts +0 -141
  48. package/src/utils/search.ts +0 -106
@@ -1,99 +0,0 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
2
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import { z } from 'zod';
4
- import { appendDecision, createManifestTemplate, parseManifest } from '../design-manifest/index.js';
5
- import { getProjectManifestPath, isWithinProjectDir } from '../utils/paths.js';
6
-
7
- export function registerRecordDesignDecisionTool(server: McpServer): void {
8
- server.tool(
9
- 'record_design_decision',
10
- 'Append a design decision (ADR) to the project design manifest — record a deliberate deviation from a pattern or principle so future sessions and other developers honour it. Creates design.manifest.md if it does not exist yet.',
11
- {
12
- title: z
13
- .string()
14
- .min(1)
15
- .describe('Short decision title, e.g. "Tabs for settings instead of sidebar".'),
16
- decision: z.string().min(1).describe('What was decided — concrete and imperative.'),
17
- rationale: z
18
- .string()
19
- .optional()
20
- .describe('Why — the trade-off that justifies the deviation.'),
21
- status: z
22
- .enum(['accepted', 'proposed', 'superseded'])
23
- .optional()
24
- .describe('Decision status. Defaults to "accepted".'),
25
- date: z
26
- .string()
27
- .regex(/^\d{4}-\d{2}-\d{2}$/)
28
- .optional()
29
- .describe('ISO date (YYYY-MM-DD). Defaults to today.'),
30
- manifestPath: z
31
- .string()
32
- .optional()
33
- .describe('Path to design.manifest.md. Defaults to the project root.')
34
- },
35
- { readOnlyHint: false },
36
- async ({ title, decision, rationale, status, date, manifestPath }) => {
37
- const path = manifestPath ?? getProjectManifestPath();
38
- if (!path.endsWith('.md')) {
39
- return {
40
- content: [
41
- { type: 'text' as const, text: `Refusing to write: "${path}" is not a .md file.` }
42
- ],
43
- isError: true
44
- };
45
- }
46
- if (manifestPath && !isWithinProjectDir(manifestPath)) {
47
- return {
48
- content: [
49
- {
50
- type: 'text' as const,
51
- text: `Refusing to write outside the project root: "${path}".`
52
- }
53
- ],
54
- isError: true
55
- };
56
- }
57
-
58
- let content: string;
59
- let created = false;
60
- try {
61
- content = await readFile(path, 'utf-8');
62
- } catch {
63
- content = createManifestTemplate({});
64
- created = true;
65
- }
66
-
67
- const today = date ?? new Date().toISOString().slice(0, 10);
68
- const updated = appendDecision(content, {
69
- date: today,
70
- title,
71
- status: status ?? 'accepted',
72
- decision,
73
- rationale
74
- });
75
-
76
- try {
77
- await writeFile(path, updated, 'utf-8');
78
- } catch (err) {
79
- return {
80
- content: [
81
- { type: 'text' as const, text: `Failed to write ${path}: ${(err as Error).message}` }
82
- ],
83
- isError: true
84
- };
85
- }
86
-
87
- const total = parseManifest(updated).decisions.length;
88
- const note = created ? ' (created the manifest)' : '';
89
- return {
90
- content: [
91
- {
92
- type: 'text' as const,
93
- text: `Recorded ADR "${title}" dated ${today} in \`${path}\`${note}. ${total} decision(s) on record.`
94
- }
95
- ]
96
- };
97
- }
98
- );
99
- }
@@ -1,92 +0,0 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
2
- import { dirname } from 'node:path';
3
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
- import { z } from 'zod';
5
- import {
6
- createManifestTemplate,
7
- scanMarkers,
8
- upsertUsagesSection
9
- } from '../design-manifest/index.js';
10
- import { getProjectManifestPath, getProjectSourceDir, isWithinProjectDir } from '../utils/paths.js';
11
-
12
- export function registerSyncDesignManifestTool(server: McpServer): void {
13
- server.tool(
14
- 'sync_design_manifest',
15
- 'Scan the project source for `data-design-pattern="…"` markers and regenerate the Pattern Usages section of design.manifest.md. Run after adding, moving, or removing pattern-following pages so the usage index stays accurate — this is what makes a pattern change tractable (grep the markers → migrate every listed file). Creates the manifest if missing.',
16
- {
17
- sourceDir: z
18
- .string()
19
- .optional()
20
- .describe('Directory to scan recursively. Defaults to ./src in the project root.'),
21
- manifestPath: z
22
- .string()
23
- .optional()
24
- .describe('Path to design.manifest.md. Defaults to the project root.')
25
- },
26
- { readOnlyHint: false },
27
- async ({ sourceDir, manifestPath }) => {
28
- const path = manifestPath ?? getProjectManifestPath();
29
- if (!path.endsWith('.md')) {
30
- return {
31
- content: [
32
- { type: 'text' as const, text: `Refusing to write: "${path}" is not a .md file.` }
33
- ],
34
- isError: true
35
- };
36
- }
37
- if (manifestPath && !isWithinProjectDir(manifestPath)) {
38
- return {
39
- content: [
40
- {
41
- type: 'text' as const,
42
- text: `Refusing to write outside the project root: "${path}".`
43
- }
44
- ],
45
- isError: true
46
- };
47
- }
48
- const src = sourceDir ?? getProjectSourceDir();
49
-
50
- // Files in the manifest are relative to the project root (the manifest's directory).
51
- const usages = await scanMarkers(src, dirname(path));
52
-
53
- let content: string;
54
- let created = false;
55
- try {
56
- content = await readFile(path, 'utf-8');
57
- } catch {
58
- content = createManifestTemplate({});
59
- created = true;
60
- }
61
-
62
- const updated = upsertUsagesSection(content, usages);
63
- try {
64
- await writeFile(path, updated, 'utf-8');
65
- } catch (err) {
66
- return {
67
- content: [
68
- { type: 'text' as const, text: `Failed to write ${path}: ${(err as Error).message}` }
69
- ],
70
- isError: true
71
- };
72
- }
73
-
74
- const byPattern = new Map<string, number>();
75
- for (const u of usages) byPattern.set(u.pattern, (byPattern.get(u.pattern) ?? 0) + 1);
76
-
77
- let text = `Synced \`${path}\`${created ? ' (created it)' : ''} — scanned \`${src}\`, found ${usages.length} marker(s)`;
78
- if (byPattern.size > 0) {
79
- const summary = [...byPattern]
80
- .sort((a, b) => a[0].localeCompare(b[0]))
81
- .map(([p, n]) => `${p} (${n})`)
82
- .join(', ');
83
- text += ` across ${byPattern.size} pattern(s): ${summary}.`;
84
- } else {
85
- text +=
86
- '. No markers yet — add `data-design-pattern="<name>"` to the root element of pages that follow a composition pattern.';
87
- }
88
-
89
- return { content: [{ type: 'text' as const, text }] };
90
- }
91
- );
92
- }
@@ -1,101 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
- import {
3
- getCatalogPath,
4
- getComponentLlmPath,
5
- getDataDir,
6
- getRecipeDir,
7
- getTemplateDir,
8
- getTemplatePath,
9
- isWithinProjectDir
10
- } from './paths.js';
11
-
12
- describe('paths', () => {
13
- const originalDataDir = process.env.DATA_DIR;
14
-
15
- afterEach(() => {
16
- if (originalDataDir === undefined) {
17
- delete process.env.DATA_DIR;
18
- } else {
19
- process.env.DATA_DIR = originalDataDir;
20
- }
21
- });
22
-
23
- describe('isWithinProjectDir', () => {
24
- const original = process.env.DESIGN_PROJECT_DIR;
25
- beforeEach(() => {
26
- process.env.DESIGN_PROJECT_DIR = '/home/user/app';
27
- });
28
- afterEach(() => {
29
- if (original === undefined) delete process.env.DESIGN_PROJECT_DIR;
30
- else process.env.DESIGN_PROJECT_DIR = original;
31
- });
32
-
33
- it('accepts paths inside the project root', () => {
34
- expect(isWithinProjectDir('/home/user/app/design.manifest.md')).toBe(true);
35
- expect(isWithinProjectDir('/home/user/app/docs/design.manifest.md')).toBe(true);
36
- });
37
- it('rejects paths outside the project root', () => {
38
- expect(isWithinProjectDir('/etc/motd.md')).toBe(false);
39
- expect(isWithinProjectDir('/home/user/app/../other/x.md')).toBe(false);
40
- expect(isWithinProjectDir('/home/user/application/x.md')).toBe(false); // prefix-but-not-child
41
- });
42
- });
43
-
44
- describe('getDataDir', () => {
45
- beforeEach(() => {
46
- delete process.env.DATA_DIR;
47
- });
48
-
49
- it('defaults to apps/docs/static relative to the package', () => {
50
- const dir = getDataDir();
51
- expect(dir.endsWith('/apps/docs/static')).toBe(true);
52
- });
53
-
54
- it('honours DATA_DIR env override', () => {
55
- process.env.DATA_DIR = '/custom/data/dir';
56
- expect(getDataDir()).toBe('/custom/data/dir');
57
- });
58
- });
59
-
60
- describe('getTemplateDir / getRecipeDir / getTemplatePath / getCatalogPath', () => {
61
- it('returns absolute paths rooted in the monorepo', () => {
62
- expect(getTemplateDir()).toMatch(/\/packages\/docs-gen\/templates$/);
63
- expect(getRecipeDir()).toMatch(/\/apps\/docs\/src\/routes\/recipes$/);
64
- expect(getTemplatePath()).toMatch(/\/packages\/docs-gen\/templates\/llms-full-template\.md$/);
65
- expect(getCatalogPath()).toMatch(/\/mcp\/component-catalog\.json$/);
66
- });
67
- });
68
-
69
- describe('getComponentLlmPath', () => {
70
- it('resolves a valid slug to a path inside the data dir', () => {
71
- const p = getComponentLlmPath('primitives', 'button');
72
- expect(p).toMatch(/\/primitives\/button\/llm\.txt$/);
73
- });
74
-
75
- it('accepts multi-word hyphenated slugs', () => {
76
- const p = getComponentLlmPath('components', 'date-picker');
77
- expect(p).toMatch(/\/components\/date-picker\/llm\.txt$/);
78
- });
79
-
80
- it('rejects uppercase in the slug', () => {
81
- expect(() => getComponentLlmPath('primitives', 'Button')).toThrow(/Invalid component slug/);
82
- });
83
-
84
- it('rejects whitespace', () => {
85
- expect(() => getComponentLlmPath('primitives', 'bad slug')).toThrow(/Invalid component slug/);
86
- });
87
-
88
- it('rejects leading or trailing hyphens', () => {
89
- expect(() => getComponentLlmPath('primitives', '-bad')).toThrow(/Invalid component slug/);
90
- expect(() => getComponentLlmPath('primitives', 'bad-')).toThrow(/Invalid component slug/);
91
- });
92
-
93
- it('rejects path traversal attempts', () => {
94
- // Any dotted segment fails the SAFE_SLUG regex first.
95
- expect(() => getComponentLlmPath('primitives', '..')).toThrow(/Invalid component slug/);
96
- expect(() => getComponentLlmPath('primitives', '../../../etc/passwd')).toThrow(
97
- /Invalid component slug/
98
- );
99
- });
100
- });
101
- });
@@ -1,78 +0,0 @@
1
- import { dirname, normalize, resolve, sep } from 'node:path';
2
- import { fileURLToPath } from 'node:url';
3
-
4
- const __dirname = dirname(fileURLToPath(import.meta.url));
5
- const packageRoot = resolve(__dirname, '..', '..');
6
-
7
- export function getDataDir(): string {
8
- return process.env.DATA_DIR ?? resolve(packageRoot, '..', '..', 'apps', 'docs', 'static');
9
- }
10
-
11
- export function getTemplateDir(): string {
12
- return resolve(packageRoot, '..', 'docs-gen', 'templates');
13
- }
14
-
15
- export function getRecipeDir(): string {
16
- return resolve(packageRoot, '..', '..', 'apps', 'docs', 'src', 'routes', 'recipes');
17
- }
18
-
19
- export function getDesignSystemDir(): string {
20
- return process.env.DESIGN_SYSTEM_DIR ?? resolve(packageRoot, '..', '..', 'design-system');
21
- }
22
-
23
- /**
24
- * Root of the *consumer* project (the app being built with Urbicon UI), used by
25
- * the design-context tools. Unlike the loaders above — which read the library's
26
- * own shipped data — these tools read/write the consumer's `design.manifest.md`
27
- * and scan its source, so the default is the process cwd (the consumer's repo
28
- * when the server is launched from their editor). Override with env vars.
29
- */
30
- export function getProjectDir(): string {
31
- return process.env.DESIGN_PROJECT_DIR ?? process.cwd();
32
- }
33
-
34
- export function getProjectManifestPath(): string {
35
- return process.env.DESIGN_MANIFEST_PATH ?? resolve(getProjectDir(), 'design.manifest.md');
36
- }
37
-
38
- export function getProjectSourceDir(): string {
39
- return process.env.DESIGN_SOURCE_DIR ?? resolve(getProjectDir(), 'src');
40
- }
41
-
42
- /**
43
- * Containment check for a caller-supplied manifest path. The write tools accept
44
- * an LLM-supplied `manifestPath`; restrict it to the project root so a model
45
- * cannot be steered into writing `.md` files elsewhere. (The user-configured
46
- * `DESIGN_MANIFEST_PATH` env default is trusted and not run through this.)
47
- */
48
- export function isWithinProjectDir(targetPath: string): boolean {
49
- const resolved = resolve(targetPath);
50
- const projectDir = normalize(getProjectDir());
51
- return resolved === projectDir || resolved.startsWith(projectDir + sep);
52
- }
53
-
54
- export function getTemplatePath(): string {
55
- return resolve(getTemplateDir(), 'llms-full-template.md');
56
- }
57
-
58
- export function getCatalogPath(): string {
59
- return resolve(getDataDir(), 'mcp', 'component-catalog.json');
60
- }
61
-
62
- /** Validate that a slug contains only safe characters (lowercase alphanumeric + hyphens). */
63
- const SAFE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
64
-
65
- export function getComponentLlmPath(group: string, slug: string): string {
66
- if (!SAFE_SLUG.test(slug)) {
67
- throw new Error(`Invalid component slug: "${slug}"`);
68
- }
69
-
70
- const resolved = resolve(getDataDir(), group, slug, 'llm.txt');
71
- const dataDir = normalize(getDataDir());
72
-
73
- if (!resolved.startsWith(dataDir)) {
74
- throw new Error(`Path traversal blocked for slug: "${slug}"`);
75
- }
76
-
77
- return resolved;
78
- }
@@ -1,141 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import type { ComponentCatalogEntry } from '../data/catalog-loader.js';
3
- import { matchComponents } from './search.js';
4
-
5
- function makeEntry(
6
- overrides: Partial<ComponentCatalogEntry> & Pick<ComponentCatalogEntry, 'name' | 'slug'>
7
- ): ComponentCatalogEntry {
8
- return {
9
- package: '@urbicon-ui/blocks',
10
- group: 'primitives',
11
- description: '',
12
- tags: [],
13
- import: `import { ${overrides.name} } from '@urbicon-ui/blocks';`,
14
- llmTxtPath: '',
15
- variants: [],
16
- keyProps: [],
17
- keyPropTypes: {},
18
- slots: [],
19
- hasExamples: false,
20
- relatedComponents: [],
21
- ...overrides
22
- };
23
- }
24
-
25
- const Button = makeEntry({
26
- name: 'Button',
27
- slug: 'button',
28
- description: 'Click to trigger an action',
29
- tags: ['action'],
30
- keyProps: ['intent', 'variant', 'size']
31
- });
32
-
33
- const Input = makeEntry({
34
- name: 'Input',
35
- slug: 'input',
36
- description: 'Single-line text field',
37
- tags: ['form'],
38
- keyProps: ['value', 'error']
39
- });
40
-
41
- const Dialog = makeEntry({
42
- name: 'Dialog',
43
- slug: 'dialog',
44
- description: 'Modal overlay container',
45
- tags: ['overlay']
46
- });
47
-
48
- const catalog = [Button, Input, Dialog];
49
-
50
- describe('matchComponents', () => {
51
- it('ranks an exact name match first', () => {
52
- const results = matchComponents(catalog, 'button');
53
- expect(results[0]?.name).toBe('Button');
54
- });
55
-
56
- it('matches by case-insensitive substring of the name', () => {
57
- const results = matchComponents(catalog, 'Butt');
58
- expect(results.some((r) => r.name === 'Button')).toBe(true);
59
- });
60
-
61
- it('fuzz-matches a single-character typo', () => {
62
- const results = matchComponents(catalog, 'Buton'); // typo: missing "t"
63
- expect(results[0]?.name).toBe('Button');
64
- });
65
-
66
- it('ignores matches that are too distant', () => {
67
- const results = matchComponents(catalog, 'xyzzy');
68
- expect(results).toEqual([]);
69
- });
70
-
71
- it('scores tag matches when the tag keyword is present in the query', () => {
72
- const results = matchComponents(catalog, 'form');
73
- expect(results[0]?.name).toBe('Input');
74
- });
75
-
76
- it('filters by the explicit tags argument', () => {
77
- const results = matchComponents(catalog, 'container', ['overlay']);
78
- expect(results.some((r) => r.name === 'Dialog')).toBe(true);
79
- });
80
-
81
- it('respects the limit', () => {
82
- const many = Array.from({ length: 10 }, (_, i) =>
83
- makeEntry({ name: `Button${i}`, slug: `button-${i}`, description: 'click' })
84
- );
85
- const results = matchComponents(many, 'button', undefined, 3);
86
- expect(results).toHaveLength(3);
87
- });
88
-
89
- it('drops words shorter than two characters before matching', () => {
90
- // "a" and "x" below are too short and should be filtered;
91
- // only "button" remains and should drive the match.
92
- const results = matchComponents(catalog, 'a x button');
93
- expect(results[0]?.name).toBe('Button');
94
- });
95
-
96
- it('uses the prop-name match as a weak signal', () => {
97
- const results = matchComponents(catalog, 'intent');
98
- expect(results.some((r) => r.name === 'Button')).toBe(true);
99
- });
100
-
101
- it('returns an empty list when the query has no usable keywords', () => {
102
- const results = matchComponents(catalog, ', , ');
103
- expect(results).toEqual([]);
104
- });
105
- });
106
-
107
- // Regression for the DateGrid/Planner discovery fix: planning-board queries
108
- // used to steer toward Calendar (a timed-event scheduler). With Planner in the
109
- // catalog they must land on Planner instead — driven purely by its catalog
110
- // description/tags/slug, no hardcoded keyword map.
111
- describe('matchComponents — Planner discovery', () => {
112
- const Calendar = makeEntry({
113
- name: 'Calendar',
114
- slug: 'calendar',
115
- description: 'Event display and date selection with month, week and day views.',
116
- tags: ['display'],
117
- relatedComponents: ['DatePicker']
118
- });
119
- const Planner = makeEntry({
120
- name: 'Planner',
121
- slug: 'planner',
122
- description:
123
- 'Date-indexed planning board — a week, month or custom-range grid whose cells hold your domain content (meals, shifts, bookings, content slots) via a generic cell snippet.',
124
- tags: ['display', 'layout'],
125
- keyProps: ['items', 'getDate', 'view', 'cell'],
126
- relatedComponents: ['Calendar', 'DatePicker']
127
- });
128
- const dateCatalog = [Calendar, Planner];
129
-
130
- for (const query of ['planner', 'meal planner', 'weekly plan', 'shift schedule', 'week board']) {
131
- it(`ranks Planner first for "${query}"`, () => {
132
- const results = matchComponents(dateCatalog, query);
133
- expect(results[0]?.name).toBe('Planner');
134
- });
135
- }
136
-
137
- it('still ranks Calendar first for an event/appointment query', () => {
138
- const results = matchComponents(dateCatalog, 'event calendar');
139
- expect(results[0]?.name).toBe('Calendar');
140
- });
141
- });
@@ -1,106 +0,0 @@
1
- import type { ComponentCatalogEntry } from '../data/catalog-loader.js';
2
-
3
- interface ScoredEntry {
4
- entry: ComponentCatalogEntry;
5
- score: number;
6
- }
7
-
8
- export function matchComponents(
9
- components: ComponentCatalogEntry[],
10
- query: string,
11
- tags?: string[],
12
- limit = 5
13
- ): ComponentCatalogEntry[] {
14
- const keywords = query
15
- .toLowerCase()
16
- .split(/[\s,\-_]+/)
17
- .filter((w) => w.length > 1);
18
-
19
- const scored: ScoredEntry[] = components.map((entry) => {
20
- let score = 0;
21
- const nameLower = entry.name.toLowerCase();
22
- const slugLower = entry.slug.toLowerCase();
23
- const descLower = entry.description.toLowerCase();
24
-
25
- for (const kw of keywords) {
26
- // Exact match
27
- if (nameLower === kw || slugLower === kw) {
28
- score += 10;
29
- } else if (nameLower.includes(kw) || slugLower.includes(kw)) {
30
- score += 7;
31
- } else {
32
- // Fuzzy match on name/slug (Levenshtein distance <= 2)
33
- const nameDist = levenshtein(nameLower, kw);
34
- const slugDist = levenshtein(slugLower, kw);
35
- const minDist = Math.min(nameDist, slugDist);
36
- if (minDist <= 1) {
37
- score += 6;
38
- } else if (minDist <= 2) {
39
- score += 3;
40
- }
41
- }
42
-
43
- // Tag match
44
- if (entry.tags.some((t) => t.toLowerCase() === kw)) {
45
- score += 5;
46
- }
47
-
48
- // Description match
49
- if (descLower.includes(kw)) {
50
- score += 3;
51
- }
52
-
53
- // Prop name match
54
- if (entry.keyProps.some((p) => p.toLowerCase().includes(kw))) {
55
- score += 1;
56
- }
57
- }
58
-
59
- if (tags && tags.length > 0) {
60
- const entryTags = entry.tags.map((t) => t.toLowerCase());
61
- for (const tag of tags) {
62
- if (entryTags.includes(tag.toLowerCase())) {
63
- score += 5;
64
- }
65
- }
66
- }
67
-
68
- return { entry, score };
69
- });
70
-
71
- return scored
72
- .filter((s) => s.score > 0)
73
- .sort((a, b) => b.score - a.score)
74
- .slice(0, limit)
75
- .map((s) => s.entry);
76
- }
77
-
78
- function levenshtein(a: string, b: string): number {
79
- if (a.length === 0) return b.length;
80
- if (b.length === 0) return a.length;
81
-
82
- // Early exit for large length differences
83
- if (Math.abs(a.length - b.length) > 2) return 3;
84
-
85
- const matrix: number[][] = [];
86
-
87
- for (let i = 0; i <= a.length; i++) {
88
- matrix[i] = [i];
89
- }
90
- for (let j = 0; j <= b.length; j++) {
91
- matrix[0]![j] = j;
92
- }
93
-
94
- for (let i = 1; i <= a.length; i++) {
95
- for (let j = 1; j <= b.length; j++) {
96
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
97
- matrix[i]![j] = Math.min(
98
- matrix[i - 1]![j]! + 1,
99
- matrix[i]![j - 1]! + 1,
100
- matrix[i - 1]![j - 1]! + cost
101
- );
102
- }
103
- }
104
-
105
- return matrix[a.length]![b.length]!;
106
- }