diff --git a/apps/contacts/apps/web/src/lib/content/help/index.ts b/apps/contacts/apps/web/src/lib/content/help/index.ts
index 392c2761b..0e195a463 100644
--- a/apps/contacts/apps/web/src/lib/content/help/index.ts
+++ b/apps/contacts/apps/web/src/lib/content/help/index.ts
@@ -1,173 +1,172 @@
/**
- * Central help content loader for Contacts app
- * This file loads and merges the central help content from @manacore/shared-help-content
+ * Help content for Contacts app
+ * Uses @manacore/shared-help-types for type-safe content definition
*/
import type { HelpContent } from '@manacore/shared-help-types';
-import { createEmptyContent } from '@manacore/shared-help-content';
-/**
- * Central help content that applies to all Manacore apps
- * In a production setup, this would be loaded from the shared-help-content package's
- * Markdown files. For now, we provide the content inline for simplicity.
- */
-export const centralHelpContent: HelpContent = {
- faq: [
- // Account FAQs
- {
- id: 'faq-account-001',
- question: 'How do I create an account?',
- answer: `
Creating an account is simple:
-
- Click the Sign Up button on the login page
- Enter your email address and choose a secure password
- Verify your email address by clicking the link we send you
- Complete your profile setup
-
- You can also sign up using your Google or Apple account for faster registration.
`,
- category: 'account',
+export function getContactsHelpContent(locale: string): HelpContent {
+ const isDE = locale === 'de';
+
+ return {
+ faq: [
+ {
+ id: 'faq-import',
+ question: isDE
+ ? 'Wie importiere ich Kontakte aus Google?'
+ : 'How do I import contacts from Google?',
+ answer: isDE
+ ? 'Um Kontakte aus Google zu importieren: Gehe zu Daten > Import , wähle Google Kontakte, melde dich bei deinem Google-Konto an, wähle die Kontakte aus und klicke auf Importieren .
'
+ : 'To import contacts from Google: Go to Data > Import , select Google Contacts, sign in to your Google account, select the contacts you want to import, and click Import .
',
+ category: 'features',
+ order: 1,
+ language: isDE ? 'de' : 'en',
+ tags: isDE ? ['import', 'google', 'kontakte'] : ['import', 'google', 'contacts'],
+ },
+ {
+ id: 'faq-export',
+ question: isDE ? 'Wie exportiere ich meine Kontakte?' : 'How do I export my contacts?',
+ answer: isDE
+ ? 'Du kannst deine Kontakte in verschiedenen Formaten exportieren: Gehe zu Daten > Export , wähle das gewünschte Format (vCard, CSV, JSON) und klicke auf Exportieren .
'
+ : 'You can export your contacts in various formats: Go to Data > Export , select the desired format (vCard, CSV, JSON), and click Export .
',
+ category: 'features',
+ order: 2,
+ language: isDE ? 'de' : 'en',
+ tags: isDE ? ['export', 'vcard', 'csv'] : ['export', 'vcard', 'csv'],
+ },
+ {
+ id: 'faq-duplicates',
+ question: isDE ? 'Wie finde ich doppelte Kontakte?' : 'How do I find duplicate contacts?',
+ answer: isDE
+ ? 'Wir erkennen automatisch potenzielle Duplikate. Gehe zu Duplikate in der Seitenleiste, überprüfe die Vorschläge und wähle Zusammenführen oder Ignorieren .
'
+ : 'We automatically detect potential duplicates. Go to Duplicates in the sidebar, review the suggestions, and choose to Merge or Ignore .
',
+ category: 'features',
+ order: 3,
+ language: isDE ? 'de' : 'en',
+ tags: isDE ? ['duplikate', 'zusammenführen'] : ['duplicates', 'merge'],
+ },
+ {
+ id: 'faq-subscription',
+ question: isDE ? 'Wie kann ich mein Abo kündigen?' : 'How do I cancel my subscription?',
+ answer: isDE
+ ? 'Du kannst dein Abo jederzeit kündigen: Gehe zu Einstellungen > Abonnement > Abo verwalten > Abo kündigen . Dein Abo bleibt bis zum Ende des Abrechnungszeitraums aktiv.
'
+ : 'You can cancel your subscription at any time: Go to Settings > Subscription > Manage Subscription > Cancel Subscription . Your subscription will remain active until the end of the billing period.
',
+ category: 'billing',
+ order: 4,
+ language: isDE ? 'de' : 'en',
+ tags: isDE ? ['abo', 'kündigung', 'abrechnung'] : ['subscription', 'cancel', 'billing'],
+ },
+ {
+ id: 'faq-privacy',
+ question: isDE ? 'Wie werden meine Daten geschützt?' : 'How is my data protected?',
+ answer: isDE
+ ? 'Wir nehmen deinen Datenschutz ernst:
Verschlüsselung : Alle Daten werden bei der Übertragung (TLS) und im Ruhezustand verschlüsseltDSGVO-konform : Wir halten uns an die EU-DatenschutzverordnungKein Datenverkauf : Wir verkaufen niemals deine persönlichen DatenDatenexport : Du kannst jederzeit alle deine Daten exportierenKontolöschung : Du kannst dein Konto und alle Daten dauerhaft löschen '
+ : 'We take your privacy seriously:
Encryption : All data is encrypted in transit (TLS) and at restGDPR Compliant : We follow EU data protection regulationsNo Data Selling : We never sell your personal dataData Export : You can export all your data at any timeAccount Deletion : You can permanently delete your account and all data ',
+ category: 'privacy',
+ order: 5,
+ language: isDE ? 'de' : 'en',
+ featured: true,
+ tags: isDE ? ['datenschutz', 'dsgvo', 'sicherheit'] : ['privacy', 'gdpr', 'security'],
+ },
+ ],
+ features: [
+ {
+ id: 'feature-management',
+ title: isDE ? 'Kontaktverwaltung' : 'Contact Management',
+ description: isDE
+ ? 'Verwalte alle deine Kontakte an einem Ort'
+ : 'Manage all your contacts in one place',
+ icon: '👥',
+ category: 'core',
+ highlights: isDE
+ ? ['Unbegrenzte Kontakte', 'Benutzerdefinierte Felder', 'Tags und Kategorien']
+ : ['Unlimited contacts', 'Custom fields', 'Tags and categories'],
+ content: '',
+ order: 1,
+ language: isDE ? 'de' : 'en',
+ },
+ {
+ id: 'feature-import-export',
+ title: 'Import & Export',
+ description: isDE
+ ? 'Importiere und exportiere Kontakte in verschiedenen Formaten'
+ : 'Import and export contacts in various formats',
+ icon: '📤',
+ category: 'core',
+ highlights: isDE
+ ? ['Google Kontakte Sync', 'vCard Import/Export', 'CSV Import/Export']
+ : ['Google Contacts sync', 'vCard import/export', 'CSV import/export'],
+ content: '',
+ order: 2,
+ language: isDE ? 'de' : 'en',
+ },
+ {
+ id: 'feature-duplicates',
+ title: isDE ? 'Duplikat-Erkennung' : 'Duplicate Detection',
+ description: isDE
+ ? 'Automatische Erkennung und Zusammenführung von Duplikaten'
+ : 'Automatic detection and merging of duplicates',
+ icon: '🔍',
+ category: 'advanced',
+ highlights: isDE
+ ? ['Intelligente Erkennung', 'Ein-Klick Zusammenführung', 'Überprüfungsmodus']
+ : ['Smart detection', 'One-click merge', 'Review mode'],
+ content: '',
+ order: 3,
+ language: isDE ? 'de' : 'en',
+ },
+ {
+ id: 'feature-favorites',
+ title: isDE ? 'Favoriten' : 'Favorites',
+ description: isDE
+ ? 'Markiere wichtige Kontakte als Favoriten'
+ : 'Mark important contacts as favorites',
+ icon: '⭐',
+ category: 'core',
+ highlights: isDE
+ ? ['Schnellzugriff', 'Verschiedene Ansichten', 'Sortierung']
+ : ['Quick access', 'Multiple views', 'Sorting'],
+ content: '',
+ order: 4,
+ language: isDE ? 'de' : 'en',
+ },
+ ],
+ shortcuts: [
+ {
+ id: 'shortcuts-general',
+ category: 'general',
+ title: isDE ? 'Allgemein' : 'General',
+ language: isDE ? 'de' : 'en',
+ order: 1,
+ shortcuts: [
+ {
+ shortcut: 'Cmd/Ctrl + K',
+ action: isDE ? 'Suche öffnen' : 'Open search',
+ },
+ {
+ shortcut: 'Cmd/Ctrl + N',
+ action: isDE ? 'Neuer Kontakt' : 'New contact',
+ },
+ {
+ shortcut: 'Cmd/Ctrl + 1-6',
+ action: isDE ? 'Navigation' : 'Navigation',
+ },
+ ],
+ },
+ ],
+ gettingStarted: [],
+ changelog: [],
+ contact: {
+ id: 'contact-support',
+ title: isDE ? 'Support kontaktieren' : 'Contact Support',
+ content: isDE
+ ? 'Unser Support-Team hilft dir bei allen Fragen.
'
+ : 'Our support team is here to help you with any questions or issues.
',
+ language: isDE ? 'de' : 'en',
order: 1,
- language: 'en',
- featured: true,
- tags: ['account', 'registration', 'signup'],
+ supportEmail: 'support@mana.how',
+ documentationUrl: 'https://mana.how/docs',
+ responseTime: isDE ? 'Normalerweise innerhalb von 24 Stunden' : 'Usually within 24 hours',
},
- {
- id: 'faq-account-001',
- question: 'Wie erstelle ich ein Konto?',
- answer: `Die Kontoerstellung ist einfach:
-
- Klicke auf Registrieren auf der Anmeldeseite
- Gib deine E-Mail-Adresse ein und wähle ein sicheres Passwort
- Bestätige deine E-Mail-Adresse durch Klick auf den Link, den wir dir senden
- Vervollständige dein Profil
-
- Du kannst dich auch mit deinem Google- oder Apple-Konto registrieren, um schneller loszulegen.
`,
- category: 'account',
- order: 1,
- language: 'de',
- featured: true,
- tags: ['konto', 'registrierung', 'anmeldung'],
- },
- // Billing FAQs
- {
- id: 'faq-billing-001',
- question: 'How do I cancel my subscription?',
- answer: `You can cancel your subscription at any time:
-
- Go to Settings > Subscription
- Click Manage Subscription
- Select Cancel Subscription
- Confirm your cancellation
-
- Your subscription will remain active until the end of the current billing period. You won't be charged again after cancellation.
`,
- category: 'billing',
- order: 1,
- language: 'en',
- featured: true,
- tags: ['subscription', 'cancel', 'billing'],
- },
- {
- id: 'faq-billing-001',
- question: 'Wie kann ich mein Abo kündigen?',
- answer: `Du kannst dein Abo jederzeit kündigen:
-
- Gehe zu Einstellungen > Abonnement
- Klicke auf Abo verwalten
- Wähle Abo kündigen
- Bestätige die Kündigung
-
- Dein Abo bleibt bis zum Ende des aktuellen Abrechnungszeitraums aktiv. Nach der Kündigung erfolgen keine weiteren Abbuchungen.
`,
- category: 'billing',
- order: 1,
- language: 'de',
- featured: true,
- tags: ['abo', 'kündigung', 'abrechnung'],
- },
- // Privacy FAQs
- {
- id: 'faq-privacy-001',
- question: 'How is my data protected?',
- answer: `We take your privacy seriously:
-
- Encryption : All data is encrypted in transit (TLS) and at rest
- GDPR Compliant : We follow EU data protection regulations
- No Data Selling : We never sell your personal data to third parties
- Data Export : You can export all your data at any time
- Account Deletion : You can permanently delete your account and all associated data
- `,
- category: 'privacy',
- order: 1,
- language: 'en',
- featured: true,
- tags: ['privacy', 'data', 'security', 'gdpr'],
- },
- {
- id: 'faq-privacy-001',
- question: 'Wie werden meine Daten geschützt?',
- answer: `Wir nehmen deinen Datenschutz ernst:
-
- Verschlüsselung : Alle Daten werden bei der Übertragung (TLS) und im Ruhezustand verschlüsselt
- DSGVO-konform : Wir halten uns an die EU-Datenschutzverordnung
- Kein Datenverkauf : Wir verkaufen niemals deine persönlichen Daten an Dritte
- Datenexport : Du kannst jederzeit alle deine Daten exportieren
- Kontolöschung : Du kannst dein Konto und alle zugehörigen Daten dauerhaft löschen
- `,
- category: 'privacy',
- order: 1,
- language: 'de',
- featured: true,
- tags: ['datenschutz', 'daten', 'sicherheit', 'dsgvo'],
- },
- ],
- features: [],
- shortcuts: [],
- gettingStarted: [
- {
- id: 'guide-welcome',
- title: 'Getting Started',
- description: 'Learn the basics and get up and running quickly',
- content: `Create Your Account
- Start by creating your free account. You can sign up with your email address or use Google/Apple sign-in for a faster setup.
- Explore the Dashboard
- After logging in, you'll see your dashboard. This is your home base where you can access all features and see important information at a glance.
- Customize Your Settings
- Visit the Settings page to personalize your experience.
`,
- difficulty: 'beginner',
- estimatedTime: '5 minutes',
- order: 1,
- language: 'en',
- },
- {
- id: 'guide-welcome',
- title: 'Erste Schritte',
- description: 'Lerne die Grundlagen und starte schnell durch',
- content: `Konto erstellen
- Beginne mit der Erstellung deines kostenlosen Kontos. Du kannst dich mit deiner E-Mail-Adresse registrieren oder Google/Apple für eine schnellere Anmeldung nutzen.
- Dashboard erkunden
- Nach dem Einloggen siehst du dein Dashboard. Dies ist deine Zentrale, von der aus du auf alle Funktionen zugreifen kannst.
- Einstellungen anpassen
- Besuche die Einstellungen, um dein Erlebnis zu personalisieren.
`,
- difficulty: 'beginner',
- estimatedTime: '5 Minuten',
- order: 1,
- language: 'de',
- },
- ],
- changelog: [],
- contact: {
- id: 'contact-support',
- title: 'Contact Support',
- content: `Need Help?
- Our support team is here to help you with any questions or issues.
- Before Contacting Us
-
- Check the FAQ section for quick answers
- Browse our Getting Started guides
- Search the help center using the search bar
- `,
- language: 'en',
- order: 1,
- supportEmail: 'support@mana.how',
- responseTime: 'Usually within 24 hours',
- },
-};
+ };
+}
diff --git a/apps/contacts/apps/web/src/routes/(app)/help/+page.svelte b/apps/contacts/apps/web/src/routes/(app)/help/+page.svelte
index 2dc853a7c..5e80c0082 100644
--- a/apps/contacts/apps/web/src/routes/(app)/help/+page.svelte
+++ b/apps/contacts/apps/web/src/routes/(app)/help/+page.svelte
@@ -1,508 +1,187 @@
- {t.title} | Contacts
+ {translations.title} | Contacts
-
-
-
-
-
- {t.back}
-
-
-
- {t.title}
-
-
- {t.subtitle}
-
-
-
-
-
-
-
-
- {#each sections as section (section.id)}
- (activeSection = section.id)}
- >
- {#if section.id === 'faq'}
-
- {:else if section.id === 'features'}
-
- {:else if section.id === 'shortcuts'}
-
- {:else if section.id === 'contact'}
-
- {/if}
- {section.label}
-
- {/each}
-
-
-
-
-
- {#if activeSection === 'faq'}
-
- {#each filteredFaqs as faq (faq.id)}
-
-
toggleFaq(faq.id)}
- >
-
- {faq.question}
-
-
-
-
-
-
- {#if expandedFaqId === faq.id}
-
- {faq.answer}
-
- {/if}
-
- {/each}
-
- {#if filteredFaqs.length === 0}
-
-
-
- {$locale === 'de' ? 'Keine Ergebnisse gefunden' : 'No results found'}
-
-
- {/if}
-
- {/if}
-
-
- {#if activeSection === 'features'}
-
- {#each features as feature}
-
-
-
- {feature.icon}
-
-
- {feature.title}
-
-
-
-
- {feature.description}
-
-
-
- {#each feature.highlights as highlight}
-
-
-
-
- {highlight}
-
- {/each}
-
-
- {/each}
-
- {/if}
-
-
- {#if activeSection === 'shortcuts'}
-
-
- {#each shortcuts as shortcut, i}
-
- {shortcut.action}
-
- {shortcut.shortcut}
-
-
- {/each}
-
-
-
-
-
-
-
- {$locale === 'de'
- ? 'Tipp: Drücke Cmd/Ctrl + K, um jederzeit schnell zur Suche zu gelangen.'
- : 'Tip: Press Cmd/Ctrl + K to quickly access search anytime.'}
-
-
- {/if}
-
-
- {#if activeSection === 'contact'}
-
-
-
-
-
-
-
-
-
{t.contactTitle}
-
{t.contactDescription}
-
-
-
-
-
-
-
-
-
-
- {$locale === 'de' ? 'Weitere Ressourcen' : 'Additional Resources'}
-
-
-
-
- {/if}
-
-
+
diff --git a/packages/shared-help-content/package.json b/packages/shared-help-content/package.json
index fe90991f9..7756edee8 100644
--- a/packages/shared-help-content/package.json
+++ b/packages/shared-help-content/package.json
@@ -34,9 +34,11 @@
"@manacore/shared-help-types": "workspace:*",
"fuse.js": "^7.0.0",
"gray-matter": "^4.0.3",
+ "isomorphic-dompurify": "^3.7.1",
"marked": "^15.0.4"
},
"devDependencies": {
+ "@types/dompurify": "^3.2.0",
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"zod": "^3.24.1"
diff --git a/packages/shared-help-content/src/index.ts b/packages/shared-help-content/src/index.ts
index 3eedabeeb..e5a67171d 100644
--- a/packages/shared-help-content/src/index.ts
+++ b/packages/shared-help-content/src/index.ts
@@ -28,6 +28,9 @@ export {
// Content merger
export { mergeContent, createEmptyContent } from './merger.js';
+// Sanitization
+export { sanitizeHtml } from './sanitize.js';
+
// Search functionality
export { buildSearchIndex, search, createSearcher, flattenContentForSearch } from './search.js';
diff --git a/packages/shared-help-content/src/loader.ts b/packages/shared-help-content/src/loader.ts
index fce836ef4..9e18e67b1 100644
--- a/packages/shared-help-content/src/loader.ts
+++ b/packages/shared-help-content/src/loader.ts
@@ -20,6 +20,12 @@ import {
gettingStartedFrontmatterSchema,
changelogFrontmatterSchema,
contactFrontmatterSchema,
+ type FAQFrontmatter,
+ type FeatureFrontmatter,
+ type ShortcutsFrontmatter,
+ type GettingStartedFrontmatter,
+ type ChangelogFrontmatter,
+ type ContactFrontmatter,
} from '@manacore/shared-help-types';
import { parseMarkdown } from './parser.js';
import { createEmptyContent } from './merger.js';
@@ -29,26 +35,28 @@ export interface LoaderOptions {
locale: SupportedLanguage;
/** Fallback locale if content not found */
fallbackLocale?: SupportedLanguage;
+ /** Optional error callback */
+ onError?: (path: string, error: unknown) => void;
}
/**
* Parse FAQ content from raw Markdown
*/
export function parseFAQContent(rawContent: string): FAQItem {
- const parsed = parseMarkdown(rawContent, faqFrontmatterSchema);
- const fm = parsed.frontmatter as Record;
+ const parsed = parseMarkdown(rawContent, faqFrontmatterSchema);
+ const fm = parsed.frontmatter;
return {
- id: fm.id as string,
- language: fm.language as SupportedLanguage,
- order: fm.order as number | undefined,
- appSpecific: fm.appSpecific as boolean | undefined,
- apps: fm.apps as string[] | undefined,
- lastUpdated: fm.lastUpdated as Date | undefined,
- question: fm.question as string,
- category: fm.category as FAQItem['category'],
- featured: fm.featured as boolean | undefined,
- tags: fm.tags as string[] | undefined,
- relatedFaqs: fm.relatedFaqs as string[] | undefined,
+ id: fm.id,
+ language: fm.language,
+ order: fm.order,
+ appSpecific: fm.appSpecific,
+ apps: fm.apps,
+ lastUpdated: fm.lastUpdated,
+ question: fm.question,
+ category: fm.category,
+ featured: fm.featured,
+ tags: fm.tags,
+ relatedFaqs: fm.relatedFaqs,
answer: parsed.html,
};
}
@@ -57,23 +65,23 @@ export function parseFAQContent(rawContent: string): FAQItem {
* Parse Feature content from raw Markdown
*/
export function parseFeatureContent(rawContent: string): FeatureItem {
- const parsed = parseMarkdown(rawContent, featureFrontmatterSchema);
- const fm = parsed.frontmatter as Record;
+ const parsed = parseMarkdown(rawContent, featureFrontmatterSchema);
+ const fm = parsed.frontmatter;
return {
- id: fm.id as string,
- language: fm.language as SupportedLanguage,
- order: fm.order as number | undefined,
- appSpecific: fm.appSpecific as boolean | undefined,
- apps: fm.apps as string[] | undefined,
- lastUpdated: fm.lastUpdated as Date | undefined,
- title: fm.title as string,
- description: fm.description as string,
- icon: fm.icon as string | undefined,
- category: fm.category as FeatureItem['category'],
- available: fm.available as boolean | undefined,
- comingSoon: fm.comingSoon as boolean | undefined,
- highlights: fm.highlights as string[] | undefined,
- learnMoreUrl: fm.learnMoreUrl as string | undefined,
+ id: fm.id,
+ language: fm.language,
+ order: fm.order,
+ appSpecific: fm.appSpecific,
+ apps: fm.apps,
+ lastUpdated: fm.lastUpdated,
+ title: fm.title,
+ description: fm.description,
+ icon: fm.icon,
+ category: fm.category,
+ available: fm.available,
+ comingSoon: fm.comingSoon,
+ highlights: fm.highlights,
+ learnMoreUrl: fm.learnMoreUrl,
content: parsed.html,
};
}
@@ -82,21 +90,21 @@ export function parseFeatureContent(rawContent: string): FeatureItem {
* Parse Shortcuts content from raw Markdown
*/
export function parseShortcutsContent(rawContent: string): ShortcutsItem {
- const parsed = parseMarkdown(rawContent, shortcutsFrontmatterSchema);
- const fm = parsed.frontmatter as Record;
+ const parsed = parseMarkdown(rawContent, shortcutsFrontmatterSchema);
+ const fm = parsed.frontmatter;
// Parse markdown table to extract shortcuts
const shortcuts = parseShortcutsTable(parsed.content);
return {
- id: fm.id as string,
- language: fm.language as SupportedLanguage,
- order: fm.order as number | undefined,
- appSpecific: fm.appSpecific as boolean | undefined,
- apps: fm.apps as string[] | undefined,
- lastUpdated: fm.lastUpdated as Date | undefined,
- category: fm.category as ShortcutsItem['category'],
- title: fm.title as string | undefined,
+ id: fm.id,
+ language: fm.language,
+ order: fm.order,
+ appSpecific: fm.appSpecific,
+ apps: fm.apps,
+ lastUpdated: fm.lastUpdated,
+ category: fm.category,
+ title: fm.title,
shortcuts,
};
}
@@ -114,8 +122,8 @@ function parseShortcutsTable(
for (const line of lines) {
const trimmed = line.trim();
- // Skip header separator
- if (trimmed.match(/^\|[-:\s|]+\|$/)) {
+ // Skip header separator (flexible: allows spaces around dashes)
+ if (trimmed.match(/^\|[\s\-:|]+\|$/)) {
inTable = true;
continue;
}
@@ -147,24 +155,27 @@ function parseShortcutsTable(
* Parse Getting Started guide content from raw Markdown
*/
export function parseGettingStartedContent(rawContent: string): GettingStartedItem {
- const parsed = parseMarkdown(rawContent, gettingStartedFrontmatterSchema);
- const fm = parsed.frontmatter as Record;
+ const parsed = parseMarkdown(
+ rawContent,
+ gettingStartedFrontmatterSchema
+ );
+ const fm = parsed.frontmatter;
// Extract steps from content (h2 headers)
const steps = parseGuideSteps(parsed.content);
return {
- id: fm.id as string,
- language: fm.language as SupportedLanguage,
- order: fm.order as number | undefined,
- appSpecific: fm.appSpecific as boolean | undefined,
- apps: fm.apps as string[] | undefined,
- lastUpdated: fm.lastUpdated as Date | undefined,
- title: fm.title as string,
- description: fm.description as string,
- difficulty: fm.difficulty as GettingStartedItem['difficulty'],
- estimatedTime: fm.estimatedTime as string | undefined,
- prerequisites: fm.prerequisites as string[] | undefined,
+ id: fm.id,
+ language: fm.language,
+ order: fm.order,
+ appSpecific: fm.appSpecific,
+ apps: fm.apps,
+ lastUpdated: fm.lastUpdated,
+ title: fm.title,
+ description: fm.description,
+ difficulty: fm.difficulty,
+ estimatedTime: fm.estimatedTime,
+ prerequisites: fm.prerequisites,
content: parsed.html,
steps,
};
@@ -180,6 +191,10 @@ function parseGuideSteps(content: string): Array<{ title: string; content: strin
for (let i = 1; i < sections.length; i++) {
const section = sections[i];
const newlineIndex = section.indexOf('\n');
+ if (newlineIndex === -1) {
+ steps.push({ title: section.trim(), content: '' });
+ continue;
+ }
const title = section.substring(0, newlineIndex).trim();
const stepContent = section.substring(newlineIndex + 1).trim();
@@ -193,23 +208,23 @@ function parseGuideSteps(content: string): Array<{ title: string; content: strin
* Parse Changelog content from raw Markdown
*/
export function parseChangelogContent(rawContent: string): ChangelogItem {
- const parsed = parseMarkdown(rawContent, changelogFrontmatterSchema);
- const fm = parsed.frontmatter as Record;
+ const parsed = parseMarkdown(rawContent, changelogFrontmatterSchema);
+ const fm = parsed.frontmatter;
return {
- id: fm.id as string,
- language: fm.language as SupportedLanguage,
- order: fm.order as number | undefined,
- appSpecific: fm.appSpecific as boolean | undefined,
- apps: fm.apps as string[] | undefined,
- lastUpdated: fm.lastUpdated as Date | undefined,
- version: fm.version as string,
- title: fm.title as string,
- releaseDate: fm.releaseDate as Date,
- type: fm.type as ChangelogItem['type'],
- summary: fm.summary as string | undefined,
- highlighted: fm.highlighted as boolean | undefined,
- changes: fm.changes as ChangelogItem['changes'],
- platforms: fm.platforms as string[] | undefined,
+ id: fm.id,
+ language: fm.language,
+ order: fm.order,
+ appSpecific: fm.appSpecific,
+ apps: fm.apps,
+ lastUpdated: fm.lastUpdated,
+ version: fm.version,
+ title: fm.title,
+ releaseDate: fm.releaseDate,
+ type: fm.type,
+ summary: fm.summary,
+ highlighted: fm.highlighted,
+ changes: fm.changes,
+ platforms: fm.platforms,
content: parsed.html,
};
}
@@ -218,22 +233,22 @@ export function parseChangelogContent(rawContent: string): ChangelogItem {
* Parse Contact content from raw Markdown
*/
export function parseContactContent(rawContent: string): ContactInfo {
- const parsed = parseMarkdown(rawContent, contactFrontmatterSchema);
- const fm = parsed.frontmatter as Record;
+ const parsed = parseMarkdown(rawContent, contactFrontmatterSchema);
+ const fm = parsed.frontmatter;
return {
- id: fm.id as string,
- language: fm.language as SupportedLanguage,
- order: fm.order as number | undefined,
- appSpecific: fm.appSpecific as boolean | undefined,
- apps: fm.apps as string[] | undefined,
- lastUpdated: fm.lastUpdated as Date | undefined,
- title: fm.title as string,
- supportEmail: fm.supportEmail as string | undefined,
- supportUrl: fm.supportUrl as string | undefined,
- discordUrl: fm.discordUrl as string | undefined,
- twitterUrl: fm.twitterUrl as string | undefined,
- documentationUrl: fm.documentationUrl as string | undefined,
- responseTime: fm.responseTime as string | undefined,
+ id: fm.id,
+ language: fm.language,
+ order: fm.order,
+ appSpecific: fm.appSpecific,
+ apps: fm.apps,
+ lastUpdated: fm.lastUpdated,
+ title: fm.title,
+ supportEmail: fm.supportEmail,
+ supportUrl: fm.supportUrl,
+ discordUrl: fm.discordUrl,
+ twitterUrl: fm.twitterUrl,
+ documentationUrl: fm.documentationUrl,
+ responseTime: fm.responseTime,
content: parsed.html,
};
}
@@ -247,7 +262,7 @@ export function loadHelpContentFromFiles(
options: LoaderOptions
): HelpContent {
const content = createEmptyContent();
- const { locale, fallbackLocale = 'en' } = options;
+ const { locale, fallbackLocale = 'en', onError } = options;
for (const [path, rawContent] of Object.entries(files)) {
try {
@@ -283,8 +298,12 @@ export function loadHelpContentFromFiles(
content.contact = contact;
}
}
- } catch {
- // Skip files that fail to parse
+ } catch (error) {
+ if (onError) {
+ onError(path, error);
+ } else {
+ console.warn('[shared-help] Failed to parse:', path, error);
+ }
}
}
diff --git a/packages/shared-help-content/src/parser.ts b/packages/shared-help-content/src/parser.ts
index ce0780405..0f310a083 100644
--- a/packages/shared-help-content/src/parser.ts
+++ b/packages/shared-help-content/src/parser.ts
@@ -5,7 +5,8 @@
import matter from 'gray-matter';
import { marked } from 'marked';
-import type { ZodSchema } from 'zod';
+import type { ZodType, ZodTypeDef } from 'zod';
+import { sanitizeHtml } from './sanitize.js';
export interface ParsedContent {
frontmatter: T;
@@ -23,7 +24,7 @@ export interface ParseOptions {
*/
export function parseMarkdown(
rawContent: string,
- schema?: ZodSchema,
+ schema?: ZodType,
options: ParseOptions = { renderHtml: true }
): ParsedContent {
const { data, content } = matter(rawContent);
@@ -40,8 +41,8 @@ export function parseMarkdown(
frontmatter = data as T;
}
- // Render HTML if requested
- const html = options.renderHtml ? (marked.parse(content) as string) : '';
+ // Render HTML if requested, then sanitize to prevent XSS
+ const html = options.renderHtml ? sanitizeHtml(marked.parse(content) as string) : '';
return {
frontmatter,
@@ -55,7 +56,7 @@ export function parseMarkdown(
*/
export function parseMarkdownFiles(
files: { filename: string; content: string }[],
- schema?: ZodSchema,
+ schema?: ZodType,
options?: ParseOptions
): Array & { filename: string }> {
return files.map(({ filename, content }) => ({
diff --git a/packages/shared-help-content/src/sanitize.ts b/packages/shared-help-content/src/sanitize.ts
new file mode 100644
index 000000000..13c6d1341
--- /dev/null
+++ b/packages/shared-help-content/src/sanitize.ts
@@ -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'],
+ });
+}
diff --git a/packages/shared-help-content/src/search.ts b/packages/shared-help-content/src/search.ts
index 4519aa5ae..b4a93a3a9 100644
--- a/packages/shared-help-content/src/search.ts
+++ b/packages/shared-help-content/src/search.ts
@@ -18,6 +18,7 @@ import type {
SearchIndexConfig,
} from '@manacore/shared-help-types';
import { generateExcerpt, stripHtml } from './parser.js';
+import { sanitizeHtml } from './sanitize.js';
const DEFAULT_CONFIG: SearchIndexConfig = {
titleWeight: 2,
@@ -135,8 +136,10 @@ function findOriginalItem(
*/
function highlightMatch(text: string, query: string): string {
if (!query.trim()) return text;
+ // Sanitize text first, then apply highlighting
+ const safeText = sanitizeHtml(text);
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
- return text.replace(regex, '$1 ');
+ return safeText.replace(regex, '$1 ');
}
/**
diff --git a/packages/shared-help-ui/README.md b/packages/shared-help-ui/README.md
new file mode 100644
index 000000000..a37dd06ce
--- /dev/null
+++ b/packages/shared-help-ui/README.md
@@ -0,0 +1,249 @@
+# @manacore/shared-help-ui
+
+Shared Svelte 5 help page components for Manacore web apps.
+
+## Quick Start
+
+### 1. Add dependencies to your web app
+
+```json
+{
+ "dependencies": {
+ "@manacore/shared-help-ui": "workspace:*",
+ "@manacore/shared-help-content": "workspace:*",
+ "@manacore/shared-help-types": "workspace:*"
+ }
+}
+```
+
+### 2. Create help content
+
+Create `src/lib/content/help/index.ts`:
+
+```typescript
+import type { HelpContent } from '@manacore/shared-help-types';
+
+export function getAppHelpContent(locale: string): HelpContent {
+ const isDE = locale === 'de';
+ return {
+ faq: [
+ {
+ id: 'faq-example',
+ question: isDE ? 'Wie funktioniert X?' : 'How does X work?',
+ answer: isDE ? 'So funktioniert X...
' : 'X works like this...
',
+ category: 'features',
+ order: 1,
+ language: isDE ? 'de' : 'en',
+ tags: ['example'],
+ },
+ ],
+ features: [],
+ shortcuts: [
+ {
+ id: 'shortcuts-general',
+ category: 'general',
+ language: isDE ? 'de' : 'en',
+ order: 1,
+ shortcuts: [
+ { shortcut: 'Cmd/Ctrl + K', action: isDE ? 'Suche' : 'Search' },
+ ],
+ },
+ ],
+ gettingStarted: [],
+ changelog: [],
+ contact: {
+ id: 'contact',
+ title: isDE ? 'Support' : 'Support',
+ content: '',
+ language: isDE ? 'de' : 'en',
+ order: 1,
+ supportEmail: 'support@mana.how',
+ responseTime: isDE ? 'Innerhalb von 24 Stunden' : 'Within 24 hours',
+ },
+ };
+}
+```
+
+### 3. Create the help page route
+
+Create `src/routes/(app)/help/+page.svelte`:
+
+```svelte
+
+
+ goto('/')}
+ showGettingStarted={false}
+ showChangelog={false}
+/>
+```
+
+## HelpPage Props
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `content` | `HelpContent` | required | Help content data |
+| `appName` | `string` | required | Display name of the app |
+| `appId` | `string` | required | App identifier |
+| `translations` | `HelpPageTranslations` | required | UI translations |
+| `searchEnabled` | `boolean` | `true` | Show search bar |
+| `showFAQ` | `boolean` | `true` | Show FAQ section |
+| `showFeatures` | `boolean` | `true` | Show Features section |
+| `showShortcuts` | `boolean` | `true` | Show Shortcuts section |
+| `showGettingStarted` | `boolean` | `true` | Show Getting Started section |
+| `showChangelog` | `boolean` | `true` | Show Changelog section |
+| `showContact` | `boolean` | `true` | Show Contact section |
+| `defaultSection` | `HelpSection` | `'faq'` | Initially active section |
+| `showBackButton` | `boolean` | `false` | Show back navigation |
+| `onBack` | `() => void` | - | Back button callback |
+
+Sections with empty content are automatically hidden.
+
+## Translations Template
+
+```typescript
+const translations: HelpPageTranslations = {
+ title: 'Help & Support',
+ subtitle: 'Find answers and learn how to use the app',
+ searchPlaceholder: 'Search help...',
+ sections: {
+ faq: 'FAQ',
+ features: 'Features',
+ shortcuts: 'Shortcuts',
+ gettingStarted: 'Getting Started',
+ changelog: 'Changelog',
+ contact: 'Contact',
+ },
+ search: {
+ noResults: 'No results for "{query}"',
+ resultsCount: '{count} results',
+ searching: 'Searching...',
+ },
+ faq: {
+ noItems: 'No FAQs available.',
+ allCategories: 'All',
+ categories: {
+ general: 'General',
+ account: 'Account',
+ billing: 'Billing',
+ features: 'Features',
+ technical: 'Technical',
+ privacy: 'Privacy',
+ },
+ },
+ features: {
+ noItems: 'No features available.',
+ comingSoon: 'Coming Soon',
+ learnMore: 'Learn More',
+ },
+ shortcuts: {
+ noItems: 'No shortcuts available.',
+ columns: {
+ shortcut: 'Shortcut',
+ action: 'Action',
+ description: 'Description',
+ },
+ },
+ gettingStarted: {
+ noItems: 'No guides available.',
+ estimatedTime: 'Estimated time',
+ difficulty: {
+ beginner: 'Beginner',
+ intermediate: 'Intermediate',
+ advanced: 'Advanced',
+ },
+ },
+ changelog: {
+ noItems: 'No changelog available.',
+ showAll: 'Show all releases',
+ types: { major: 'Major', minor: 'Minor', patch: 'Patch', beta: 'Beta' },
+ labels: {
+ features: 'New Features',
+ improvements: 'Improvements',
+ bugFixes: 'Bug Fixes',
+ },
+ },
+ contact: {
+ noInfo: 'No contact info available.',
+ email: 'Send email',
+ responseTime: 'Response time',
+ },
+ common: {
+ back: 'Back',
+ showMore: 'Show more',
+ showLess: 'Show less',
+ },
+};
+```
+
+## Content Types
+
+### FAQ
+
+```typescript
+{
+ id: string; // Unique ID
+ question: string; // The question
+ answer: string; // HTML answer (auto-sanitized)
+ category: 'general' | 'account' | 'billing' | 'features' | 'technical' | 'privacy';
+ order: number;
+ language: 'en' | 'de' | 'fr' | 'it' | 'es';
+ featured?: boolean;
+ tags?: string[];
+}
+```
+
+### Shortcuts
+
+```typescript
+{
+ id: string;
+ category: 'navigation' | 'editing' | 'general' | 'app-specific';
+ language: string;
+ order: number;
+ shortcuts: Array<{
+ shortcut: string; // e.g. "Cmd/Ctrl + K"
+ action: string; // e.g. "Open search"
+ description?: string;
+ }>;
+}
+```
+
+### Contact
+
+```typescript
+{
+ id: string;
+ title: string;
+ content: string; // HTML (auto-sanitized)
+ language: string;
+ order: number;
+ supportEmail?: string;
+ supportUrl?: string;
+ discordUrl?: string;
+ documentationUrl?: string;
+ responseTime?: string;
+}
+```
+
+## Security
+
+All HTML content is automatically sanitized via `isomorphic-dompurify` in the parser layer.
+Content passed through `{@html}` in components is safe against XSS.
+
+## Reference Implementation
+
+See `apps/contacts/apps/web/src/routes/(app)/help/+page.svelte` for a complete working example.
diff --git a/packages/shared-help-ui/src/components/ChangelogEntry.svelte b/packages/shared-help-ui/src/components/ChangelogEntry.svelte
index f9cfde552..642faf8f7 100644
--- a/packages/shared-help-ui/src/components/ChangelogEntry.svelte
+++ b/packages/shared-help-ui/src/components/ChangelogEntry.svelte
@@ -1,12 +1,7 @@
@@ -83,7 +81,7 @@
{#if item.changes.features && item.changes.features.length > 0}
- New Features
+ {changeLabels.features}
{#each item.changes.features as change}
@@ -104,7 +102,7 @@
{#if item.changes.improvements && item.changes.improvements.length > 0}
- Improvements
+ {changeLabels.improvements}
{#each item.changes.improvements as change}
@@ -124,7 +122,9 @@
{#if item.changes.bugfixes && item.changes.bugfixes.length > 0}
-
Bug Fixes
+
+ {changeLabels.bugFixes}
+
{#each item.changes.bugfixes as change}
diff --git a/packages/shared-help-ui/src/components/ChangelogSection.svelte b/packages/shared-help-ui/src/components/ChangelogSection.svelte
index b30f45018..2bd921053 100644
--- a/packages/shared-help-ui/src/components/ChangelogSection.svelte
+++ b/packages/shared-help-ui/src/components/ChangelogSection.svelte
@@ -27,7 +27,7 @@
{:else}
{#each displayedItems() as item (item.id)}
-
+
{/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})
{/if}
diff --git a/packages/shared-help-ui/src/components/FAQSection.svelte b/packages/shared-help-ui/src/components/FAQSection.svelte
index c29623a56..5bf0d30a2 100644
--- a/packages/shared-help-ui/src/components/FAQSection.svelte
+++ b/packages/shared-help-ui/src/components/FAQSection.svelte
@@ -15,14 +15,8 @@
let selectedCategory = $state('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}
{#each categories as category}
- {@const hasItems = items.some((item) => item.category === category)}
- {#if hasItems}
- (selectedCategory = category)}
- >
- {getCategoryLabel(category)}
-
- {/if}
+ (selectedCategory = category)}
+ >
+ {getCategoryLabel(category)}
+
{/each}
{/if}
diff --git a/packages/shared-help-ui/src/components/GettingStartedGuide.svelte b/packages/shared-help-ui/src/components/GettingStartedGuide.svelte
index 0a1b26e02..f4a046071 100644
--- a/packages/shared-help-ui/src/components/GettingStartedGuide.svelte
+++ b/packages/shared-help-ui/src/components/GettingStartedGuide.svelte
@@ -5,9 +5,7 @@
let selectedGuideId = $state(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 @@
- {#if selectedGuide()}
- {@const guide = selectedGuide()}
+ {#if guide}
{guide.title}
diff --git a/packages/shared-help-ui/src/components/HelpSearch.svelte b/packages/shared-help-ui/src/components/HelpSearch.svelte
index a4fb50798..7a9fe56fb 100644
--- a/packages/shared-help-ui/src/components/HelpSearch.svelte
+++ b/packages/shared-help-ui/src/components/HelpSearch.svelte
@@ -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 @@
}
-
+
{#if isSearching}
-
+
{:else}
-
+
{#if results.length === 0}
@@ -154,17 +170,21 @@
{:else}
{#each results as result, index (result.id)}
-
+
selectResult(result)}
+ onmousedown={(e) => {
+ e.preventDefault();
+ selectResult(result);
+ }}
>
{getTypeIcon(result.type)}
diff --git a/packages/shared-help-ui/src/components/KeyboardShortcuts.svelte b/packages/shared-help-ui/src/components/KeyboardShortcuts.svelte
index cd8673895..f56e041cd 100644
--- a/packages/shared-help-ui/src/components/KeyboardShortcuts.svelte
+++ b/packages/shared-help-ui/src/components/KeyboardShortcuts.svelte
@@ -25,9 +25,15 @@
- Shortcut
- Action
- Description
+ {translations.shortcuts.columns.shortcut}
+ {translations.shortcuts.columns.action}
+ {translations.shortcuts.columns.description}
diff --git a/packages/shared-help-ui/src/index.ts b/packages/shared-help-ui/src/index.ts
index 6fda8226d..55b97330c 100644
--- a/packages/shared-help-ui/src/index.ts
+++ b/packages/shared-help-ui/src/index.ts
@@ -27,6 +27,7 @@ export type {
FeaturesOverviewProps,
KeyboardShortcutsProps,
GettingStartedGuideProps,
+ ChangelogEntryProps,
ChangelogSectionProps,
ContactSectionProps,
HelpSearchProps,
diff --git a/packages/shared-help-ui/src/pages/HelpPage.svelte b/packages/shared-help-ui/src/pages/HelpPage.svelte
index 4e57f682a..703f5ab97 100644
--- a/packages/shared-help-ui/src/pages/HelpPage.svelte
+++ b/packages/shared-help-ui/src/pages/HelpPage.svelte
@@ -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}
-
+
{#if activeSection === 'faq' && showFAQ}
{:else if activeSection === 'features' && showFeatures}
diff --git a/packages/shared-help-ui/src/types.ts b/packages/shared-help-ui/src/types.ts
index 00fdee74e..1a77f75bf 100644
--- a/packages/shared-help-ui/src/types.ts
+++ b/packages/shared-help-ui/src/types.ts
@@ -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;
+}
+
export interface FeaturesOverviewProps {
items: HelpContent['features'];
translations: Pick;