diff --git a/src/pages/api/contribute/knowledgebase.ts b/src/pages/api/contribute/knowledgebase.ts index d44bfbe..ccdd56c 100644 --- a/src/pages/api/contribute/knowledgebase.ts +++ b/src/pages/api/contribute/knowledgebase.ts @@ -1,20 +1,20 @@ -// src/pages/api/contribute/knowledgebase.ts (UPDATED - Using consolidated API responses) +// src/pages/api/contribute/knowledgebase.ts - SIMPLIFIED: Issues only, minimal validation import type { APIRoute } from 'astro'; import { withAPIAuth } from '../../../utils/auth.js'; -import { apiResponse, apiError, apiServerError, apiSpecial, handleAPIRequest } from '../../../utils/api.js'; +import { apiResponse, apiError, apiServerError, handleAPIRequest } from '../../../utils/api.js'; import { GitContributionManager } from '../../../utils/gitContributions.js'; import { z } from 'zod'; export const prerender = false; -// Simplified schema for document-based contributions +// Simple schema - all fields optional except for having some content const KnowledgebaseContributionSchema = z.object({ - toolName: z.string().min(1), - title: z.string().min(1), - description: z.string().min(1), - content: z.string().default(''), - externalLink: z.string().url().optional().or(z.literal('')), - difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']), + toolName: z.string().optional().nullable().transform(val => val || undefined), + title: z.string().optional().nullable().transform(val => val || undefined), + description: z.string().optional().nullable().transform(val => val || undefined), + content: z.string().optional().nullable().transform(val => val || undefined), + externalLink: z.string().url().optional().nullable().catch(undefined), + difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']).optional().nullable().catch(undefined), categories: z.string().transform(str => { try { return JSON.parse(str); } catch { return []; } }).pipe(z.array(z.string()).default([])), @@ -23,32 +23,27 @@ const KnowledgebaseContributionSchema = z.object({ }).pipe(z.array(z.string()).default([])), uploadedFiles: z.string().transform(str => { try { return JSON.parse(str); } catch { return []; } - }).pipe(z.array(z.any()).default([])) + }).pipe(z.array(z.any()).default([])), + reason: z.string().optional().nullable().transform(val => val || undefined) }); interface KnowledgebaseContributionData { - type: 'add'; - article: { - toolName: string; - title: string; - description: string; - content: string; - externalLink?: string; - difficulty: string; - categories: string[]; - tags: string[]; - uploadedFiles: any[]; - }; - metadata: { - submitter: string; - reason?: string; - }; + toolName?: string; + title?: string; + description?: string; + content?: string; + externalLink?: string; + difficulty?: string; + categories: string[]; + tags: string[]; + uploadedFiles: any[]; + reason?: string; } // Rate limiting const rateLimitStore = new Map(); const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour -const RATE_LIMIT_MAX = 5; // Max 5 submissions per hour per user +const RATE_LIMIT_MAX = 3; // Max 3 submissions per hour per user function checkRateLimit(userEmail: string): boolean { const now = Date.now(); @@ -67,205 +62,23 @@ function checkRateLimit(userEmail: string): boolean { return true; } -function validateKnowledgebaseData(data: any): { valid: boolean; errors?: string[] } { - // Only check that they provided SOMETHING - const hasContent = data.content?.trim().length > 0; - const hasLink = data.externalLink?.trim().length > 0; - const hasFiles = data.uploadedFiles?.length > 0; +function validateKnowledgebaseData(data: KnowledgebaseContributionData): { valid: boolean; errors?: string[] } { + // Very minimal validation - just check that SOMETHING was provided + // Use nullish coalescing to avoid “possibly undefined” errors in strict mode + const hasContent = (data.content ?? '').trim().length > 0; + const hasLink = (data.externalLink ?? '').trim().length > 0; + const hasFiles = Array.isArray(data.uploadedFiles) && data.uploadedFiles.length > 0; + const hasTitle = (data.title ?? '').trim().length > 0; + const hasDescription = (data.description ?? '').trim().length > 0; - if (!hasContent && !hasLink && !hasFiles) { - return { valid: false, errors: ['Must provide content, link, or files'] }; + if (!hasContent && !hasLink && !hasFiles && !hasTitle && !hasDescription) { + return { + valid: false, + errors: ['Please provide at least a title, description, content, external link, or upload files'] + }; } - return { valid: true }; // That's it - maximum freedom -} - -function generateArticleSlug(title: string, toolName: string): string { - const baseSlug = title.toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - - const toolSlug = toolName.toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - - return `${toolSlug}-${baseSlug}`; -} - -function generateMarkdownContent(article: any): string { - const now = new Date(); - - // Generate frontmatter - const frontmatter = { - title: article.title, - tool_name: article.toolName, - description: article.description, - last_updated: now.toISOString().split('T')[0], - author: 'CC24-Team', - difficulty: article.difficulty, - categories: article.categories, - tags: article.tags, - review_status: 'pending_review' - }; - - const frontmatterYaml = Object.entries(frontmatter) - .map(([key, value]) => { - if (Array.isArray(value)) { - return `${key}: [${value.map(v => `"${v}"`).join(', ')}]`; - } else { - return `${key}: "${value}"`; - } - }) - .join('\n'); - - // Generate content sections - let content = `---\n${frontmatterYaml}\n---\n\n`; - content += `# ${article.title}\n\n`; - content += `${article.description}\n\n`; - - // Add user content if provided - if (article.content && article.content.trim().length > 0) { - content += `## Content\n\n${article.content}\n\n`; - } - - // Add external link if provided - if (article.externalLink && article.externalLink.trim().length > 0) { - content += `## External Resources\n\n`; - content += `- [External Documentation](${article.externalLink})\n\n`; - } - - // Add uploaded files section - if (article.uploadedFiles && article.uploadedFiles.length > 0) { - content += `## Uploaded Files\n\n`; - article.uploadedFiles.forEach((file: any) => { - const fileType = file.name.toLowerCase(); - let icon = '📎'; - if (fileType.includes('.pdf')) icon = '📄'; - else if (fileType.match(/\.(png|jpg|jpeg|gif|webp)$/)) icon = '🖼️'; - else if (fileType.match(/\.(mp4|webm|mov|avi)$/)) icon = '🎥'; - else if (fileType.match(/\.(doc|docx)$/)) icon = '📝'; - else if (fileType.match(/\.(zip|tar|gz)$/)) icon = '📦'; - - content += `- ${icon} [${file.name}](${file.url})\n`; - }); - content += '\n'; - } - - content += `---\n\n`; - content += `*This article was contributed via the CC24-Hub knowledge base submission system.*\n`; - - return content; -} - -// Extended GitContributionManager for knowledgebase -class KnowledgebaseGitManager extends GitContributionManager { - async submitKnowledgebaseContribution(data: KnowledgebaseContributionData): Promise<{success: boolean, message: string, prUrl?: string, branchName?: string}> { - const branchName = `kb-add-${Date.now()}`; - - try { - // Create branch - await this.createBranch(branchName); - - // Generate file content - const slug = generateArticleSlug(data.article.title, data.article.toolName); - const markdownContent = generateMarkdownContent(data.article); - - // Write article file - const articlePath = `src/content/knowledgebase/${slug}.md`; - await this.writeFile(articlePath, markdownContent); - - // Commit changes - const commitMessage = `Add knowledgebase contribution: ${data.article.title} - -Contributed by: ${data.metadata.submitter} -Tool: ${data.article.toolName} -Type: Document-based contribution -Difficulty: ${data.article.difficulty} -Categories: ${data.article.categories.join(', ')} -Files: ${data.article.uploadedFiles.length} uploaded -${data.article.externalLink ? `External Link: ${data.article.externalLink}` : ''} - -${data.metadata.reason ? `Reason: ${data.metadata.reason}` : ''}`; - - await this.commitChanges(commitMessage); - - // Push branch - await this.pushBranch(branchName); - - // Create pull request - const prUrl = await this.createPullRequest( - branchName, - `Add KB Article: ${data.article.title}`, - this.generateKnowledgebasePRDescription(data) - ); - - return { - success: true, - message: 'Knowledge base article submitted successfully', - prUrl, - branchName - }; - - } catch (error) { - // Cleanup on failure - try { - await this.deleteBranch(branchName); - } catch (cleanupError) { - console.error('Failed to cleanup branch:', cleanupError); - } - - throw error; - } - } - - private generateKnowledgebasePRDescription(data: KnowledgebaseContributionData): string { - return `## Knowledge Base Article: ${data.article.title} - -**Tool:** ${data.article.toolName} -**Submitted by:** ${data.metadata.submitter} -**Difficulty:** ${data.article.difficulty} - -### Article Details -- **Description:** ${data.article.description} -- **Categories:** ${data.article.categories.length > 0 ? data.article.categories.join(', ') : 'None'} -- **Tags:** ${data.article.tags.length > 0 ? data.article.tags.join(', ') : 'None'} -- **Content:** ${data.article.content && data.article.content.trim().length > 0 ? `~${data.article.content.split(/\s+/).length} words` : 'Document/link-based'} -- **Uploaded Files:** ${data.article.uploadedFiles.length} files -${data.article.externalLink ? `- **External Link:** ${data.article.externalLink}` : ''} - -${data.metadata.reason ? `### Reason for Contribution -${data.metadata.reason} - -` : ''}### Content Overview -${data.article.content && data.article.content.trim().length > 0 ? ` -**Provided Content:** -${data.article.content.substring(0, 200)}${data.article.content.length > 200 ? '...' : ''} -` : ''} -${data.article.uploadedFiles.length > 0 ? ` -**Uploaded Files:** -${data.article.uploadedFiles.map((file: any) => `- ${file.name} (${file.url})`).join('\n')} -` : ''} - -### Review Checklist -- [ ] Article content is accurate and helpful -- [ ] All uploaded files are accessible and appropriate -- [ ] External links are valid and safe -- [ ] Categories and tags are relevant -- [ ] Difficulty level is appropriate -- [ ] Content is well-organized and clear -- [ ] No sensitive or inappropriate content -- [ ] Proper attribution for external sources - -### Files Changed -- \`src/content/knowledgebase/${generateArticleSlug(data.article.title, data.article.toolName)}.md\` (new article) - ---- -*This contribution was submitted via the CC24-Hub document-based knowledge base system.*`; - } + return { valid: true }; } export const POST: APIRoute = async ({ request }) => { @@ -288,12 +101,12 @@ export const POST: APIRoute = async ({ request }) => { try { formData = await request.formData(); } catch (error) { - return apiSpecial.invalidJSON(); + return apiError.badRequest('Invalid form data'); } const rawData = Object.fromEntries(formData); - // Validate request data + // Validate and sanitize data let validatedData; try { validatedData = KnowledgebaseContributionSchema.parse(rawData); @@ -308,39 +121,40 @@ export const POST: APIRoute = async ({ request }) => { return apiError.badRequest('Invalid request data'); } - // Additional validation - const kbValidation = validateKnowledgebaseData(validatedData); - if (!kbValidation.valid) { - return apiError.validation('Content validation failed', kbValidation.errors); + // Basic content validation + const contentValidation = validateKnowledgebaseData(validatedData); + if (!contentValidation.valid) { + return apiError.validation('Content validation failed', contentValidation.errors); } - // Prepare contribution data - const contributionData: KnowledgebaseContributionData = { - type: 'add', - article: validatedData, - metadata: { - submitter: userEmail, - reason: rawData.reason as string || undefined - } - }; - - // Submit contribution via Git - const gitManager = new KnowledgebaseGitManager(); - const result = await gitManager.submitKnowledgebaseContribution(contributionData); - - if (result.success) { - console.log(`[KB CONTRIBUTION] "${validatedData.title}" for ${validatedData.toolName} by ${userEmail} - PR: ${result.prUrl}`); - - return apiResponse.created({ - message: result.message, - prUrl: result.prUrl, - branchName: result.branchName + // Submit as issue via Git + try { + const gitManager = new GitContributionManager(); + const result = await gitManager.submitKnowledgebaseContribution({ + ...validatedData, + submitter: userEmail }); - } else { - console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title}" by ${userEmail}: ${result.message}`); + + if (result.success) { + console.log(`[KB CONTRIBUTION] "${validatedData.title || 'Article'}" by ${userEmail} - Issue: ${result.issueUrl}`); + + return apiResponse.created({ + success: true, + message: result.message, + issueUrl: result.issueUrl, + issueNumber: result.issueNumber + }); + } else { + console.error(`[KB CONTRIBUTION FAILED] "${validatedData.title || 'Article'}" by ${userEmail}: ${result.message}`); + + return apiServerError.internal(`Contribution failed: ${result.message}`); + } + } catch (error) { + console.error(`[KB GIT ERROR] "${validatedData.title || 'Article'}" by ${userEmail}:`, error); - return apiServerError.internal(`Contribution failed: ${result.message}`); + const errorMessage = error instanceof Error ? error.message : 'Git operation failed'; + return apiServerError.internal(`Submission failed: ${errorMessage}`); } }, 'Knowledgebase contribution processing failed'); -}; \ No newline at end of file +}; diff --git a/src/pages/api/contribute/tool.ts b/src/pages/api/contribute/tool.ts index 3157ff0..0961f0d 100644 --- a/src/pages/api/contribute/tool.ts +++ b/src/pages/api/contribute/tool.ts @@ -45,7 +45,7 @@ const ContributionRequestSchema = z.object({ // Rate limiting const rateLimitStore = new Map(); const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour -const RATE_LIMIT_MAX = 5; // 5 contributions per hour per user +const RATE_LIMIT_MAX = 15; // 15 contributions per hour per user function checkRateLimit(userId: string): boolean { const now = Date.now(); diff --git a/src/pages/contribute/knowledgebase.astro b/src/pages/contribute/knowledgebase.astro index 4fd35f6..2a4a63b 100644 --- a/src/pages/contribute/knowledgebase.astro +++ b/src/pages/contribute/knowledgebase.astro @@ -1,5 +1,5 @@ --- -// src/pages/contribute/knowledgebase.astro +// src/pages/contribute/knowledgebase.astro - SIMPLIFIED: Issues only, minimal validation import BaseLayout from '../../layouts/BaseLayout.astro'; import { withAuth } from '../../utils/auth.js'; import { getToolsData } from '../../utils/dataService.js'; @@ -9,39 +9,60 @@ export const prerender = false; // Check authentication const authResult = await withAuth(Astro); if (authResult instanceof Response) { - return authResult; // Redirect to login + return authResult; } const { authenticated, userEmail, userId } = authResult; +// Load tools for reference (optional dropdown) const data = await getToolsData(); const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.name)); --- - -
-
- + +
+ + +
+

