feat: major update with network graphs, themes, todo extensions, and more

## New Features

### Network Graph Visualization (Contacts, Calendar, Todo)
- D3.js force simulation for physics-based layout
- Zoom & pan with mouse/touchpad
- Keyboard shortcuts: +/- zoom, 0 reset, Esc deselect, / search, F focus
- Filtering by tags, company/location/project, connection strength
- Shared components in @manacore/shared-ui

### Central Tags API (mana-core-auth)
- CRUD endpoints for tags
- Schema: tags table with userId, name, color, app
- Shared tag components in @manacore/shared-ui

### Custom Themes System
- Theme editor with live preview and color picker
- Community theme gallery
- Theme sharing (public, unlisted, private)
- Backend API in mana-core-auth

### Todo App Extensions
- Glass-pill design for task input and items
- Settings page with 20+ preferences
- Task edit modal with inline editing
- Statistics page with visualizations
- PWA support with offline capabilities
- Multiple kanban boards

### Contacts App Features
- Duplicate detection
- Photo upload
- Batch operations
- Enhanced favorites page with multiple view modes
- Alphabet view improvements
- Search modal

### Help System
- @manacore/shared-help-content
- @manacore/shared-help-ui
- @manacore/shared-help-types

### Other Features
- Themes page for all apps
- Referral system frontend
- CommandBar (global search)
- Skeleton loaders
- Settings page improvements

## Bug Fixes
- Network graph simulation initialization
- Database schema TEXT for user_id columns (Better Auth compatibility)
- Various styling fixes

## Documentation
- Daily report for 2025-12-10
- CI/CD deployment guide

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-12-10 02:37:46 +01:00
parent e84371aa94
commit ee42b6cc76
381 changed files with 39284 additions and 6275 deletions

View file

@ -0,0 +1,32 @@
---
id: contact-support
title: Support kontaktieren
language: de
order: 1
supportEmail: support@manacore.app
responseTime: In der Regel innerhalb von 24 Stunden
---
## Brauchst du Hilfe?
Unser Support-Team hilft dir bei allen Fragen oder Problemen.
### Bevor du uns kontaktierst
- Schau in den **FAQ**-Bereich für schnelle Antworten
- Durchstöbere unsere **Erste Schritte** Anleitungen
- Nutze die Suchfunktion im Hilfe-Center
### Kontaktmöglichkeiten
- **E-Mail**: Schreib uns an support@manacore.app
- **Antwortzeit**: Wir antworten in der Regel innerhalb von 24 Stunden an Werktagen
### Was du angeben solltest
Wenn du den Support kontaktierst, gib bitte an:
1. Deine Konto-E-Mail-Adresse
2. Eine klare Beschreibung deines Problems
3. Schritte zur Reproduktion (falls zutreffend)
4. Screenshots, wenn sie bei der Erklärung helfen

View file

@ -0,0 +1,32 @@
---
id: contact-support
title: Contact Support
language: en
order: 1
supportEmail: support@manacore.app
responseTime: Usually within 24 hours
---
## 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
### Contact Options
- **Email**: Send us a message at support@manacore.app
- **Response Time**: We typically respond within 24 hours on business days
### What to Include
When contacting support, please include:
1. Your account email address
2. A clear description of your issue
3. Steps to reproduce the problem (if applicable)
4. Screenshots if they help explain the issue

View file

@ -0,0 +1,21 @@
---
id: faq-account-001
question: Wie erstelle ich ein Konto?
category: account
order: 1
language: de
featured: true
tags:
- konto
- registrierung
- anmeldung
---
Die Kontoerstellung ist einfach:
1. Klicke auf **Registrieren** auf der Anmeldeseite
2. Gib deine E-Mail-Adresse ein und wähle ein sicheres Passwort
3. Bestätige deine E-Mail-Adresse durch Klick auf den Link, den wir dir senden
4. Vervollständige dein Profil
Du kannst dich auch mit deinem Google- oder Apple-Konto registrieren, um schneller loszulegen.

View file

