finalize knowledgebase commit

This commit is contained in:
overcuriousity 2025-07-25 23:23:38 +02:00
parent 6892aaf7de
commit fbe8d4d4e1
5 changed files with 521 additions and 849 deletions

View File

@ -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 type { APIRoute } from 'astro';
import { withAPIAuth } from '../../../utils/auth.js'; 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 { GitContributionManager } from '../../../utils/gitContributions.js';
import { z } from 'zod'; import { z } from 'zod';
export const prerender = false; export const prerender = false;
// Simplified schema for document-based contributions // Simple schema - all fields optional except for having some content
const KnowledgebaseContributionSchema = z.object({ const KnowledgebaseContributionSchema = z.object({
toolName: z.string().min(1), toolName: z.string().optional().nullable().transform(val => val || undefined),
title: z.string().min(1), title: z.string().optional().nullable().transform(val => val || undefined),
description: z.string().min(1), description: z.string().optional().nullable().transform(val => val || undefined),
content: z.string().default(''), content: z.string().optional().nullable().transform(val => val || undefined),
externalLink: z.string().url().optional().or(z.literal('')), externalLink: z.string().url().optional().nullable().catch(undefined),
difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']), difficulty: z.enum(['novice', 'beginner', 'intermediate', 'advanced', 'expert']).optional().nullable().catch(undefined),
categories: z.string().transform(str => { categories: z.string().transform(str => {
try { return JSON.parse(str); } catch { return []; } try { return JSON.parse(str); } catch { return []; }
}).pipe(z.array(z.string()).default([])), }).pipe(z.array(z.string()).default([])),
@ -23,32 +23,27 @@ const KnowledgebaseContributionSchema = z.object({
}).pipe(z.array(z.string()).default([])), }).pipe(z.array(z.string()).default([])),
uploadedFiles: z.string().transform(str => { uploadedFiles: z.string().transform(str => {
try { return JSON.parse(str); } catch { return []; } 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 { interface KnowledgebaseContributionData {
type: 'add'; toolName?: string;
article: { title?: string;
toolName: string; description?: string;
title: string; content?: string;
description: string; externalLink?: string;
content: string; difficulty?: string;
externalLink?: string; categories: string[];
difficulty: string; tags: string[];
categories: string[]; uploadedFiles: any[];
tags: string[]; reason?: string;
uploadedFiles: any[];
};
metadata: {
submitter: string;
reason?: string;
};
} }
// Rate limiting // Rate limiting
const rateLimitStore = new Map<string, { count: number; resetTime: number }>(); const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour 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 { function checkRateLimit(userEmail: string): boolean {
const now = Date.now(); const now = Date.now();
@ -67,205 +62,23 @@ function checkRateLimit(userEmail: string): boolean {
return true; return true;
} }
function validateKnowledgebaseData(data: any): { valid: boolean; errors?: string[] } { function validateKnowledgebaseData(data: KnowledgebaseContributionData): { valid: boolean; errors?: string[] } {
// Only check that they provided SOMETHING // Very minimal validation - just check that SOMETHING was provided
const hasContent = data.content?.trim().length > 0; // Use nullish coalescing to avoid “possibly undefined” errors in strict mode
const hasLink = data.externalLink?.trim().length > 0; const hasContent = (data.content ?? '').trim().length > 0;
const hasFiles = data.uploadedFiles?.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) { if (!hasContent && !hasLink && !hasFiles && !hasTitle && !hasDescription) {
return { valid: false, errors: ['Must provide content, link, or files'] }; 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 return { valid: true };
}
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.*`;
}
} }
export const POST: APIRoute = async ({ request }) => { export const POST: APIRoute = async ({ request }) => {
@ -288,12 +101,12 @@ export const POST: APIRoute = async ({ request }) => {
try { try {
formData = await request.formData(); formData = await request.formData();
} catch (error) { } catch (error) {
return apiSpecial.invalidJSON(); return apiError.badRequest('Invalid form data');
} }
const rawData = Object.fromEntries(formData); const rawData = Object.fromEntries(formData);
// Validate request data // Validate and sanitize data
let validatedData; let validatedData;
try { try {
validatedData = KnowledgebaseContributionSchema.parse(rawData); validatedData = KnowledgebaseContributionSchema.parse(rawData);
@ -308,39 +121,40 @@ export const POST: APIRoute = async ({ request }) => {
return apiError.badRequest('Invalid request data'); return apiError.badRequest('Invalid request data');
} }
// Additional validation // Basic content validation
const kbValidation = validateKnowledgebaseData(validatedData); const contentValidation = validateKnowledgebaseData(validatedData);
if (!kbValidation.valid) { if (!contentValidation.valid) {
return apiError.validation('Content validation failed', kbValidation.errors); return apiError.validation('Content validation failed', contentValidation.errors);
} }
// Prepare contribution data // Submit as issue via Git
const contributionData: KnowledgebaseContributionData = { try {
type: 'add', const gitManager = new GitContributionManager();
article: validatedData, const result = await gitManager.submitKnowledgebaseContribution({
metadata: { ...validatedData,
submitter: userEmail, 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
}); });
} 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'); }, 'Knowledgebase contribution processing failed');
}; };

View File

@ -45,7 +45,7 @@ const ContributionRequestSchema = z.object({
// Rate limiting // Rate limiting
const rateLimitStore = new Map<string, { count: number; resetTime: number }>(); const rateLimitStore = new Map<string, { count: number; resetTime: number }>();
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour 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 { function checkRateLimit(userId: string): boolean {
const now = Date.now(); const now = Date.now();

View File

@ -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 BaseLayout from '../../layouts/BaseLayout.astro';
import { withAuth } from '../../utils/auth.js'; import { withAuth } from '../../utils/auth.js';
import { getToolsData } from '../../utils/dataService.js'; import { getToolsData } from '../../utils/dataService.js';
@ -9,39 +9,60 @@ export const prerender = false;
// Check authentication // Check authentication
const authResult = await withAuth(Astro); const authResult = await withAuth(Astro);
if (authResult instanceof Response) { if (authResult instanceof Response) {
return authResult; // Redirect to login return authResult;
} }
const { authenticated, userEmail, userId } = authResult; const { authenticated, userEmail, userId } = authResult;
// Load tools for reference (optional dropdown)
const data = await getToolsData(); const data = await getToolsData();
const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.name)); const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.name));
--- ---
<BaseLayout title="Contribute Knowledge Base Article" description="Submit articles and documentation for the CC24-Hub knowledge base"> <BaseLayout title="Contribute Knowledge Base Article">
<main> <div class="container" style="max-width: 900px; margin: 0 auto; padding: 2rem 1rem;">
<div class="container">
<div class="page-header"> <!-- Header -->
<h1>Submit Knowledge Base Article</h1> <div class="hero-section">
<p>Upload documents, provide links, or submit articles for review by maintainers.</p> <h1>Submit Knowledge Base Article</h1>
</div> <p>Share documentation, tutorials, or insights about DFIR tools and methods. Your contribution will be submitted as an issue for maintainer review.</p>
{userEmail && <p><strong>Submitting as:</strong> {userEmail}</p>}
</div>
<div class="card"> <!-- Main Form -->
<form id="kb-form" style="padding: 2rem;"> <div class="card">
<!-- Tool Selection --> <form id="kb-form" novalidate>
<div class="form-group">
<label for="tool-name" class="form-label">Related Tool</label> <!-- Basic Information -->
<select id="tool-name" name="toolName" class="form-input"> <div class="form-section">
<option value="">Select a tool</option> <h3 class="section-title">Basic Information</h3>
{sortedTools.map(tool => (
<option value={tool.name}>{tool.name} ({tool.type})</option> <div class="form-grid-2">
))} <div class="form-group">
</select> <label for="tool-name" class="form-label">Related Tool (Optional)</label>
<select id="tool-name" name="toolName" class="form-input">
<option value="">Select a tool...</option>
{sortedTools.map(tool => (
<option value={tool.name}>{tool.name} ({tool.type})</option>
))}
</select>
</div>
<div class="form-group">
<label for="difficulty" class="form-label">Difficulty Level (Optional)</label>
<select id="difficulty" name="difficulty" class="form-input">
<option value="">Select difficulty...</option>
<option value="novice">Novice</option>
<option value="beginner">Beginner</option>
<option value="intermediate">Intermediate</option>
<option value="advanced">Advanced</option>
<option value="expert">Expert</option>
</select>
</div>
</div> </div>
<!-- Article Title -->
<div class="form-group"> <div class="form-group">
<label for="title" class="form-label">Article Title</label> <label for="title" class="form-label">Article Title (Optional)</label>
<input <input
type="text" type="text"
id="title" id="title"
@ -52,36 +73,36 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
/> />
</div> </div>
<!-- Description -->
<div class="form-group"> <div class="form-group">
<label for="description" class="form-label">Description</label> <label for="description" class="form-label">Description (Optional)</label>
<textarea <textarea
id="description" id="description"
name="description" name="description"
maxlength="300" maxlength="300"
rows="3" rows="3"
placeholder="Brief summary of what this article covers (20-300 characters)" placeholder="Brief summary of what this article covers"
class="form-input" class="form-input"
></textarea> ></textarea>
<small class="form-help">This will be shown in search results and article listings.</small>
</div> </div>
</div>
<!-- Article Content --> <!-- Content -->
<div class="form-section">
<h3 class="section-title">Content</h3>
<div class="form-group"> <div class="form-group">
<label for="content" class="form-label">Article Content</label> <label for="content" class="form-label">Article Content (Optional)</label>
<textarea <textarea
id="content" id="content"
name="content" name="content"
rows="8" rows="8"
placeholder="Provide article content, notes, or instructions. You can also upload documents below instead." placeholder="Provide your content, documentation, tutorial steps, or notes here..."
class="form-input" class="form-input"
></textarea> ></textarea>
<small class="form-help">Optional if you're uploading documents. Use this for additional context or instructions.</small>
</div> </div>
<!-- External Link -->
<div class="form-group"> <div class="form-group">
<label for="external-link" class="form-label">External Link</label> <label for="external-link" class="form-label">External Link (Optional)</label>
<input <input
type="url" type="url"
id="external-link" id="external-link"
@ -89,72 +110,66 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
placeholder="https://example.com/documentation" placeholder="https://example.com/documentation"
class="form-input" class="form-input"
/> />
<small class="form-help">Link to external documentation, tutorials, or resources.</small>
</div> </div>
</div>
<!-- File Upload Section --> <!-- File Upload -->
<div class="form-section">
<h3 class="section-title">Upload Files</h3>
<div class="form-group"> <div class="form-group">
<label class="form-label">Upload Documents</label> <label class="form-label">Documents, Images, Videos (Optional)</label>
<div class="upload-area" id="upload-area"> <div class="upload-area" id="upload-area">
<input type="file" id="file-input" multiple accept=".pdf,.doc,.docx,.txt,.md,.zip,.png,.jpg,.jpeg,.gif,.mp4" style="display: none;"> <input type="file" id="file-input" multiple accept=".pdf,.doc,.docx,.txt,.md,.zip,.png,.jpg,.jpeg,.gif,.mp4,.webm" style="display: none;">
<div class="upload-placeholder"> <div class="upload-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1"> <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/> <polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/> <line x1="12" y1="15" x2="12" y2="3"/>
</svg> </svg>
<p style="margin: 0.5rem 0 0 0;">Click to select files or drag & drop</p> <p>Click to select files or drag & drop</p>
<small>PDFs, documents, images, archives, etc.</small> <small>PDFs, documents, images, archives, etc.</small>
</div> </div>
</div> </div>
<div id="file-list" style="margin-top: 1rem; display: none;"> <div id="file-list" class="file-list" style="display: none;">
<h5>Selected Files</h5> <h5>Selected Files</h5>
<div id="files-container"></div> <div id="files-container"></div>
</div> </div>
</div> </div>
</div>
<!-- Difficulty Level --> <!-- Additional Information -->
<div class="form-group"> <div class="form-section">
<label for="difficulty" class="form-label">Difficulty Level</label> <h3 class="section-title">Additional Information</h3>
<select id="difficulty" name="difficulty" class="form-input">
<option value="">Select difficulty</option> <div class="form-grid-2">
<option value="novice">Novice - No prior experience needed</option> <div class="form-group">
<option value="beginner">Beginner - Basic familiarity helpful</option> <label for="categories" class="form-label">Categories (Optional)</label>
<option value="intermediate">Intermediate - Some experience required</option> <input
<option value="advanced">Advanced - Significant experience needed</option> type="text"
<option value="expert">Expert - Deep technical knowledge required</option> id="categories"
</select> name="categories"
placeholder="setup, configuration, troubleshooting"
class="form-input"
/>
<small class="form-help">Comma-separated categories</small>
</div>
<div class="form-group">
<label for="tags" class="form-label">Tags (Optional)</label>
<input
type="text"
id="tags"
name="tags"
placeholder="installation, docker, linux, windows"
class="form-input"
/>
<small class="form-help">Comma-separated tags</small>
</div>
</div> </div>
<!-- Categories -->
<div class="form-group"> <div class="form-group">
<label for="categories" class="form-label">Categories</label> <label for="reason" class="form-label">Reason for Contribution (Optional)</label>
<input
type="text"
id="categories"
name="categories"
placeholder="setup, configuration, troubleshooting"
class="form-input"
/>
<small class="form-help">Comma-separated categories that describe the article type.</small>
</div>
<!-- Tags -->
<div class="form-group">
<label for="tags" class="form-label">Tags</label>
<input
type="text"
id="tags"
name="tags"
placeholder="installation, docker, linux, windows"
class="form-input"
/>
<small class="form-help">Comma-separated tags for better searchability.</small>
</div>
<!-- Reason for Contribution -->
<div class="form-group">
<label for="reason" class="form-label">Reason for Contribution</label>
<textarea <textarea
id="reason" id="reason"
name="reason" name="reason"
@ -163,139 +178,131 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
class="form-input" class="form-input"
></textarea> ></textarea>
</div> </div>
</div>
<!-- Submit Button --> <!-- Submit Button -->
<div style="display: flex; gap: 1rem; margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--color-border);"> <div class="form-actions">
<button type="submit" id="submit-btn" class="btn btn-accent" style="flex: 1;"> <a href="/" class="btn btn-secondary">Cancel</a>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <button type="submit" id="submit-btn" class="btn btn-accent">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/> <span id="submit-text">Submit Article</span>
</svg> <span id="submit-spinner" style="display: none;">⏳</span>
Submit Article </button>
</button> </div>
</div> </form>
</form>
</div>
</div> </div>
<!-- Success Modal --> <!-- Success Modal -->
<div id="success-modal" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); z-index: 1000; align-items: center; justify-content: center;"> <div id="success-modal"
<div class="card" style="max-width: 500px; width: 90%; margin: 2rem;"> style="display:none; position:fixed; top:0; left:0; width:100%; height:100%;
<div style="text-align: center;"> background:rgba(0,0,0,.5); z-index:1000; align-items:center; justify-content:center;">
<div style="background-color: var(--color-accent); color: white; width: 64px; height: 64px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem;"> <div class="card" style="max-width:500px; width:90%; margin:2rem; text-align:center;">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <div style="font-size:3rem; margin-bottom:1rem;">✅</div>
<polyline points="20,6 9,17 4,12"/> <h3 style="margin-bottom:1rem;">Article Submitted!</h3>
</svg> <p id="success-message" style="margin-bottom:1.5rem;">
</div> Your knowledgebase article has been submitted as an issue for review by maintainers.
<h3 style="margin-bottom: 1rem;">Article Submitted!</h3> </p>
<p id="success-message" style="margin-bottom: 1.5rem; line-height: 1.5;"></p> <div style="display:flex; gap:1rem; justify-content:center;">
<div style="display: flex; gap: 1rem; justify-content: center;"> <a id="issue-link" href="#" target="_blank" class="btn btn-primary" style="display:none;">View Issue</a>
<a id="pr-link" href="#" target="_blank" class="btn btn-primary" style="display: none;">View Pull Request</a> <a href="/" class="btn btn-secondary">Back to Home</a>
<a href="/" class="btn btn-secondary">Back to Home</a>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- Message Display --> <!-- Message Container -->
<div id="message-container" style="position: fixed; top: 20px; right: 20px; z-index: 1000;"></div> <div id="message-container" class="message-container"></div>
</main> </div>
</BaseLayout> </BaseLayout>
<script> <script>
// FIXED: Properly typed interfaces for TypeScript compliance interface UploadedFile {
interface UploadedFile { id: string;
id: string; file: File;
file: File; name: string;
name: string; uploaded: boolean;
uploaded: boolean; url?: string;
url?: string; }
declare global {
interface Window {
removeFile: (fileId: string) => void;
}
}
class KnowledgebaseForm {
private uploadedFiles: UploadedFile[] = [];
private isSubmitting = false;
private elements: Record<string, HTMLElement | null> = {};
constructor() {
this.init();
} }
// Extend Window interface for global functions private init() {
declare global { // Get elements
interface Window { this.elements = {
removeFile: (fileId: string) => void; form: document.getElementById('kb-form'),
submitBtn: document.getElementById('submit-btn'),
submitText: document.getElementById('submit-text'),
submitSpinner: document.getElementById('submit-spinner'),
fileInput: document.getElementById('file-input'),
uploadArea: document.getElementById('upload-area'),
fileList: document.getElementById('file-list'),
filesContainer: document.getElementById('files-container'),
successModal: document.getElementById('success-modal')
};
if (!this.elements.form || !this.elements.submitBtn) {
console.error('[KB FORM] Critical elements missing');
return;
} }
this.setupEventListeners();
this.setupFileUpload();
} }
// FIXED: State management with proper typing private setupEventListeners() {
let uploadedFiles: UploadedFile[] = []; // Form submission
this.elements.form?.addEventListener('submit', (e) => {
// FIXED: Properly typed element selection with specific HTML element types
const elements = {
form: document.getElementById('kb-form') as HTMLFormElement | null,
submitBtn: document.getElementById('submit-btn') as HTMLButtonElement | null,
fileInput: document.getElementById('file-input') as HTMLInputElement | null,
uploadArea: document.getElementById('upload-area') as HTMLElement | null,
fileList: document.getElementById('file-list') as HTMLElement | null,
filesContainer: document.getElementById('files-container') as HTMLElement | null
};
// Check for critical elements
const criticalElements: Array<keyof typeof elements> = ['form', 'submitBtn'];
const missingElements = criticalElements.filter(key => !elements[key]);
if (missingElements.length > 0) {
console.error('[KB FORM ERROR] Missing critical elements:', missingElements);
} else {
console.log('[KB FORM DEBUG] All critical elements found, initializing form');
}
function validateForm(): boolean {
return true; // Always return true - no validation
}
// Update submit button state with null checks
function updateSubmitButton(): void {
if (elements.submitBtn) {
const isValid = validateForm();
elements.submitBtn.disabled = !isValid;
console.log('[KB FORM DEBUG] Button state:', isValid ? 'enabled' : 'disabled');
}
}
// File upload handling with proper null checks
function setupFileUpload(): void {
if (!elements.fileInput || !elements.uploadArea) return;
elements.uploadArea.addEventListener('click', () => {
if (elements.fileInput) {
elements.fileInput.click();
}
});
elements.uploadArea.addEventListener('dragover', (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
if (elements.uploadArea) { if (!this.isSubmitting) {
elements.uploadArea.style.borderColor = 'var(--color-accent)'; this.handleSubmit();
} }
}); });
}
elements.uploadArea.addEventListener('dragleave', () => {
if (elements.uploadArea) { private setupFileUpload() {
elements.uploadArea.style.borderColor = 'var(--color-border)'; if (!this.elements.fileInput || !this.elements.uploadArea) return;
}
this.elements.uploadArea.addEventListener('click', () => {
(this.elements.fileInput as HTMLInputElement)?.click();
}); });
elements.uploadArea.addEventListener('drop', (e: DragEvent) => { this.elements.uploadArea.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
if (elements.uploadArea) { this.elements.uploadArea?.classList.add('drag-over');
elements.uploadArea.style.borderColor = 'var(--color-border)'; });
}
this.elements.uploadArea.addEventListener('dragleave', () => {
this.elements.uploadArea?.classList.remove('drag-over');
});
this.elements.uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
this.elements.uploadArea?.classList.remove('drag-over');
if (e.dataTransfer?.files) { if (e.dataTransfer?.files) {
handleFiles(Array.from(e.dataTransfer.files)); this.handleFiles(Array.from(e.dataTransfer.files));
} }
}); });
elements.fileInput.addEventListener('change', (e: Event) => { this.elements.fileInput.addEventListener('change', (e) => {
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
if (target?.files) { if (target?.files) {
handleFiles(Array.from(target.files)); this.handleFiles(Array.from(target.files));
} }
}); });
} }
function handleFiles(files: File[]): void { private handleFiles(files: File[]) {
files.forEach(file => { files.forEach(file => {
const fileId = Date.now() + '-' + Math.random().toString(36).substr(2, 9); const fileId = Date.now() + '-' + Math.random().toString(36).substr(2, 9);
const newFile: UploadedFile = { const newFile: UploadedFile = {
@ -304,15 +311,14 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
name: file.name, name: file.name,
uploaded: false uploaded: false
}; };
uploadedFiles.push(newFile); this.uploadedFiles.push(newFile);
uploadFile(fileId); this.uploadFile(fileId);
}); });
renderFileList(); this.renderFileList();
updateSubmitButton();
} }
async function uploadFile(fileId: string): Promise<void> { private async uploadFile(fileId: string) {
const fileItem = uploadedFiles.find(f => f.id === fileId); const fileItem = this.uploadedFiles.find(f => f.id === fileId);
if (!fileItem) return; if (!fileItem) return;
const formData = new FormData(); const formData = new FormData();
@ -329,63 +335,60 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
const result = await response.json(); const result = await response.json();
fileItem.uploaded = true; fileItem.uploaded = true;
fileItem.url = result.url; fileItem.url = result.url;
renderFileList(); this.renderFileList();
} else { } else {
throw new Error('Upload failed'); throw new Error('Upload failed');
} }
} catch (error) { } catch (error) {
showMessage('error', `Failed to upload ${fileItem.name}`); this.showMessage('error', `Failed to upload ${fileItem.name}`);
removeFile(fileId); this.removeFile(fileId);
} }
} }
function removeFile(fileId: string): void { private removeFile(fileId: string) {
uploadedFiles = uploadedFiles.filter(f => f.id !== fileId); this.uploadedFiles = this.uploadedFiles.filter(f => f.id !== fileId);
renderFileList(); this.renderFileList();
updateSubmitButton();
} }
function renderFileList(): void { private renderFileList() {
if (!elements.filesContainer || !elements.fileList) return; if (!this.elements.filesContainer || !this.elements.fileList) return;
if (uploadedFiles.length > 0) { if (this.uploadedFiles.length > 0) {
elements.fileList.style.display = 'block'; (this.elements.fileList as HTMLElement).style.display = 'block';
elements.filesContainer.innerHTML = uploadedFiles.map(file => ` (this.elements.filesContainer as HTMLElement).innerHTML = this.uploadedFiles.map(file => `
<div class="file-item" style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: 0.25rem; margin-bottom: 0.5rem;"> <div class="file-item">
<div style="flex: 1;"> <div class="file-info">
<strong>${file.name}</strong> <strong>${file.name}</strong>
<div style="font-size: 0.875rem; color: var(--color-text-secondary);"> <div class="file-meta">
${(file.file.size / 1024 / 1024).toFixed(2)} MB ${(file.file.size / 1024 / 1024).toFixed(2)} MB
${file.uploaded ? ${file.uploaded ?
'<span style="color: var(--color-success);">✓ Uploaded</span>' : '<span class="file-status success">✓ Uploaded</span>' :
'<span style="color: var(--color-warning);">⏳ Uploading...</span>' '<span class="file-status pending">⏳ Uploading...</span>'
} }
</div> </div>
</div> </div>
<button type="button" onclick="window.removeFile('${file.id}')" class="btn btn-small" style="background: var(--color-danger); color: white;">Remove</button> <button type="button" onclick="window.removeFile('${file.id}')" class="btn btn-danger btn-small">Remove</button>
</div> </div>
`).join(''); `).join('');
} else { } else {
elements.fileList.style.display = 'none'; (this.elements.fileList as HTMLElement).style.display = 'none';
} }
} }
async function handleSubmit(e: Event): Promise<void> { private async handleSubmit() {
e.preventDefault(); if (this.isSubmitting) return;
console.log('[KB FORM DEBUG] Form submitted');
this.isSubmitting = true;
if (!elements.submitBtn || !elements.form) { // Update UI
console.log('[KB FORM DEBUG] Submission blocked - form missing'); (this.elements.submitBtn as HTMLButtonElement).disabled = true;
return; (this.elements.submitText as HTMLElement).textContent = 'Submitting...';
} (this.elements.submitSpinner as HTMLElement).style.display = 'inline';
elements.submitBtn.classList.add('loading');
elements.submitBtn.innerHTML = '⏳ Submitting...';
try { try {
const formData = new FormData(elements.form); const formData = new FormData(this.elements.form as HTMLFormElement);
// Process categories and tags with proper null handling // Process categories and tags
const categoriesValue = (formData.get('categories') as string) || ''; const categoriesValue = (formData.get('categories') as string) || '';
const tagsValue = (formData.get('tags') as string) || ''; const tagsValue = (formData.get('tags') as string) || '';
@ -395,78 +398,83 @@ const sortedTools = data.tools.sort((a: any, b: any) => a.name.localeCompare(b.n
formData.set('tags', JSON.stringify(tags)); formData.set('tags', JSON.stringify(tags));
// Add uploaded files // Add uploaded files
formData.set('uploadedFiles', JSON.stringify(uploadedFiles.filter(f => f.uploaded))); formData.set('uploadedFiles', JSON.stringify(this.uploadedFiles.filter(f => f.uploaded)));
console.log('[KB FORM DEBUG] Submitting to API...');
const response = await fetch('/api/contribute/knowledgebase', { const response = await fetch('/api/contribute/knowledgebase', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
console.log('[KB FORM DEBUG] Response status:', response.status);
const result = await response.json(); const result = await response.json();
console.log('[KB FORM DEBUG] Response data:', result);
if (result.success) { if (result.success) {
// Show success modal this.showSuccess(result);
const successModal = document.getElementById('success-modal');
const successMessage = document.getElementById('success-message');
const prLink = document.getElementById('pr-link') as HTMLAnchorElement;
if (successModal && successMessage) {
successMessage.textContent = 'Your knowledge base article has been submitted successfully and will be reviewed by the maintainers.';
if (result.prUrl && prLink) {
prLink.href = result.prUrl;
prLink.style.display = 'inline-flex';
}
successModal.style.display = 'flex';
}
// Reset form with proper typing
if (elements.form) {
elements.form.reset();
}
uploadedFiles = [];
renderFileList();
updateSubmitButton();
} else { } else {
showMessage('error', result.error || 'Submission failed'); throw new Error(result.error || 'Submission failed');
} }
} catch (error) { } catch (error) {
console.error('[KB FORM ERROR] Submission error:', error); console.error('[KB FORM] Submission error:', error);
showMessage('error', 'An error occurred during submission'); this.showMessage('error', 'Submission failed. Please try again.');
} finally { } finally {
if (elements.submitBtn) { this.isSubmitting = false;
elements.submitBtn.classList.remove('loading'); (this.elements.submitBtn as HTMLButtonElement).disabled = false;
elements.submitBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/></svg> Submit Article'; (this.elements.submitText as HTMLElement).textContent = 'Submit Article';
} (this.elements.submitSpinner as HTMLElement).style.display = 'none';
} }
} }
function showMessage(type: 'success' | 'error' | 'warning', message: string): void { private showSuccess(result: any) {
const successMessage = document.getElementById('success-message');
const issueLink = document.getElementById('issue-link') as HTMLAnchorElement;
if (successMessage) {
successMessage.textContent = 'Your knowledge base article has been submitted as an issue for review by maintainers.';
}
if (result.issueUrl && issueLink) {
issueLink.href = result.issueUrl;
issueLink.style.display = 'inline-flex';
}
(this.elements.successModal as HTMLElement).style.display = 'flex';
// Reset form
(this.elements.form as HTMLFormElement).reset();
this.uploadedFiles = [];
this.renderFileList();
}
private showMessage(type: 'success' | 'error' | 'warning', message: string) {
const container = document.getElementById('message-container'); const container = document.getElementById('message-container');
if (!container) return; if (!container) return;
const messageEl = document.createElement('div'); const messageEl = document.createElement('div');
messageEl.className = `message message-${type}`; messageEl.className = `message message-${type}`;
messageEl.style.cssText = `
padding: 1rem; margin-bottom: 0.5rem; border-radius: 0.25rem;
background-color: var(--color-${type === 'error' ? 'danger' : type === 'warning' ? 'warning' : 'success'});
color: white; animation: slideIn 0.3s ease;
`;
messageEl.textContent = message; messageEl.textContent = message;
container.appendChild(messageEl); container.appendChild(messageEl);
setTimeout(() => messageEl.remove(), 5000); setTimeout(() => messageEl.remove(), 5000);
} }
// Make removeFile available globally with proper typing // Public method for file removal
window.removeFile = removeFile; public removeFileById(fileId: string) {
this.removeFile(fileId);
}
}
// Initialize // Global instance
setupFileUpload(); let formInstance: KnowledgebaseForm;
console.log('[KB FORM DEBUG] Form initialization complete');
// Global function for file removal
window.removeFile = (fileId: string) => {
if (formInstance) {
formInstance.removeFileById(fileId);
}
};
// Initialize form
document.addEventListener('DOMContentLoaded', () => {
formInstance = new KnowledgebaseForm();
});
</script> </script>

View File

@ -1,4 +1,4 @@
// src/utils/gitContributions.ts - MINIMAL: Issues only, no filesystem/git operations // src/utils/gitContributions.ts
import { dump } from 'js-yaml'; import { dump } from 'js-yaml';
export interface ContributionData { export interface ContributionData {
@ -35,6 +35,20 @@ export interface GitOperationResult {
issueNumber?: number; 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 { interface GitConfig {
provider: 'gitea' | 'github' | 'gitlab'; provider: 'gitea' | 'github' | 'gitlab';
apiEndpoint: string; apiEndpoint: string;
@ -86,6 +100,20 @@ export class GitContributionManager {
} }
} }
async submitKnowledgebaseContribution(data: KnowledgebaseContribution): Promise<GitOperationResult> {
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 { private generateYAML(tool: any): string {
// Clean tool object // Clean tool object
const cleanTool: any = { const cleanTool: any = {
@ -175,6 +203,61 @@ export class GitContributionManager {
} }
} }
private async createKnowledgebaseIssue(data: KnowledgebaseContribution): Promise<string> {
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 { private generateIssueBody(data: ContributionData, toolYaml: string): string {
return `## ${data.type === 'add' ? 'Add' : 'Update'} Tool: ${data.tool.name} return `## ${data.type === 'add' ? 'Add' : 'Update'} Tool: ${data.tool.name}
@ -210,4 +293,103 @@ ${data.metadata.reason}
--- ---
*Submitted via CC24-Hub contribution form*`; *Submitted via CC24-Hub contribution form*`;
} }
}
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');
}
}

View File

@ -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(`<pre><code class="language-${this.escapeHtml(lang)}">${this.escapeHtml(content.trim())}</code></pre>`);
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, `<h${i}>$1</h${i}>`);
}
return html;
}
private parseEmphasis(html: string): string {
// Bold: **text** or __text__
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>');
// Italic: *text* or _text_
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
html = html.replace(/_(.*?)_/g, '<em>$1</em>');
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,
'<img src="$2" alt="$1" style="max-width: 100%; height: auto; border-radius: 0.25rem; margin: 0.5rem 0;" />');
// Links: [text](url)
html = html.replace(/\[([^\]]*)\]\(([^)]*)\)/g,
`<a href="$2"${linkTarget}>$1</a>`);
return html;
}
private parseInlineCode(html: string): string {
// Inline code: `code`
html = html.replace(/`([^`]*)`/g, '<code>$1</code>');
return html;
}
private parseLists(html: string): string {
// Unordered lists
html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '<li>$1</li>');
// Ordered lists
html = html.replace(/^[\s]*\d+\.\s+(.+)$/gm, '<li>$1</li>');
// Wrap consecutive list items in ul/ol
html = html.replace(/(<li>.*<\/li>)/s, (match) => {
// Simple approach: assume unordered list
return `<ul>${match}</ul>`;
});
return html;
}
private parseBlockquotes(html: string): string {
// Blockquotes: > text
html = html.replace(/^>\s+(.+)$/gm, '<blockquote>$1</blockquote>');
// Merge consecutive blockquotes
html = html.replace(/(<\/blockquote>)\s*(<blockquote>)/g, ' ');
return html;
}
private parseHorizontalRules(html: string): string {
// Horizontal rules: --- or ***
html = html.replace(/^[-*]{3,}$/gm, '<hr>');
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 <br>
const withBreaks = trimmed.replace(/\n/g, '<br>');
// Wrap in paragraph if not empty and not already a block element
if (withBreaks && !this.isBlockElement(withBreaks)) {
return `<p>${withBreaks}</p>`;
}
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(/<script[^>]*>[\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);
}