Submit Knowledge Base Article

+

Share documentation, tutorials, or insights about DFIR tools and methods. Your contribution will be submitted as an issue for maintainer review.

+ {userEmail &&

Submitting as: {userEmail}

} +
-
-
- -
- - + +
+ + + +
+

Basic Information

+ +
+
+ + +
+ +
+ + +
-
- + a.name.localeCompare(b.n />
-
- + - This will be shown in search results and article listings.
+
- + +
+

Content

+
- + - Optional if you're uploading documents. Use this for additional context or instructions.
-
- + a.name.localeCompare(b.n placeholder="https://example.com/documentation" class="form-input" /> - Link to external documentation, tutorials, or resources.
+
- + +
+

Upload Files

+
- +
- +
-

Click to select files or drag & drop

+

Click to select files or drag & drop

PDFs, documents, images, archives, etc.
- +
- -
- - + +
+

Additional Information

+ +
+
+ + + Comma-separated categories +
+ +
+ + + Comma-separated tags +
-
- - - Comma-separated categories that describe the article type. -
- - -
- - - Comma-separated tags for better searchability. -
- - -
- +
+
- -
- -
- -
+ +
+ Cancel + +
+
-
+ +
+
\ No newline at end of file diff --git a/src/utils/gitContributions.ts b/src/utils/gitContributions.ts index adb6b36..b4917d9 100644 --- a/src/utils/gitContributions.ts +++ b/src/utils/gitContributions.ts @@ -1,4 +1,4 @@ -// src/utils/gitContributions.ts - MINIMAL: Issues only, no filesystem/git operations +// src/utils/gitContributions.ts import { dump } from 'js-yaml'; export interface ContributionData { @@ -35,6 +35,20 @@ export interface GitOperationResult { issueNumber?: number; } +interface KnowledgebaseContribution { + toolName?: string; + title?: string; + description?: string; + content?: string; + externalLink?: string; + difficulty?: string; + categories?: string[]; + tags?: string[]; + uploadedFiles?: { name: string; url: string }[]; + submitter: string; + reason?: string; +} + interface GitConfig { provider: 'gitea' | 'github' | 'gitlab'; apiEndpoint: string; @@ -86,6 +100,20 @@ export class GitContributionManager { } } + async submitKnowledgebaseContribution(data: KnowledgebaseContribution): Promise { + try { + const issueUrl = await this.createKnowledgebaseIssue(data); + + return { + success: true, + message: 'Knowledge base article submitted as issue', + issueUrl + }; + } catch (error) { + throw new Error(`KB issue creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + private generateYAML(tool: any): string { // Clean tool object const cleanTool: any = { @@ -175,6 +203,61 @@ export class GitContributionManager { } } + private async createKnowledgebaseIssue(data: KnowledgebaseContribution): Promise { + const title = `Knowledge Base: ${data.title || data.toolName || 'New Article'}`; + const body = this.generateKnowledgebaseIssueBody(data); + + let apiUrl: string; + let requestBody: any; + + switch (this.config.provider) { + case 'gitea': + apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`; + requestBody = { title, body }; + break; + + case 'github': + apiUrl = `${this.config.apiEndpoint}/repos/${this.config.repoOwner}/${this.config.repoName}/issues`; + requestBody = { title, body }; + break; + + case 'gitlab': + apiUrl = `${this.config.apiEndpoint}/projects/${encodeURIComponent(this.config.repoOwner + '/' + this.config.repoName)}/issues`; + requestBody = { title, description: body }; + break; + + default: + throw new Error(`Unsupported git provider: ${this.config.provider}`); + } + + const response = await fetch(apiUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.apiToken}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`HTTP ${response.status}: ${errorText}`); + } + + const issueData = await response.json(); + + // Extract issue URL + switch (this.config.provider) { + case 'gitea': + case 'github': + return issueData.html_url || issueData.url; + case 'gitlab': + return issueData.web_url; + default: + throw new Error('Unknown provider response format'); + } + } + private generateIssueBody(data: ContributionData, toolYaml: string): string { return `## ${data.type === 'add' ? 'Add' : 'Update'} Tool: ${data.tool.name} @@ -210,4 +293,103 @@ ${data.metadata.reason} --- *Submitted via CC24-Hub contribution form*`; } -} \ No newline at end of file + + private generateKnowledgebaseIssueBody(data: KnowledgebaseContribution): string { + const sections: string[] = []; + + /* ------------------------------------------------------------------ */ + /* Header */ + /* ------------------------------------------------------------------ */ + sections.push(`## Knowledge Base Article: ${data.title ?? 'Untitled'}`); + sections.push(''); + sections.push(`**Submitted by:** ${data.submitter}`); + if (data.toolName) sections.push(`**Related Tool:** ${data.toolName}`); + if (data.difficulty) sections.push(`**Difficulty:** ${data.difficulty}`); + sections.push(''); + + /* ------------------------------------------------------------------ */ + /* Description */ + /* ------------------------------------------------------------------ */ + if (data.description) { + sections.push('### Description'); + sections.push(data.description); + sections.push(''); + } + + /* ------------------------------------------------------------------ */ + /* Content */ + /* ------------------------------------------------------------------ */ + if (data.content) { + sections.push('### Article Content'); + sections.push('```markdown'); + sections.push(data.content); + sections.push('```'); + sections.push(''); + } + + /* ------------------------------------------------------------------ */ + /* External resources */ + /* ------------------------------------------------------------------ */ + if (data.externalLink) { + sections.push('### External Resource'); + sections.push(`- [External Documentation](${data.externalLink})`); + sections.push(''); + } + + /* ------------------------------------------------------------------ */ + /* Uploaded files */ + /* ------------------------------------------------------------------ */ + if (Array.isArray(data.uploadedFiles) && data.uploadedFiles.length) { + sections.push('### Uploaded Files'); + data.uploadedFiles.forEach((file) => { + const fileType = file.name.toLowerCase(); + let icon = '📎'; + + if (fileType.endsWith('.pdf')) icon = '📄'; + else if (/(png|jpe?g|gif|webp)$/.test(fileType)) icon = '🖼️'; + else if (/(mp4|webm|mov|avi)$/.test(fileType)) icon = '🎥'; + else if (/(docx?)$/.test(fileType)) icon = '📝'; + else if (/(zip|tar|gz)$/.test(fileType)) icon = '📦'; + + sections.push(`- ${icon} [${file.name}](${file.url})`); + }); + sections.push(''); + } + + /* ------------------------------------------------------------------ */ + /* Categories & Tags */ + /* ------------------------------------------------------------------ */ + const hasCategories = Array.isArray(data.categories) && data.categories.length > 0; + const hasTags = Array.isArray(data.tags) && data.tags.length > 0; + + if (hasCategories || hasTags) { + sections.push('### Metadata'); + if (hasCategories) sections.push(`**Categories:** ${data.categories!.join(', ')}`); + if (hasTags) sections.push(`**Tags:** ${data.tags!.join(', ')}`); + sections.push(''); + } + + /* ------------------------------------------------------------------ */ + /* Reason */ + /* ------------------------------------------------------------------ */ + if (data.reason) { + sections.push('### Reason for Contribution'); + sections.push(data.reason); + sections.push(''); + } + + /* ------------------------------------------------------------------ */ + /* Footer */ + /* ------------------------------------------------------------------ */ + sections.push('### For Maintainers'); + sections.push('1. Review the content for quality and accuracy'); + sections.push('2. Create the appropriate markdown file in `src/content/knowledgebase/`'); + sections.push('3. Process any uploaded files as needed'); + sections.push('4. Close this issue when the article is published'); + sections.push(''); + sections.push('---'); + sections.push('*Submitted via CC24-Hub knowledge base contribution form*'); + + return sections.join('\n'); + } +} diff --git a/src/utils/markdown.ts b/src/utils/markdown.ts deleted file mode 100644 index f154be4..0000000 --- a/src/utils/markdown.ts +++ /dev/null @@ -1,332 +0,0 @@ -// src/utils/markdown.ts -// Simple markdown parser for client-side preview functionality -// Note: For production, consider using a proper markdown library like marked or markdown-it - -export interface MarkdownParseOptions { - sanitize?: boolean; - breaks?: boolean; - linkTarget?: string; -} - -export class SimpleMarkdownParser { - private options: MarkdownParseOptions; - - constructor(options: MarkdownParseOptions = {}) { - this.options = { - sanitize: true, - breaks: true, - linkTarget: '_blank', - ...options - }; - } - - /** - * Parse markdown to HTML - */ - parse(markdown: string): string { - if (!markdown || markdown.trim().length === 0) { - return ''; - } - - let html = markdown; - - // Handle code blocks first (to prevent processing content inside them) - html = this.parseCodeBlocks(html); - - // Parse headers - html = this.parseHeaders(html); - - // Parse bold and italic - html = this.parseEmphasis(html); - - // Parse links and images - html = this.parseLinksAndImages(html); - - // Parse inline code - html = this.parseInlineCode(html); - - // Parse lists - html = this.parseLists(html); - - // Parse blockquotes - html = this.parseBlockquotes(html); - - // Parse horizontal rules - html = this.parseHorizontalRules(html); - - // Parse line breaks and paragraphs - html = this.parseLineBreaks(html); - - // Sanitize if needed - if (this.options.sanitize) { - html = this.sanitizeHtml(html); - } - - return html.trim(); - } - - private parseCodeBlocks(html: string): string { - // Replace code blocks with placeholders to protect them - const codeBlocks: string[] = []; - - // Match ```code``` blocks - html = html.replace(/```([\s\S]*?)```/g, (match, code) => { - const index = codeBlocks.length; - const lang = code.split('\n')[0].trim(); - const content = code.includes('\n') ? code.substring(code.indexOf('\n') + 1) : code; - - codeBlocks.push(`
${this.escapeHtml(content.trim())}
`); - return `__CODEBLOCK_${index}__`; - }); - - // Restore code blocks at the end - codeBlocks.forEach((block, index) => { - html = html.replace(`__CODEBLOCK_${index}__`, block); - }); - - return html; - } - - private parseHeaders(html: string): string { - // H1-H6 headers - for (let i = 6; i >= 1; i--) { - const headerRegex = new RegExp(`^#{${i}}\\s+(.+)$`, 'gm'); - html = html.replace(headerRegex, `$1`); - } - return html; - } - - private parseEmphasis(html: string): string { - // Bold: **text** or __text__ - html = html.replace(/\*\*(.*?)\*\*/g, '$1'); - html = html.replace(/__(.*?)__/g, '$1'); - - // Italic: *text* or _text_ - html = html.replace(/\*(.*?)\*/g, '$1'); - html = html.replace(/_(.*?)_/g, '$1'); - - return html; - } - - private parseLinksAndImages(html: string): string { - const linkTarget = this.options.linkTarget ? ` target="${this.options.linkTarget}" rel="noopener noreferrer"` : ''; - - // Images: ![alt](src) - html = html.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, - '$1'); - - // Links: [text](url) - html = html.replace(/\[([^\]]*)\]\(([^)]*)\)/g, - `$1`); - - return html; - } - - private parseInlineCode(html: string): string { - // Inline code: `code` - html = html.replace(/`([^`]*)`/g, '$1'); - return html; - } - - private parseLists(html: string): string { - // Unordered lists - html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '
  • $1
  • '); - - // Ordered lists - html = html.replace(/^[\s]*\d+\.\s+(.+)$/gm, '
  • $1
  • '); - - // Wrap consecutive list items in ul/ol - html = html.replace(/(
  • .*<\/li>)/s, (match) => { - // Simple approach: assume unordered list - return `
      ${match}
    `; - }); - - return html; - } - - private parseBlockquotes(html: string): string { - // Blockquotes: > text - html = html.replace(/^>\s+(.+)$/gm, '
    $1
    '); - - // Merge consecutive blockquotes - html = html.replace(/(<\/blockquote>)\s*(
    )/g, ' '); - - return html; - } - - private parseHorizontalRules(html: string): string { - // Horizontal rules: --- or *** - html = html.replace(/^[-*]{3,}$/gm, '
    '); - return html; - } - - private parseLineBreaks(html: string): string { - if (!this.options.breaks) { - return html; - } - - // Split into paragraphs (double line breaks) - const paragraphs = html.split(/\n\s*\n/); - - const processedParagraphs = paragraphs.map(paragraph => { - const trimmed = paragraph.trim(); - - // Skip if already wrapped in HTML tag - if (trimmed.startsWith('<') && trimmed.endsWith('>')) { - return trimmed; - } - - // Single line breaks become
    - const withBreaks = trimmed.replace(/\n/g, '
    '); - - // Wrap in paragraph if not empty and not already a block element - if (withBreaks && !this.isBlockElement(withBreaks)) { - return `

    ${withBreaks}

    `; - } - - return withBreaks; - }); - - return processedParagraphs.filter(p => p.trim()).join('\n\n'); - } - - private isBlockElement(html: string): boolean { - const blockTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'div', 'ul', 'ol', 'li', 'blockquote', 'pre', 'hr']; - return blockTags.some(tag => html.startsWith(`<${tag}`)); - } - - private sanitizeHtml(html: string): string { - // Very basic HTML sanitization - for production use a proper library - const allowedTags = [ - 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', - 'p', 'br', 'strong', 'em', 'code', 'pre', - 'a', 'img', 'ul', 'ol', 'li', 'blockquote', 'hr' - ]; - - // Remove script tags and event handlers - html = html.replace(/]*>[\s\S]*?<\/script>/gi, ''); - html = html.replace(/\bon\w+\s*=\s*"[^"]*"/gi, ''); - html = html.replace(/\bon\w+\s*=\s*'[^']*'/gi, ''); - html = html.replace(/javascript:/gi, ''); - - // This is a very basic sanitizer - for production use a proper library like DOMPurify - return html; - } - - private escapeHtml(text: string): string { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; - } - - /** - * Extract plain text from markdown (for word/character counting) - */ - extractText(markdown: string): string { - // Remove markdown syntax and return plain text - let text = markdown; - - // Remove code blocks - text = text.replace(/```[\s\S]*?```/g, ''); - - // Remove inline code - text = text.replace(/`[^`]*`/g, ''); - - // Remove images - text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, ''); - - // Remove links but keep text - text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1'); - - // Remove headers - text = text.replace(/^#{1,6}\s+/gm, ''); - - // Remove emphasis - text = text.replace(/\*\*(.*?)\*\*/g, '$1'); - text = text.replace(/\*(.*?)\*/g, '$1'); - text = text.replace(/__(.*?)__/g, '$1'); - text = text.replace(/_(.*?)_/g, '$1'); - - // Remove blockquotes - text = text.replace(/^>\s+/gm, ''); - - // Remove list markers - text = text.replace(/^[\s]*[-*+]\s+/gm, ''); - text = text.replace(/^[\s]*\d+\.\s+/gm, ''); - - // Remove horizontal rules - text = text.replace(/^[-*]{3,}$/gm, ''); - - // Clean up whitespace - text = text.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim(); - - return text; - } - - /** - * Count words in markdown text - */ - countWords(markdown: string): number { - const plainText = this.extractText(markdown); - if (!plainText.trim()) return 0; - return plainText.trim().split(/\s+/).length; - } - - /** - * Count characters in markdown text - */ - countCharacters(markdown: string): number { - return this.extractText(markdown).length; - } - - /** - * Generate table of contents from headers - */ - generateTOC(markdown: string): Array<{level: number, text: string, anchor: string}> { - const headers: Array<{level: number, text: string, anchor: string}> = []; - const lines = markdown.split('\n'); - - lines.forEach(line => { - const headerMatch = line.match(/^(#{1,6})\s+(.+)$/); - if (headerMatch) { - const level = headerMatch[1].length; - const text = headerMatch[2].trim(); - const anchor = text.toLowerCase() - .replace(/[^a-z0-9\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, ''); - - headers.push({ level, text, anchor }); - } - }); - - return headers; - } -} - -// Convenience functions for global use -export function parseMarkdown(markdown: string, options?: MarkdownParseOptions): string { - const parser = new SimpleMarkdownParser(options); - return parser.parse(markdown); -} - -export function extractTextFromMarkdown(markdown: string): string { - const parser = new SimpleMarkdownParser(); - return parser.extractText(markdown); -} - -export function countWordsInMarkdown(markdown: string): number { - const parser = new SimpleMarkdownParser(); - return parser.countWords(markdown); -} - -export function countCharactersInMarkdown(markdown: string): number { - const parser = new SimpleMarkdownParser(); - return parser.countCharacters(markdown); -} - -export function generateMarkdownTOC(markdown: string): Array<{level: number, text: string, anchor: string}> { - const parser = new SimpleMarkdownParser(); - return parser.generateTOC(markdown); -} \ No newline at end of file