@ -0,0 +1,21 @@
---
id: faq-billing-001
question: Wie kann ich mein Abo kündigen?
category: billing
order: 1
language: de
featured: true
tags:
- abo
- kündigung
- abrechnung
---
Du kannst dein Abo jederzeit kündigen:
1. Gehe zu **Einstellungen** > **Abonnement**
2. Klicke auf **Abo verwalten**
3. Wähle **Abo kündigen**
4. Bestätige die Kündigung
Dein Abo bleibt bis zum Ende des aktuellen Abrechnungszeitraums aktiv. Nach der Kündigung erfolgen keine weiteren Abbuchungen.

View file

@ -0,0 +1,23 @@
---
id: faq-privacy-001
question: Wie werden meine Daten geschützt?
category: privacy
order: 1
language: de
featured: true
tags:
- datenschutz
- daten
- sicherheit
- dsgvo
---
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
Weitere Details findest du in unserer [Datenschutzerklärung](/privacy).

View file

@ -0,0 +1,21 @@
---
id: faq-account-001
question: How do I create an account?
category: account
order: 1
language: en
featured: true
tags:
- account
- registration
- signup
---
Creating an account is simple:
1. Click the **Sign Up** button on the login page
2. Enter your email address and choose a secure password
3. Verify your email address by clicking the link we send you
4. Complete your profile setup
You can also sign up using your Google or Apple account for faster registration.

View file

@ -0,0 +1,21 @@
---
id: faq-billing-001
question: How do I cancel my subscription?
category: billing
order: 1
language: en
featured: true
tags:
- subscription
- cancel
- billing
---
You can cancel your subscription at any time:
1. Go to **Settings** > **Subscription**
2. Click **Manage Subscription**
3. Select **Cancel Subscription**
4. Confirm your cancellation
Your subscription will remain active until the end of the current billing period. You won't be charged again after cancellation.

View file

@ -0,0 +1,23 @@
---
id: faq-privacy-001
question: How is my data protected?
category: privacy
order: 1
language: en
featured: true
tags:
- privacy
- data
- security
- gdpr
---
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
For more details, please read our [Privacy Policy](/privacy).

View file

@ -0,0 +1,29 @@
---
id: guide-welcome
title: Erste Schritte
description: Lerne die Grundlagen und starte schnell durch
difficulty: beginner
estimatedTime: 5 Minuten
order: 1
language: de
---
## 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 und wichtige Informationen auf einen Blick sehen kannst.
## Einstellungen anpassen
Besuche die Einstellungen, um dein Erlebnis zu personalisieren. Du kannst anpassen:
- **Design**: Wähle zwischen hell, dunkel oder Systemmodus
- **Sprache**: Wähle deine bevorzugte Sprache
- **Benachrichtigungen**: Konfiguriere, wie du benachrichtigt werden möchtest
## Hilfe jederzeit verfügbar
Wenn du Hilfe brauchst, klicke auf das Hilfe-Symbol oder besuche den Hilfe-Bereich. Du kannst auch unser Support-Team kontaktieren, wenn du Fragen hast.

View file

@ -0,0 +1,29 @@
---
id: guide-welcome
title: Getting Started
description: Learn the basics and get up and running quickly
difficulty: beginner
estimatedTime: 5 minutes
order: 1
language: en
---
## 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. You can adjust:
- **Theme**: Choose between light, dark, or system mode
- **Language**: Select your preferred language
- **Notifications**: Configure how you want to be notified
## Get Help Anytime
If you need assistance, click the help icon or visit the Help section. You can also reach out to our support team if you have questions.

View file

@ -0,0 +1,44 @@
{
"name": "@manacore/shared-help-content",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
},
"./loader": {
"types": "./src/loader.ts",
"default": "./src/loader.ts"
},
"./parser": {
"types": "./src/parser.ts",
"default": "./src/parser.ts"
},
"./search": {
"types": "./src/search.ts",
"default": "./src/search.ts"
},
"./merger": {
"types": "./src/merger.ts",
"default": "./src/merger.ts"
}
},
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/shared-help-types": "workspace:*",
"fuse.js": "^7.0.0",
"gray-matter": "^4.0.3",
"marked": "^15.0.4"
},
"devDependencies": {
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"zod": "^3.24.1"
}
}

