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

@ -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'>;