fix(packages): fix type errors in consolidated packages + add missing files

- credits: fix mobile import paths (./operations → ../operations)
- feedback: fix createFeedbackService import (./feedback → ./api),
  recover missing types.ts from git history
- help: add package files (were untracked after consolidation)
- Update lockfile after package restructuring

All packages pass tsc --noEmit (excluding expected .svelte imports).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 17:53:34 +01:00
parent cbd19c24ed
commit 5e05c532a2
30 changed files with 3917 additions and 6341 deletions

View file

@ -1,6 +1,6 @@
import React from 'react';
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { formatCreditCost } from './operations';
import { formatCreditCost } from '../operations';
interface CreditBalanceProps {
/** Current credit balance */

View file

@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native';
import { formatCreditCost } from './operations';
import { formatCreditCost } from '../operations';
interface CreditToastProps {
/** The operation name or description */

View file

@ -23,7 +23,7 @@ import type {
FeedbackResponse,
FeedbackListResponse,
VoteResponse,
} from './feedback';
} from './api';
import type { FeedbackServiceConfig } from './types';
/**

View file

@ -0,0 +1,36 @@
{
"name": "@manacore/help",
"version": "1.0.0",
"private": true,
"description": "Unified help package — types, content utilities, and UI components",
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"type-check": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"fuse.js": "^7.1.0",
"gray-matter": "^4.0.3",
"isomorphic-dompurify": "^3.7.1",
"zod": "^3.24.1"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-i18n": "^4.0.0",
"typescript": "^5.9.3"
},
"peerDependencies": {
"svelte": "^5.0.0",
"svelte-i18n": "^4.0.0"
}
}

View file

@ -0,0 +1,152 @@
<script lang="ts">
import type { ChangelogEntryProps } from '../types.js';
let { item, translations }: ChangelogEntryProps = $props();
let expanded = $state(false);
function getTypeColor(type: string): string {
switch (type) {
case 'major':
return 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400';
case 'minor':
return 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
case 'patch':
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
case 'beta':
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
default:
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
}
}
function formatDate(date: Date): string {
return new Date(date).toLocaleDateString(undefined, {
year: 'numeric',
month: 'long',
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">
<button
type="button"
class="flex w-full items-start justify-between text-left"
onclick={() => (expanded = !expanded)}
>
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-mono text-lg font-semibold text-gray-900 dark:text-gray-100">
v{item.version}
</span>
<span class={`rounded-full px-2 py-0.5 text-xs font-medium ${getTypeColor(item.type)}`}>
{typeLabels[item.type] ?? item.type}
</span>
{#if item.highlighted}
<span
class="rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
>
Featured
</span>
{/if}
</div>
<h4 class="mt-1 font-medium text-gray-800 dark:text-gray-200">
{item.title}
</h4>
<p class="text-sm text-gray-500 dark:text-gray-400">
{formatDate(item.releaseDate)}
</p>
</div>
<span
class="mt-1 text-gray-500 transition-transform duration-200 dark:text-gray-400"
class:rotate-180={expanded}
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
{#if expanded}
<div class="mt-4 space-y-4">
{#if item.summary}
<p class="text-gray-600 dark:text-gray-400">{item.summary}</p>
{/if}
{#if item.changes}
{#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">
{changeLabels.features}
</h5>
<ul class="space-y-1">
{#each item.changes.features as change}
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<span class="text-green-500">+</span>
<span>
<strong>{change.title}</strong>
{#if change.description}
- {change.description}
{/if}
</span>
</li>
{/each}
</ul>
</div>
{/if}
{#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">
{changeLabels.improvements}
</h5>
<ul class="space-y-1">
{#each item.changes.improvements as change}
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<span class="text-blue-500"></span>
<span>
<strong>{change.title}</strong>
{#if change.description}
- {change.description}
{/if}
</span>
</li>
{/each}
</ul>
</div>
{/if}
{#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">
{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">
<span class="text-red-500">×</span>
<span>
<strong>{change.title}</strong>
{#if change.description}
- {change.description}
{/if}
</span>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{#if item.content}
<div class="prose prose-sm dark:prose-invert max-w-none">
{@html item.content}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,45 @@
<script lang="ts">
import type { ChangelogSectionProps } from '../types.js';
import ChangelogEntry from './ChangelogEntry.svelte';
let { items, translations, maxItems = 10 }: ChangelogSectionProps = $props();
let showAll = $state(false);
const sortedItems = $derived(() => {
return [...items].sort(
(a, b) => new Date(b.releaseDate).getTime() - new Date(a.releaseDate).getTime()
);
});
const displayedItems = $derived(() => {
if (showAll) return sortedItems();
return sortedItems().slice(0, maxItems);
});
const hasMore = $derived(items.length > maxItems && !showAll);
</script>
{#if items.length === 0}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.changelog.noItems}
</p>
{:else}
<div>
{#each displayedItems() as item (item.id)}
<ChangelogEntry {item} {translations} />
{/each}
{#if hasMore}
<div class="pt-4 text-center">
<button
type="button"
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
onclick={() => (showAll = true)}
>
{translations.changelog.showAll} ({items.length - maxItems})
</button>
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,123 @@
<script lang="ts">
import type { ContactSectionProps } from '../types.js';
let { contact, translations }: ContactSectionProps = $props();
</script>
{#if !contact}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.contact.noInfo}
</p>
{:else}
<div class="space-y-6">
<div class="prose prose-sm dark:prose-invert max-w-none">
{@html contact.content}
</div>
<div class="grid gap-4 sm:grid-cols-2">
{#if contact.supportEmail}
<a
href={`mailto:${contact.supportEmail}`}
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<div
class="bg-primary-100 text-primary-600 dark:bg-primary-900 dark:text-primary-400 flex h-10 w-10 items-center justify-center rounded-full"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">
{translations.contact.email}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{contact.supportEmail}
</p>
</div>
</a>
{/if}
{#if contact.responseTime}
<div
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 dark:border-gray-700"
>
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900 dark:text-green-400"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">
{translations.contact.responseTime}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{contact.responseTime}
</p>
</div>
</div>
{/if}
{#if contact.discordUrl}
<a
href={contact.discordUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-900 dark:text-indigo-400"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path
d="M20.317 4.37a19.791 19.791 0 00-4.885-1.515.074.074 0 00-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 00-5.487 0 12.64 12.64 0 00-.617-1.25.077.077 0 00-.079-.037A19.736 19.736 0 003.677 4.37a.07.07 0 00-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 00.031.057 19.9 19.9 0 005.993 3.03.078.078 0 00.084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 00-.041-.106 13.107 13.107 0 01-1.872-.892.077.077 0 01-.008-.128 10.2 10.2 0 00.372-.292.074.074 0 01.077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 01.078.01c.12.098.246.198.373.292a.077.077 0 01-.006.127 12.299 12.299 0 01-1.873.892.077.077 0 00-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 00.084.028 19.839 19.839 0 006.002-3.03.077.077 0 00.032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 00-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"
/>
</svg>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">Discord</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Join our community</p>
</div>
</a>
{/if}
{#if contact.documentationUrl}
<a
href={contact.documentationUrl}
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-3 rounded-lg border border-gray-200 p-4 transition-colors hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<div
class="flex h-10 w-10 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
</div>
<div>
<p class="font-medium text-gray-900 dark:text-gray-100">Documentation</p>
<p class="text-sm text-gray-600 dark:text-gray-400">Read the docs</p>
</div>
</a>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,46 @@
<script lang="ts">
import type { FAQItem } from './content';
interface Props {
item: FAQItem;
expanded?: boolean;
onToggle?: () => void;
}
let { item, expanded = false, onToggle }: Props = $props();
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggle?.();
}
}
</script>
<div class="border-b border-gray-200 dark:border-gray-700">
<button
type="button"
class="flex w-full items-center justify-between py-4 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
aria-expanded={expanded}
onclick={onToggle}
onkeydown={handleKeyDown}
>
<span class="pr-4 font-medium text-gray-900 dark:text-gray-100">
{item.question}
</span>
<span
class="flex-shrink-0 text-gray-500 transition-transform duration-200 dark:text-gray-400"
class:rotate-180={expanded}
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</span>
</button>
{#if expanded}
<div class="prose prose-sm dark:prose-invert max-w-none pb-4 text-gray-600 dark:text-gray-300">
{@html item.answer}
</div>
{/if}
</div>

View file

@ -0,0 +1,108 @@
<script lang="ts">
import type { FAQItem, FAQCategory } from './content';
import type { FAQSectionProps } from '../types.js';
import FAQItemComponent from './FAQItem.svelte';
let {
items,
translations,
showCategories = true,
maxItems,
expandFirst = false,
}: FAQSectionProps = $props();
let expandedId = $state<string | null>(expandFirst && items.length > 0 ? items[0].id : null);
let selectedCategory = $state<FAQCategory | 'all'>('all');
let showAll = $state(false);
// 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;
if (selectedCategory !== 'all') {
result = result.filter((item) => item.category === selectedCategory);
}
if (maxItems && !showAll) {
result = result.slice(0, maxItems);
}
return result;
});
const hasMore = $derived(maxItems ? items.length > maxItems && !showAll : false);
function toggleItem(id: string) {
expandedId = expandedId === id ? null : id;
}
function getCategoryLabel(category: FAQCategory): string {
return translations.faq.categories[category] ?? category;
}
</script>
<div class="space-y-4">
{#if showCategories && items.length > 0}
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
class:bg-primary-100={selectedCategory === 'all'}
class:text-primary-700={selectedCategory === 'all'}
class:dark:bg-primary-900={selectedCategory === 'all'}
class:dark:text-primary-300={selectedCategory === 'all'}
class:bg-gray-100={selectedCategory !== 'all'}
class:text-gray-600={selectedCategory !== 'all'}
class:dark:bg-gray-800={selectedCategory !== 'all'}
class:dark:text-gray-400={selectedCategory !== 'all'}
onclick={() => (selectedCategory = 'all')}
>
{translations.faq.allCategories}
</button>
{#each categories as category}
<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}
{#if filteredItems().length === 0}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.faq.noItems}
</p>
{:else}
<div class="divide-y divide-gray-200 dark:divide-gray-700">
{#each filteredItems() as item (item.id)}
<FAQItemComponent
{item}
expanded={expandedId === item.id}
onToggle={() => toggleItem(item.id)}
/>
{/each}
</div>
{/if}
{#if hasMore}
<div class="pt-4 text-center">
<button
type="button"
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
onclick={() => (showAll = true)}
>
{translations.common.showMore}
</button>
</div>
{/if}
</div>

View file

@ -0,0 +1,68 @@
<script lang="ts">
import type { FeatureItem } from './content';
interface Props {
item: FeatureItem;
learnMoreLabel?: string;
comingSoonLabel?: string;
}
let { item, learnMoreLabel = 'Learn more', comingSoonLabel = 'Coming soon' }: Props = $props();
</script>
<div
class="relative rounded-lg border border-gray-200 bg-white p-5 transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-800"
>
{#if item.comingSoon}
<span
class="absolute right-3 top-3 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
>
{comingSoonLabel}
</span>
{/if}
<div class="mb-3 flex items-center gap-3">
{#if item.icon}
<span class="text-2xl">{item.icon}</span>
{/if}
<h3 class="font-semibold text-gray-900 dark:text-gray-100">
{item.title}
</h3>
</div>
<p class="mb-3 text-sm text-gray-600 dark:text-gray-400">
{item.description}
</p>
{#if item.highlights && item.highlights.length > 0}
<ul class="mb-3 space-y-1">
{#each item.highlights as highlight}
<li class="flex items-start gap-2 text-sm text-gray-600 dark:text-gray-400">
<svg
class="text-primary-500 mt-0.5 h-4 w-4 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{highlight}
</li>
{/each}
</ul>
{/if}
{#if item.learnMoreUrl}
<a
href={item.learnMoreUrl}
class="text-primary-600 dark:text-primary-400 text-sm font-medium hover:underline"
>
{learnMoreLabel}
</a>
{/if}
</div>

View file

@ -0,0 +1,50 @@
<script lang="ts">
import type { FeaturesOverviewProps } from '../types.js';
import FeatureCard from './FeatureCard.svelte';
let { items, translations }: FeaturesOverviewProps = $props();
const groupedItems = $derived(() => {
const groups: Record<string, typeof items> = {
'getting-started': [],
core: [],
advanced: [],
integration: [],
};
for (const item of items) {
const category = item.category || 'core';
if (groups[category]) {
groups[category].push(item);
} else {
groups.core.push(item);
}
}
return groups;
});
const hasItems = $derived(items.length > 0);
</script>
{#if !hasItems}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.features.noItems}
</p>
{:else}
<div class="space-y-8">
{#each Object.entries(groupedItems()) as [_category, categoryItems]}
{#if categoryItems.length > 0}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each categoryItems as item (item.id)}
<FeatureCard
{item}
learnMoreLabel={translations.features.learnMore}
comingSoonLabel={translations.features.comingSoon}
/>
{/each}
</div>
{/if}
{/each}
</div>
{/if}

View file

@ -0,0 +1,108 @@
<script lang="ts">
import type { GettingStartedGuideProps } from '../types.js';
let { items, translations }: GettingStartedGuideProps = $props();
let selectedGuideId = $state<string | null>(items.length > 0 ? items[0].id : null);
const guide = $derived(items.find((item) => item.id === selectedGuideId) ?? null);
function getDifficultyLabel(difficulty: string): string {
return (
translations.gettingStarted.difficulty[
difficulty as keyof typeof translations.gettingStarted.difficulty
] ?? difficulty
);
}
function getDifficultyColor(difficulty: string): string {
switch (difficulty) {
case 'beginner':
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
case 'intermediate':
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
case 'advanced':
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
default:
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
}
}
</script>
{#if items.length === 0}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.gettingStarted.noItems}
</p>
{:else}
<div class="flex flex-col gap-6 lg:flex-row">
<!-- Guide List -->
<div class="w-full space-y-2 lg:w-64">
{#each items as item (item.id)}
<button
type="button"
class="w-full rounded-lg border p-3 text-left transition-colors {selectedGuideId ===
item.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800'}"
onclick={() => (selectedGuideId = item.id)}
>
<h4 class="font-medium text-gray-900 dark:text-gray-100">
{item.title}
</h4>
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs">
<span class={`rounded-full px-2 py-0.5 ${getDifficultyColor(item.difficulty)}`}>
{getDifficultyLabel(item.difficulty)}
</span>
{#if item.estimatedTime}
<span class="text-gray-500 dark:text-gray-400">
{item.estimatedTime}
</span>
{/if}
</div>
</button>
{/each}
</div>
<!-- Guide Content -->
<div class="flex-1">
{#if guide}
<div>
<h3 class="mb-2 text-xl font-semibold text-gray-900 dark:text-gray-100">
{guide.title}
</h3>
<p class="mb-4 text-gray-600 dark:text-gray-400">
{guide.description}
</p>
{#if guide.steps && guide.steps.length > 0}
<div class="space-y-6">
{#each guide.steps as step, index}
<div class="flex gap-4">
<div
class="bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-sm font-semibold"
>
{index + 1}
</div>
<div class="flex-1">
<h4 class="mb-2 font-medium text-gray-900 dark:text-gray-100">
{step.title}
</h4>
<div
class="prose prose-sm dark:prose-invert max-w-none text-gray-600 dark:text-gray-400"
>
{step.content}
</div>
</div>
</div>
{/each}
</div>
{:else}
<div class="prose prose-sm dark:prose-invert max-w-none">
{@html guide.content}
</div>
{/if}
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1,218 @@
<script lang="ts">
import type { HelpSearchProps } from '../types.js';
import type { SearchResult } from './content';
import { createSearcher } from './loader';
let { content, translations, placeholder, onResultSelect }: HelpSearchProps = $props();
let query = $state('');
let results = $state<SearchResult[]>([]);
let isSearching = $state(false);
let showResults = $state(false);
let selectedIndex = $state(-1);
const searcher = $derived(createSearcher(content));
let debounceTimer: ReturnType<typeof setTimeout>;
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
query = target.value;
selectedIndex = -1;
clearTimeout(debounceTimer);
if (query.trim().length < 2) {
results = [];
showResults = false;
return;
}
isSearching = true;
debounceTimer = setTimeout(() => {
results = searcher(query, { limit: 8 });
isSearching = false;
showResults = true;
}, 300);
}
function handleKeyDown(event: KeyboardEvent) {
if (!showResults || results.length === 0) return;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
selectedIndex = Math.min(selectedIndex + 1, results.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
selectedIndex = Math.max(selectedIndex - 1, -1);
break;
case 'Enter':
event.preventDefault();
if (selectedIndex >= 0 && results[selectedIndex]) {
selectResult(results[selectedIndex]);
}
break;
case 'Escape':
showResults = false;
selectedIndex = -1;
break;
}
}
function selectResult(result: SearchResult) {
onResultSelect(result);
query = '';
results = [];
showResults = false;
selectedIndex = -1;
}
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 {
switch (type) {
case 'faq':
return '?';
case 'feature':
return '★';
case 'guide':
return '📖';
case 'changelog':
return '📋';
default:
return '•';
}
}
function getTypeLabel(type: string): string {
switch (type) {
case 'faq':
return 'FAQ';
case 'feature':
return 'Feature';
case 'guide':
return 'Guide';
case 'changelog':
return 'Changelog';
default:
return type;
}
}
</script>
<div class="relative" data-help-search>
<div class="relative">
<input
type="text"
value={query}
oninput={handleInput}
onkeydown={handleKeyDown}
onfocus={() => query.length >= 2 && (showResults = true)}
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"
aria-hidden="true"
>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{:else}
<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"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{/if}
</div>
</div>
{#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">
{translations.search.noResults.replace('{query}', query)}
</div>
{:else}
<ul class="max-h-96 overflow-auto py-2">
{#each results as result, index (result.id)}
<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'
: ''}"
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>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="truncate font-medium text-gray-900 dark:text-gray-100">
{@html result.highlight ?? result.title}
</span>
<span
class="flex-shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-400"
>
{getTypeLabel(result.type)}
</span>
</div>
<p class="mt-0.5 truncate text-sm text-gray-500 dark:text-gray-400">
{result.excerpt}
</p>
</div>
</button>
</li>
{/each}
</ul>
<div
class="border-t border-gray-200 px-4 py-2 text-xs text-gray-500 dark:border-gray-700 dark:text-gray-400"
>
{translations.search.resultsCount.replace('{count}', String(results.length))}
</div>
{/if}
</div>
{/if}
</div>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import type { KeyboardShortcutsProps } from '../types.js';
let { items, translations }: KeyboardShortcutsProps = $props();
const allShortcuts = $derived(() => {
return items.flatMap((item) =>
item.shortcuts.map((shortcut) => ({
...shortcut,
category: item.category,
title: item.title,
}))
);
});
const hasItems = $derived(allShortcuts().length > 0);
</script>
{#if !hasItems}
<p class="py-8 text-center text-gray-500 dark:text-gray-400">
{translations.shortcuts.noItems}
</p>
{:else}
<div class="overflow-x-auto">
<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"
>{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">
{#each allShortcuts() as shortcut}
<tr>
<td class="py-3 pr-4">
<kbd
class="rounded bg-gray-100 px-2 py-1 font-mono text-xs text-gray-800 dark:bg-gray-800 dark:text-gray-200"
>
{shortcut.shortcut}
</kbd>
</td>
<td class="py-3 pr-4 text-gray-900 dark:text-gray-100">
{shortcut.action}
</td>
<td class="py-3 text-gray-600 dark:text-gray-400">
{shortcut.description || '-'}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}

View file

@ -0,0 +1,152 @@
/**
* Help Content Type Definitions
* Defines the structure for all help content types
*/
// ============================================================================
// Base Types
// ============================================================================
export type SupportedLanguage = 'en' | 'de' | 'fr' | 'it' | 'es';
export type FAQCategory = 'general' | 'account' | 'billing' | 'features' | 'technical' | 'privacy';
export type FeatureCategory = 'getting-started' | 'core' | 'advanced' | 'integration';
export type GuideDifficulty = 'beginner' | 'intermediate' | 'advanced';
export type ChangelogType = 'major' | 'minor' | 'patch' | 'beta';
export type ShortcutCategory = 'navigation' | 'editing' | 'general' | 'app-specific';
// ============================================================================
// Content Item Types
// ============================================================================
export interface BaseContentItem {
id: string;
language: SupportedLanguage;
order?: number;
appSpecific?: boolean;
apps?: string[];
lastUpdated?: Date;
}
export interface FAQItem extends BaseContentItem {
question: string;
answer: string;
category: FAQCategory;
featured?: boolean;
tags?: string[];
relatedFaqs?: string[];
}
export interface FeatureItem extends BaseContentItem {
title: string;
description: string;
content: string;
icon?: string;
category: FeatureCategory;
available?: boolean;
comingSoon?: boolean;
highlights?: string[];
learnMoreUrl?: string;
}
export interface KeyboardShortcut {
shortcut: string;
action: string;
description?: string;
}
export interface ShortcutsItem extends BaseContentItem {
category: ShortcutCategory;
title?: string;
shortcuts: KeyboardShortcut[];
}
export interface GuideStep {
title: string;
content: string;
duration?: string;
}
export interface GettingStartedItem extends BaseContentItem {
title: string;
description: string;
content: string;
difficulty: GuideDifficulty;
estimatedTime?: string;
prerequisites?: string[];
steps?: GuideStep[];
}
export interface ChangelogChange {
title: string;
description?: string;
category?: string;
}
export interface ChangelogItem extends BaseContentItem {
version: string;
title: string;
releaseDate: Date;
type: ChangelogType;
summary?: string;
content: string;
highlighted?: boolean;
changes?: {
features?: ChangelogChange[];
improvements?: ChangelogChange[];
bugfixes?: ChangelogChange[];
};
platforms?: string[];
}
export interface ContactInfo extends BaseContentItem {
title: string;
content: string;
supportEmail?: string;
supportUrl?: string;
discordUrl?: string;
twitterUrl?: string;
documentationUrl?: string;
responseTime?: string;
}
// ============================================================================
// Aggregated Content Types
// ============================================================================
export interface HelpContent {
faq: FAQItem[];
features: FeatureItem[];
shortcuts: ShortcutsItem[];
gettingStarted: GettingStartedItem[];
changelog: ChangelogItem[];
contact: ContactInfo | null;
}
export interface AppHelpContent {
appId: string;
appName: string;
content: HelpContent;
}
// ============================================================================
// Configuration Types
// ============================================================================
export interface HelpContentConfig {
appId: string;
locale: SupportedLanguage;
fallbackLocale?: SupportedLanguage;
includeAppSpecific?: boolean;
}
export interface MergeContentOptions {
appId: string;
locale: SupportedLanguage;
/** If true, app-specific content replaces central content with same ID */
overrideById?: boolean;
}

View file

@ -0,0 +1,70 @@
/**
* @manacore/help Unified help package
*
* Consolidates shared-help-types + shared-help-content + shared-help-ui.
*/
// === Types (from shared-help-types) ===
export * from './content';
export * from './schemas';
export * from './search-types';
export { getManaFAQs, getManaFeature } from './mana-faq';
export { getPrivacyFAQs, type PrivacyFAQOptions } from './privacy-faq';
// === Content utilities (from shared-help-content) ===
export {
parseMarkdown,
parseMarkdownFiles,
stripHtml,
generateExcerpt,
type ParsedContent,
type ParseOptions,
} from './parser';
export {
parseFAQContent,
parseFeatureContent,
parseShortcutsContent,
parseGettingStartedContent,
parseChangelogContent,
parseContactContent,
loadHelpContentFromFiles,
type LoaderOptions,
} from './loader';
export { mergeContent, createEmptyContent } from './merger';
export { sanitizeHtml } from './sanitize';
export { buildSearchIndex, search, createSearcher, flattenContentForSearch } from './search-engine';
// === UI Components (from shared-help-ui) ===
export { default as HelpPage } from './pages/HelpPage.svelte';
export { default as FAQSection } from './components/FAQSection.svelte';
export { default as FAQItem } from './components/FAQItem.svelte';
export { default as FeaturesOverview } from './components/FeaturesOverview.svelte';
export { default as FeatureCard } from './components/FeatureCard.svelte';
export { default as KeyboardShortcuts } from './components/KeyboardShortcuts.svelte';
export { default as GettingStartedGuide } from './components/GettingStartedGuide.svelte';
export { default as ChangelogSection } from './components/ChangelogSection.svelte';
export { default as ChangelogEntry } from './components/ChangelogEntry.svelte';
export { default as ContactSection } from './components/ContactSection.svelte';
export { default as HelpSearch } from './components/HelpSearch.svelte';
export {
defaultTranslationsDE,
defaultTranslationsEN,
getHelpTranslations,
} from './translations';
export type {
HelpPageProps,
HelpPageTranslations,
HelpSection,
FAQSectionProps,
FeaturesOverviewProps,
KeyboardShortcutsProps,
GettingStartedGuideProps,
ChangelogEntryProps,
ChangelogSectionProps,
ContactSectionProps,
HelpSearchProps,
} from './ui-types';

311
packages/help/src/loader.ts Normal file
View file

@ -0,0 +1,311 @@
/**
* Content Loader
* Utilities for loading help content from various sources
*/
import type {
HelpContent,
FAQItem,
FeatureItem,
ShortcutsItem,
GettingStartedItem,
ChangelogItem,
ContactInfo,
SupportedLanguage,
} from './content';
import {
faqFrontmatterSchema,
featureFrontmatterSchema,
shortcutsFrontmatterSchema,
gettingStartedFrontmatterSchema,
changelogFrontmatterSchema,
contactFrontmatterSchema,
type FAQFrontmatter,
type FeatureFrontmatter,
type ShortcutsFrontmatter,
type GettingStartedFrontmatter,
type ChangelogFrontmatter,
type ContactFrontmatter,
} from './content';
import { parseMarkdown } from './parser.js';
import { createEmptyContent } from './merger.js';
export interface LoaderOptions {
/** Locale to load */
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<FAQFrontmatter>(rawContent, faqFrontmatterSchema);
const fm = parsed.frontmatter;
return {
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,
};
}
/**
* Parse Feature content from raw Markdown
*/
export function parseFeatureContent(rawContent: string): FeatureItem {
const parsed = parseMarkdown<FeatureFrontmatter>(rawContent, featureFrontmatterSchema);
const fm = parsed.frontmatter;
return {
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,
};
}
/**
* Parse Shortcuts content from raw Markdown
*/
export function parseShortcutsContent(rawContent: string): ShortcutsItem {
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,
language: fm.language,
order: fm.order,
appSpecific: fm.appSpecific,
apps: fm.apps,
lastUpdated: fm.lastUpdated,
category: fm.category,
title: fm.title,
shortcuts,
};
}
/**
* Parse a markdown table into keyboard shortcuts
*/
function parseShortcutsTable(
content: string
): Array<{ shortcut: string; action: string; description?: string }> {
const shortcuts: Array<{ shortcut: string; action: string; description?: string }> = [];
const lines = content.split('\n');
let inTable = false;
for (const line of lines) {
const trimmed = line.trim();
// Skip header separator (flexible: allows spaces around dashes)
if (trimmed.match(/^\|[\s\-:|]+\|$/)) {
inTable = true;
continue;
}
// Parse table row
if (inTable && trimmed.startsWith('|') && trimmed.endsWith('|')) {
const cells = trimmed
.slice(1, -1)
.split('|')
.map((cell) => cell.trim());
if (cells.length >= 2) {
shortcuts.push({
shortcut: cells[0],
action: cells[1],
description: cells[2] || undefined,
});
}
} else if (inTable && !trimmed.startsWith('|')) {
// End of table
break;
}
}
return shortcuts;
}
/**
* Parse Getting Started guide content from raw Markdown
*/
export function parseGettingStartedContent(rawContent: string): GettingStartedItem {
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,
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,
};
}
/**
* Parse guide steps from markdown content (h2 headers)
*/
function parseGuideSteps(content: string): Array<{ title: string; content: string }> {
const steps: Array<{ title: string; content: string }> = [];
const sections = content.split(/^## /m);
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();
steps.push({ title, content: stepContent });
}
return steps;
}
/**
* Parse Changelog content from raw Markdown
*/
export function parseChangelogContent(rawContent: string): ChangelogItem {
const parsed = parseMarkdown<ChangelogFrontmatter>(rawContent, changelogFrontmatterSchema);
const fm = parsed.frontmatter;
return {
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,
};
}
/**
* Parse Contact content from raw Markdown
*/
export function parseContactContent(rawContent: string): ContactInfo {
const parsed = parseMarkdown<ContactFrontmatter>(rawContent, contactFrontmatterSchema);
const fm = parsed.frontmatter;
return {
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,
};
}
/**
* Load help content from a map of file paths to content
* This is the main entry point for content loading
*/
export function loadHelpContentFromFiles(
files: Record<string, string>,
options: LoaderOptions
): HelpContent {
const content = createEmptyContent();
const { locale, fallbackLocale = 'en', onError } = options;
for (const [path, rawContent] of Object.entries(files)) {
try {
// Determine content type from path
if (path.includes('/faq/')) {
const faq = parseFAQContent(rawContent);
if (faq.language === locale || faq.language === fallbackLocale) {
content.faq.push(faq);
}
} else if (path.includes('/features/')) {
const feature = parseFeatureContent(rawContent);
if (feature.language === locale || feature.language === fallbackLocale) {
content.features.push(feature);
}
} else if (path.includes('/shortcuts/')) {
const shortcuts = parseShortcutsContent(rawContent);
if (shortcuts.language === locale || shortcuts.language === fallbackLocale) {
content.shortcuts.push(shortcuts);
}
} else if (path.includes('/getting-started/')) {
const guide = parseGettingStartedContent(rawContent);
if (guide.language === locale || guide.language === fallbackLocale) {
content.gettingStarted.push(guide);
}
} else if (path.includes('/changelog/')) {
const changelog = parseChangelogContent(rawContent);
if (changelog.language === locale || changelog.language === fallbackLocale) {
content.changelog.push(changelog);
}
} else if (path.includes('/contact/')) {
const contact = parseContactContent(rawContent);
if (contact.language === locale || contact.language === fallbackLocale) {
content.contact = contact;
}
}
} catch (error) {
if (onError) {
onError(path, error);
} else {
console.warn('[shared-help] Failed to parse:', path, error);
}
}
}
return content;
}