View file

@ -0,0 +1,52 @@
/**
* @manacore/shared-help-content
* Central help content and utilities for loading, parsing, and searching
*/
// Parser utilities
export {
parseMarkdown,
parseMarkdownFiles,
stripHtml,
generateExcerpt,
type ParsedContent,
type ParseOptions,
} from './parser.js';
// Content loader
export {
parseFAQContent,
parseFeatureContent,
parseShortcutsContent,
parseGettingStartedContent,
parseChangelogContent,
parseContactContent,
loadHelpContentFromFiles,
type LoaderOptions,
} from './loader.js';
// Content merger
export { mergeContent, createEmptyContent } from './merger.js';
// Search functionality
export { buildSearchIndex, search, createSearcher, flattenContentForSearch } from './search.js';
// Re-export types for convenience
export type {
HelpContent,
FAQItem,
FeatureItem,
ShortcutsItem,
GettingStartedItem,
ChangelogItem,
ContactInfo,
SupportedLanguage,
MergeContentOptions,
} from '@manacore/shared-help-types';
export type {
SearchResult,
SearchOptions,
SearchIndexConfig,
SearchableItem,
} from '@manacore/shared-help-types';

View file

@ -0,0 +1,292 @@
/**
* Content Loader
* Utilities for loading help content from various sources
*/
import type {
HelpContent,
FAQItem,
FeatureItem,
ShortcutsItem,
GettingStartedItem,
ChangelogItem,
ContactInfo,
SupportedLanguage,
} from '@manacore/shared-help-types';
import {
faqFrontmatterSchema,
featureFrontmatterSchema,
shortcutsFrontmatterSchema,
gettingStartedFrontmatterSchema,
changelogFrontmatterSchema,
contactFrontmatterSchema,
} from '@manacore/shared-help-types';
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;
}
/**
* Parse FAQ content from raw Markdown
*/
export function parseFAQContent(rawContent: string): FAQItem {
const parsed = parseMarkdown(rawContent, faqFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
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,
answer: parsed.html,
};
}
/**
* Parse Feature content from raw Markdown
*/
export function parseFeatureContent(rawContent: string): FeatureItem {
const parsed = parseMarkdown(rawContent, featureFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
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,
content: parsed.html,
};
}
/**
* Parse Shortcuts content from raw Markdown
*/
export function parseShortcutsContent(rawContent: string): ShortcutsItem {
const parsed = parseMarkdown(rawContent, shortcutsFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
// 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,
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
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(rawContent, gettingStartedFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
// 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,
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');
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(rawContent, changelogFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
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,
content: parsed.html,
};
}
/**
* Parse Contact content from raw Markdown
*/
export function parseContactContent(rawContent: string): ContactInfo {
const parsed = parseMarkdown(rawContent, contactFrontmatterSchema);
const fm = parsed.frontmatter as Record<string, unknown>;
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,
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' } = 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 {
// Skip files that fail to parse
}
}
return content;
}

View file

@ -0,0 +1,119 @@
/**
* Content Merger
* Merges central help content with app-specific content
*/
import type { HelpContent, MergeContentOptions } from '@manacore/shared-help-types';
/**
* 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,86 @@
/**
* Markdown + Frontmatter Parser
* Parses Markdown files with YAML frontmatter
*/
import matter from 'gray-matter';
import { marked } from 'marked';
import type { ZodSchema } from 'zod';
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?: ZodSchema<T>,
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
const html = options.renderHtml ? (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?: ZodSchema<T>,
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,209 @@
/**
* 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 '@manacore/shared-help-types';
import type {
SearchableItem,
SearchResult,
SearchOptions,
SearchIndexConfig,
} from '@manacore/shared-help-types';
import { generateExcerpt, stripHtml } from './parser.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;
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.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,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}