diff --git a/.env.example b/.env.example index 9c885f7..3733af2 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ DISCORD_TOKEN="" # Your bot token CLIENT_ID="" # Your bot's application ID +GUIDES_CHANNEL_ID="" # The ID of the channel where guides will be posted \ No newline at end of file diff --git a/.gitignore b/.gitignore index 497c128..e6f1a18 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,7 @@ yarn-error.log* !.env.example .env .env.local -.env.*.local \ No newline at end of file +.env.*.local + +# guides tracker +guides-tracker.json \ No newline at end of file diff --git a/docs/GUIDE_SYNC.md b/docs/GUIDE_SYNC.md new file mode 100644 index 0000000..f4b203e --- /dev/null +++ b/docs/GUIDE_SYNC.md @@ -0,0 +1,27 @@ +# Guide Synchronization System + +The bot automatically synchronizes guide markdown files from `src/commands/guides/subjects/` to a Discord channel when it starts up. + +## Setup + +Add to your `.env.local` file: +``` +GUIDES_CHANNEL_ID=1234567890123456789 +``` + +## Commands + +- `npm run sync-guides` - Manual sync (updates only changed guides) +- `npm run sync-guides:init` - Force sync (posts all guides fresh) + +## Guide Format + +Guides need frontmatter with a `name` field: + +```markdown +--- +name: JavaScript +--- + +Your guide content here... +``` \ No newline at end of file diff --git a/package.json b/package.json index c66397c..2a56c61 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "test": "pnpm run build:dev && node --test dist/**/*.test.js", "test:ci": "node --test dist/**/*.test.js", "prepare": "husky", - "pre-commit": "lint-staged" + "pre-commit": "lint-staged", + "sync-guides": "tsx scripts/sync-guides.js", + "sync-guides:init": "tsx scripts/sync-guides.js --initialize" }, "keywords": [], "author": "", diff --git a/scripts/sync-guides.js b/scripts/sync-guides.js new file mode 100644 index 0000000..9270b0b --- /dev/null +++ b/scripts/sync-guides.js @@ -0,0 +1,56 @@ +#!/usr/bin/env node + +/** + * Standalone script for synchronizing guides to Discord channel + * Usage: npm run sync-guides [--initialize] + */ + +import { Client, GatewayIntentBits } from 'discord.js'; +import { config } from '../src/env.js'; +import { syncGuidesToChannel, initializeGuidesChannel } from '../src/util/post-guides.js'; + +async function main() { + const args = process.argv.slice(2); + const shouldInitialize = args.includes('--initialize'); + + if (!config.guides.channelId) { + console.error('❌ GUIDES_CHANNEL_ID environment variable is required'); + console.error('Please set it in your environment variables'); + process.exit(1); + } + + console.log(`🤖 Starting Discord client for guide sync...`); + + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + ], + }); + + try { + await client.login(config.discord.token); + console.log(`✅ Logged in as ${client.user?.tag}`); + + if (shouldInitialize) { + console.log('🚀 Initializing guides channel (will post all guides fresh)...'); + await initializeGuidesChannel(client, config.guides.channelId); + } else { + console.log('🔄 Synchronizing guides...'); + await syncGuidesToChannel(client, config.guides.channelId); + } + + console.log('✅ Guide synchronization completed successfully'); + } catch (error) { + console.error('❌ Guide synchronization failed:', error); + process.exit(1); + } finally { + await client.destroy(); + console.log('👋 Discord client disconnected'); + } +} + +main().catch((error) => { + console.error('❌ Unexpected error:', error); + process.exit(1); +}); \ No newline at end of file diff --git a/src/commands/guides/subjects/a11y.md b/src/commands/guides/subjects/a11y.md new file mode 100644 index 0000000..7312e84 --- /dev/null +++ b/src/commands/guides/subjects/a11y.md @@ -0,0 +1,12 @@ +--- +name: Accessibility (A11y) +--- + +Improve your web accessibility skills with these valuable free resources: + +**Reference & Guidelines** +- [**MDN Web Accessibility**]() - Comprehensive guides and best practices. +- [**WAI (Web Accessibility Initiative)**]() - Official guidelines and resources for web accessibility. +- [**a11y Project**]() - Community-driven resources and checklists. +- [**WebAIM**]() - Articles, tools, and training on web accessibility. +- [**web.dev Accessibility**]() - Tutorials and best practices for building accessible web experiences. \ No newline at end of file diff --git a/src/commands/guides/subjects/backend.md b/src/commands/guides/subjects/backend.md new file mode 100644 index 0000000..3999423 --- /dev/null +++ b/src/commands/guides/subjects/backend.md @@ -0,0 +1,16 @@ +--- +name: Backend Development +--- + +Boost your backend development skills with these top free resources: + +**Structured Learning Paths** +- [**Roadmap**]() - A comprehensive guide to backend technologies and concepts. + +**Backends** +- SQLlite (recommended) - Lightweight, disk-based database. +- PostgreSQL (recommended) - Advanced, open-source relational database. +- Mongodb (only pick if you have to.) - NoSQL database for flexible, JSON-like documents. +- Redis - In-memory data structure store, used as a database, cache, and message broker. + + diff --git a/src/commands/guides/subjects/css.md b/src/commands/guides/subjects/css.md index e164269..b798986 100644 --- a/src/commands/guides/subjects/css.md +++ b/src/commands/guides/subjects/css.md @@ -1,8 +1,15 @@ --- -name: Css +name: CSS --- -CSS can be tricky to get right, especially with the various browsers and their quirks. Here are some common issues and solutions: -1. **Box Model Issues**: Ensure you understand the CSS box model (content, padding, border, margin). Use `box-sizing: border-box;` to make width and height include padding and border. -2. **Specificity Problems**: If your styles aren't applying, check the specificity of your selectors. More specific selectors override less specific ones. -3. **Flexbox and Grid Layouts**: These modern layout systems can be complex. Make sure to understand their properties and how they interact. \ No newline at end of file +Level up your CSS skills with these free and practical resources: + +**Reference & Guides** +- [**MDN**]() - Comprehensive CSS documentation. +- [**web.dev**]() - Modern CSS best practices and tutorials. +- [**CSS Tricks**]() - Tips, tricks, and in-depth articles. + +**Interactive Learning** +- [**CSS Grid Garden**]() - Master CSS Grid with fun exercises. +- [**Flexbox Froggy**]() - Learn Flexbox by solving interactive challenges. +- [**A Complete Guide to Flexbox**]() - Detailed reference and examples. diff --git a/src/commands/guides/subjects/frontend.md b/src/commands/guides/subjects/frontend.md new file mode 100644 index 0000000..0de338c --- /dev/null +++ b/src/commands/guides/subjects/frontend.md @@ -0,0 +1,18 @@ +--- +name: Frontend Development +--- + +Sharpen your frontend skills with these curated free resources: + +**Structured Learning Paths** +- [**The Odin Project**]() - A full curriculum from basics to projects. +- [**Roadmap**]() - Step-by-step guide of what to learn next in frontend development. + +**Practice & Challenges** +- [**Frontend Mentor**]() - Build real projects and improve your coding skills. + +**Reference & Guides** +- [**JavaScript Info**]() - Deep dive into JavaScript concepts and examples. +- [**CSS Tricks**]() - Tips, tricks, and guides for CSS and modern layouts. +- [**MDN Web Docs**]() - Comprehensive reference for HTML, CSS, and JS. + diff --git a/src/commands/guides/subjects/html.md b/src/commands/guides/subjects/html.md deleted file mode 100644 index b036714..0000000 --- a/src/commands/guides/subjects/html.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -name: HTML ---- - -HTML is the backbone of web development, but beginners often face some common issues. Here are a few tips to help you avoid them: \ No newline at end of file diff --git a/src/commands/guides/subjects/javascript.md b/src/commands/guides/subjects/javascript.md new file mode 100644 index 0000000..6c97469 --- /dev/null +++ b/src/commands/guides/subjects/javascript.md @@ -0,0 +1,16 @@ +--- +name: JavaScript +--- + +Enhance your JavaScript skills with these essential free resources: + +**Reference & Guides** +- [**MDN JavaScript**]() - Complete documentation and tutorials. +- [**JavaScript Info**]() - In-depth guides from basics to advanced concepts. +- [**Eloquent JavaScript**]() - A modern introduction to programming using JavaScript. +- [**web.dev**]() - Learn JavaScript with practical examples and best practices. +- [**Roadmap**]() - A structured path to mastering JavaScript. + +**Interactive Learning & Challenges** +- [**Frontend Mentor (JS Projects)**]() - Apply JS in real-world projects. +- [**FreeCodeCamp (JS Course)**]() - Learn JavaScript through interactive coding challenges. \ No newline at end of file diff --git a/src/commands/guides/subjects/tailwind.md b/src/commands/guides/subjects/tailwind-with-vite.md similarity index 93% rename from src/commands/guides/subjects/tailwind.md rename to src/commands/guides/subjects/tailwind-with-vite.md index 28082b7..54870e4 100644 --- a/src/commands/guides/subjects/tailwind.md +++ b/src/commands/guides/subjects/tailwind-with-vite.md @@ -1,5 +1,5 @@ --- -name: Tailwind Setup Issues +name: Tailwind with Vite --- We've heard you have issues with setting up Tailwind in Vite. Have you followed all the steps described in the docs? diff --git a/src/commands/guides/subjects/vue.md b/src/commands/guides/subjects/vue.md new file mode 100644 index 0000000..3134458 --- /dev/null +++ b/src/commands/guides/subjects/vue.md @@ -0,0 +1,15 @@ +--- +name: Vue +--- + +Enhance your Vue.js skills with these valuable free resources: + +**Official Documentation & Guides** +- [**Vue.js Official Documentation**]() - Comprehensive guides and API references. + +**Community Guides** +- [**How to learn Vue**]() - A guide to learning Vue effectively. + +**Tools** +- [**Vueuse**]() - A collection of essential Vue Composition Utilities. +- [**Vue Playground**]() - An online editor to experiment with Vue.js code snippets. \ No newline at end of file diff --git a/src/env.ts b/src/env.ts index 833adcf..98da1f7 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,5 +1,9 @@ import './loadEnvFile.js'; +function optionalEnv(key: string): string | undefined { + return process.env[key]; +} + function requireEnv(key: string): string { const value = process.env[key]; if (!value) { @@ -16,6 +20,10 @@ export const config = { token: requireEnv('DISCORD_TOKEN'), clientId: requireEnv('CLIENT_ID'), }, + guides: { + channelId: requireEnv('GUIDES_CHANNEL_ID'), + trackerPath: optionalEnv('GUIDES_TRACKER_PATH'), + }, // Add more config sections as needed: // database: { // url: requireEnv('DATABASE_URL'), diff --git a/src/events/ready.ts b/src/events/ready.ts index 9b5bbcb..46f5dee 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,12 +1,22 @@ import { Events } from 'discord.js'; +import { config } from '../env.js'; import { createEvent } from '../util/events.js'; +import { syncGuidesToChannel } from '../util/post-guides.js'; export const readyEvent = createEvent( { name: Events.ClientReady, once: true, }, - (client) => { + async (client) => { console.log(`Ready! Logged in as ${client.user.tag}`); + + // Sync guides to channel + try { + console.log(`🔄 Starting guide sync to channel ${config.guides.channelId}...`); + await syncGuidesToChannel(client, config.guides.channelId); + } catch (error) { + console.error('❌ Failed to sync guides:', error); + } } ); diff --git a/src/util/post-guides.ts b/src/util/post-guides.ts new file mode 100644 index 0000000..8cd0c0c --- /dev/null +++ b/src/util/post-guides.ts @@ -0,0 +1,212 @@ +import { createHash } from 'node:crypto'; +import { readdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ChannelType, type Client, EmbedBuilder, type TextChannel } from 'discord.js'; +import { config } from '../env.js'; +import { parseMarkdown } from './markdown.js'; + +export type GuideInfo = { + name: string; + filename: string; + hash: string; + messageId?: string; + content: string; + frontmatter: Record; +}; + +const guidesColors = [0xff5733, 0x33ff57, 0x3357ff, 0xff33a8, 0xa833ff, 0x33fff5]; +const getRandomColor = () => guidesColors[Math.floor(Math.random() * guidesColors.length)]; +const createGuideEmbed = (guide: GuideInfo) => + new EmbedBuilder() + .setTitle(guide.name) + .setDescription(guide.content) + .setColor(getRandomColor()) + .setFooter({ text: `Last updated: ${new Date().toLocaleDateString()}` }); + +export type GuideTracker = { + [filename: string]: { + hash: string; + messageId?: string; + }; +}; + +const GUIDES_DIR = fileURLToPath(new URL('../commands/guides/subjects/', import.meta.url)); + +const TRACKER_FILE = config.guides.trackerPath ?? 'guides-tracker.json'; + +const calculateHash = (content: string): string => { + return createHash('sha256').update(content, 'utf8').digest('hex'); +}; + +const loadTracker = async (): Promise => { + try { + const content = await readFile(TRACKER_FILE, 'utf8'); + return JSON.parse(content); + } catch { + console.log('No existing tracker file found, starting fresh'); + return {}; + } +}; + +const saveTracker = async (tracker: GuideTracker): Promise => { + await writeFile(TRACKER_FILE, JSON.stringify(tracker, null, 2), 'utf8'); +}; + +const scanGuideFiles = async (): Promise => { + const files = await readdir(GUIDES_DIR); + const guides: GuideInfo[] = []; + + for (const filename of files) { + if (!filename.endsWith('.md')) { + continue; + } + + const filePath = join(GUIDES_DIR, filename); + const content = await readFile(filePath, 'utf8'); + const { frontmatter, content: markdownContent } = await parseMarkdown(content); + + const hash = calculateHash(content); + const name = (frontmatter.name as string) || filename.replace('.md', ''); + + guides.push({ + name, + filename, + hash, + content: markdownContent, + frontmatter, + }); + } + + return guides; +}; + +const postGuideToChannel = async (channel: TextChannel, guide: GuideInfo): Promise => { + const message = await channel.send({ + embeds: [createGuideEmbed(guide)], + }); + + console.log(`✅ Posted guide "${guide.name}" (${guide.filename})`); + return message.id; +}; + +const editGuideMessage = async ( + channel: TextChannel, + messageId: string, + guide: GuideInfo +): Promise => { + try { + const message = await channel.messages.fetch(messageId); + await message.edit({ + embeds: [createGuideEmbed(guide)], + }); + + console.log(`📝 Updated guide "${guide.name}" (${guide.filename})`); + } catch (error) { + console.error(`Failed to edit message ${messageId} for guide "${guide.name}":`, error); + throw error; + } +}; + +const deleteGuideMessage = async ( + channel: TextChannel, + messageId: string, + guideName: string +): Promise => { + try { + const message = await channel.messages.fetch(messageId); + await message.delete(); + + console.log(`🗑️ Deleted guide "${guideName}"`); + } catch (error) { + console.error(`Failed to delete message ${messageId} for guide "${guideName}":`, error); + } +}; + +export const syncGuidesToChannel = async (client: Client, channelId: string): Promise => { + console.log('🔄 Starting guide synchronization...'); + + try { + const channel = await client.channels.fetch(channelId); + if (!channel || !channel.isTextBased() || channel.type !== ChannelType.GuildText) { + throw new Error(`Channel ${channelId} is not a valid text channel`); + } + // Load current state + const tracker = await loadTracker(); + const currentGuides = await scanGuideFiles(); + + // Create maps for easier lookup + const currentGuideMap = new Map(currentGuides.map((guide) => [guide.filename, guide])); + const trackedFiles = new Set(Object.keys(tracker)); + const currentFiles = new Set(currentGuides.map((guide) => guide.filename)); + + // Find changes + const newFiles = [...currentFiles].filter((file) => !trackedFiles.has(file)); + const deletedFiles = [...trackedFiles].filter((file) => !currentFiles.has(file)); + const modifiedFiles = [...currentFiles].filter((file) => { + const guide = currentGuideMap.get(file); + return guide && trackedFiles.has(file) && tracker[file].hash !== guide.hash; + }); + + console.log( + `📊 Found: ${newFiles.length} new, ${modifiedFiles.length} modified, ${deletedFiles.length} deleted` + ); + + // Process deletions first + for (const filename of deletedFiles) { + const messageId = tracker[filename].messageId; + if (messageId) { + await deleteGuideMessage(channel, messageId, filename); + } + delete tracker[filename]; + } + + // Process new guides + for (const filename of newFiles) { + const guide = currentGuideMap.get(filename)!; + const messageId = await postGuideToChannel(channel, guide); + + tracker[filename] = { + hash: guide.hash, + messageId, + }; + } + + // Process modifications + for (const filename of modifiedFiles) { + const guide = currentGuideMap.get(filename)!; + const messageId = tracker[filename].messageId; + + if (messageId) { + await editGuideMessage(channel, messageId, guide); + } else { + // If no message ID, treat as new + const newMessageId = await postGuideToChannel(channel, guide); + tracker[filename].messageId = newMessageId; + } + + tracker[filename].hash = guide.hash; + } + + await saveTracker(tracker); + + const totalChanges = newFiles.length + modifiedFiles.length + deletedFiles.length; + if (totalChanges === 0) { + console.log('✨ All guides are up to date!'); + } else { + console.log(`✅ Guide synchronization complete! Made ${totalChanges} changes.`); + } + } catch (error) { + console.error('❌ Guide synchronization failed:', error); + throw error; + } +}; + +export const initializeGuidesChannel = async (client: Client, channelId: string): Promise => { + console.log('🚀 Initializing guides channel...'); + + // Clear existing tracker for fresh start + await saveTracker({}); + + await syncGuidesToChannel(client, channelId); +};