View file

@ -0,0 +1,95 @@
/**
* Shared Mana/Credits FAQ content
* Reusable across all apps that have a /mana subscription page
*/
import type { FAQItem, FeatureItem } from './content.js';
/**
* Returns shared FAQ items explaining the Mana credit system.
* Apps can spread these into their help content's faq array.
*
* @example
* ```ts
* import { getManaFAQs } from './content';
*
* export function getMyAppHelpContent(locale: string): HelpContent {
* return {
* faq: [
* ...appSpecificFaqs,
* ...getManaFAQs(locale),
* ],
* ...
* };
* }
* ```
*/
export function getManaFAQs(locale: string): FAQItem[] {
const isDE = locale === 'de';
return [
{
id: 'faq-mana-what',
question: isDE ? 'Was ist Mana?' : 'What is Mana?',
answer: isDE
? '<p><strong>Mana</strong> ist die universelle Währung im ManaCore-Ökosystem. Mit Mana bezahlst du für Premium-Funktionen wie KI-Generierungen, Cloud-Modelle und erweiterte Features — app-übergreifend mit einem einzigen Guthaben.</p><ul><li>1 Mana ≈ 1 Cent (im Abo)</li><li>Dein Guthaben gilt in <strong>allen ManaCore-Apps</strong></li><li>Jede App zeigt deinen aktuellen Stand unter <strong>Mana</strong> im Account-Menü</li></ul>'
: '<p><strong>Mana</strong> is the universal currency in the ManaCore ecosystem. With Mana you pay for premium features like AI generations, cloud models, and advanced features — across all apps with a single balance.</p><ul><li>1 Mana ≈ 1 cent (with subscription)</li><li>Your balance works across <strong>all ManaCore apps</strong></li><li>Each app shows your current balance under <strong>Mana</strong> in the account menu</li></ul>',
category: 'billing',
order: 90,
language: isDE ? 'de' : 'en',
featured: true,
tags: isDE
? ['mana', 'credits', 'guthaben', 'währung']
: ['mana', 'credits', 'balance', 'currency'],
},
{
id: 'faq-mana-get',
question: isDE ? 'Wie bekomme ich Mana?' : 'How do I get Mana?',
answer: isDE
? '<p>Es gibt drei Wege, Mana zu erhalten:</p><ul><li><strong>Free-Tier</strong>: Jeder Nutzer erhält <strong>50 Mana/Monat</strong> kostenlos</li><li><strong>Mana Quelle</strong> (Abo): Monatliche Mana-Pakete von 500 bis 10.000 Mana (ab 4,99€/Monat, 20% Jahresrabatt)</li><li><strong>Mana Trank</strong> (Einmalkauf): Sofort-Pakete von 350 bis 2.800 Mana (ab 4,90€)</li></ul><p>Abonnenten können außerdem Mana an andere Nutzer <strong>verschenken</strong>.</p>'
: '<p>There are three ways to get Mana:</p><ul><li><strong>Free tier</strong>: Every user receives <strong>50 Mana/month</strong> for free</li><li><strong>Mana Source</strong> (subscription): Monthly Mana packages from 500 to 10,000 Mana (from €4.99/month, 20% yearly discount)</li><li><strong>Mana Potion</strong> (one-time purchase): Instant packages from 350 to 2,800 Mana (from €4.90)</li></ul><p>Subscribers can also <strong>gift</strong> Mana to other users.</p>',
category: 'billing',
order: 91,
language: isDE ? 'de' : 'en',
tags: isDE
? ['mana', 'kaufen', 'abo', 'kostenlos', 'stream', 'trank']
: ['mana', 'buy', 'subscription', 'free', 'stream', 'potion'],
},
{
id: 'faq-mana-use',
question: isDE ? 'Wofür wird Mana verwendet?' : 'What is Mana used for?',
answer: isDE
? '<p>Mana wird für Premium-Funktionen innerhalb der ManaCore-Apps verwendet:</p><ul><li><strong>Chat</strong>: Cloud-KI-Modelle (Claude, GPT, DeepSeek) — lokale Modelle sind kostenlos</li><li><strong>Picture</strong>: KI-Bildgenerierungen (nach 3 kostenlosen Generierungen)</li><li><strong>Context</strong>: KI-Textgenerierung und -analyse</li><li><strong>Weitere Apps</strong>: KI-gestützte Features in Planta, Questions, etc.</li></ul><p>Basis-Funktionen wie Aufgaben, Kalender, Kontakte, Dateien und Chats mit lokalen Modellen sind <strong>immer kostenlos</strong>.</p>'
: '<p>Mana is used for premium features within ManaCore apps:</p><ul><li><strong>Chat</strong>: Cloud AI models (Claude, GPT, DeepSeek) — local models are free</li><li><strong>Picture</strong>: AI image generations (after 3 free generations)</li><li><strong>Context</strong>: AI text generation and analysis</li><li><strong>More apps</strong>: AI-powered features in Planta, Questions, etc.</li></ul><p>Core features like tasks, calendar, contacts, files, and chats with local models are <strong>always free</strong>.</p>',
category: 'billing',
order: 92,
language: isDE ? 'de' : 'en',
tags: isDE
? ['mana', 'verbrauch', 'kosten', 'kostenlos']
: ['mana', 'usage', 'costs', 'free'],
},
];
}
/**
* Returns a shared feature item for the Mana credit system.
*/
export function getManaFeature(locale: string): FeatureItem {
const isDE = locale === 'de';
return {
id: 'feature-mana',
title: isDE ? 'Mana-Credits' : 'Mana Credits',
description: isDE
? 'Universelles Guthaben für Premium-Features in allen ManaCore-Apps — 150 Mana/Monat kostenlos.'
: 'Universal balance for premium features across all ManaCore apps — 150 Mana/month for free.',
icon: '✨',
category: 'core',
highlights: isDE
? ['150 Mana/Monat kostenlos', 'App-übergreifend', 'Abos & Einmalkäufe', 'Mana verschenken']
: ['150 Mana/month free', 'Cross-app', 'Subscriptions & one-time', 'Gift Mana'],
content: '',
order: 99,
language: isDE ? 'de' : 'en',
};
}

