fix(shared-help): harden help system with XSS protection, i18n, type safety, and reference implementation

- Add HTML sanitization via isomorphic-dompurify in parser layer to prevent XSS
- Replace all hardcoded English strings with translations (FAQSection, KeyboardShortcuts, ChangelogEntry/Section)
- Remove unsafe `as` type casting in loader.ts, use Zod-inferred generics instead
- Add error logging in content loader (replaces silent catch blocks)
- Fix HelpSearch blur handling (mousedown+preventDefault instead of setTimeout hack)
- Add ARIA attributes to HelpSearch for accessibility
- Derive FAQ categories from items instead of hardcoding all 6
- Fix null-safety in GettingStartedGuide.svelte
- Fix unused appId variable in HelpPage.svelte, add scroll-reset on tab switch
- Rebuild Contacts help page as reference implementation using shared HelpPage component
- Add README with quick-start guide, props docs, and translations template

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-24 22:38:46 +01:00
parent 8356ac634a
commit 42dd7d2a7a
18 changed files with 845 additions and 801 deletions

View file

@ -34,9 +34,11 @@
"@manacore/shared-help-types": "workspace:*",
"fuse.js": "^7.0.0",
"gray-matter": "^4.0.3",
"isomorphic-dompurify": "^3.7.1",
"marked": "^15.0.4"
},
"devDependencies": {
"@types/dompurify": "^3.2.0",
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"zod": "^3.24.1"

View file

@ -28,6 +28,9 @@ export {
// Content merger
export { mergeContent, createEmptyContent } from './merger.js';
// Sanitization
export { sanitizeHtml } from './sanitize.js';
// Search functionality
export { buildSearchIndex, search, createSearcher, flattenContentForSearch } from './search.js';

View file

@ -20,6 +20,12 @@ import {
gettingStartedFrontmatterSchema,
changelogFrontmatterSchema,
contactFrontmatterSchema,
type FAQFrontmatter,
type FeatureFrontmatter,
type ShortcutsFrontmatter,
type GettingStartedFrontmatter,
type ChangelogFrontmatter,
type ContactFrontmatter,
} from '@manacore/shared-help-types';
import { parseMarkdown } from './parser.js';
import { createEmptyContent } from './merger.js';
@ -29,26 +35,28 @@ export interface LoaderOptions {
locale: SupportedLanguage;
/** Fallback locale if content not found */
fallbackLocale?: SupportedLanguage;
/** Optional error callback */
onError?: (path: string, error: unknown) => void;
}
/**
* Parse FAQ content from raw Markdown
*/
export function parseFAQContent(rawContent: string): FAQItem {
const parsed = parseMarkdown(rawContent, faqFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
const parsed = parseMarkdown<FAQFrontmatter>(rawContent, faqFrontmatterSchema);
const fm = parsed.frontmatter;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
question: fm.question as string,
category: fm.category as FAQItem['category'],
featured: fm.featured as boolean | undefined,
tags: fm.tags as string[] | undefined,
relatedFaqs: fm.relatedFaqs as string[] | undefined,
id: fm.id,
language: fm.language,
order: fm.order,
appSpecific: fm.appSpecific,
apps: fm.apps,
lastUpdated: fm.lastUpdated,
question: fm.question,
category: fm.category,
featured: fm.featured,
tags: fm.tags,
relatedFaqs: fm.relatedFaqs,
answer: parsed.html,
};
}
@ -57,23 +65,23 @@ export function parseFAQContent(rawContent: string): FAQItem {
* Parse Feature content from raw Markdown
*/
export function parseFeatureContent(rawContent: string): FeatureItem {
const parsed = parseMarkdown(rawContent, featureFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
const parsed = parseMarkdown<FeatureFrontmatter>(rawContent, featureFrontmatterSchema);
const fm = parsed.frontmatter;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
title: fm.title as string,
description: fm.description as string,
icon: fm.icon as string | undefined,
category: fm.category as FeatureItem['category'],
available: fm.available as boolean | undefined,
comingSoon: fm.comingSoon as boolean | undefined,
highlights: fm.highlights as string[] | undefined,
learnMoreUrl: fm.learnMoreUrl as string | undefined,
id: fm.id,
language: fm.language,
order: fm.order,
appSpecific: fm.appSpecific,
apps: fm.apps,
lastUpdated: fm.lastUpdated,
title: fm.title,
description: fm.description,
icon: fm.icon,
category: fm.category,
available: fm.available,
comingSoon: fm.comingSoon,
highlights: fm.highlights,
learnMoreUrl: fm.learnMoreUrl,
content: parsed.html,
};
}
@ -82,21 +90,21 @@ export function parseFeatureContent(rawContent: string): FeatureItem {
* Parse Shortcuts content from raw Markdown
*/
export function parseShortcutsContent(rawContent: string): ShortcutsItem {
const parsed = parseMarkdown(rawContent, shortcutsFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
const parsed = parseMarkdown<ShortcutsFrontmatter>(rawContent, shortcutsFrontmatterSchema);
const fm = parsed.frontmatter;
// Parse markdown table to extract shortcuts
const shortcuts = parseShortcutsTable(parsed.content);
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
category: fm.category as ShortcutsItem['category'],
title: fm.title as string | undefined,
id: fm.id,
language: fm.language,
order: fm.order,
appSpecific: fm.appSpecific,
apps: fm.apps,
lastUpdated: fm.lastUpdated,
category: fm.category,
title: fm.title,
shortcuts,
};
}
@ -114,8 +122,8 @@ function parseShortcutsTable(
for (const line of lines) {
const trimmed = line.trim();
// Skip header separator
if (trimmed.match(/^\|[-:\s|]+\|$/)) {
// Skip header separator (flexible: allows spaces around dashes)
if (trimmed.match(/^\|[\s\-:|]+\|$/)) {
inTable = true;
continue;
}
@ -147,24 +155,27 @@ function parseShortcutsTable(
* Parse Getting Started guide content from raw Markdown
*/
export function parseGettingStartedContent(rawContent: string): GettingStartedItem {
const parsed = parseMarkdown(rawContent, gettingStartedFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
const parsed = parseMarkdown<GettingStartedFrontmatter>(
rawContent,
gettingStartedFrontmatterSchema
);
const fm = parsed.frontmatter;
// Extract steps from content (h2 headers)
const steps = parseGuideSteps(parsed.content);
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
title: fm.title as string,
description: fm.description as string,
difficulty: fm.difficulty as GettingStartedItem['difficulty'],
estimatedTime: fm.estimatedTime as string | undefined,
prerequisites: fm.prerequisites as string[] | undefined,
id: fm.id,
language: fm.language,
order: fm.order,
appSpecific: fm.appSpecific,
apps: fm.apps,
lastUpdated: fm.lastUpdated,
title: fm.title,
description: fm.description,
difficulty: fm.difficulty,
estimatedTime: fm.estimatedTime,
prerequisites: fm.prerequisites,
content: parsed.html,
steps,
};
@ -180,6 +191,10 @@ function parseGuideSteps(content: string): Array<{ title: string; content: strin
for (let i = 1; i < sections.length; i++) {
const section = sections[i];
const newlineIndex = section.indexOf('\n');
if (newlineIndex === -1) {
steps.push({ title: section.trim(), content: '' });
continue;
}
const title = section.substring(0, newlineIndex).trim();
const stepContent = section.substring(newlineIndex + 1).trim();
@ -193,23 +208,23 @@ function parseGuideSteps(content: string): Array<{ title: string; content: strin
* Parse Changelog content from raw Markdown
*/
export function parseChangelogContent(rawContent: string): ChangelogItem {
const parsed = parseMarkdown(rawContent, changelogFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
const parsed = parseMarkdown<ChangelogFrontmatter>(rawContent, changelogFrontmatterSchema);
const fm = parsed.frontmatter;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
version: fm.version as string,
title: fm.title as string,
releaseDate: fm.releaseDate as Date,
type: fm.type as ChangelogItem['type'],
summary: fm.summary as string | undefined,
highlighted: fm.highlighted as boolean | undefined,
changes: fm.changes as ChangelogItem['changes'],
platforms: fm.platforms as string[] | undefined,
id: fm.id,
language: fm.language,
order: fm.order,
appSpecific: fm.appSpecific,
apps: fm.apps,
lastUpdated: fm.lastUpdated,
version: fm.version,
title: fm.title,
releaseDate: fm.releaseDate,
type: fm.type,
summary: fm.summary,
highlighted: fm.highlighted,
changes: fm.changes,
platforms: fm.platforms,
content: parsed.html,
};
}
@ -218,22 +233,22 @@ export function parseChangelogContent(rawContent: string): ChangelogItem {
* Parse Contact content from raw Markdown
*/
export function parseContactContent(rawContent: string): ContactInfo {
const parsed = parseMarkdown(rawContent, contactFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
const parsed = parseMarkdown<ContactFrontmatter>(rawContent, contactFrontmatterSchema);
const fm = parsed.frontmatter;
return {
id: fm.id as string,
language: fm.language as SupportedLanguage,
order: fm.order as number | undefined,
appSpecific: fm.appSpecific as boolean | undefined,
apps: fm.apps as string[] | undefined,
lastUpdated: fm.lastUpdated as Date | undefined,
title: fm.title as string,
supportEmail: fm.supportEmail as string | undefined,
supportUrl: fm.supportUrl as string | undefined,
discordUrl: fm.discordUrl as string | undefined,
twitterUrl: fm.twitterUrl as string | undefined,
documentationUrl: fm.documentationUrl as string | undefined,
responseTime: fm.responseTime as string | undefined,
id: fm.id,
language: fm.language,
order: fm.order,
appSpecific: fm.appSpecific,
apps: fm.apps,
lastUpdated: fm.lastUpdated,
title: fm.title,
supportEmail: fm.supportEmail,
supportUrl: fm.supportUrl,
discordUrl: fm.discordUrl,
twitterUrl: fm.twitterUrl,
documentationUrl: fm.documentationUrl,
responseTime: fm.responseTime,
content: parsed.html,
};
}
@ -247,7 +262,7 @@ export function loadHelpContentFromFiles(
options: LoaderOptions
): HelpContent {
const content = createEmptyContent();
const { locale, fallbackLocale = 'en' } = options;
const { locale, fallbackLocale = 'en', onError } = options;
for (const [path, rawContent] of Object.entries(files)) {
try {
@ -283,8 +298,12 @@ export function loadHelpContentFromFiles(
content.contact = contact;
}
}
} catch {
// Skip files that fail to parse
} catch (error) {
if (onError) {
onError(path, error);
} else {
console.warn('[shared-help] Failed to parse:', path, error);
}
}
}

View file

@ -5,7 +5,8 @@
import matter from 'gray-matter';
import { marked } from 'marked';
import type { ZodSchema } from 'zod';
import type { ZodType, ZodTypeDef } from 'zod';
import { sanitizeHtml } from './sanitize.js';
export interface ParsedContent<T> {
frontmatter: T;
@ -23,7 +24,7 @@ export interface ParseOptions {
*/
export function parseMarkdown<T>(
rawContent: string,
schema?: ZodSchema<T>,
schema?: ZodType<T, ZodTypeDef, unknown>,
options: ParseOptions = { renderHtml: true }
): ParsedContent<T> {
const { data, content } = matter(rawContent);
@ -40,8 +41,8 @@ export function parseMarkdown<T>(
frontmatter = data as T;
}
// Render HTML if requested
const html = options.renderHtml ? (marked.parse(content) as string) : '';
// Render HTML if requested, then sanitize to prevent XSS
const html = options.renderHtml ? sanitizeHtml(marked.parse(content) as string) : '';
return {
frontmatter,
@ -55,7 +56,7 @@ export function parseMarkdown<T>(
*/
export function parseMarkdownFiles<T>(
files: { filename: string; content: string }[],
schema?: ZodSchema<T>,
schema?: ZodType<T, ZodTypeDef, unknown>,
options?: ParseOptions
): Array<ParsedContent<T> & { filename: string }> {
return files.map(({ filename, content }) => ({

View file

@ -0,0 +1,53 @@
/**
* HTML Sanitization
* Prevents XSS when rendering Markdown-generated HTML via {@html}
*/
import DOMPurify from 'isomorphic-dompurify';
/**
* Sanitize HTML content to prevent XSS attacks.
* Allows safe HTML tags commonly used in help content (headings, lists, links, etc.)
*/
export function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'p',
'br',
'hr',
'ul',
'ol',
'li',
'a',
'strong',
'b',
'em',
'i',
'code',
'pre',
'blockquote',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'mark',
'kbd',
'img',
'span',
'div',
'dl',
'dt',
'dd',
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'src', 'alt', 'title', 'class', 'id'],
ADD_ATTR: ['target'],
});
}

View file

@ -18,6 +18,7 @@ import type {
SearchIndexConfig,
} from '@manacore/shared-help-types';
import { generateExcerpt, stripHtml } from './parser.js';
import { sanitizeHtml } from './sanitize.js';
const DEFAULT_CONFIG: SearchIndexConfig = {
titleWeight: 2,
@ -135,8 +136,10 @@ function findOriginalItem(
*/
function highlightMatch(text: string, query: string): string {
if (!query.trim()) return text;
// Sanitize text first, then apply highlighting
const safeText = sanitizeHtml(text);
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '<mark>$1</mark>');
return safeText.replace(regex, '<mark>$1</mark>');
}
/**

View file

@ -0,0 +1,249 @@
# @manacore/shared-help-ui
Shared Svelte 5 help page components for Manacore web apps.
## Quick Start
### 1. Add dependencies to your web app
```json
{
"dependencies": {
"@manacore/shared-help-ui": "workspace:*",
"@manacore/shared-help-content": "workspace:*",
"@manacore/shared-help-types": "workspace:*"
}
}
```
### 2. Create help content
Create `src/lib/content/help/index.ts`:
```typescript
import type { HelpContent } from '@manacore/shared-help-types';
export function getAppHelpContent(locale: string): HelpContent {
const isDE = locale === 'de';
return {
faq: [
{
id: 'faq-example',
question: isDE ? 'Wie funktioniert X?' : 'How does X work?',
answer: isDE ? '<p>So funktioniert X...</p>' : '<p>X works like this...</p>',
category: 'features',
order: 1,
language: isDE ? 'de' : 'en',
tags: ['example'],
},
],
features: [],
shortcuts: [
{
id: 'shortcuts-general',
category: 'general',
language: isDE ? 'de' : 'en',
order: 1,
shortcuts: [
{ shortcut: 'Cmd/Ctrl + K', action: isDE ? 'Suche' : 'Search' },
],
},
],
gettingStarted: [],
changelog: [],
contact: {
id: 'contact',
title: isDE ? 'Support' : 'Support',
content: '',
language: isDE ? 'de' : 'en',
order: 1,
supportEmail: 'support@mana.how',
responseTime: isDE ? 'Innerhalb von 24 Stunden' : 'Within 24 hours',
},
};
}
```
### 3. Create the help page route
Create `src/routes/(app)/help/+page.svelte`:
```svelte
<script lang="ts">
import { locale } from 'svelte-i18n';
import { HelpPage } from '@manacore/shared-help-ui';
import type { HelpPageTranslations } from '@manacore/shared-help-ui';
import { getAppHelpContent } from '$lib/content/help/index.js';
const content = $derived(getAppHelpContent($locale ?? 'en'));
const translations: HelpPageTranslations = $derived(/* see translations template below */);
</script>
<HelpPage
{content}
appName="MyApp"
appId="myapp"
{translations}
showBackButton
onBack={() => goto('/')}
showGettingStarted={false}
showChangelog={false}
/>
```
## HelpPage Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `content` | `HelpContent` | required | Help content data |
| `appName` | `string` | required | Display name of the app |
| `appId` | `string` | required | App identifier |
| `translations` | `HelpPageTranslations` | required | UI translations |
| `searchEnabled` | `boolean` | `true` | Show search bar |
| `showFAQ` | `boolean` | `true` | Show FAQ section |
| `showFeatures` | `boolean` | `true` | Show Features section |
| `showShortcuts` | `boolean` | `true` | Show Shortcuts section |
| `showGettingStarted` | `boolean` | `true` | Show Getting Started section |
| `showChangelog` | `boolean` | `true` | Show Changelog section |
| `showContact` | `boolean` | `true` | Show Contact section |
| `defaultSection` | `HelpSection` | `'faq'` | Initially active section |
| `showBackButton` | `boolean` | `false` | Show back navigation |
| `onBack` | `() => void` | - | Back button callback |
Sections with empty content are automatically hidden.
## Translations Template
```typescript
const translations: HelpPageTranslations = {
title: 'Help & Support',
subtitle: 'Find answers and learn how to use the app',
searchPlaceholder: 'Search help...',
sections: {
faq: 'FAQ',
features: 'Features',
shortcuts: 'Shortcuts',
gettingStarted: 'Getting Started',
changelog: 'Changelog',
contact: 'Contact',
},
search: {
noResults: 'No results for "{query}"',
resultsCount: '{count} results',
searching: 'Searching...',
},
faq: {
noItems: 'No FAQs available.',
allCategories: 'All',
categories: {
general: 'General',
account: 'Account',
billing: 'Billing',
features: 'Features',
technical: 'Technical',
privacy: 'Privacy',
},
},
features: {
noItems: 'No features available.',
comingSoon: 'Coming Soon',
learnMore: 'Learn More',
},
shortcuts: {
noItems: 'No shortcuts available.',
columns: {
shortcut: 'Shortcut',
action: 'Action',
description: 'Description',
},
},
gettingStarted: {
noItems: 'No guides available.',
estimatedTime: 'Estimated time',
difficulty: {
beginner: 'Beginner',
intermediate: 'Intermediate',
advanced: 'Advanced',
},
},
changelog: {
noItems: 'No changelog available.',
showAll: 'Show all releases',
types: { major: 'Major', minor: 'Minor', patch: 'Patch', beta: 'Beta' },
labels: {
features: 'New Features',
improvements: 'Improvements',
bugFixes: 'Bug Fixes',
},
},
contact: {
noInfo: 'No contact info available.',
email: 'Send email',
responseTime: 'Response time',
},
common: {
back: 'Back',
showMore: 'Show more',
showLess: 'Show less',
},
};
```
## Content Types
### FAQ
```typescript
{
id: string; // Unique ID
question: string; // The question
answer: string; // HTML answer (auto-sanitized)
category: 'general' | 'account' | 'billing' | 'features' | 'technical' | 'privacy';
order: number;
language: 'en' | 'de' | 'fr' | 'it' | 'es';
featured?: boolean;
tags?: string[];
}
```
### Shortcuts
```typescript
{
id: string;
category: 'navigation' | 'editing' | 'general' | 'app-specific';
language: string;
order: number;
shortcuts: Array<{
shortcut: string; // e.g. "Cmd/Ctrl + K"
action: string; // e.g. "Open search"
description?: string;
}>;
}
```
### Contact
```typescript
{
id: string;
title: string;
content: string; // HTML (auto-sanitized)
language: string;
order: number;
supportEmail?: string;
supportUrl?: string;
discordUrl?: string;
documentationUrl?: string;
responseTime?: string;
}
```
## Security
All HTML content is automatically sanitized via `isomorphic-dompurify` in the parser layer.
Content passed through `{@html}` in components is safe against XSS.
## Reference Implementation
See `apps/contacts/apps/web/src/routes/(app)/help/+page.svelte` for a complete working example.

View file

@ -1,12 +1,7 @@
<script lang="ts">
import type { ChangelogItem } from '@manacore/shared-help-types';
import type { ChangelogEntryProps } from '../types.js';
interface Props {
item: ChangelogItem;
typeLabels: Record<string, string>;
}
let { item, typeLabels }: Props = $props();
let { item, translations }: ChangelogEntryProps = $props();
let expanded = $state(false);
@ -32,6 +27,9 @@
day: 'numeric',
});
}
const typeLabels = translations.changelog.types;
const changeLabels = translations.changelog.labels;
</script>
<div class="border-b border-gray-200 py-4 dark:border-gray-700">
@ -83,7 +81,7 @@
{#if item.changes.features && item.changes.features.length > 0}
<div>
<h5 class="mb-2 text-sm font-semibold text-green-600 dark:text-green-400">
New Features
{changeLabels.features}
</h5>
<ul class="space-y-1">
{#each item.changes.features as change}
@ -104,7 +102,7 @@
{#if item.changes.improvements && item.changes.improvements.length > 0}
<div>
<h5 class="mb-2 text-sm font-semibold text-blue-600 dark:text-blue-400">
Improvements
{changeLabels.improvements}
</h5>
<ul class="space-y-1">
{#each item.changes.improvements as change}
@ -124,7 +122,9 @@
{#if item.changes.bugfixes && item.changes.bugfixes.length > 0}
<div>
<h5 class="mb-2 text-sm font-semibold text-red-600 dark:text-red-400">Bug Fixes</h5>
<h5 class="mb-2 text-sm font-semibold text-red-600 dark:text-red-400">
{changeLabels.bugFixes}
</h5>
<ul class="space-y-1">
{#each item.changes.bugfixes as change}
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">

View file

@ -27,7 +27,7 @@
{:else}
<div>
{#each displayedItems() as item (item.id)}
<ChangelogEntry {item} typeLabels={translations.changelog.types} />
<ChangelogEntry {item} {translations} />
{/each}
{#if hasMore}
@ -37,7 +37,7 @@
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
onclick={() => (showAll = true)}
>
Show all releases ({items.length - maxItems} more)
{translations.changelog.showAll} ({items.length - maxItems})
</button>
</div>
{/if}

View file

@ -15,14 +15,8 @@
let selectedCategory = $state<FAQCategory | 'all'>('all');
let showAll = $state(false);
const categories: FAQCategory[] = [
'general',
'account',
'billing',
'features',
'technical',
'privacy',
];
// Derive categories from actual items instead of hardcoding
const categories = $derived([...new Set(items.map((item) => item.category))] as FAQCategory[]);
const filteredItems = $derived(() => {
let result = items;
@ -62,27 +56,24 @@
class:dark:text-gray-400={selectedCategory !== 'all'}
onclick={() => (selectedCategory = 'all')}
>
All
{translations.faq.allCategories}
</button>
{#each categories as category}
{@const hasItems = items.some((item) => item.category === category)}
{#if hasItems}
<button
type="button"
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
class:bg-primary-100={selectedCategory === category}
class:text-primary-700={selectedCategory === category}
class:dark:bg-primary-900={selectedCategory === category}
class:dark:text-primary-300={selectedCategory === category}
class:bg-gray-100={selectedCategory !== category}
class:text-gray-600={selectedCategory !== category}
class:dark:bg-gray-800={selectedCategory !== category}
class:dark:text-gray-400={selectedCategory !== category}
onclick={() => (selectedCategory = category)}
>
{getCategoryLabel(category)}
</button>
{/if}
<button
type="button"
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
class:bg-primary-100={selectedCategory === category}
class:text-primary-700={selectedCategory === category}
class:dark:bg-primary-900={selectedCategory === category}
class:dark:text-primary-300={selectedCategory === category}
class:bg-gray-100={selectedCategory !== category}
class:text-gray-600={selectedCategory !== category}
class:dark:bg-gray-800={selectedCategory !== category}
class:dark:text-gray-400={selectedCategory !== category}
onclick={() => (selectedCategory = category)}
>
{getCategoryLabel(category)}
</button>
{/each}
</div>
{/if}

View file

@ -5,9 +5,7 @@
let selectedGuideId = $state<string | null>(items.length > 0 ? items[0].id : null);
const selectedGuide = $derived(() => {
return items.find((item) => item.id === selectedGuideId) ?? null;
});
const guide = $derived(items.find((item) => item.id === selectedGuideId) ?? null);
function getDifficultyLabel(difficulty: string): string {
return (
@ -67,8 +65,7 @@
<!-- Guide Content -->
<div class="flex-1">
{#if selectedGuide()}
{@const guide = selectedGuide()}
{#if guide}
<div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
{guide.title}

View file

@ -69,11 +69,11 @@
selectedIndex = -1;
}
function handleBlur() {
// Delay to allow click on result
setTimeout(() => {
showResults = false;
}, 200);
function handleBlur(event: FocusEvent) {
// Only close if focus moves outside the search container
const relatedTarget = event.relatedTarget as HTMLElement | null;
if (relatedTarget?.closest('[data-help-search]')) return;
showResults = false;
}
function getTypeIcon(type: string): string {
@ -107,7 +107,7 @@
}
</script>
<div class="relative">
<div class="relative" data-help-search>
<div class="relative">
<input
type="text"
@ -118,10 +118,19 @@
onblur={handleBlur}
placeholder={placeholder ?? translations.search.noResults}
class="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-500 transition-colors focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
aria-label={placeholder ?? translations.search.noResults}
role="combobox"
aria-expanded={showResults}
aria-haspopup="listbox"
/>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
{#if isSearching}
<svg class="h-5 w-5 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24">
<svg
class="h-5 w-5 animate-spin text-gray-400"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
@ -131,7 +140,13 @@
></path>
</svg>
{:else}
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg
class="h-5 w-5 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
@ -146,6 +161,7 @@
{#if showResults}
<div
class="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
role="listbox"
>
{#if results.length === 0}
<div class="p-4 text-center text-sm text-gray-500 dark:text-gray-400">
@ -154,17 +170,21 @@
{:else}
<ul class="max-h-96 overflow-auto py-2">
{#each results as result, index (result.id)}
<li>
<li role="option" aria-selected={selectedIndex === index}>
<button
type="button"
class="flex w-full items-start gap-3 px-4 py-2 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 {selectedIndex ===
index
? 'bg-primary-50 dark:bg-primary-900/20'
: ''}"
onclick={() => selectResult(result)}
onmousedown={(e) => {
e.preventDefault();
selectResult(result);
}}
>
<span
class="mt-0.5 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded bg-gray-100 text-xs dark:bg-gray-700"
aria-label={getTypeLabel(result.type)}
>
{getTypeIcon(result.type)}
</span>

View file

@ -25,9 +25,15 @@
<table class="w-full text-left text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Shortcut</th>
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100">Action</th>
<th class="pb-3 font-semibold text-gray-900 dark:text-gray-100">Description</th>
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100"
>{translations.shortcuts.columns.shortcut}</th
>
<th class="pb-3 pr-4 font-semibold text-gray-900 dark:text-gray-100"
>{translations.shortcuts.columns.action}</th
>
<th class="pb-3 font-semibold text-gray-900 dark:text-gray-100"
>{translations.shortcuts.columns.description}</th
>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">

View file

@ -27,6 +27,7 @@ export type {
FeaturesOverviewProps,
KeyboardShortcutsProps,
GettingStartedGuideProps,
ChangelogEntryProps,
ChangelogSectionProps,
ContactSectionProps,
HelpSearchProps,

View file

@ -12,7 +12,7 @@
let {
content,
appName,
appId: _appId,
appId,
translations,
searchEnabled = true,
showFAQ = true,
@ -57,9 +57,13 @@
const visibleSections = $derived(sections.filter((s) => s.show));
let contentRef: HTMLDivElement;
function setActiveSection(section: HelpSection) {
activeSection = section;
onSectionChange?.(section);
// Scroll content area into view on tab change
contentRef?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function handleSearchResultSelect(result: SearchResult) {
@ -151,7 +155,7 @@
{/if}
<!-- Content -->
<div class="min-h-[400px]">
<div class="min-h-[400px]" bind:this={contentRef}>
{#if activeSection === 'faq' && showFAQ}
<FAQSection items={content.faq} {translations} expandFirst />
{:else if activeSection === 'features' && showFeatures}

View file

@ -27,6 +27,7 @@ export interface HelpPageTranslations {
};
faq: {
noItems: string;
allCategories: string;
categories: {
general: string;
account: string;
@ -43,6 +44,11 @@ export interface HelpPageTranslations {
};
shortcuts: {
noItems: string;
columns: {
shortcut: string;
action: string;
description: string;
};
};
gettingStarted: {
noItems: string;
@ -55,12 +61,18 @@ export interface HelpPageTranslations {
};
changelog: {
noItems: string;
showAll: string;
types: {
major: string;
minor: string;
patch: string;
beta: string;
};
labels: {
features: string;
improvements: string;
bugFixes: string;
};
};
contact: {
noInfo: string;
@ -113,6 +125,11 @@ export interface FAQSectionProps {
expandFirst?: boolean;
}
export interface ChangelogEntryProps {
item: HelpContent['changelog'][number];
translations: Pick<HelpPageTranslations, 'changelog'>;
}
export interface FeaturesOverviewProps {
items: HelpContent['features'];
translations: Pick<HelpPageTranslations, 'features'>;