diff --git a/README.md b/README.md index 923974b..a229964 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Lightweight, composable utilities for documentation sites. - **compass** — Headless tree navigation state machine (pure, portable to any runtime) - **teleport** — Keyboard bindings + DOM integration for compass - **lantern** — Theme toggle with flash-free hydration -- **atlas** — Magic links with build-time resolution and broken link detection +- **atlas** — Remark plugin for Wikipedia-style `[[magic links]]` - **lighthouse** — 404 recovery with fuzzy matching Core packages are framework-agnostic. Astro wrappers available for teleport, lantern, and lighthouse. diff --git a/packages/atlas/README.md b/packages/atlas/README.md index b1c658b..7246c3b 100644 --- a/packages/atlas/README.md +++ b/packages/atlas/README.md @@ -1,90 +1,49 @@ # atlas -Wikipedia-style magic links with build-time resolution and broken link detection. +Remark plugin for Wikipedia-style magic links. -## What Ships - -``` -atlas/ -├── remark-magic-links.mjs # Transforms :id and [[id]] syntax to URLs -├── link-resolver.ts # Core resolution logic -├── link-checker.ts # Broken link detection + reporting -└── index.ts # Unified API -``` - -## Link Syntax +## Syntax ```markdown - -Check out [:context] for more info. -See [:context|:ctx|:context-management] for fallback resolution. - - -Check out [[context]] for more info. -[[context|Learn about context]] with custom display text. -``` + +Check out [[context-collapse]] for more. +[[context-collapse|Learn about context]] with custom display text. -## API - -```typescript -interface LinkTarget { - id: string; - slug: string; - url: string; - aliases?: string[]; - placeholder?: boolean; -} - -type ResolveResult = - | { status: 'resolved'; target: LinkTarget } - | { status: 'placeholder'; target: LinkTarget } - | { status: 'unresolved'; id: string }; - -function createLinkResolver(targets: LinkTarget[]): { - resolve(id: string): ResolveResult; - resolveFirst(ids: string[]): ResolveResult; -}; - -function remarkMagicLinks(config: { - targets: LinkTarget[]; - syntax?: 'colon' | 'wiki' | 'both'; - unresolvedBehavior?: 'text' | 'warn' | 'error'; - placeholderClass?: string; -}): RemarkPlugin; - -function checkLinks(config: { - contentDir: string; - targets: LinkTarget[]; -}): { - broken: { file: string; line: number; id: string }[]; - placeholders: { file: string; line: number; id: string }[]; -}; + +Check out [:context-collapse] for more. +[:context-collapse|Learn about context] with custom display text. ``` -## Resolution Priority - -1. Exact `id` match -2. Match in `aliases` array -3. Slug fallback -4. Unresolved → plain text (graceful degradation) - ## Usage ```javascript // astro.config.mjs -import { remarkMagicLinks } from 'atlas'; - -const targets = concepts.map(c => ({ - id: c.data.id || c.slug, - slug: c.slug, - url: `/concepts/${c.slug}/`, - aliases: c.data.aliases, - placeholder: c.data.placeholder, -})); +import { remarkMagicLinks } from '@sailkit/atlas'; export default { markdown: { - remarkPlugins: [[remarkMagicLinks, { targets }]], + remarkPlugins: [ + [remarkMagicLinks, { + urlBuilder: (id) => `/concepts/${id}/`, + }], + ], }, }; ``` + +## API + +```typescript +remarkMagicLinks(config: { + /** Build URL from link ID */ + urlBuilder: (id: string) => string; + /** Syntax style: 'wiki' (default), 'colon', or 'both' */ + syntax?: 'wiki' | 'colon' | 'both'; +}): RemarkPlugin; +``` + +## How It Works + +1. You write `[[some-page]]` in markdown +2. Plugin finds the syntax in text nodes (not code blocks) +3. Transforms to `[some-page](/concepts/some-page/)` using your `urlBuilder` diff --git a/packages/atlas/package.json b/packages/atlas/package.json new file mode 100644 index 0000000..4cb4db3 --- /dev/null +++ b/packages/atlas/package.json @@ -0,0 +1,56 @@ +{ + "name": "@sailkit/atlas", + "version": "0.1.0", + "description": "Wikipedia-style magic links with build-time resolution", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./remark": { + "import": "./dist/remark-magic-links.js", + "types": "./dist/remark-magic-links.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "unist-util-visit": "^5.0.0" + }, + "devDependencies": { + "@types/mdast": "^4.0.0", + "@types/node": "^20.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "typescript": "^5.3.0", + "unified": "^11.0.0", + "vitest": "^2.0.0" + }, + "peerDependencies": { + "unified": ">=10.0.0" + }, + "peerDependenciesMeta": { + "unified": { + "optional": true + } + }, + "keywords": [ + "remark", + "mdast", + "magic-links", + "wiki-links", + "markdown", + "astro" + ], + "license": "MIT" +} diff --git a/packages/atlas/src/index.ts b/packages/atlas/src/index.ts new file mode 100644 index 0000000..2065a43 --- /dev/null +++ b/packages/atlas/src/index.ts @@ -0,0 +1,2 @@ +export type { LinkSyntax, RemarkMagicLinksConfig } from './remark-magic-links.js'; +export { remarkMagicLinks, default as remarkMagicLinksDefault } from './remark-magic-links.js'; diff --git a/packages/atlas/src/remark-magic-links.test.ts b/packages/atlas/src/remark-magic-links.test.ts new file mode 100644 index 0000000..8d7e006 --- /dev/null +++ b/packages/atlas/src/remark-magic-links.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from 'vitest'; +import { unified } from 'unified'; +import remarkParse from 'remark-parse'; +import remarkStringify from 'remark-stringify'; +import { remarkMagicLinks } from './remark-magic-links.js'; + +const urlBuilder = (id: string) => `/concepts/${id}/`; + +async function process(markdown: string, config: Partial[0]> = {}) { + const result = await unified() + .use(remarkParse) + .use(remarkMagicLinks, { urlBuilder, ...config }) + .use(remarkStringify) + .process(markdown); + return String(result); +} + +describe('remarkMagicLinks', () => { + describe('wiki syntax (default)', () => { + it('transforms [[id]] to links', async () => { + const result = await process('Check out [[context-collapse]] for more info.'); + expect(result).toContain('[context-collapse](/concepts/context-collapse/)'); + }); + + it('supports custom display text [[id|text]]', async () => { + const result = await process('Learn about [[hallucination|AI hallucinations]].'); + expect(result).toContain('[AI hallucinations](/concepts/hallucination/)'); + }); + + it('handles multiple links in same paragraph', async () => { + const result = await process('See [[foo]] and [[bar]] for details.'); + expect(result).toContain('[foo](/concepts/foo/)'); + expect(result).toContain('[bar](/concepts/bar/)'); + }); + }); + + describe('colon syntax', () => { + it('transforms [:id] to links', async () => { + const result = await process('Check out [:context-collapse] for more info.', { syntax: 'colon' }); + expect(result).toContain('[context-collapse](/concepts/context-collapse/)'); + }); + + it('supports custom display text [:id|text]', async () => { + const result = await process('Learn about [:hallucination|AI hallucinations].', { syntax: 'colon' }); + expect(result).toContain('[AI hallucinations](/concepts/hallucination/)'); + }); + }); + + describe('both syntax', () => { + it('handles mixed syntax in same document', async () => { + const result = await process( + 'First [:foo] and then [[bar]].', + { syntax: 'both' } + ); + expect(result).toContain('[foo](/concepts/foo/)'); + expect(result).toContain('[bar](/concepts/bar/)'); + }); + }); + + describe('edge cases', () => { + it('handles link at start of text', async () => { + const result = await process('[[foo]] is important.'); + expect(result).toContain('[foo](/concepts/foo/)'); + }); + + it('handles link at end of text', async () => { + const result = await process('Learn about [[foo]]'); + expect(result).toContain('[foo](/concepts/foo/)'); + }); + + it('preserves surrounding text', async () => { + const result = await process('Before [[foo]] after.'); + expect(result).toContain('Before'); + expect(result).toContain('after'); + }); + + it('handles text with no magic links', async () => { + const result = await process('Just regular text here.'); + expect(result).toContain('Just regular text here.'); + }); + + it('does not match inside code blocks', async () => { + const result = await process('```\n[[not-a-link]]\n```'); + expect(result).toContain('[[not-a-link]]'); + expect(result).not.toContain('/concepts/not-a-link/'); + }); + + it('does not match inside inline code', async () => { + const result = await process('Use `[[syntax]]` for links.'); + expect(result).toContain('`[[syntax]]`'); + }); + }); + + describe('urlBuilder callback', () => { + it('uses custom urlBuilder', async () => { + const customBuilder = (id: string) => `/custom/${id}.html`; + const result = await unified() + .use(remarkParse) + .use(remarkMagicLinks, { urlBuilder: customBuilder }) + .use(remarkStringify) + .process('See [[my-page]] here.'); + expect(String(result)).toContain('[my-page](/custom/my-page.html)'); + }); + }); +}); diff --git a/packages/atlas/src/remark-magic-links.ts b/packages/atlas/src/remark-magic-links.ts new file mode 100644 index 0000000..c914046 --- /dev/null +++ b/packages/atlas/src/remark-magic-links.ts @@ -0,0 +1,116 @@ +import type { Root, Text, Link, Parent } from 'mdast'; +import { visit } from 'unist-util-visit'; + +export type LinkSyntax = 'colon' | 'wiki' | 'both'; + +export interface RemarkMagicLinksConfig { + /** Build URL from link ID */ + urlBuilder: (id: string) => string; + /** Syntax style to parse (default: 'wiki') */ + syntax?: LinkSyntax; +} + +/** Minimal plugin type compatible with unified */ +type RemarkPlugin = (config: RemarkMagicLinksConfig) => (tree: Root) => void; + +// Regex patterns for magic link syntax +// Colon syntax: [:id] or [:id|Display Text] +const COLON_LINK_PATTERN = /\[:([^\]|]+)(?:\|([^\]]+))?\]/g; +// Wiki syntax: [[id]] or [[id|Display Text]] +const WIKI_LINK_PATTERN = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g; + +interface ParsedLink { + fullMatch: string; + id: string; + displayText?: string; + startIndex: number; +} + +function parseMagicLinks(text: string, syntax: LinkSyntax): ParsedLink[] { + const links: ParsedLink[] = []; + + if (syntax === 'colon' || syntax === 'both') { + let match: RegExpExecArray | null; + COLON_LINK_PATTERN.lastIndex = 0; + while ((match = COLON_LINK_PATTERN.exec(text)) !== null) { + links.push({ + fullMatch: match[0], + id: match[1].trim(), + displayText: match[2]?.trim(), + startIndex: match.index, + }); + } + } + + if (syntax === 'wiki' || syntax === 'both') { + let match: RegExpExecArray | null; + WIKI_LINK_PATTERN.lastIndex = 0; + while ((match = WIKI_LINK_PATTERN.exec(text)) !== null) { + links.push({ + fullMatch: match[0], + id: match[1].trim(), + displayText: match[2]?.trim(), + startIndex: match.index, + }); + } + } + + return links.sort((a, b) => a.startIndex - b.startIndex); +} + +/** + * Remark plugin that transforms magic link syntax into actual links. + * + * Supports two syntax styles: + * - Colon: `[:id]` or `[:id|Display Text]` + * - Wiki: `[[id]]` or `[[id|Display Text]]` + */ +export const remarkMagicLinks: RemarkPlugin = (config) => { + const { urlBuilder, syntax = 'wiki' } = config; + + return (tree: Root) => { + visit(tree, 'text', (node: Text, index: number | undefined, parent: Parent | undefined) => { + if (!parent || index === undefined) return; + + const magicLinks = parseMagicLinks(node.value, syntax); + if (magicLinks.length === 0) return; + + const newNodes: (Text | Link)[] = []; + let lastIndex = 0; + + for (const link of magicLinks) { + // Text before this link + if (link.startIndex > lastIndex) { + newNodes.push({ + type: 'text', + value: node.value.slice(lastIndex, link.startIndex), + }); + } + + // Create link node + const displayText = link.displayText || link.id; + const url = urlBuilder(link.id); + + newNodes.push({ + type: 'link', + url, + children: [{ type: 'text', value: displayText }], + }); + + lastIndex = link.startIndex + link.fullMatch.length; + } + + // Remaining text after last link + if (lastIndex < node.value.length) { + newNodes.push({ + type: 'text', + value: node.value.slice(lastIndex), + }); + } + + (parent.children as (Text | Link)[]).splice(index, 1, ...newNodes); + }); + }; +}; + +export default remarkMagicLinks; diff --git a/packages/atlas/tsconfig.json b/packages/atlas/tsconfig.json new file mode 100644 index 0000000..bf8aaf8 --- /dev/null +++ b/packages/atlas/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/atlas/vitest.config.ts b/packages/atlas/vitest.config.ts new file mode 100644 index 0000000..6ec74ee --- /dev/null +++ b/packages/atlas/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/lantern/README.md b/packages/lantern/README.md index 87a1bee..233e32f 100644 --- a/packages/lantern/README.md +++ b/packages/lantern/README.md @@ -1,20 +1,36 @@ -# lantern +# @sailkit/lantern -Theme toggle with correct hydration (no flash of wrong theme). +Theme toggle with flash-free hydration. -## What Ships +## Quick Start (Astro) -``` -lantern/ -├── core.ts # initTheme, toggleTheme, getTheme, onThemeChange -├── ThemeToggle.astro # Drop-in component -├── theme-base.css # CSS variable structure -├── theme-dark.css # Default dark palette -└── theme-light.css # Default light palette +```astro +--- +import { initScript } from '@sailkit/lantern'; +import ThemeToggle from '@sailkit/lantern/ThemeToggle.astro'; +--- + + + - +Include `initScript` before paint to prevent flash of wrong theme: + +```astro + +## Optional Example CSS + +```typescript +import '@sailkit/lantern/theme-dark.css'; +import '@sailkit/lantern/theme-light.css'; ``` + +These are minimal examples. Override with your own theme CSS. diff --git a/packages/lantern/ThemeToggle.astro b/packages/lantern/ThemeToggle.astro new file mode 100644 index 0000000..f907ced --- /dev/null +++ b/packages/lantern/ThemeToggle.astro @@ -0,0 +1,79 @@ +--- +/** + * Theme toggle button component. + * + * Provides a button that toggles between light and dark themes. + * Style with CSS using the .theme-toggle class or pass a custom class. + */ +interface Props { + class?: string; + /** Show text label next to icon (default: true) */ + showLabel?: boolean; +} + +const { class: className = '', showLabel = true } = Astro.props; +--- + + + + + + diff --git a/packages/lantern/package.json b/packages/lantern/package.json new file mode 100644 index 0000000..898bc66 --- /dev/null +++ b/packages/lantern/package.json @@ -0,0 +1,44 @@ +{ + "name": "@sailkit/lantern", + "version": "0.1.0", + "description": "Theme toggle with flash-free hydration", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./ThemeToggle.astro": "./ThemeToggle.astro", + "./theme-dark.css": "./theme-dark.css", + "./theme-light.css": "./theme-light.css" + }, + "files": [ + "dist", + "ThemeToggle.astro", + "theme-dark.css", + "theme-light.css" + ], + "scripts": { + "build": "tsc", + "prepare": "npm run build", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "jsdom": "^25.0.0", + "typescript": "^5.3.0", + "vitest": "^2.0.0" + }, + "keywords": [ + "theme", + "dark-mode", + "light-mode", + "toggle", + "flash-free", + "hydration" + ], + "license": "MIT" +} diff --git a/packages/lantern/src/core.test.ts b/packages/lantern/src/core.test.ts new file mode 100644 index 0000000..5d75c38 --- /dev/null +++ b/packages/lantern/src/core.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { initTheme, getTheme, setTheme, toggleTheme, onThemeChange, initScript } from './core.js'; + +describe('lantern', () => { + beforeEach(() => { + // Reset DOM + document.documentElement.removeAttribute('data-theme'); + localStorage.clear(); + }); + + describe('initTheme', () => { + it('should set default theme when nothing stored', () => { + const theme = initTheme(); + expect(theme).toBe('dark'); + expect(document.documentElement.getAttribute('data-theme')).toBe('dark'); + }); + + it('should restore theme from localStorage', () => { + localStorage.setItem('theme', 'light'); + const theme = initTheme(); + expect(theme).toBe('light'); + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + }); + }); + + describe('getTheme', () => { + it('should return current theme from DOM', () => { + document.documentElement.setAttribute('data-theme', 'light'); + expect(getTheme()).toBe('light'); + }); + + it('should return default when no theme set', () => { + expect(getTheme()).toBe('dark'); + }); + }); + + describe('setTheme', () => { + it('should set theme in DOM and localStorage', () => { + setTheme('light'); + expect(document.documentElement.getAttribute('data-theme')).toBe('light'); + expect(localStorage.getItem('theme')).toBe('light'); + }); + + it('should notify listeners', () => { + const callback = vi.fn(); + onThemeChange(callback); + setTheme('light'); + expect(callback).toHaveBeenCalledWith('light'); + }); + }); + + describe('toggleTheme', () => { + it('should toggle from dark to light', () => { + initTheme(); + const next = toggleTheme(); + expect(next).toBe('light'); + expect(getTheme()).toBe('light'); + }); + + it('should toggle from light to dark', () => { + setTheme('light'); + const next = toggleTheme(); + expect(next).toBe('dark'); + expect(getTheme()).toBe('dark'); + }); + }); + + describe('onThemeChange', () => { + it('should subscribe and unsubscribe', () => { + const callback = vi.fn(); + const unsubscribe = onThemeChange(callback); + + setTheme('light'); + expect(callback).toHaveBeenCalledTimes(1); + + unsubscribe(); + setTheme('dark'); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe('initScript', () => { + it('should be a valid inline script', () => { + expect(initScript).toContain('localStorage'); + expect(initScript).toContain('data-theme'); + }); + }); +}); diff --git a/packages/lantern/src/core.ts b/packages/lantern/src/core.ts new file mode 100644 index 0000000..38d7c3f --- /dev/null +++ b/packages/lantern/src/core.ts @@ -0,0 +1,87 @@ +/** + * Lantern - Theme toggle with flash-free hydration + * + * Pure functions for theme management. Works in any runtime. + * Convention: stores in localStorage key 'theme', sets data-theme on . + */ + +export type Theme = 'light' | 'dark'; + +const STORAGE_KEY = 'theme'; +const DEFAULT_THEME: Theme = 'dark'; + +type ThemeChangeCallback = (theme: Theme) => void; +const listeners: Set = new Set(); + +/** + * Initialize theme from localStorage or default. + * Call this early (before paint) to prevent flash. + * + * @returns The current theme + */ +export function initTheme(): Theme { + if (typeof window === 'undefined') return DEFAULT_THEME; + + const stored = localStorage.getItem(STORAGE_KEY) as Theme | null; + const theme = stored || DEFAULT_THEME; + document.documentElement.setAttribute('data-theme', theme); + return theme; +} + +/** + * Get the current theme from the DOM. + * + * @returns The current theme + */ +export function getTheme(): Theme { + if (typeof window === 'undefined') return DEFAULT_THEME; + return (document.documentElement.getAttribute('data-theme') as Theme) || DEFAULT_THEME; +} + +/** + * Set the theme explicitly. + * + * @param theme - The theme to set + */ +export function setTheme(theme: Theme): void { + if (typeof window === 'undefined') return; + + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem(STORAGE_KEY, theme); + notifyListeners(theme); +} + +/** + * Toggle between light and dark themes. + * + * @returns The new theme after toggling + */ +export function toggleTheme(): Theme { + const current = getTheme(); + const next: Theme = current === 'dark' ? 'light' : 'dark'; + setTheme(next); + return next; +} + +/** + * Subscribe to theme changes. + * + * @param callback - Function called when theme changes + * @returns Unsubscribe function + */ +export function onThemeChange(callback: ThemeChangeCallback): () => void { + listeners.add(callback); + return () => { + listeners.delete(callback); + }; +} + +function notifyListeners(theme: Theme): void { + listeners.forEach((cb) => cb(theme)); +} + +/** + * Inline script string for flash prevention. + * Include this in a diff --git a/packages/lighthouse/README.md b/packages/lighthouse/README.md index a4da5cf..c2ab2f3 100644 --- a/packages/lighthouse/README.md +++ b/packages/lighthouse/README.md @@ -55,7 +55,8 @@ const defaultMatcher = createCompositeMatcher([ ```astro // src/pages/404.astro --- -import { NotFound } from 'astro-lighthouse'; +import NotFound from '@sailkit/lighthouse/NotFound.astro'; +import Layout from '../layouts/Layout.astro'; const pages = posts.map(p => ({ url: `/posts/${p.slug}/`, @@ -63,11 +64,18 @@ const pages = posts.map(p => ({ section: 'Posts' })); --- - + + + + Go Home + Browse Posts + + + ``` ## Behavior diff --git a/packages/lighthouse/package.json b/packages/lighthouse/package.json new file mode 100644 index 0000000..c9e4274 --- /dev/null +++ b/packages/lighthouse/package.json @@ -0,0 +1,39 @@ +{ + "name": "@sailkit/lighthouse", + "version": "0.1.0", + "description": "404 recovery with fuzzy matching and auto-redirect", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./NotFound.astro": "./NotFound.astro", + "./styles.css": "./styles.css" + }, + "files": [ + "dist", + "NotFound.astro", + "styles.css" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.3.0", + "vitest": "^2.0.0" + }, + "keywords": [ + "404", + "fuzzy-matching", + "levenshtein", + "redirect", + "astro" + ], + "license": "MIT" +} diff --git a/packages/lighthouse/src/core.test.ts b/packages/lighthouse/src/core.test.ts new file mode 100644 index 0000000..180ebb8 --- /dev/null +++ b/packages/lighthouse/src/core.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { findMatches, shouldAutoRedirect } from './core.js'; +import type { Page, ScoredPage } from './types.js'; + +const pages: Page[] = [ + { url: '/concepts/hallucination/', title: 'Hallucination', section: 'Concepts' }, + { url: '/concepts/context-collapse/', title: 'Context Collapse', section: 'Concepts' }, + { url: '/patterns/context-management/', title: 'Context Management', section: 'Patterns' }, + { url: '/failure-modes/lost-in-middle/', title: 'Lost in the Middle', section: 'Failure Modes' }, + { url: '/', title: 'Home' }, +]; + +describe('findMatches', () => { + it('returns matches sorted by score', () => { + const matches = findMatches('/concepts/hallucination/', pages); + expect(matches.length).toBeGreaterThan(0); + expect(matches[0].url).toBe('/concepts/hallucination/'); + expect(matches[0].score).toBe(1); + }); + + it('finds content that moved sections', () => { + // Same slug, different section + const matches = findMatches('/old-section/hallucination/', pages); + expect(matches.length).toBeGreaterThan(0); + expect(matches[0].url).toBe('/concepts/hallucination/'); + }); + + it('filters by threshold', () => { + const matches = findMatches('/xyz123/', pages, { threshold: 0.5 }); + // Very different path should have low scores + expect(matches.length).toBe(0); + }); + + it('respects maxResults', () => { + const matches = findMatches('/context/', pages, { maxResults: 2 }); + expect(matches.length).toBeLessThanOrEqual(2); + }); + + it('uses custom matcher', () => { + const customMatcher = { score: () => 0.99 }; + const matches = findMatches('/anything/', pages, { matcher: customMatcher }); + expect(matches.every((m) => m.score === 0.99)).toBe(true); + }); +}); + +describe('shouldAutoRedirect', () => { + it('returns false for no matches', () => { + expect(shouldAutoRedirect([])).toBe(false); + }); + + it('returns true for single match', () => { + const matches: ScoredPage[] = [{ url: '/test/', title: 'Test', score: 0.3 }]; + expect(shouldAutoRedirect(matches)).toBe(true); + }); + + it('returns true for high score clear winner', () => { + const matches: ScoredPage[] = [ + { url: '/test1/', title: 'Test 1', score: 0.9 }, + { url: '/test2/', title: 'Test 2', score: 0.3 }, + ]; + expect(shouldAutoRedirect(matches, 0.6)).toBe(true); + }); + + it('returns false when no clear winner', () => { + const matches: ScoredPage[] = [ + { url: '/test1/', title: 'Test 1', score: 0.7 }, + { url: '/test2/', title: 'Test 2', score: 0.65 }, + ]; + expect(shouldAutoRedirect(matches, 0.6)).toBe(false); + }); + + it('returns false when score below threshold', () => { + const matches: ScoredPage[] = [ + { url: '/test1/', title: 'Test 1', score: 0.5 }, + { url: '/test2/', title: 'Test 2', score: 0.2 }, + ]; + expect(shouldAutoRedirect(matches, 0.6)).toBe(false); + }); +}); diff --git a/packages/lighthouse/src/core.ts b/packages/lighthouse/src/core.ts new file mode 100644 index 0000000..a6650b4 --- /dev/null +++ b/packages/lighthouse/src/core.ts @@ -0,0 +1,50 @@ +import type { Page, ScoredPage, FindMatchesConfig } from './types.js'; +import { defaultMatcher } from './matchers.js'; + +/** + * Find pages that best match the requested path. + * Returns matches sorted by score (highest first), filtered by threshold. + */ +export function findMatches( + requestedPath: string, + pages: Page[], + config: FindMatchesConfig = {} +): ScoredPage[] { + const { matcher = defaultMatcher, threshold = 0.15, maxResults = 5 } = config; + + // Score all pages + const scored: ScoredPage[] = pages.map((page) => ({ + ...page, + score: matcher.score(requestedPath, page), + })); + + // Sort by score descending + scored.sort((a, b) => b.score - a.score); + + // Filter by threshold and limit results + return scored.filter((p) => p.score > threshold).slice(0, maxResults); +} + +/** + * Determine if we should auto-redirect based on matches. + * Auto-redirect if: + * 1. Single match (only one result above threshold) + * 2. OR strong match with clear winner (high score AND significantly better than alternatives) + */ +export function shouldAutoRedirect( + matches: ScoredPage[], + autoRedirectThreshold: number = 0.6 +): boolean { + if (matches.length === 0) return false; + + // Single match - always redirect + if (matches.length === 1) return true; + + const bestMatch = matches[0]; + + // Strong match with clear winner + const strongMatch = bestMatch.score > autoRedirectThreshold; + const clearWinner = matches.length > 1 && bestMatch.score > matches[1].score + 0.2; + + return strongMatch && clearWinner; +} diff --git a/packages/lighthouse/src/index.ts b/packages/lighthouse/src/index.ts new file mode 100644 index 0000000..7df89bb --- /dev/null +++ b/packages/lighthouse/src/index.ts @@ -0,0 +1,22 @@ +// Types +export type { + Page, + ScoredPage, + Matcher, + FindMatchesConfig, + CompositeMatcherConfig, + NotFoundConfig, +} from './types.js'; + +// Core functions +export { findMatches, shouldAutoRedirect } from './core.js'; + +// Matchers +export { + levenshteinDistance, + levenshteinMatcher, + exactSlugMatcher, + tokenOverlapMatcher, + createCompositeMatcher, + defaultMatcher, +} from './matchers.js'; diff --git a/packages/lighthouse/src/matchers.test.ts b/packages/lighthouse/src/matchers.test.ts new file mode 100644 index 0000000..9a4fe3c --- /dev/null +++ b/packages/lighthouse/src/matchers.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from 'vitest'; +import { + levenshteinDistance, + levenshteinMatcher, + exactSlugMatcher, + tokenOverlapMatcher, + createCompositeMatcher, + defaultMatcher, +} from './matchers.js'; +import type { Page } from './types.js'; + +describe('levenshteinDistance', () => { + it('returns 0 for identical strings', () => { + expect(levenshteinDistance('hello', 'hello')).toBe(0); + }); + + it('returns correct distance for single edit', () => { + expect(levenshteinDistance('hello', 'hallo')).toBe(1); + }); + + it('returns length for completely different strings', () => { + expect(levenshteinDistance('abc', 'xyz')).toBe(3); + }); + + it('handles empty strings', () => { + expect(levenshteinDistance('', 'hello')).toBe(5); + expect(levenshteinDistance('hello', '')).toBe(5); + expect(levenshteinDistance('', '')).toBe(0); + }); +}); + +describe('levenshteinMatcher', () => { + const page: Page = { url: '/concepts/context-collapse/', title: 'Context Collapse' }; + + it('scores identical paths as 1', () => { + expect(levenshteinMatcher.score('/concepts/context-collapse/', page)).toBe(1); + }); + + it('scores similar paths highly', () => { + const score = levenshteinMatcher.score('/concepts/context-collaps/', page); + expect(score).toBeGreaterThan(0.9); + }); + + it('scores very different paths low', () => { + const score = levenshteinMatcher.score('/foo/bar/', page); + expect(score).toBeLessThan(0.5); + }); +}); + +describe('exactSlugMatcher', () => { + const page: Page = { url: '/concepts/hallucination/', title: 'Hallucination' }; + + it('scores exact slug match as 1', () => { + const score = exactSlugMatcher.score('/old-section/hallucination/', page); + expect(score).toBe(1); + }); + + it('scores partial slug match as 0.5', () => { + const score = exactSlugMatcher.score('/concepts/hallucinations/', page); + expect(score).toBe(0.5); + }); + + it('scores no slug match as 0', () => { + const score = exactSlugMatcher.score('/concepts/context/', page); + expect(score).toBe(0); + }); +}); + +describe('tokenOverlapMatcher', () => { + const page: Page = { url: '/concepts/context-collapse/', title: 'Context Collapse' }; + + it('scores high when tokens overlap', () => { + const score = tokenOverlapMatcher.score('/context-collapse/', page); + expect(score).toBeGreaterThan(0.5); + }); + + it('scores partial token matches', () => { + const score = tokenOverlapMatcher.score('/collapse/', page); + expect(score).toBeGreaterThan(0); + }); + + it('scores zero for no overlap', () => { + const score = tokenOverlapMatcher.score('/foo/bar/', page); + expect(score).toBe(0); + }); +}); + +describe('createCompositeMatcher', () => { + it('combines matchers with weights', () => { + const page: Page = { url: '/concepts/test/', title: 'Test' }; + + const composite = createCompositeMatcher([ + { matcher: { score: () => 1 }, weight: 0.5 }, + { matcher: { score: () => 0 }, weight: 0.5 }, + ]); + + expect(composite.score('/test/', page)).toBe(0.5); + }); + + it('normalizes weights', () => { + const page: Page = { url: '/test/', title: 'Test' }; + + const composite = createCompositeMatcher([ + { matcher: { score: () => 1 }, weight: 2 }, + { matcher: { score: () => 0 }, weight: 2 }, + ]); + + expect(composite.score('/test/', page)).toBe(0.5); + }); +}); + +describe('defaultMatcher', () => { + const pages: Page[] = [ + { url: '/concepts/hallucination/', title: 'Hallucination' }, + { url: '/patterns/context-management/', title: 'Context Management' }, + ]; + + it('prioritizes exact slug matches', () => { + // Slug matches exactly but section different + const score = defaultMatcher.score('/old/hallucination/', pages[0]); + expect(score).toBeGreaterThan(0.5); + }); + + it('scores similar URLs higher than different ones', () => { + const similar = defaultMatcher.score('/concepts/hallucinations/', pages[0]); + const different = defaultMatcher.score('/foo/bar/', pages[0]); + expect(similar).toBeGreaterThan(different); + }); +}); diff --git a/packages/lighthouse/src/matchers.ts b/packages/lighthouse/src/matchers.ts new file mode 100644 index 0000000..fa103f8 --- /dev/null +++ b/packages/lighthouse/src/matchers.ts @@ -0,0 +1,135 @@ +import type { Matcher, Page, CompositeMatcherConfig } from './types.js'; + +/** + * Calculate Levenshtein distance between two strings. + * Returns the number of single-character edits (insertions, deletions, substitutions) needed. + */ +export function levenshteinDistance(a: string, b: string): number { + const matrix: number[][] = Array(b.length + 1) + .fill(null) + .map(() => Array(a.length + 1).fill(null)); + + for (let i = 0; i <= a.length; i++) matrix[0][i] = i; + for (let j = 0; j <= b.length; j++) matrix[j][0] = j; + + for (let j = 1; j <= b.length; j++) { + for (let i = 1; i <= a.length; i++) { + const indicator = a[i - 1] === b[j - 1] ? 0 : 1; + matrix[j][i] = Math.min( + matrix[j][i - 1] + 1, // deletion + matrix[j - 1][i] + 1, // insertion + matrix[j - 1][i - 1] + indicator // substitution + ); + } + } + + return matrix[b.length][a.length]; +} + +/** + * Levenshtein-based similarity matcher. + * Scores based on edit distance between requested path and page URL. + */ +export const levenshteinMatcher: Matcher = { + score(requestedPath: string, page: Page): number { + const requested = requestedPath.toLowerCase(); + const pageUrl = page.url.toLowerCase(); + const maxLen = Math.max(requested.length, pageUrl.length); + if (maxLen === 0) return 1; + const distance = levenshteinDistance(requested, pageUrl); + return 1 - distance / maxLen; + }, +}; + +/** + * Exact slug matcher. + * Gives high score when the last path segment (slug) matches exactly. + * Useful for detecting content that moved to a different section. + */ +export const exactSlugMatcher: Matcher = { + score(requestedPath: string, page: Page): number { + const requestedParts = requestedPath.toLowerCase().split('/').filter(Boolean); + const pageParts = page.url.toLowerCase().split('/').filter(Boolean); + + const requestedSlug = requestedParts[requestedParts.length - 1]; + const pageSlug = pageParts[pageParts.length - 1]; + + if (requestedSlug && pageSlug && requestedSlug === pageSlug) { + return 1; + } + + // Partial match - slug contains or is contained by + if (requestedSlug && pageSlug) { + if (pageSlug.includes(requestedSlug) || requestedSlug.includes(pageSlug)) { + return 0.5; + } + } + + return 0; + }, +}; + +/** + * Token overlap matcher. + * Scores based on how many words/tokens from the URL appear in the page URL or title. + */ +export const tokenOverlapMatcher: Matcher = { + score(requestedPath: string, page: Page): number { + const requested = requestedPath.toLowerCase(); + const pageUrl = page.url.toLowerCase(); + const pageTitle = page.title.toLowerCase(); + + // Tokenize - split on non-alphanumeric characters + const requestedTokens = requested.split(/[^a-z0-9]+/).filter(Boolean); + const urlTokens = pageUrl.split(/[^a-z0-9]+/).filter(Boolean); + const titleTokens = pageTitle.split(/\s+/).filter(Boolean); + const allPageTokens = [...urlTokens, ...titleTokens]; + + if (requestedTokens.length === 0) return 0; + + let matches = 0; + for (const reqToken of requestedTokens) { + for (const pageToken of allPageTokens) { + if (pageToken.includes(reqToken) || reqToken.includes(pageToken)) { + matches++; + break; + } + } + } + + return matches / requestedTokens.length; + }, +}; + +/** + * Create a composite matcher that combines multiple matchers with weights. + */ +export function createCompositeMatcher(strategies: CompositeMatcherConfig[]): Matcher { + // Normalize weights to sum to 1 + const totalWeight = strategies.reduce((sum, s) => sum + s.weight, 0); + const normalized = strategies.map((s) => ({ + ...s, + weight: s.weight / totalWeight, + })); + + return { + score(requestedPath: string, page: Page): number { + let totalScore = 0; + for (const { matcher, weight } of normalized) { + totalScore += matcher.score(requestedPath, page) * weight; + } + return Math.min(totalScore, 1); + }, + }; +} + +/** + * Default matcher optimized for 404 recovery. + * Prioritizes exact slug matches (content moved), then Levenshtein similarity, + * then token overlap. + */ +export const defaultMatcher = createCompositeMatcher([ + { matcher: exactSlugMatcher, weight: 0.6 }, + { matcher: levenshteinMatcher, weight: 0.2 }, + { matcher: tokenOverlapMatcher, weight: 0.2 }, +]); diff --git a/packages/lighthouse/src/types.ts b/packages/lighthouse/src/types.ts new file mode 100644 index 0000000..9be061f --- /dev/null +++ b/packages/lighthouse/src/types.ts @@ -0,0 +1,65 @@ +/** + * A page that can be matched against for 404 recovery. + */ +export interface Page { + /** Full URL path to this page */ + url: string; + /** Display title of the page */ + title: string; + /** Optional section/category name */ + section?: string; +} + +/** + * A page with its match score. + */ +export interface ScoredPage extends Page { + /** Match score from 0-1, higher is better */ + score: number; +} + +/** + * A matcher that scores how well a page matches a requested path. + */ +export interface Matcher { + /** Calculate score from 0-1, higher is better */ + score(requestedPath: string, page: Page): number; +} + +/** + * Configuration for findMatches function. + */ +export interface FindMatchesConfig { + /** Custom matcher to use (default: defaultMatcher) */ + matcher?: Matcher; + /** Minimum score threshold (default: 0.15) */ + threshold?: number; + /** Maximum number of matches to return (default: 5) */ + maxResults?: number; +} + +/** + * Configuration for creating a composite matcher. + */ +export interface CompositeMatcherConfig { + /** The matcher to use */ + matcher: Matcher; + /** Weight from 0-1 for this matcher's contribution to final score */ + weight: number; +} + +/** + * Configuration for the NotFound component. + */ +export interface NotFoundConfig { + /** List of valid pages to match against */ + pages: Page[]; + /** Score threshold for auto-redirect (default: 0.6) */ + autoRedirectThreshold?: number; + /** Maximum suggestions to show (default: 5) */ + maxSuggestions?: number; + /** Custom matcher (default: defaultMatcher) */ + matcher?: Matcher; + /** Delay before auto-redirect in ms (default: 1500) */ + redirectDelay?: number; +} diff --git a/packages/lighthouse/styles.css b/packages/lighthouse/styles.css new file mode 100644 index 0000000..11024ed --- /dev/null +++ b/packages/lighthouse/styles.css @@ -0,0 +1,147 @@ +/* Default styles for lighthouse 404 page */ +/* Can be overridden by importing site's design system */ + +.lighthouse-container { + text-align: center; + padding: 2rem 0; + max-width: 600px; + margin: 0 auto; +} + +.lighthouse-code { + font-size: 4rem; + font-weight: bold; + color: var(--lighthouse-accent, #6366f1); + margin-bottom: 0.5rem; +} + +.lighthouse-title { + font-size: 1.5rem; + color: var(--lighthouse-text, #1f2937); + margin-bottom: 1rem; +} + +.lighthouse-description { + color: var(--lighthouse-text-muted, #6b7280); + margin-bottom: 2rem; + line-height: 1.6; +} + +/* Redirect notice */ +.lighthouse-redirect { + background-color: var(--lighthouse-surface, #f3f4f6); + border: 1px solid var(--lighthouse-accent, #6366f1); + border-radius: 0.5rem; + padding: 1rem; + margin: 1.5rem auto; + max-width: 400px; +} + +.lighthouse-redirect p { + margin: 0; + color: var(--lighthouse-text, #1f2937); +} + +.lighthouse-redirect a { + color: var(--lighthouse-accent, #6366f1); +} + +/* Suggestions */ +.lighthouse-suggestions { + margin: 2rem auto; + max-width: 400px; + text-align: left; +} + +.lighthouse-suggestions-title { + font-size: 1rem; + color: var(--lighthouse-text, #1f2937); + margin-bottom: 1rem; + text-align: center; +} + +.lighthouse-suggestion-list { + list-style: none; + padding: 0; + margin: 0; +} + +.lighthouse-suggestion-item { + margin-bottom: 0.75rem; +} + +.lighthouse-suggestion-link { + display: block; + padding: 0.75rem 1rem; + background-color: var(--lighthouse-surface, #f3f4f6); + border: 1px solid var(--lighthouse-border, #e5e7eb); + border-radius: 0.5rem; + text-decoration: none; + transition: all 0.2s ease; +} + +.lighthouse-suggestion-link:hover { + border-color: var(--lighthouse-accent, #6366f1); + background-color: var(--lighthouse-accent-dim, #eef2ff); +} + +.lighthouse-suggestion-title { + color: var(--lighthouse-accent, #6366f1); + font-weight: 500; + display: block; +} + +.lighthouse-suggestion-section { + font-size: 0.75rem; + color: var(--lighthouse-text-muted, #6b7280); + margin-top: 0.25rem; + display: block; +} + +.lighthouse-suggestion-url { + font-size: 0.75rem; + color: var(--lighthouse-text-muted, #6b7280); + font-family: monospace; + opacity: 0.7; + display: block; + margin-top: 0.25rem; +} + +/* Spinner */ +.lighthouse-spinner { + display: inline-block; + width: 1rem; + height: 1rem; + border: 2px solid var(--lighthouse-border, #e5e7eb); + border-top-color: var(--lighthouse-accent, #6366f1); + border-radius: 50%; + animation: lighthouse-spin 0.8s linear infinite; + margin-right: 0.5rem; + vertical-align: middle; +} + +@keyframes lighthouse-spin { + to { + transform: rotate(360deg); + } +} + +/* Actions */ +.lighthouse-actions { + margin-top: 2rem; + display: flex; + gap: 1rem; + justify-content: center; + flex-wrap: wrap; +} + +/* Utility classes */ +.lighthouse-hidden { + display: none; +} + +.lighthouse-searching { + color: var(--lighthouse-text-muted, #6b7280); + text-align: center; + padding: 1rem; +} diff --git a/packages/lighthouse/tsconfig.json b/packages/lighthouse/tsconfig.json new file mode 100644 index 0000000..1a26890 --- /dev/null +++ b/packages/lighthouse/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/lighthouse/vitest.config.ts b/packages/lighthouse/vitest.config.ts new file mode 100644 index 0000000..6ec74ee --- /dev/null +++ b/packages/lighthouse/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); diff --git a/packages/teleport/README.md b/packages/teleport/README.md index a292868..b3cfe31 100644 --- a/packages/teleport/README.md +++ b/packages/teleport/README.md @@ -1,78 +1,128 @@ -# teleport +# @sailkit/teleport -Keyboard bindings that wire to compass navigators. +Vim-style keyboard navigation bindings with DOM integration. -## Layers +## Quick Start (Astro) +```astro +--- +import Teleport from '@sailkit/teleport/Teleport.astro'; +--- + + + +
Content
+ + + ``` -Layer 1: Key bindings (pure functions) -Layer 2a: DOM adapter (applies highlight class, scrollIntoView) -Layer 2b: State callbacks (wire to React/Vue/etc state) -Layer 3: Full integration (batteries included) + +## Default Bindings + +| Key | Action | +|-----|--------| +| `j` / `ArrowDown` | Next item in sidebar | +| `k` / `ArrowUp` | Previous item in sidebar | +| `Ctrl+d` | Scroll content down | +| `Ctrl+u` | Scroll content up | +| `l` / `ArrowRight` | Next page | +| `h` / `ArrowLeft` | Previous page | +| `Enter` | Navigate to highlighted item | +| `t` | Open fuzzy finder (when enabled) | +| `Escape` | Clear highlight | + +## Astro Component Props + +```astro + ``` -Layer 2a applies state directly to the DOM via classes. Layer 2b instead calls back into your framework's state system, letting you manage highlight state in React, Vue, or whatever you're using. +## Programmatic API -## API +Three layers of abstraction for custom integrations: ```typescript -// Layer 1: Keyboard event handling -interface KeyBindings { - next?: string[]; // default ['j', 'ArrowDown'] - prev?: string[]; // default ['k', 'ArrowUp'] - select?: string[]; // default ['Enter'] - nextGroup?: string[]; // default [']]', 'Tab'] - prevGroup?: string[]; // default ['[[', 'Shift+Tab'] - escape?: string[]; // default ['Escape'] -} +// Layer 3: Full integration (batteries included) +import { initTeleport } from '@sailkit/teleport'; + +const teleport = initTeleport({ + itemSelector: '.nav-item', + onNextPage: () => router.push(nextUrl), + onPrevPage: () => router.push(prevUrl), + onOpenFinder: () => openFuzzyFinder(), +}); + +// Cleanup +teleport.destroy(); + +// Layer 2: DOM adapter only +import { createDOMNavigator } from '@sailkit/teleport'; + +const navigator = createDOMNavigator({ + getItems: () => document.querySelectorAll('.item'), + highlightClass: 'my-highlight', + onSelect: (item, index) => console.log('Selected', item), +}); + +navigator.next(); +navigator.prev(); +navigator.goTo(5); + +// Layer 1: Pure key bindings +import { createKeyboardHandler, DEFAULT_BINDINGS } from '@sailkit/teleport'; -function createKeyboardHandler(config: { - bindings?: KeyBindings; - onNext?: () => void; - onPrev?: () => void; - onSelect?: () => void; - onNextGroup?: () => void; - onPrevGroup?: () => void; - onEscape?: () => void; - ignoreWhenTyping?: boolean; // default true -}): { - handleKeydown: (event: KeyboardEvent) => void; - destroy: () => void; -}; - -// Layer 2: DOM adapter -function createDOMNavigator(config: { - getItems: () => HTMLElement[]; - highlightClass?: string; // default 'highlight' - activeClass?: string; // default 'active' - scrollBehavior?: ScrollIntoViewOptions; - onSelect?: (item: HTMLElement) => void; -}): { - navigator: Navigator; - highlight(index: number): void; - clearHighlight(): void; -}; - -// Layer 3: Full integration -function initTeleport(config: { - itemSelector: string; - groupSelector?: string; - highlightClass?: string; - bindings?: KeyBindings; - onSelect?: (item: HTMLElement) => void; -}): { - destroy: () => void; -}; +const handler = createKeyboardHandler({ + bindings: { ...DEFAULT_BINDINGS, nextItem: ['n'] }, + onNextItem: () => navigator.next(), + onPrevItem: () => navigator.prev(), +}); + +document.addEventListener('keydown', handler.handleKeydown); ``` -## Astro Integration +## Custom Bindings -```astro ---- -import { Teleport } from 'astro-teleport'; ---- - +```typescript +import { initTeleport } from '@sailkit/teleport'; + +initTeleport({ + itemSelector: '.nav-item', + bindings: { + nextItem: ['n', 'ArrowDown'], + prevItem: ['p', 'ArrowUp'], + scrollDown: ['Ctrl+f'], + scrollUp: ['Ctrl+b'], + }, +}); +``` + +## Styling + +Default highlight styles are injected. Override with CSS: + +```css +.teleport-highlight { + outline: 2px solid var(--color-accent); + background-color: var(--color-accent-dim); +} +``` + +## Fuzzy Finder Integration + +Listen for the `teleport:open-finder` event: + +```javascript +document.addEventListener('teleport:open-finder', () => { + // Open your fuzzy finder UI + // Use @sailkit/compass data structure for items +}); ``` diff --git a/packages/teleport/Teleport.astro b/packages/teleport/Teleport.astro new file mode 100644 index 0000000..9c5f39e --- /dev/null +++ b/packages/teleport/Teleport.astro @@ -0,0 +1,243 @@ +--- +/** + * Teleport - Vim-style keyboard navigation for Astro sites. + * + * Provides j/k navigation in sidebar, Ctrl+d/u for content scroll, + * h/l for prev/next page, and t for fuzzy finder. + */ +interface Props { + /** CSS selector for navigable items (default: '.nav-item') */ + itemSelector?: string; + /** CSS selector for content container (default: 'main') */ + contentSelector?: string; + /** CSS selector for sidebar container (default: '.sidebar') */ + sidebarSelector?: string; + /** Class to add to highlighted item (default: 'teleport-highlight') */ + highlightClass?: string; + /** Enable fuzzy finder (requires onOpenFinder callback) */ + enableFinder?: boolean; +} + +const { + itemSelector = '.nav-item', + contentSelector = 'main', + sidebarSelector = '.sidebar', + highlightClass = 'teleport-highlight', + enableFinder = false, +} = Astro.props; +--- + + + + diff --git a/packages/teleport/package.json b/packages/teleport/package.json new file mode 100644 index 0000000..e91770c --- /dev/null +++ b/packages/teleport/package.json @@ -0,0 +1,48 @@ +{ + "name": "@sailkit/teleport", + "version": "0.1.0", + "description": "Vim-style keyboard navigation bindings with DOM integration", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./Teleport.astro": "./Teleport.astro" + }, + "files": [ + "dist", + "Teleport.astro" + ], + "scripts": { + "build": "tsc", + "prepare": "npm run build", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit" + }, + "peerDependencies": { + "@sailkit/compass": ">=0.1.0" + }, + "peerDependenciesMeta": { + "@sailkit/compass": { + "optional": true + } + }, + "devDependencies": { + "@sailkit/compass": "file:../compass", + "jsdom": "^25.0.0", + "typescript": "^5.3.0", + "vitest": "^2.0.0" + }, + "keywords": [ + "vim", + "keyboard", + "navigation", + "keybindings", + "hjkl" + ], + "license": "MIT" +} diff --git a/packages/teleport/src/dom.test.ts b/packages/teleport/src/dom.test.ts new file mode 100644 index 0000000..7fdef15 --- /dev/null +++ b/packages/teleport/src/dom.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createDOMNavigator } from './dom.js'; + +describe('createDOMNavigator', () => { + let container: HTMLElement; + let items: HTMLElement[]; + + beforeEach(() => { + // Set up DOM + container = document.createElement('div'); + document.body.appendChild(container); + + // Create test items + items = []; + for (let i = 0; i < 5; i++) { + const item = document.createElement('a'); + item.className = 'nav-item'; + item.textContent = `Item ${i}`; + item.href = `/page-${i}`; + container.appendChild(item); + items.push(item); + } + }); + + it('starts with no highlight', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + }); + + expect(navigator.currentIndex).toBe(-1); + expect(navigator.currentItem).toBe(null); + }); + + it('next() highlights first item when starting from no highlight', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + }); + + navigator.next(); + + expect(navigator.currentIndex).toBe(0); + expect(navigator.currentItem).toBe(items[0]); + expect(items[0].classList.contains('teleport-highlight')).toBe(true); + }); + + it('next() advances through items', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + }); + + navigator.next(); // 0 + navigator.next(); // 1 + navigator.next(); // 2 + + expect(navigator.currentIndex).toBe(2); + expect(items[2].classList.contains('teleport-highlight')).toBe(true); + expect(items[0].classList.contains('teleport-highlight')).toBe(false); + }); + + it('next() wraps around at end', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + }); + + // Go to last item + for (let i = 0; i < 5; i++) { + navigator.next(); + } + expect(navigator.currentIndex).toBe(4); + + // Wrap to start + navigator.next(); + expect(navigator.currentIndex).toBe(0); + }); + + it('prev() highlights last item when starting from no highlight', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + }); + + navigator.prev(); + + expect(navigator.currentIndex).toBe(4); + expect(navigator.currentItem).toBe(items[4]); + }); + + it('prev() moves backwards through items', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + }); + + navigator.goTo(3); + navigator.prev(); + + expect(navigator.currentIndex).toBe(2); + }); + + it('prev() wraps around at start', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + }); + + navigator.goTo(0); + navigator.prev(); + + expect(navigator.currentIndex).toBe(4); + }); + + it('goTo() jumps to specific index', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + }); + + navigator.goTo(3); + + expect(navigator.currentIndex).toBe(3); + expect(items[3].classList.contains('teleport-highlight')).toBe(true); + }); + + it('clear() removes highlight', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + }); + + navigator.next(); + navigator.clear(); + + expect(navigator.currentIndex).toBe(-1); + expect(items[0].classList.contains('teleport-highlight')).toBe(false); + }); + + it('uses custom highlightClass', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + highlightClass: 'my-highlight', + }); + + navigator.next(); + + expect(items[0].classList.contains('my-highlight')).toBe(true); + expect(items[0].classList.contains('teleport-highlight')).toBe(false); + }); + + it('calls onHighlightChange callback', () => { + const onHighlightChange = vi.fn(); + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + onHighlightChange, + }); + + navigator.next(); + + expect(onHighlightChange).toHaveBeenCalledWith(items[0], 0); + }); + + it('reports correct count', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + }); + + expect(navigator.count).toBe(5); + }); + + it('refresh() updates items list', () => { + const navigator = createDOMNavigator({ + getItems: () => Array.from(container.querySelectorAll('.nav-item')), + }); + + // Add a new item + const newItem = document.createElement('a'); + newItem.className = 'nav-item'; + container.appendChild(newItem); + + navigator.refresh(); + + expect(navigator.count).toBe(6); + }); +}); diff --git a/packages/teleport/src/dom.ts b/packages/teleport/src/dom.ts new file mode 100644 index 0000000..085fbba --- /dev/null +++ b/packages/teleport/src/dom.ts @@ -0,0 +1,186 @@ +/** + * Layer 2: DOM adapter + * + * Handles DOM operations like highlighting elements and scrolling. + * Works with any list of elements - can be used standalone or with compass. + */ + +import type { DOMNavigatorConfig, DOMNavigator } from './types.js'; + +/** + * Default scroll behavior + */ +const DEFAULT_SCROLL_BEHAVIOR: ScrollIntoViewOptions = { + behavior: 'smooth', + block: 'nearest', + inline: 'nearest', +}; + +/** + * Create a DOM navigator for a list of elements. + * + * Manages highlighting and scrolling for keyboard navigation. + * The getItems function is called each time to support dynamic lists. + */ +export function createDOMNavigator(config: DOMNavigatorConfig): DOMNavigator { + const { + getItems, + highlightClass = 'teleport-highlight', + scrollBehavior = DEFAULT_SCROLL_BEHAVIOR, + onSelect, + onHighlightChange, + } = config; + + let currentIndex = -1; + let items: HTMLElement[] = []; + + function refresh(): void { + items = getItems(); + // Clamp current index to valid range + if (items.length === 0) { + currentIndex = -1; + } else if (currentIndex >= items.length) { + currentIndex = items.length - 1; + } + } + + function clearHighlight(): void { + // Remove highlight from all items (not just current, in case list changed) + items.forEach((item) => item.classList.remove(highlightClass)); + } + + function applyHighlight(): void { + clearHighlight(); + if (currentIndex >= 0 && currentIndex < items.length) { + const item = items[currentIndex]; + item.classList.add(highlightClass); + // scrollIntoView may not exist in test environments + if (typeof item.scrollIntoView === 'function') { + item.scrollIntoView(scrollBehavior); + } + onHighlightChange?.(item, currentIndex); + } else { + onHighlightChange?.(null, -1); + } + } + + function goTo(index: number): void { + refresh(); + if (index >= 0 && index < items.length) { + currentIndex = index; + applyHighlight(); + } + } + + function next(): void { + refresh(); + if (items.length === 0) return; + + if (currentIndex < 0) { + // Start at first item + currentIndex = 0; + } else if (currentIndex < items.length - 1) { + currentIndex++; + } else { + // Wrap to start + currentIndex = 0; + } + applyHighlight(); + } + + function prev(): void { + refresh(); + if (items.length === 0) return; + + if (currentIndex < 0) { + // Start at last item + currentIndex = items.length - 1; + } else if (currentIndex > 0) { + currentIndex--; + } else { + // Wrap to end + currentIndex = items.length - 1; + } + applyHighlight(); + } + + function clear(): void { + clearHighlight(); + currentIndex = -1; + onHighlightChange?.(null, -1); + } + + function select(): void { + if (currentIndex >= 0 && currentIndex < items.length) { + onSelect?.(items[currentIndex], currentIndex); + } + } + + // Initialize items + refresh(); + + return { + get currentIndex() { + return currentIndex; + }, + get currentItem() { + return currentIndex >= 0 && currentIndex < items.length + ? items[currentIndex] + : null; + }, + get count() { + return items.length; + }, + next, + prev, + goTo, + clear, + refresh, + }; +} + +/** + * Scroll an element by a given amount. + */ +export function scrollElement( + element: HTMLElement | Window, + amount: number, + direction: 'up' | 'down' +): void { + const delta = direction === 'down' ? amount : -amount; + + if (element instanceof Window) { + element.scrollBy({ top: delta, behavior: 'smooth' }); + } else { + element.scrollBy({ top: delta, behavior: 'smooth' }); + } +} + +/** + * Get the viewport height for scroll calculations. + */ +export function getViewportHeight(): number { + return window.innerHeight; +} + +/** + * Sync DOM navigator with current URL. + * Finds the item matching the current path and highlights it. + */ +export function syncWithCurrentPath( + navigator: DOMNavigator, + getItems: () => HTMLElement[], + pathAttribute: string = 'href' +): void { + const currentPath = window.location.pathname; + const items = getItems(); + + const index = items.findIndex((item) => { + const href = item.getAttribute(pathAttribute); + return href === currentPath; + }); + + if (index !== -1) { + navigator.goTo(index); + } +} diff --git a/packages/teleport/src/index.ts b/packages/teleport/src/index.ts new file mode 100644 index 0000000..ea74283 --- /dev/null +++ b/packages/teleport/src/index.ts @@ -0,0 +1,67 @@ +/** + * Teleport - Vim-style keyboard navigation bindings + * + * Three layers of abstraction: + * - Layer 1: Pure key binding functions (keys.ts) + * - Layer 2: DOM adapter for highlighting/scrolling (dom.ts) + * - Layer 3: Full integration (teleport.ts) + * + * @example + * ```typescript + * // Layer 3: Full integration (most common) + * import { initTeleport } from '@sailkit/teleport'; + * + * const teleport = initTeleport({ + * itemSelector: '.nav-item', + * onNextPage: () => navigate('next'), + * onPrevPage: () => navigate('prev'), + * }); + * + * // Layer 1 + 2: Custom integration + * import { createKeyboardHandler, createDOMNavigator } from '@sailkit/teleport'; + * + * const navigator = createDOMNavigator({ + * getItems: () => document.querySelectorAll('.item'), + * }); + * + * const keyHandler = createKeyboardHandler({ + * onNextItem: () => navigator.next(), + * onPrevItem: () => navigator.prev(), + * }); + * ``` + */ + +// Types +export type { + KeyBindings, + ParsedKey, + KeyboardHandlerConfig, + KeyboardHandler, + DOMNavigatorConfig, + DOMNavigator, + TeleportConfig, + Teleport, + FinderItem, + FuzzyFinderConfig, +} from './types.js'; + +// Layer 1: Key bindings +export { + DEFAULT_BINDINGS, + parseKey, + matchesKey, + matchesAnyKey, + isTypingContext, + createKeyboardHandler, +} from './keys.js'; + +// Layer 2: DOM adapter +export { + createDOMNavigator, + scrollElement, + getViewportHeight, + syncWithCurrentPath, +} from './dom.js'; + +// Layer 3: Full integration +export { initTeleport, injectTeleportStyles } from './teleport.js'; diff --git a/packages/teleport/src/keys.test.ts b/packages/teleport/src/keys.test.ts new file mode 100644 index 0000000..d9abeac --- /dev/null +++ b/packages/teleport/src/keys.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + parseKey, + matchesKey, + matchesAnyKey, + isTypingContext, + createKeyboardHandler, + DEFAULT_BINDINGS, +} from './keys.js'; + +describe('parseKey', () => { + it('parses simple keys', () => { + expect(parseKey('j')).toEqual({ + key: 'j', + ctrl: false, + alt: false, + shift: false, + meta: false, + }); + }); + + it('parses Ctrl modifier', () => { + expect(parseKey('Ctrl+d')).toEqual({ + key: 'd', + ctrl: true, + alt: false, + shift: false, + meta: false, + }); + }); + + it('parses multiple modifiers', () => { + expect(parseKey('Ctrl+Shift+k')).toEqual({ + key: 'k', + ctrl: true, + alt: false, + shift: true, + meta: false, + }); + }); + + it('parses Meta/Cmd modifier', () => { + expect(parseKey('Meta+k')).toEqual({ + key: 'k', + ctrl: false, + alt: false, + shift: false, + meta: true, + }); + + expect(parseKey('Cmd+k')).toEqual({ + key: 'k', + ctrl: false, + alt: false, + shift: false, + meta: true, + }); + }); + + it('parses special keys', () => { + expect(parseKey('ArrowDown')).toEqual({ + key: 'arrowdown', + ctrl: false, + alt: false, + shift: false, + meta: false, + }); + + expect(parseKey('Escape')).toEqual({ + key: 'escape', + ctrl: false, + alt: false, + shift: false, + meta: false, + }); + }); +}); + +describe('matchesKey', () => { + function createEvent( + key: string, + modifiers: Partial<{ + ctrlKey: boolean; + altKey: boolean; + shiftKey: boolean; + metaKey: boolean; + }> = {} + ): KeyboardEvent { + return { + key, + ctrlKey: modifiers.ctrlKey ?? false, + altKey: modifiers.altKey ?? false, + shiftKey: modifiers.shiftKey ?? false, + metaKey: modifiers.metaKey ?? false, + } as KeyboardEvent; + } + + it('matches simple keys', () => { + const parsed = parseKey('j'); + expect(matchesKey(createEvent('j'), parsed)).toBe(true); + expect(matchesKey(createEvent('k'), parsed)).toBe(false); + }); + + it('matches keys with modifiers', () => { + const parsed = parseKey('Ctrl+d'); + expect(matchesKey(createEvent('d', { ctrlKey: true }), parsed)).toBe(true); + expect(matchesKey(createEvent('d'), parsed)).toBe(false); + expect(matchesKey(createEvent('d', { ctrlKey: true, shiftKey: true }), parsed)).toBe( + false + ); + }); + + it('is case insensitive for key matching', () => { + const parsed = parseKey('j'); + expect(matchesKey(createEvent('J'), parsed)).toBe(true); + }); +}); + +describe('matchesAnyKey', () => { + function createEvent(key: string): KeyboardEvent { + return { key, ctrlKey: false, altKey: false, shiftKey: false, metaKey: false } as KeyboardEvent; + } + + it('matches any pattern in the list', () => { + expect(matchesAnyKey(createEvent('j'), ['j', 'ArrowDown'])).toBe(true); + expect(matchesAnyKey(createEvent('ArrowDown'), ['j', 'ArrowDown'])).toBe(true); + expect(matchesAnyKey(createEvent('k'), ['j', 'ArrowDown'])).toBe(false); + }); +}); + +describe('isTypingContext', () => { + it('returns true for input elements', () => { + const input = document.createElement('input'); + const event = { target: input } as KeyboardEvent; + expect(isTypingContext(event)).toBe(true); + }); + + it('returns true for textarea elements', () => { + const textarea = document.createElement('textarea'); + const event = { target: textarea } as KeyboardEvent; + expect(isTypingContext(event)).toBe(true); + }); + + it('returns true for contenteditable elements', () => { + const div = document.createElement('div'); + div.setAttribute('contenteditable', 'true'); + const event = { target: div } as KeyboardEvent; + expect(isTypingContext(event)).toBe(true); + }); + + it('returns false for regular elements', () => { + const div = document.createElement('div'); + const event = { target: div } as KeyboardEvent; + expect(isTypingContext(event)).toBe(false); + }); +}); + +describe('createKeyboardHandler', () => { + function createEvent( + key: string, + target: HTMLElement = document.createElement('div'), + modifiers: Partial<{ + ctrlKey: boolean; + }> = {} + ): KeyboardEvent { + return { + key, + target, + ctrlKey: modifiers.ctrlKey ?? false, + altKey: false, + shiftKey: false, + metaKey: false, + preventDefault: vi.fn(), + } as unknown as KeyboardEvent; + } + + it('calls onNextItem for j key', () => { + const onNextItem = vi.fn(); + const handler = createKeyboardHandler({ onNextItem }); + + const event = createEvent('j'); + const handled = handler.handleKeydown(event); + + expect(handled).toBe(true); + expect(onNextItem).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + }); + + it('calls onPrevItem for k key', () => { + const onPrevItem = vi.fn(); + const handler = createKeyboardHandler({ onPrevItem }); + + const event = createEvent('k'); + handler.handleKeydown(event); + + expect(onPrevItem).toHaveBeenCalled(); + }); + + it('calls onScrollDown for Ctrl+d', () => { + const onScrollDown = vi.fn(); + const handler = createKeyboardHandler({ onScrollDown }); + + const event = createEvent('d', document.createElement('div'), { ctrlKey: true }); + handler.handleKeydown(event); + + expect(onScrollDown).toHaveBeenCalled(); + }); + + it('ignores keys when typing in input', () => { + const onNextItem = vi.fn(); + const handler = createKeyboardHandler({ onNextItem }); + + const input = document.createElement('input'); + const event = createEvent('j', input); + const handled = handler.handleKeydown(event); + + expect(handled).toBe(false); + expect(onNextItem).not.toHaveBeenCalled(); + }); + + it('does not ignore keys when ignoreWhenTyping is false', () => { + const onNextItem = vi.fn(); + const handler = createKeyboardHandler({ onNextItem, ignoreWhenTyping: false }); + + const input = document.createElement('input'); + const event = createEvent('j', input); + handler.handleKeydown(event); + + expect(onNextItem).toHaveBeenCalled(); + }); + + it('supports custom bindings', () => { + const onNextItem = vi.fn(); + const handler = createKeyboardHandler({ + onNextItem, + bindings: { nextItem: ['n'] }, + }); + + // 'j' should no longer work + expect(handler.handleKeydown(createEvent('j'))).toBe(false); + + // 'n' should work + expect(handler.handleKeydown(createEvent('n'))).toBe(true); + expect(onNextItem).toHaveBeenCalled(); + }); +}); + +describe('DEFAULT_BINDINGS', () => { + it('has all expected bindings', () => { + expect(DEFAULT_BINDINGS.nextItem).toContain('j'); + expect(DEFAULT_BINDINGS.prevItem).toContain('k'); + expect(DEFAULT_BINDINGS.scrollDown).toContain('Ctrl+d'); + expect(DEFAULT_BINDINGS.scrollUp).toContain('Ctrl+u'); + expect(DEFAULT_BINDINGS.nextPage).toContain('l'); + expect(DEFAULT_BINDINGS.prevPage).toContain('h'); + expect(DEFAULT_BINDINGS.select).toContain('Enter'); + expect(DEFAULT_BINDINGS.openFinder).toContain('t'); + expect(DEFAULT_BINDINGS.escape).toContain('Escape'); + }); +}); diff --git a/packages/teleport/src/keys.ts b/packages/teleport/src/keys.ts new file mode 100644 index 0000000..a2260f2 --- /dev/null +++ b/packages/teleport/src/keys.ts @@ -0,0 +1,191 @@ +/** + * Layer 1: Pure key binding functions + * + * Handles parsing key patterns and matching against KeyboardEvents. + * No DOM dependencies - pure functions only. + */ + +import type { + KeyBindings, + ParsedKey, + KeyboardHandlerConfig, + KeyboardHandler, +} from './types.js'; + +/** + * Default key bindings + */ +export const DEFAULT_BINDINGS: Required = { + nextItem: ['j', 'ArrowDown'], + prevItem: ['k', 'ArrowUp'], + scrollDown: ['Ctrl+d'], + scrollUp: ['Ctrl+u'], + nextPage: ['l', 'ArrowRight'], + prevPage: ['h', 'ArrowLeft'], + select: ['Enter'], + openFinder: ['t'], + escape: ['Escape'], +}; + +/** + * Parse a key pattern string into components. + * + * Supports modifiers: Ctrl, Alt, Shift, Meta (Cmd on Mac) + * Examples: 'j', 'Ctrl+d', 'Shift+Tab', 'Meta+k' + */ +export function parseKey(pattern: string): ParsedKey { + const parts = pattern.split('+'); + const key = parts.pop()!.toLowerCase(); + + return { + key, + ctrl: parts.some((p) => p.toLowerCase() === 'ctrl'), + alt: parts.some((p) => p.toLowerCase() === 'alt'), + shift: parts.some((p) => p.toLowerCase() === 'shift'), + meta: parts.some((p) => p.toLowerCase() === 'meta' || p.toLowerCase() === 'cmd'), + }; +} + +/** + * Check if a KeyboardEvent matches a parsed key pattern. + */ +export function matchesKey(event: KeyboardEvent, parsed: ParsedKey): boolean { + const eventKey = event.key.toLowerCase(); + + // Handle special key names + const normalizedEventKey = + eventKey === ' ' ? 'space' : eventKey === 'arrowdown' ? 'arrowdown' : eventKey; + const normalizedParsedKey = parsed.key.toLowerCase(); + + if (normalizedEventKey !== normalizedParsedKey) { + return false; + } + + if (event.ctrlKey !== parsed.ctrl) return false; + if (event.altKey !== parsed.alt) return false; + if (event.shiftKey !== parsed.shift) return false; + if (event.metaKey !== parsed.meta) return false; + + return true; +} + +/** + * Check if a KeyboardEvent matches any of the given key patterns. + */ +export function matchesAnyKey(event: KeyboardEvent, patterns: string[]): boolean { + return patterns.some((pattern) => matchesKey(event, parseKey(pattern))); +} + +/** + * Check if the event target is a typing context (input, textarea, contenteditable). + */ +export function isTypingContext(event: KeyboardEvent): boolean { + const target = event.target as HTMLElement | null; + if (!target) return false; + + const tagName = target.tagName.toLowerCase(); + if (tagName === 'input' || tagName === 'textarea') return true; + // Check contentEditable attribute directly for better test compatibility + if (target.isContentEditable || target.getAttribute('contenteditable') === 'true') { + return true; + } + + return false; +} + +/** + * Create a keyboard handler that maps key events to callbacks. + */ +export function createKeyboardHandler( + config: KeyboardHandlerConfig +): KeyboardHandler { + const { + bindings = {}, + onNextItem, + onPrevItem, + onScrollDown, + onScrollUp, + onNextPage, + onPrevPage, + onSelect, + onOpenFinder, + onEscape, + ignoreWhenTyping = true, + } = config; + + const mergedBindings: Required = { + ...DEFAULT_BINDINGS, + ...bindings, + }; + + function handleKeydown(event: KeyboardEvent): boolean { + // Skip if typing in input/textarea + if (ignoreWhenTyping && isTypingContext(event)) { + return false; + } + + // Check each binding + if (onNextItem && matchesAnyKey(event, mergedBindings.nextItem)) { + event.preventDefault(); + onNextItem(); + return true; + } + + if (onPrevItem && matchesAnyKey(event, mergedBindings.prevItem)) { + event.preventDefault(); + onPrevItem(); + return true; + } + + if (onScrollDown && matchesAnyKey(event, mergedBindings.scrollDown)) { + event.preventDefault(); + onScrollDown(); + return true; + } + + if (onScrollUp && matchesAnyKey(event, mergedBindings.scrollUp)) { + event.preventDefault(); + onScrollUp(); + return true; + } + + if (onNextPage && matchesAnyKey(event, mergedBindings.nextPage)) { + event.preventDefault(); + onNextPage(); + return true; + } + + if (onPrevPage && matchesAnyKey(event, mergedBindings.prevPage)) { + event.preventDefault(); + onPrevPage(); + return true; + } + + if (onSelect && matchesAnyKey(event, mergedBindings.select)) { + event.preventDefault(); + onSelect(); + return true; + } + + if (onOpenFinder && matchesAnyKey(event, mergedBindings.openFinder)) { + event.preventDefault(); + onOpenFinder(); + return true; + } + + if (onEscape && matchesAnyKey(event, mergedBindings.escape)) { + event.preventDefault(); + onEscape(); + return true; + } + + return false; + } + + return { + handleKeydown, + destroy: () => { + // No cleanup needed for pure handler + }, + }; +} diff --git a/packages/teleport/src/teleport.ts b/packages/teleport/src/teleport.ts new file mode 100644 index 0000000..35e5fed --- /dev/null +++ b/packages/teleport/src/teleport.ts @@ -0,0 +1,169 @@ +/** + * Layer 3: Full Teleport integration + * + * Wires together key bindings, DOM navigation, and callbacks + * for a complete vim-style navigation experience. + */ + +import type { TeleportConfig, Teleport } from './types.js'; +import { createKeyboardHandler } from './keys.js'; +import { createDOMNavigator, scrollElement, getViewportHeight } from './dom.js'; + +/** + * Initialize Teleport with full keyboard navigation. + * + * @example + * ```typescript + * const teleport = initTeleport({ + * itemSelector: '.nav-item', + * onSelect: (item) => window.location.href = item.getAttribute('href'), + * onNextPage: () => navigateToNext(), + * onPrevPage: () => navigateToPrev(), + * }); + * + * // Cleanup when done + * teleport.destroy(); + * ``` + */ +export function initTeleport(config: TeleportConfig): Teleport { + const { + itemSelector, + contentContainer, + sidebarContainer, + highlightClass = 'teleport-highlight', + bindings, + onSelect, + onNextPage, + onPrevPage, + onOpenFinder, + scrollAmount, + } = config; + + // Resolve containers + const resolveContainer = ( + container: HTMLElement | string | undefined, + fallbackSelector: string + ): HTMLElement | null => { + if (container instanceof HTMLElement) return container; + if (typeof container === 'string') return document.querySelector(container); + return document.querySelector(fallbackSelector); + }; + + const content = + resolveContainer(contentContainer, 'main') || document.documentElement; + const sidebar = resolveContainer(sidebarContainer, '.sidebar'); + + // Create DOM navigator for sidebar items + const navigator = createDOMNavigator({ + getItems: () => Array.from(document.querySelectorAll(itemSelector)), + highlightClass, + scrollBehavior: { behavior: 'smooth', block: 'nearest' }, + onSelect: (item, index) => { + // Default: navigate to href if present + const href = item.getAttribute('href'); + if (href && onSelect) { + onSelect(item); + } else if (href) { + window.location.href = href; + } + }, + }); + + // Calculate scroll amount (default: half viewport) + const getScrollAmount = () => scrollAmount ?? getViewportHeight() / 2; + + // Create keyboard handler + const keyHandler = createKeyboardHandler({ + bindings, + onNextItem: () => navigator.next(), + onPrevItem: () => navigator.prev(), + onScrollDown: () => { + scrollElement(content, getScrollAmount(), 'down'); + }, + onScrollUp: () => { + scrollElement(content, getScrollAmount(), 'up'); + }, + onNextPage: () => { + if (onNextPage) { + onNextPage(); + } else { + // Default: click current highlighted item if it has a "next" sibling + const current = navigator.currentItem; + if (current) { + const href = current.getAttribute('href'); + if (href) window.location.href = href; + } + } + }, + onPrevPage: () => { + if (onPrevPage) { + onPrevPage(); + } + }, + onSelect: () => { + const current = navigator.currentItem; + if (current) { + if (onSelect) { + onSelect(current); + } else { + const href = current.getAttribute('href'); + if (href) window.location.href = href; + } + } + }, + onOpenFinder: () => { + if (onOpenFinder) { + onOpenFinder(); + } + }, + onEscape: () => { + navigator.clear(); + }, + }); + + // Attach global keydown listener + const handleKeydown = (event: KeyboardEvent) => { + keyHandler.handleKeydown(event); + }; + + document.addEventListener('keydown', handleKeydown); + + // Sync with current URL on init + const currentPath = window.location.pathname; + const items = Array.from(document.querySelectorAll(itemSelector)); + const currentIndex = items.findIndex( + (item) => item.getAttribute('href') === currentPath + ); + if (currentIndex !== -1) { + navigator.goTo(currentIndex); + } + + return { + navigator, + destroy() { + document.removeEventListener('keydown', handleKeydown); + navigator.clear(); + keyHandler.destroy(); + }, + }; +} + +/** + * Inject CSS for teleport highlight styling. + * Call this once to add default styles, or provide your own CSS. + */ +export function injectTeleportStyles(highlightClass: string = 'teleport-highlight'): void { + const styleId = 'teleport-styles'; + if (document.getElementById(styleId)) return; + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + .${highlightClass} { + outline: 2px solid var(--color-accent, #3b82f6); + outline-offset: -2px; + background-color: var(--color-accent-dim, rgba(59, 130, 246, 0.1)); + } + `; + document.head.appendChild(style); +} diff --git a/packages/teleport/src/types.ts b/packages/teleport/src/types.ts new file mode 100644 index 0000000..ecd9a52 --- /dev/null +++ b/packages/teleport/src/types.ts @@ -0,0 +1,162 @@ +/** + * Type definitions for Teleport keyboard navigation. + */ + +/** + * Key binding configuration - maps actions to key patterns. + * Each action can have multiple keys that trigger it. + */ +export interface KeyBindings { + /** Navigate to next item in sidebar (default: ['j', 'ArrowDown']) */ + nextItem?: string[]; + /** Navigate to previous item in sidebar (default: ['k', 'ArrowUp']) */ + prevItem?: string[]; + /** Scroll content down (default: ['Ctrl+d']) */ + scrollDown?: string[]; + /** Scroll content up (default: ['Ctrl+u']) */ + scrollUp?: string[]; + /** Go to next page (default: ['l', 'ArrowRight']) */ + nextPage?: string[]; + /** Go to previous page (default: ['h', 'ArrowLeft']) */ + prevPage?: string[]; + /** Select/navigate to current item (default: ['Enter']) */ + select?: string[]; + /** Open fuzzy finder (default: ['t']) */ + openFinder?: string[]; + /** Close/escape (default: ['Escape']) */ + escape?: string[]; +} + +/** + * Parsed key pattern for matching + */ +export interface ParsedKey { + key: string; + ctrl: boolean; + alt: boolean; + shift: boolean; + meta: boolean; +} + +/** + * Keyboard handler configuration + */ +export interface KeyboardHandlerConfig { + bindings?: KeyBindings; + onNextItem?: () => void; + onPrevItem?: () => void; + onScrollDown?: () => void; + onScrollUp?: () => void; + onNextPage?: () => void; + onPrevPage?: () => void; + onSelect?: () => void; + onOpenFinder?: () => void; + onEscape?: () => void; + /** Ignore keystrokes when typing in input/textarea (default: true) */ + ignoreWhenTyping?: boolean; +} + +/** + * Keyboard handler return type + */ +export interface KeyboardHandler { + /** Handle a keydown event, returns true if handled */ + handleKeydown: (event: KeyboardEvent) => boolean; + /** Clean up event listeners */ + destroy: () => void; +} + +/** + * DOM navigator configuration + */ +export interface DOMNavigatorConfig { + /** Function to get navigable items */ + getItems: () => HTMLElement[]; + /** Class to add to highlighted item (default: 'teleport-highlight') */ + highlightClass?: string; + /** Scroll behavior options */ + scrollBehavior?: ScrollIntoViewOptions; + /** Callback when an item is selected */ + onSelect?: (item: HTMLElement, index: number) => void; + /** Callback when highlight changes */ + onHighlightChange?: (item: HTMLElement | null, index: number) => void; +} + +/** + * DOM navigator return type + */ +export interface DOMNavigator { + /** Currently highlighted index (-1 if none) */ + readonly currentIndex: number; + /** Currently highlighted element */ + readonly currentItem: HTMLElement | null; + /** Total item count */ + readonly count: number; + /** Move to next item */ + next(): void; + /** Move to previous item */ + prev(): void; + /** Go to specific index */ + goTo(index: number): void; + /** Clear highlight */ + clear(): void; + /** Refresh items list */ + refresh(): void; +} + +/** + * Full teleport configuration + */ +export interface TeleportConfig { + /** Selector for navigable items in sidebar */ + itemSelector: string; + /** Container for scrolling content (default: document.documentElement) */ + contentContainer?: HTMLElement | string; + /** Sidebar container for scrolling nav (default: first .sidebar) */ + sidebarContainer?: HTMLElement | string; + /** Class to add to highlighted item */ + highlightClass?: string; + /** Custom key bindings */ + bindings?: KeyBindings; + /** Callback when item is selected */ + onSelect?: (item: HTMLElement) => void; + /** Callback for next page navigation */ + onNextPage?: () => void; + /** Callback for previous page navigation */ + onPrevPage?: () => void; + /** Callback to open fuzzy finder */ + onOpenFinder?: () => void; + /** Scroll amount for Ctrl+d/u (default: half viewport) */ + scrollAmount?: number; +} + +/** + * Teleport instance + */ +export interface Teleport { + /** DOM navigator for sidebar items */ + readonly navigator: DOMNavigator; + /** Clean up all event listeners */ + destroy(): void; +} + +/** + * Fuzzy finder item with metadata + */ +export interface FinderItem { + slug: string; + title: string; + element?: HTMLElement; +} + +/** + * Fuzzy finder configuration + */ +export interface FuzzyFinderConfig { + /** Items to search */ + items: FinderItem[]; + /** Callback when item is selected */ + onSelect: (item: FinderItem) => void; + /** Callback when finder is closed */ + onClose: () => void; +} diff --git a/packages/teleport/tsconfig.json b/packages/teleport/tsconfig.json new file mode 100644 index 0000000..62d5907 --- /dev/null +++ b/packages/teleport/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/teleport/vitest.config.ts b/packages/teleport/vitest.config.ts new file mode 100644 index 0000000..647a9e5 --- /dev/null +++ b/packages/teleport/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + }, +});