119
packages/help/src/merger.ts Normal file
View file

@ -0,0 +1,119 @@
/**
* Content Merger
* Merges central help content with app-specific content
*/
import type { HelpContent, MergeContentOptions } from './content';
/**
* Filter content items by locale and app
*/
function filterItems<T extends { language: string; appSpecific?: boolean; apps?: string[] }>(
items: T[],
locale: string,
appId: string
): T[] {
return items.filter((item) => {
// Filter by language
if (item.language !== locale) {
return false;
}
// Include non-app-specific items
if (!item.appSpecific) {
return true;
}
// Include app-specific items for this app
return item.apps?.includes(appId) ?? false;
});
}
/**
* Merge two arrays, optionally replacing items with same ID
*/
function mergeArrays<T extends { id: string }>(
central: T[],
appSpecific: T[],
overrideById: boolean
): T[] {
if (!overrideById) {
return [...central, ...appSpecific];
}
const appIds = new Set(appSpecific.map((item) => item.id));
const filtered = central.filter((item) => !appIds.has(item.id));
return [...filtered, ...appSpecific];
}
/**
* Sort items by order property
*/
function sortByOrder<T extends { order?: number }>(items: T[]): T[] {
return [...items].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
}
/**
* Merge central help content with app-specific content
*/
export function mergeContent(
central: HelpContent,
appSpecific: Partial<HelpContent>,
options: MergeContentOptions
): HelpContent {
const { appId, locale, overrideById = true } = options;
// Filter central content by locale and app
const filteredCentral: HelpContent = {
faq: filterItems(central.faq, locale, appId),
features: filterItems(central.features, locale, appId),
shortcuts: filterItems(central.shortcuts, locale, appId),
gettingStarted: filterItems(central.gettingStarted, locale, appId),
changelog: filterItems(central.changelog, locale, appId),
contact: central.contact?.language === locale ? central.contact : null,
};
// Filter app-specific content
const filteredApp: Partial<HelpContent> = {
faq: appSpecific.faq ? filterItems(appSpecific.faq, locale, appId) : [],
features: appSpecific.features ? filterItems(appSpecific.features, locale, appId) : [],
shortcuts: appSpecific.shortcuts ? filterItems(appSpecific.shortcuts, locale, appId) : [],
gettingStarted: appSpecific.gettingStarted
? filterItems(appSpecific.gettingStarted, locale, appId)
: [],
changelog: appSpecific.changelog ? filterItems(appSpecific.changelog, locale, appId) : [],
contact: appSpecific.contact?.language === locale ? appSpecific.contact : null,
};
// Merge and sort
return {
faq: sortByOrder(mergeArrays(filteredCentral.faq, filteredApp.faq ?? [], overrideById)),
features: sortByOrder(
mergeArrays(filteredCentral.features, filteredApp.features ?? [], overrideById)
),
shortcuts: sortByOrder(
mergeArrays(filteredCentral.shortcuts, filteredApp.shortcuts ?? [], overrideById)
),
gettingStarted: sortByOrder(
mergeArrays(filteredCentral.gettingStarted, filteredApp.gettingStarted ?? [], overrideById)
),
changelog: sortByOrder(
mergeArrays(filteredCentral.changelog, filteredApp.changelog ?? [], overrideById)
),
contact: filteredApp.contact ?? filteredCentral.contact,
};
}
/**
* Create an empty HelpContent object
*/
export function createEmptyContent(): HelpContent {
return {
faq: [],
features: [],
shortcuts: [],
gettingStarted: [],
changelog: [],
contact: null,
};
}

