mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
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:
parent
e84371aa94
commit
ee42b6cc76
381 changed files with 39284 additions and 6275 deletions
32
packages/shared-help-content/content/contact/de/support.md
Normal file
32
packages/shared-help-content/content/contact/de/support.md
Normal 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
|
||||
32
packages/shared-help-content/content/contact/en/support.md
Normal file
32
packages/shared-help-content/content/contact/en/support.md
Normal 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
|
||||
21
packages/shared-help-content/content/faq/de/account.md
Normal file
21
packages/shared-help-content/content/faq/de/account.md
Normal 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.
|
||||
21
packages/shared-help-content/content/faq/de/billing.md
Normal file
21
packages/shared-help-content/content/faq/de/billing.md
Normal 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.
|
||||
23
packages/shared-help-content/content/faq/de/privacy.md
Normal file
23
packages/shared-help-content/content/faq/de/privacy.md
Normal 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).
|
||||
21
packages/shared-help-content/content/faq/en/account.md
Normal file
21
packages/shared-help-content/content/faq/en/account.md
Normal 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.
|
||||
21
packages/shared-help-content/content/faq/en/billing.md
Normal file
21
packages/shared-help-content/content/faq/en/billing.md
Normal 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.
|
||||
23
packages/shared-help-content/content/faq/en/privacy.md
Normal file
23
packages/shared-help-content/content/faq/en/privacy.md
Normal 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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
44
packages/shared-help-content/package.json
Normal file
44
packages/shared-help-content/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
52
packages/shared-help-content/src/index.ts
Normal file
52
packages/shared-help-content/src/index.ts
Normal 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';
|
||||
292
packages/shared-help-content/src/loader.ts
Normal file
292
packages/shared-help-content/src/loader.ts
Normal 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;
|
||||
}
|
||||
119
packages/shared-help-content/src/merger.ts
Normal file
119
packages/shared-help-content/src/merger.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
86
packages/shared-help-content/src/parser.ts
Normal file
86
packages/shared-help-content/src/parser.ts
Normal 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() + '...';
|
||||
}
|
||||
209
packages/shared-help-content/src/search.ts
Normal file
209
packages/shared-help-content/src/search.ts
Normal 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);
|
||||
}
|
||||
16
packages/shared-help-content/tsconfig.json
Normal file
16
packages/shared-help-content/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue