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:

-
    -
  1. Click the Sign Up button on the login page
  2. -
  3. Enter your email address and choose a secure password
  4. -
  5. Verify your email address by clicking the link we send you
  6. -
  7. Complete your profile setup
  8. -
-

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:

' + : '

We take your privacy seriously:

', + 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:

-
    -
  1. Klicke auf Registrieren auf der Anmeldeseite
  2. -
  3. Gib deine E-Mail-Adresse ein und wähle ein sicheres Passwort
  4. -
  5. Bestätige deine E-Mail-Adresse durch Klick auf den Link, den wir dir senden
  6. -
  7. Vervollständige dein Profil
  8. -
-

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:

-
    -
  1. Go to Settings > Subscription
  2. -
  3. Click Manage Subscription
  4. -
  5. Select Cancel Subscription
  6. -
  7. Confirm your cancellation
  8. -
-

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:

-
    -
  1. Gehe zu Einstellungen > Abonnement
  2. -
  3. Klicke auf Abo verwalten
  4. -
  5. Wähle Abo kündigen
  6. -
  7. Bestätige die Kündigung
  8. -
-

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:

- `, - 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:

- `, - 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

- `, - 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.title} -

-

- {t.subtitle} -

-
- - -
- -
- -
-
- - -
- {#each sections as section (section.id)} - - {/each} -
- - -
- - {#if activeSection === 'faq'} -
- {#each filteredFaqs as faq (faq.id)} -
- - - {#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}

-
-
-
- - -
- -
- -
-
-

- {t.email} -

-

support@mana.how

-
- -
- -
-
- -
-
-

- {$locale === 'de' ? 'Antwortzeit' : 'Response Time'} -

-

- {t.responseTime} -

-
-
-
- - - -
- {/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} - - {/if} + {/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} - +
      @@ -154,17 +170,21 @@ {:else}
        {#each results as result, index (result.id)} -
      • +