View file

@ -0,0 +1,173 @@
<script lang="ts">
import type { HelpPageProps, HelpSection } from '../types.js';
import type { SearchResult } from './content';
import HelpSearch from '../components/HelpSearch.svelte';
import FAQSection from '../components/FAQSection.svelte';
import FeaturesOverview from '../components/FeaturesOverview.svelte';
import KeyboardShortcuts from '../components/KeyboardShortcuts.svelte';
import GettingStartedGuide from '../components/GettingStartedGuide.svelte';
import ChangelogSection from '../components/ChangelogSection.svelte';
import ContactSection from '../components/ContactSection.svelte';
let {
content,
appName,
appId,
translations,
searchEnabled = true,
showFAQ = true,
showFeatures = true,
showShortcuts = true,
showGettingStarted = true,
showChangelog = true,
showContact = true,
defaultSection = 'faq',
showBackButton = false,
onBack,
onSectionChange,
onSearch,
}: HelpPageProps = $props();
let activeSection = $state<HelpSection>(defaultSection);
const sections: { id: HelpSection; label: string; show: boolean }[] = $derived([
{ id: 'faq', label: translations.sections.faq, show: showFAQ && content.faq.length > 0 },
{
id: 'features',
label: translations.sections.features,
show: showFeatures && content.features.length > 0,
},
{
id: 'shortcuts',
label: translations.sections.shortcuts,
show: showShortcuts && content.shortcuts.length > 0,
},
{
id: 'getting-started',
label: translations.sections.gettingStarted,
show: showGettingStarted && content.gettingStarted.length > 0,
},
{
id: 'changelog',
label: translations.sections.changelog,
show: showChangelog && content.changelog.length > 0,
},
{ id: 'contact', label: translations.sections.contact, show: showContact && !!content.contact },
]);
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) {
// Navigate to the appropriate section based on result type
switch (result.type) {
case 'faq':
setActiveSection('faq');
break;
case 'feature':
setActiveSection('features');
break;
case 'guide':
setActiveSection('getting-started');
break;
case 'changelog':
setActiveSection('changelog');
break;
}
onSearch?.(result.title, [result]);
}
</script>
<div class="mx-auto max-w-4xl px-4 py-8">
<!-- Header -->
<div class="mb-8">
{#if showBackButton}
<button
type="button"
class="mb-4 flex items-center gap-1 text-sm text-gray-600 transition-colors hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
onclick={onBack}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
{translations.common.back}
</button>
{/if}
<h1 class="text-2xl font-bold text-gray-900 dark:text-gray-100">
{translations.title}
</h1>
{#if translations.subtitle}
<p class="mt-1 text-gray-600 dark:text-gray-400">
{translations.subtitle} - {appName}
</p>
{/if}
</div>
<!-- Search -->
{#if searchEnabled}
<div class="mb-8">
<HelpSearch
{content}
{translations}
placeholder={translations.searchPlaceholder}
onResultSelect={handleSearchResultSelect}
/>
</div>
{/if}
<!-- Navigation Tabs -->
{#if visibleSections.length > 1}
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav class="-mb-px flex space-x-4 overflow-x-auto" aria-label="Help sections">
{#each visibleSections as section (section.id)}
<button
type="button"
class="whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium transition-colors"
class:border-primary-500={activeSection === section.id}
class:text-primary-600={activeSection === section.id}
class:dark:text-primary-400={activeSection === section.id}
class:border-transparent={activeSection !== section.id}
class:text-gray-500={activeSection !== section.id}
class:hover:text-gray-700={activeSection !== section.id}
class:dark:text-gray-400={activeSection !== section.id}
class:dark:hover:text-gray-300={activeSection !== section.id}
onclick={() => setActiveSection(section.id)}
>
{section.label}
</button>
{/each}
</nav>
</div>
{/if}
<!-- Content -->
<div class="min-h-[400px]" bind:this={contentRef}>
{#if activeSection === 'faq' && showFAQ}
<FAQSection items={content.faq} {translations} expandFirst />
{:else if activeSection === 'features' && showFeatures}
<FeaturesOverview items={content.features} {translations} />
{:else if activeSection === 'shortcuts' && showShortcuts}
<KeyboardShortcuts items={content.shortcuts} {translations} />
{:else if activeSection === 'getting-started' && showGettingStarted}
<GettingStartedGuide items={content.gettingStarted} {translations} />
{:else if activeSection === 'changelog' && showChangelog}
<ChangelogSection items={content.changelog} {translations} />
{:else if activeSection === 'contact' && showContact}
<ContactSection contact={content.contact} {translations} />
{/if}
</div>
</div>

View file

@ -0,0 +1,87 @@
/**
* Markdown + Frontmatter Parser
* Parses Markdown files with YAML frontmatter
*/
import matter from 'gray-matter';
import { marked } from 'marked';
import type { ZodType, ZodTypeDef } from 'zod';
import { sanitizeHtml } from './sanitize.js';
export interface ParsedContent<T> {
frontmatter: T;
content: string;
html: string;
}
export interface ParseOptions {
/** Convert Markdown to HTML */
renderHtml?: boolean;
}
/**
* Parse a Markdown file with frontmatter
*/
export function parseMarkdown<T>(
rawContent: string,
schema?: ZodType<T, ZodTypeDef, unknown>,
options: ParseOptions = { renderHtml: true }
): ParsedContent<T> {
const { data, content } = matter(rawContent);
// Validate frontmatter if schema provided
let frontmatter: T;
if (schema) {
const result = schema.safeParse(data);
if (!result.success) {
throw new Error(`Invalid frontmatter: ${result.error.message}`);
}
frontmatter = result.data;
} else {
frontmatter = data as T;
}
// Render HTML if requested, then sanitize to prevent XSS
const html = options.renderHtml ? sanitizeHtml(marked.parse(content) as string) : '';
return {
frontmatter,
content: content.trim(),
html,
};
}
/**
* Parse multiple Markdown files
*/
export function parseMarkdownFiles<T>(
files: { filename: string; content: string }[],
schema?: ZodType<T, ZodTypeDef, unknown>,
options?: ParseOptions
): Array<ParsedContent<T> & { filename: string }> {
return files.map(({ filename, content }) => ({
filename,
...parseMarkdown<T>(content, schema, options),
}));
}
/**
* Extract text content from HTML (for search indexing)
*/
export function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Generate excerpt from content
*/
export function generateExcerpt(content: string, maxLength = 150): string {
const text = stripHtml(content);
if (text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength).trim() + '...';
}

View file

@ -0,0 +1,109 @@
/**
* Shared Privacy, Data Protection & Tech Independence FAQ content
* Reusable across all apps covers GDPR, encryption, self-hosting,
* tech stack independence, data export, account deletion
*/
import type { FAQItem } from './content.js';
export interface PrivacyFAQOptions {
/**
* What kind of data does the app handle?
* Used in the main privacy FAQ to make it specific.
* @example 'Aufgaben' / 'tasks', 'Dateien' / 'files', 'Chats' / 'chats'
*/
dataTypeDE: string;
dataTypeEN: string;
/**
* Optional extra bullet points for the main privacy FAQ (app-specific).
* Each entry is a single `<li>` string (without the `<li>` tags).
* @example ['<strong>Lokale Modelle</strong>: Deine Daten verlassen nie unseren Server']
*/
extraBulletsDE?: string[];
extraBulletsEN?: string[];
}
/**
* Returns shared FAQ items covering privacy, data protection, and tech independence.
* The main privacy FAQ can be customized per app via options.
*
* @example
* ```ts
* import { getPrivacyFAQs } from './content';
*
* const privacyFAQs = getPrivacyFAQs(locale, {
* dataTypeDE: 'Aufgaben',
* dataTypeEN: 'tasks',
* extraBulletsDE: ['<strong>Offline</strong>: Funktioniert auch ohne Internet'],
* extraBulletsEN: ['<strong>Offline</strong>: Works without internet'],
* });
* ```
*/
export function getPrivacyFAQs(locale: string, options: PrivacyFAQOptions): FAQItem[] {
const isDE = locale === 'de';
const extraDE = (options.extraBulletsDE || []).map((b) => `<li>${b}</li>`).join('');
const extraEN = (options.extraBulletsEN || []).map((b) => `<li>${b}</li>`).join('');
return [
{
id: 'faq-privacy',
question: isDE
? `Wie werden meine ${options.dataTypeDE} geschützt?`
: `How are my ${options.dataTypeEN} protected?`,
answer: isDE
? `<p>Deine Daten sind sicher bei ManaCore:</p><ul><li><strong>Verschlüsselung</strong>: Alle Daten werden bei der Übertragung (TLS) verschlüsselt</li><li><strong>DSGVO-konform</strong>: Wir halten uns an die EU-Datenschutzverordnung</li><li><strong>Kein Datenverkauf</strong>: Deine ${options.dataTypeDE} werden nie an Dritte verkauft oder für Werbung genutzt</li><li><strong>Self-Hosted</strong>: ManaCore läuft auf eigenen Servern — keine Abhängigkeit von Cloud-Anbietern wie AWS oder Google</li><li><strong>Open Source</strong>: Der Quellcode ist einsehbar — Transparenz statt Vertrauen</li>${extraDE}</ul>`
: `<p>Your data is secure with ManaCore:</p><ul><li><strong>Encryption</strong>: All data is encrypted in transit (TLS)</li><li><strong>GDPR compliant</strong>: We follow EU data protection regulations</li><li><strong>No data selling</strong>: Your ${options.dataTypeEN} are never sold to third parties or used for advertising</li><li><strong>Self-hosted</strong>: ManaCore runs on its own servers — no dependency on cloud providers like AWS or Google</li><li><strong>Open source</strong>: The source code is viewable — transparency over trust</li>${extraEN}</ul>`,
category: 'privacy',
order: 95,
language: isDE ? 'de' : 'en',
featured: true,
tags: isDE
? ['datenschutz', 'dsgvo', 'sicherheit', 'verschlüsselung']
: ['privacy', 'gdpr', 'security', 'encryption'],
},
{
id: 'faq-tech-independence',
question: isDE
? 'Wie unabhängig ist ManaCore von großen Tech-Konzernen?'
: 'How independent is ManaCore from big tech companies?',
answer: isDE
? '<p>ManaCore ist bewusst <strong>technologisch unabhängig</strong> aufgebaut:</p><ul><li><strong>Eigene Server</strong>: Alle Dienste laufen auf einem eigenen Mac Mini Server — kein AWS, kein Google Cloud, kein Azure</li><li><strong>Eigene KI</strong>: Lokale KI-Modelle (Gemma, Qwen, LLaVA) laufen auf unserem eigenen GPU-Server mit NVIDIA RTX 3090 — deine Daten verlassen nie unsere Infrastruktur</li><li><strong>Keine Google/Apple-Anmeldung</strong>: Eigenes Auth-System (Mana Core Auth) — kein OAuth über Drittanbieter, keine Tracking-Cookies von Google oder Facebook</li><li><strong>Eigene Suche</strong>: SearXNG Meta-Suchmaschine statt Google Search API</li><li><strong>Eigener Speicher</strong>: MinIO (S3-kompatibel) statt AWS S3 oder Google Cloud Storage</li><li><strong>Eigene Datenbank</strong>: PostgreSQL auf eigenem Server statt Cloud-Datenbanken</li><li><strong>Keine Tracking-SDKs</strong>: Kein Google Analytics, kein Facebook Pixel, kein Amplitude — eigene Analytics mit Umami</li></ul><p>Das Ziel: Ein digitales Zuhause, das dir gehört — nicht Big Tech.</p>'
: '<p>ManaCore is deliberately built to be <strong>technologically independent</strong>:</p><ul><li><strong>Own servers</strong>: All services run on a dedicated Mac Mini server — no AWS, no Google Cloud, no Azure</li><li><strong>Own AI</strong>: Local AI models (Gemma, Qwen, LLaVA) run on our own GPU server with NVIDIA RTX 3090 — your data never leaves our infrastructure</li><li><strong>No Google/Apple login</strong>: Own auth system (Mana Core Auth) — no OAuth via third parties, no tracking cookies from Google or Facebook</li><li><strong>Own search</strong>: SearXNG meta-search engine instead of Google Search API</li><li><strong>Own storage</strong>: MinIO (S3-compatible) instead of AWS S3 or Google Cloud Storage</li><li><strong>Own database</strong>: PostgreSQL on own server instead of cloud databases</li><li><strong>No tracking SDKs</strong>: No Google Analytics, no Facebook Pixel, no Amplitude — own analytics with Umami</li></ul><p>The goal: A digital home that belongs to you — not big tech.</p>',
category: 'privacy',
order: 96,
language: isDE ? 'de' : 'en',
featured: true,
tags: isDE
? ['unabhängig', 'self-hosted', 'eigene-server', 'kein-google', 'kein-aws']
: ['independent', 'self-hosted', 'own-servers', 'no-google', 'no-aws'],
},
{
id: 'faq-data-export',
question: isDE ? 'Kann ich meine Daten exportieren?' : 'Can I export my data?',
answer: isDE
? '<p>Ja! Du hast jederzeit volle Kontrolle über deine Daten:</p><ul><li><strong>Datenexport</strong>: Exportiere alle deine Daten in gängigen, offenen Formaten (JSON, CSV, vCard, iCal — je nach App)</li><li><strong>Datenportabilität</strong>: Gemäß DSGVO Art. 20 hast du das Recht auf Datenübertragbarkeit</li><li><strong>Keine Vendor-Lock-in</strong>: Deine Daten gehören dir — nicht uns. Wir nutzen offene Standards, damit du jederzeit zu einem anderen Dienst wechseln kannst</li><li><strong>Standard-Formate</strong>: Keine proprietären Formate — alles ist mit gängigen Tools les- und importierbar</li></ul>'
: '<p>Yes! You have full control over your data at all times:</p><ul><li><strong>Data export</strong>: Export all your data in common, open formats (JSON, CSV, vCard, iCal — depending on the app)</li><li><strong>Data portability</strong>: Under GDPR Art. 20 you have the right to data portability</li><li><strong>No vendor lock-in</strong>: Your data belongs to you — not us. We use open standards so you can switch to another service at any time</li><li><strong>Standard formats</strong>: No proprietary formats — everything is readable and importable with common tools</li></ul>',
category: 'privacy',
order: 97,
language: isDE ? 'de' : 'en',
tags: isDE
? ['export', 'daten', 'portabilität', 'dsgvo', 'offene-formate']
: ['export', 'data', 'portability', 'gdpr', 'open-formats'],
},
{
id: 'faq-account-deletion',
question: isDE ? 'Kann ich mein Konto löschen?' : 'Can I delete my account?',
answer: isDE
? '<p>Ja, jederzeit:</p><ul><li>Gehe zu <strong>Profil > Konto löschen</strong></li><li>Alle deine Daten werden <strong>dauerhaft und unwiderruflich</strong> gelöscht</li><li>Dies umfasst alle Inhalte, Einstellungen und persönlichen Daten</li><li>Geteilte Inhalte (z.B. Share-Links) werden ebenfalls deaktiviert</li></ul><p>Wir empfehlen, vorher einen Datenexport durchzuführen.</p>'
: '<p>Yes, at any time:</p><ul><li>Go to <strong>Profile > Delete Account</strong></li><li>All your data will be <strong>permanently and irreversibly</strong> deleted</li><li>This includes all content, settings, and personal data</li><li>Shared content (e.g., share links) will also be deactivated</li></ul><p>We recommend performing a data export beforehand.</p>',
category: 'privacy',
order: 98,
language: isDE ? 'de' : 'en',
tags: isDE
? ['konto', 'löschen', 'daten', 'entfernen']
: ['account', 'delete', 'data', 'remove'],
},
];
}

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

@ -0,0 +1,130 @@
/**
* Zod Schemas for Help Content Validation
* Used to validate Markdown frontmatter
*/
import { z } from 'zod';
// ============================================================================
// Base Schemas
// ============================================================================
export const supportedLanguageSchema = z.enum(['en', 'de', 'fr', 'it', 'es']);
export const faqCategorySchema = z.enum([
'general',
'account',
'billing',
'features',
'technical',
'privacy',
]);
export const featureCategorySchema = z.enum(['getting-started', 'core', 'advanced', 'integration']);
export const guideDifficultySchema = z.enum(['beginner', 'intermediate', 'advanced']);
export const changelogTypeSchema = z.enum(['major', 'minor', 'patch', 'beta']);
export const shortcutCategorySchema = z.enum(['navigation', 'editing', 'general', 'app-specific']);
// ============================================================================
// Content Item Schemas (for Frontmatter)
// ============================================================================
const baseContentSchema = z.object({
id: z.string().min(1),
language: supportedLanguageSchema,
order: z.number().optional().default(0),
appSpecific: z.boolean().optional().default(false),
apps: z.array(z.string()).optional().default([]),
lastUpdated: z.coerce.date().optional(),
});
export const faqFrontmatterSchema = baseContentSchema.extend({
question: z.string().min(1),
category: faqCategorySchema,
featured: z.boolean().optional().default(false),
tags: z.array(z.string()).optional().default([]),
relatedFaqs: z.array(z.string()).optional().default([]),
});
export const featureFrontmatterSchema = baseContentSchema.extend({
title: z.string().min(1),
description: z.string().min(1),
icon: z.string().optional(),
category: featureCategorySchema,
available: z.boolean().optional().default(true),
comingSoon: z.boolean().optional().default(false),
highlights: z.array(z.string()).optional().default([]),
learnMoreUrl: z.string().url().optional(),
});
export const shortcutSchema = z.object({
shortcut: z.string().min(1),
action: z.string().min(1),
description: z.string().optional(),
});
export const shortcutsFrontmatterSchema = baseContentSchema.extend({
category: shortcutCategorySchema,
title: z.string().optional(),
});
export const guideStepSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
duration: z.string().optional(),
});
export const gettingStartedFrontmatterSchema = baseContentSchema.extend({
title: z.string().min(1),
description: z.string().min(1),
difficulty: guideDifficultySchema,
estimatedTime: z.string().optional(),
prerequisites: z.array(z.string()).optional().default([]),
});
export const changelogChangeSchema = z.object({
title: z.string().min(1),
description: z.string().optional(),
category: z.string().optional(),
});
export const changelogFrontmatterSchema = baseContentSchema.extend({
version: z.string().min(1),
title: z.string().min(1),
releaseDate: z.coerce.date(),
type: changelogTypeSchema,
summary: z.string().optional(),
highlighted: z.boolean().optional().default(false),
changes: z
.object({
features: z.array(changelogChangeSchema).optional(),
improvements: z.array(changelogChangeSchema).optional(),
bugfixes: z.array(changelogChangeSchema).optional(),
})
.optional(),
platforms: z.array(z.string()).optional().default(['all']),
});
export const contactFrontmatterSchema = baseContentSchema.extend({
title: z.string().min(1),
supportEmail: z.string().email().optional(),
supportUrl: z.string().url().optional(),
discordUrl: z.string().url().optional(),
twitterUrl: z.string().url().optional(),
documentationUrl: z.string().url().optional(),
responseTime: z.string().optional(),
});
// ============================================================================
// Type Exports from Schemas
// ============================================================================
export type FAQFrontmatter = z.infer<typeof faqFrontmatterSchema>;
export type FeatureFrontmatter = z.infer<typeof featureFrontmatterSchema>;
export type ShortcutsFrontmatter = z.infer<typeof shortcutsFrontmatterSchema>;
export type GettingStartedFrontmatter = z.infer<typeof gettingStartedFrontmatterSchema>;
export type ChangelogFrontmatter = z.infer<typeof changelogFrontmatterSchema>;
export type ContactFrontmatter = z.infer<typeof contactFrontmatterSchema>;

View file

@ -0,0 +1,212 @@
/**
* Search Functionality using Fuse.js
* Provides full-text search across help content
*/
import Fuse, { type IFuseOptions } from 'fuse.js';
import type {
HelpContent,
FAQItem,
FeatureItem,
GettingStartedItem,
ChangelogItem,
} from './content';
import type {
SearchableItem,
SearchResult,
SearchOptions,
SearchIndexConfig,
} from './content';
import { generateExcerpt, stripHtml } from './parser.js';
import { sanitizeHtml } from './sanitize.js';
const DEFAULT_CONFIG: SearchIndexConfig = {
titleWeight: 2,
contentWeight: 1,
tagsWeight: 1.5,
threshold: 0.3,
minMatchCharLength: 2,
};
/**
* Convert HelpContent to searchable items
*/
export function flattenContentForSearch(content: HelpContent): SearchableItem[] {
const items: SearchableItem[] = [];
// FAQs
for (const faq of content.faq) {
items.push({
id: faq.id,
type: 'faq',
title: faq.question,
question: faq.question,
content: stripHtml(faq.answer),
tags: faq.tags,
});
}
// Features
for (const feature of content.features) {
items.push({
id: feature.id,
type: 'feature',
title: feature.title,
description: feature.description,
content: stripHtml(feature.content),
tags: feature.highlights,
});
}
// Getting Started Guides
for (const guide of content.gettingStarted) {
items.push({
id: guide.id,
type: 'guide',
title: guide.title,
description: guide.description,
content: stripHtml(guide.content),
});
}
// Changelog
for (const log of content.changelog) {
items.push({
id: log.id,
type: 'changelog',
title: `${log.version} - ${log.title}`,
content: stripHtml(log.content),
description: log.summary,
});
}
return items;
}
/**
* Build a Fuse.js search index from help content
*/
export function buildSearchIndex(
content: HelpContent,
config: SearchIndexConfig = DEFAULT_CONFIG
): Fuse<SearchableItem> {
const items = flattenContentForSearch(content);
const fuseOptions: IFuseOptions<SearchableItem> = {
keys: [
{ name: 'title', weight: config.titleWeight ?? 2 },
{ name: 'question', weight: config.titleWeight ?? 2 },
{ name: 'content', weight: config.contentWeight ?? 1 },
{ name: 'description', weight: config.contentWeight ?? 1 },
{ name: 'tags', weight: config.tagsWeight ?? 1.5 },
],
threshold: config.threshold ?? 0.3,
includeScore: true,
minMatchCharLength: config.minMatchCharLength ?? 2,
ignoreLocation: true,
};
return new Fuse(items, fuseOptions);
}
/**
* Find the original item from content
*/
function findOriginalItem(
id: string,
type: string,
content: HelpContent
): FAQItem | FeatureItem | GettingStartedItem | ChangelogItem | null {
switch (type) {
case 'faq':
return content.faq.find((item) => item.id === id) ?? null;
case 'feature':
return content.features.find((item) => item.id === id) ?? null;
case 'guide':
return content.gettingStarted.find((item) => item.id === id) ?? null;
case 'changelog':
return content.changelog.find((item) => item.id === id) ?? null;
default:
return null;
}
}
/**
* Highlight matching text in content
*/
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 safeText.replace(regex, '<mark>$1</mark>');
}
/**
* Search help content
*/
export function search(
index: Fuse<SearchableItem>,
query: string,
content: HelpContent,
options: SearchOptions = {}
): SearchResult[] {
const { limit = 10, threshold, types, appId } = options;
if (!query.trim()) {
return [];
}
let results = index.search(query, { limit: limit * 2 });
// Filter by type if specified
if (types && types.length > 0) {
results = results.filter((r) => types.includes(r.item.type));
}
// Filter by app if specified
if (appId) {
results = results.filter((r) => {
const originalItem = findOriginalItem(r.item.id, r.item.type, content);
if (!originalItem) return true;
if (!originalItem.appSpecific) return true;
return originalItem.apps?.includes(appId);
});
}
// Apply threshold filter if specified
if (threshold !== undefined) {
results = results.filter((r) => (r.score ?? 1) <= threshold);
}
// Limit results
results = results.slice(0, limit);
const mappedResults: SearchResult[] = [];
for (const result of results) {
const originalItem = findOriginalItem(result.item.id, result.item.type, content);
if (!originalItem) continue;
mappedResults.push({
id: result.item.id,
type: result.item.type,
title: result.item.title,
excerpt: generateExcerpt(result.item.content, 150),
score: result.score ?? 1,
highlight: highlightMatch(result.item.title, query),
item: originalItem,
});
}
return mappedResults;
}
/**
* Create a search function with pre-built index
*/
export function createSearcher(content: HelpContent, config?: SearchIndexConfig) {
const index = buildSearchIndex(content, config);
return (query: string, options?: SearchOptions) => search(index, query, content, options);
}

View file

@ -0,0 +1,71 @@
/**
* Search-related Type Definitions
*/
import type { FAQItem, FeatureItem, GettingStartedItem, ChangelogItem } from './content.js';
// ============================================================================
// Searchable Item Types
// ============================================================================
export type SearchableContentType = 'faq' | 'feature' | 'guide' | 'changelog';
export interface SearchableItem {
id: string;
type: SearchableContentType;
title: string;
content: string;
tags?: string[];
question?: string;
description?: string;
}
// ============================================================================
// Search Result Types
// ============================================================================
export interface SearchResult {
id: string;
type: SearchableContentType;
title: string;
excerpt: string;
score: number;
highlight?: string;
/** Original item reference */
item: FAQItem | FeatureItem | GettingStartedItem | ChangelogItem;
}
export interface SearchOptions {
/** Maximum number of results to return */
limit?: number;
/** Minimum score threshold (0-1, lower is more strict) */
threshold?: number;
/** Filter by content type */
types?: SearchableContentType[];
/** Filter by app ID (for app-specific content) */
appId?: string;
}
export interface SearchIndexConfig {
/** Weight for title/question field */
titleWeight?: number;
/** Weight for content field */
contentWeight?: number;
/** Weight for tags field */
tagsWeight?: number;
/** Fuzzy match threshold (0-1, lower is more strict) */
threshold?: number;
/** Minimum characters to start searching */
minMatchCharLength?: number;
}
// ============================================================================
// Search State Types (for UI)
// ============================================================================
export interface SearchState {
query: string;
results: SearchResult[];
isSearching: boolean;
hasSearched: boolean;
}

View file

@ -0,0 +1,175 @@
/**
* Default translations for the HelpPage component.
* Apps can use these directly or override individual fields.
*/
import type { HelpPageTranslations } from './types.js';
export const defaultTranslationsDE: HelpPageTranslations = {
title: 'Hilfe & Support',
subtitle: 'Finde Antworten und lerne die App kennen',
searchPlaceholder: 'Hilfe durchsuchen...',
sections: {
faq: 'FAQ',
features: 'Features',
shortcuts: 'Tastenkürzel',
gettingStarted: 'Erste Schritte',
changelog: 'Änderungen',
contact: 'Kontakt',
},
search: {
noResults: 'Keine Ergebnisse für "{query}"',
resultsCount: '{count} Ergebnisse',
searching: 'Suche...',
},
faq: {
noItems: 'Keine häufigen Fragen verfügbar.',
allCategories: 'Alle',
categories: {
general: 'Allgemein',
account: 'Konto',
billing: 'Abrechnung',
features: 'Funktionen',
technical: 'Technisch',
privacy: 'Datenschutz',
},
},
features: {
noItems: 'Keine Features verfügbar.',
comingSoon: 'Demnächst',
learnMore: 'Mehr erfahren',
},
shortcuts: {
noItems: 'Keine Tastenkürzel verfügbar.',
columns: {
shortcut: 'Kürzel',
action: 'Aktion',
description: 'Beschreibung',
},
},
gettingStarted: {
noItems: 'Keine Anleitungen verfügbar.',
estimatedTime: 'Geschätzte Zeit',
difficulty: {
beginner: 'Einsteiger',
intermediate: 'Fortgeschritten',
advanced: 'Experte',
},
},
changelog: {
noItems: 'Keine Änderungen verfügbar.',
showAll: 'Alle Versionen anzeigen',
types: {
major: 'Hauptversion',
minor: 'Nebenversion',
patch: 'Patch',
beta: 'Beta',
},
labels: {
features: 'Neue Funktionen',
improvements: 'Verbesserungen',
bugFixes: 'Fehlerbehebungen',
},
},
contact: {
noInfo: 'Keine Kontaktinformationen verfügbar.',
email: 'E-Mail senden',
responseTime: 'Antwortzeit',
},
common: {
back: 'Zurück',
showMore: 'Mehr anzeigen',
showLess: 'Weniger anzeigen',
},
};
export const defaultTranslationsEN: 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 frequently asked questions 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 keyboard 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 information available.',
email: 'Send email',
responseTime: 'Response time',
},
common: {
back: 'Back',
showMore: 'Show more',
showLess: 'Show less',
},
};
/**
* Get default translations for a locale, with optional overrides.
* Use this to customize only the fields that differ per app (e.g. subtitle).
*/
export function getHelpTranslations(
locale: string,
overrides?: Partial<HelpPageTranslations>
): HelpPageTranslations {
const base = locale === 'de' ? defaultTranslationsDE : defaultTranslationsEN;
if (!overrides) return base;
return { ...base, ...overrides };
}

View file

@ -0,0 +1,164 @@
/**
* Component Props and Translation Types
*/
import type { HelpContent, SearchResult } from './content';
// ============================================================================
// Translation Types
// ============================================================================
export interface HelpPageTranslations {
title: string;
subtitle?: string;
searchPlaceholder: string;
sections: {
faq: string;
features: string;
shortcuts: string;
gettingStarted: string;
changelog: string;
contact: string;
};
search: {
noResults: string;
resultsCount: string;
searching: string;
};
faq: {
noItems: string;
allCategories: string;
categories: {
general: string;
account: string;
billing: string;
features: string;
technical: string;
privacy: string;
};
};
features: {
noItems: string;
comingSoon: string;
learnMore: string;
};
shortcuts: {
noItems: string;
columns: {
shortcut: string;
action: string;
description: string;
};
};
gettingStarted: {
noItems: string;
estimatedTime: string;
difficulty: {
beginner: string;
intermediate: string;
advanced: string;
};
};
changelog: {
noItems: string;
showAll: string;
types: {
major: string;
minor: string;
patch: string;
beta: string;
};
labels: {
features: string;
improvements: string;
bugFixes: string;
};
};
contact: {
noInfo: string;
email: string;
responseTime: string;
};
common: {
back: string;
showMore: string;
showLess: string;
};
}
// ============================================================================
// Component Props
// ============================================================================
export type HelpSection =
| 'faq'
| 'features'
| 'shortcuts'
| 'getting-started'
| 'changelog'
| 'contact';
export interface HelpPageProps {
content: HelpContent;
appName: string;
appId: string;
translations: HelpPageTranslations;
searchEnabled?: boolean;
showFAQ?: boolean;
showFeatures?: boolean;
showShortcuts?: boolean;
showGettingStarted?: boolean;
showChangelog?: boolean;
showContact?: boolean;
defaultSection?: HelpSection;
showBackButton?: boolean;
onBack?: () => void;
onSectionChange?: (section: HelpSection) => void;
onSearch?: (query: string, results: SearchResult[]) => void;
}
export interface FAQSectionProps {
items: HelpContent['faq'];
translations: Pick<HelpPageTranslations, 'faq' | 'common'>;
showCategories?: boolean;
maxItems?: number;
expandFirst?: boolean;
}
export interface ChangelogEntryProps {
item: HelpContent['changelog'][number];
translations: Pick<HelpPageTranslations, 'changelog'>;
}
export interface FeaturesOverviewProps {
items: HelpContent['features'];
translations: Pick<HelpPageTranslations, 'features'>;
}
export interface KeyboardShortcutsProps {
items: HelpContent['shortcuts'];
translations: Pick<HelpPageTranslations, 'shortcuts'>;
}
export interface GettingStartedGuideProps {
items: HelpContent['gettingStarted'];
translations: Pick<HelpPageTranslations, 'gettingStarted'>;
}
export interface ChangelogSectionProps {
items: HelpContent['changelog'];
translations: Pick<HelpPageTranslations, 'changelog'>;
maxItems?: number;
}
export interface ContactSectionProps {
contact: HelpContent['contact'];
translations: Pick<HelpPageTranslations, 'contact'>;
}
export interface HelpSearchProps {
content: HelpContent;
translations: Pick<HelpPageTranslations, 'search'>;
placeholder?: string;
onResultSelect: (result: SearchResult) => void;
}

View file

@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"declaration": true
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}