diff --git a/.credo.exs b/.credo.exs new file mode 100644 index 0000000..8c34d8a --- /dev/null +++ b/.credo.exs @@ -0,0 +1,134 @@ +# This file contains the configuration for Credo +# +# If you find that you need to change the default configuration, +# it's easiest to just edit this file directly. +# +# More information on Credo can be found here: +# https://github.com/rrrene/credo +%{ + configs: [ + %{ + name: "default", + files: %{ + included: ["lib/", "test/"], + excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] + }, + plugins: [], + requires: [], + strict: false, + parse_timeout: 5000, + color: true, + checks: %{ + enabled: [ + ## Consistency Checks + {Credo.Check.Consistency.ExceptionNames, []}, + {Credo.Check.Consistency.LineEndings, []}, + {Credo.Check.Consistency.ParameterPatternMatching, []}, + {Credo.Check.Consistency.SpaceAroundOperators, []}, + {Credo.Check.Consistency.SpaceInParentheses, []}, + {Credo.Check.Consistency.TabsOrSpaces, []}, + + ## Design Checks + {Credo.Check.Design.AliasUsage, [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, + {Credo.Check.Design.TagFIXME, []}, + {Credo.Check.Design.TagTODO, [exit_status: 0]}, + + ## Readability Checks + {Credo.Check.Readability.AliasOrder, []}, + {Credo.Check.Readability.FunctionNames, []}, + {Credo.Check.Readability.LargeNumbers, []}, + {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, + {Credo.Check.Readability.ModuleAttributeNames, []}, + {Credo.Check.Readability.ModuleDoc, []}, + {Credo.Check.Readability.ModuleNames, []}, + {Credo.Check.Readability.ParenthesesInCondition, []}, + {Credo.Check.Readability.ParenthesesOnZeroArityDefs, []}, + {Credo.Check.Readability.PipeIntoAnonymousFunctions, []}, + {Credo.Check.Readability.PredicateFunctionNames, []}, + {Credo.Check.Readability.PreferImplicitTry, []}, + {Credo.Check.Readability.RedundantBlankLines, []}, + {Credo.Check.Readability.Semicolons, []}, + {Credo.Check.Readability.SpaceAfterCommas, []}, + {Credo.Check.Readability.StringSigils, []}, + {Credo.Check.Readability.TrailingBlankLine, []}, + {Credo.Check.Readability.TrailingWhiteSpace, []}, + {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, + {Credo.Check.Readability.VariableNames, []}, + {Credo.Check.Readability.WithSingleClause, []}, + + ## Refactoring Opportunities + {Credo.Check.Refactor.Apply, []}, + {Credo.Check.Refactor.CondStatements, []}, + {Credo.Check.Refactor.CyclomaticComplexity, []}, + {Credo.Check.Refactor.FunctionArity, []}, + {Credo.Check.Refactor.LongQuoteBlocks, []}, + {Credo.Check.Refactor.MatchInCondition, []}, + {Credo.Check.Refactor.MapJoin, []}, + {Credo.Check.Refactor.NegatedConditionsInUnless, []}, + {Credo.Check.Refactor.NegatedConditionsWithElse, []}, + {Credo.Check.Refactor.Nesting, []}, + {Credo.Check.Refactor.UnlessWithElse, []}, + {Credo.Check.Refactor.WithClauses, []}, + {Credo.Check.Refactor.FilterFilter, []}, + {Credo.Check.Refactor.RejectReject, []}, + {Credo.Check.Refactor.RedundantWithClauseResult, []}, + + ## Warnings + {Credo.Check.Warning.ApplicationConfigInModuleAttribute, []}, + {Credo.Check.Warning.BoolOperationOnSameValues, []}, + {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, + {Credo.Check.Warning.IExPry, []}, + {Credo.Check.Warning.IoInspect, []}, + {Credo.Check.Warning.OperationOnSameValues, []}, + {Credo.Check.Warning.OperationWithConstantResult, []}, + {Credo.Check.Warning.RaiseInsideRescue, []}, + {Credo.Check.Warning.SpecWithStruct, []}, + {Credo.Check.Warning.WrongTestFileExtension, []}, + {Credo.Check.Warning.UnusedEnumOperation, []}, + {Credo.Check.Warning.UnusedFileOperation, []}, + {Credo.Check.Warning.UnusedKeywordOperation, []}, + {Credo.Check.Warning.UnusedListOperation, []}, + {Credo.Check.Warning.UnusedPathOperation, []}, + {Credo.Check.Warning.UnusedRegexOperation, []}, + {Credo.Check.Warning.UnusedStringOperation, []}, + {Credo.Check.Warning.UnusedTupleOperation, []}, + {Credo.Check.Warning.UnsafeExec, []} + ], + disabled: [ + # Controversial and experimental checks (opt-in, just move the check to `:enabled`) + {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, + {Credo.Check.Consistency.UnusedVariableNames, []}, + {Credo.Check.Design.DuplicatedCode, []}, + {Credo.Check.Design.SkipTestWithoutComment, []}, + {Credo.Check.Readability.AliasAs, []}, + {Credo.Check.Readability.BlockPipe, []}, + {Credo.Check.Readability.ImplTrue, []}, + {Credo.Check.Readability.MultiAlias, []}, + {Credo.Check.Readability.NestedFunctionCalls, []}, + {Credo.Check.Readability.SeparateAliasRequire, []}, + {Credo.Check.Readability.SingleFunctionToBlockPipe, []}, + {Credo.Check.Readability.SinglePipe, []}, + {Credo.Check.Readability.Specs, []}, + {Credo.Check.Readability.StrictModuleLayout, []}, + {Credo.Check.Readability.WithCustomTaggedTuple, []}, + {Credo.Check.Refactor.ABCSize, []}, + {Credo.Check.Refactor.AppendSingleItem, []}, + {Credo.Check.Refactor.DoubleBooleanNegation, []}, + {Credo.Check.Refactor.FilterReject, []}, + {Credo.Check.Refactor.IoPuts, []}, + {Credo.Check.Refactor.MapMap, []}, + {Credo.Check.Refactor.ModuleDependencies, []}, + {Credo.Check.Refactor.NegatedIsNil, []}, + {Credo.Check.Refactor.PipeChainStart, []}, + {Credo.Check.Refactor.RejectFilter, []}, + {Credo.Check.Refactor.VariableRebinding, []}, + {Credo.Check.Warning.LazyLogging, []}, + {Credo.Check.Warning.LeakyEnvironment, []}, + {Credo.Check.Warning.MapGetUnsafePass, []}, + {Credo.Check.Warning.MixEnv, []}, + {Credo.Check.Warning.UnsafeToAtom, []} + ] + } + } + ] +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d0a736a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# Git +.git +.gitignore + +# Build artifacts +_build +deps +*.ez +blog_engine + +# Documentation +doc +docs + +# Test +test +cover +*.coverdata + +# Development +.elixir_ls +.vscode +.idea + +# OS +.DS_Store +Thumbs.db + +# Data files (mount as volume instead) +priv/data/*.json + +# CI/CD +.github + +# Other +README.md +CHANGELOG.md +CONTRIBUTING.md +CODE_OF_CONDUCT.md +SECURITY.md +Makefile +examples +scripts +backups diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d25bc63 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,33 @@ +# This file is used by various editors and IDEs to maintain +# consistent coding styles across different editors and IDEs. +# See https://editorconfig.org for more information. + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{ex,exs}] +indent_style = space +indent_size = 2 + +[*.{md,markdown}] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[Makefile] +indent_style = tab + +[*.json] +indent_size = 2 + +[*.sh] +indent_style = space +indent_size = 2 diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..0a70dc0 --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,5 @@ +# Used by "mix format" +[ + inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], + line_length: 120 +] diff --git a/.git-hooks/commit-msg b/.git-hooks/commit-msg new file mode 100755 index 0000000..8786981 --- /dev/null +++ b/.git-hooks/commit-msg @@ -0,0 +1,39 @@ +#!/bin/bash + +# Commit message hook for BlogEngine +# Validates commit message format +# Install: ln -sf ../../.git-hooks/commit-msg .git/hooks/commit-msg + +commit_msg_file=$1 +commit_msg=$(cat "$commit_msg_file") + +# Skip merge commits +if echo "$commit_msg" | grep -q "^Merge"; then + exit 0 +fi + +# Check minimum length +if [ ${#commit_msg} -lt 10 ]; then + echo "❌ Commit message too short (minimum 10 characters)" + echo " Current: ${#commit_msg} characters" + exit 1 +fi + +# Check for imperative mood (starts with verb) +first_word=$(echo "$commit_msg" | head -n1 | awk '{print $1}') +if echo "$first_word" | grep -qE '^(Add|Fix|Update|Remove|Refactor|Document|Test|Improve|Create|Delete|Move|Rename|Extract|Optimize|Merge|Bump|Revert)'; then + exit 0 +fi + +# Warning for non-standard format +echo "⚠️ Warning: Commit message should start with an imperative verb" +echo " Examples: Add, Fix, Update, Remove, Refactor, etc." +echo " Current: $first_word" +echo "" +echo "Continue anyway? (y/N)" +read -r response +if [[ "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then + exit 0 +else + exit 1 +fi diff --git a/.git-hooks/pre-commit b/.git-hooks/pre-commit new file mode 100755 index 0000000..c784e5b --- /dev/null +++ b/.git-hooks/pre-commit @@ -0,0 +1,51 @@ +#!/bin/bash + +# Pre-commit hook for BlogEngine +# Runs code quality checks before allowing commit +# Install: ln -sf ../../.git-hooks/pre-commit .git/hooks/pre-commit + +set -e + +echo "Running pre-commit checks..." +echo "" + +# Check if mix is available +if ! command -v mix &> /dev/null; then + echo "⚠️ Mix not found. Skipping Elixir checks." + exit 0 +fi + +# Run formatter check +echo "🔍 Checking code formatting..." +if ! mix format --check-formatted 2>&1 | grep -q "mix format"; then + echo "✓ Code is properly formatted" +else + echo "❌ Code formatting issues found" + echo " Run: mix format" + exit 1 +fi + +# Run Credo +echo "" +echo "🔍 Running Credo..." +if mix credo --strict 2>&1 | grep -q "Warnings"; then + echo "⚠️ Credo warnings found" + echo " Run: mix credo --strict" + # Don't fail on warnings, just notify +else + echo "✓ Credo checks passed" +fi + +# Run tests +echo "" +echo "🧪 Running tests..." +if mix test --trace; then + echo "✓ All tests passed" +else + echo "❌ Tests failed" + exit 1 +fi + +echo "" +echo "✅ Pre-commit checks passed!" +echo "" diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..9d92858 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# Funding options for BlogEngine +# Uncomment and update with your funding platforms + +# github: [codeforgood-org] +# patreon: username +# open_collective: blogengine +# ko_fi: username +# tidelift: npm/package-name +# community_bridge: project-name +# liberapay: username +# issuehunt: username +# custom: ['https://example.com'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..63b1fa7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,47 @@ +--- +name: Bug Report +about: Create a report to help us improve +title: '[BUG] ' +labels: bug +assignees: '' +--- + +## Bug Description + +A clear and concise description of what the bug is. + +## Steps To Reproduce + +1. Go to '...' +2. Run command '...' +3. Enter input '...' +4. See error + +## Expected Behavior + +A clear and concise description of what you expected to happen. + +## Actual Behavior + +A clear and concise description of what actually happened. + +## Error Messages + +``` +Paste any error messages here +``` + +## Environment + +- **OS:** [e.g. macOS 13.0, Ubuntu 22.04, Windows 11] +- **Elixir version:** [run `elixir --version`] +- **Erlang/OTP version:** [shown in `elixir --version` output] +- **BlogEngine version/commit:** [e.g. v1.0.0 or commit hash] + +## Additional Context + +Add any other context about the problem here. Screenshots, logs, or data files can be helpful. + +## Possible Solution + +If you have ideas about what might be causing this or how to fix it, please share! diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..0db02b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,54 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: '[FEATURE] ' +labels: enhancement +assignees: '' +--- + +## Feature Description + +A clear and concise description of the feature you'd like to see. + +## Problem It Solves + +Describe the problem this feature would solve. Ex. I'm always frustrated when [...] + +## Proposed Solution + +Describe how you envision this feature working. Be as detailed as possible. + +## Alternative Solutions + +Describe any alternative solutions or features you've considered. + +## Use Cases + +Describe specific use cases where this feature would be valuable: + +1. Use case 1... +2. Use case 2... +3. Use case 3... + +## Implementation Ideas + +If you have ideas about how this could be implemented, share them here: + +- Module/file changes needed +- New functions/commands +- Data structure changes +- UI/UX considerations + +## Breaking Changes + +Would this feature require breaking changes? If so, describe them. + +## Additional Context + +Add any other context, mockups, or screenshots about the feature request here. + +## Willingness to Contribute + +- [ ] I'd be willing to implement this feature +- [ ] I'd be willing to help test this feature +- [ ] I'd be willing to write documentation for this feature diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..653b07d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,29 @@ +--- +name: Question +about: Ask a question about using BlogEngine +title: '[QUESTION] ' +labels: question +assignees: '' +--- + +## Question + +What would you like to know? + +## Context + +Provide any relevant context that might help us answer your question: + +- What are you trying to accomplish? +- What have you tried so far? +- Any relevant code snippets or commands? + +## Environment (if relevant) + +- **OS:** [e.g. macOS, Ubuntu, Windows] +- **Elixir version:** [run `elixir --version`] +- **BlogEngine version:** [e.g. v1.0.0] + +## Additional Information + +Any other information that might be helpful. diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 0000000..606f7f6 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,92 @@ +## Description + +Provide a clear and concise description of what this PR does. + +Fixes #(issue number) + +## Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Code refactoring +- [ ] Performance improvement +- [ ] Test improvement + +## Changes Made + +List the specific changes made in this PR: + +- Change 1 +- Change 2 +- Change 3 + +## Testing + +Describe the tests you ran to verify your changes: + +- [ ] All existing tests pass (`mix test`) +- [ ] Added new tests for new functionality +- [ ] Manual testing performed (describe below) + +**Manual Testing Details:** +``` +Describe manual testing steps and results +``` + +## Checklist + +- [ ] My code follows the style guidelines of this project (`mix format`) +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings (`mix compile --warnings-as-errors`) +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes (`mix test`) +- [ ] I have run `mix credo` and addressed any issues +- [ ] I have updated the CHANGELOG.md with my changes +- [ ] Any dependent changes have been merged and published + +## Screenshots (if applicable) + +Add screenshots to help explain your changes. + +## Performance Impact + +Describe any performance implications of this change: + +- [ ] No performance impact +- [ ] Performance improved (provide benchmarks) +- [ ] Performance degraded (explain why necessary) + +## Breaking Changes + +If this PR introduces breaking changes, describe them here and provide migration instructions: + +**Breaking Changes:** +- None + +**Migration Guide:** +- N/A + +## Additional Notes + +Any additional information that reviewers should know. + +## Reviewer Notes + +Things you want reviewers to focus on or questions you have: + +- +- +- + +--- + +**For Maintainers:** +- [ ] Approved +- [ ] CI checks pass +- [ ] Documentation updated +- [ ] CHANGELOG.md updated +- [ ] Ready to merge diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ef58589 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,60 @@ +version: 2 +updates: + # Enable version updates for Elixir dependencies + - package-ecosystem: "mix" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 10 + reviewers: + - "codeforgood-org" + labels: + - "dependencies" + - "automated" + commit-message: + prefix: "deps" + include: "scope" + groups: + dev-dependencies: + patterns: + - "credo" + - "dialyxir" + - "ex_doc" + - "excoveralls" + update-types: + - "minor" + - "patch" + production-dependencies: + patterns: + - "jason" + update-types: + - "minor" + - "patch" + + # Enable version updates for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + labels: + - "github-actions" + - "automated" + commit-message: + prefix: "ci" + + # Enable version updates for Docker + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + labels: + - "docker" + - "automated" + commit-message: + prefix: "docker" diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..f530d5c --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,49 @@ +# Configuration for labeler - https://github.com/actions/labeler + +documentation: + - changed-files: + - any-glob-to-any-file: + - 'docs/**/*' + - '*.md' + - 'README*' + - 'CHANGELOG*' + - 'CONTRIBUTING*' + +tests: + - changed-files: + - any-glob-to-any-file: 'test/**/*' + +dependencies: + - changed-files: + - any-glob-to-any-file: + - 'mix.exs' + - 'mix.lock' + +docker: + - changed-files: + - any-glob-to-any-file: + - 'Dockerfile' + - 'docker-compose.yml' + - '.dockerignore' + +github-actions: + - changed-files: + - any-glob-to-any-file: '.github/workflows/*' + +configuration: + - changed-files: + - any-glob-to-any-file: + - 'config/**/*' + - '.formatter.exs' + - '.credo.exs' + - '.editorconfig' + +core: + - changed-files: + - any-glob-to-any-file: 'lib/**/*' + +scripts: + - changed-files: + - any-glob-to-any-file: + - 'scripts/**/*' + - 'Makefile' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a8ede03 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,171 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +permissions: + contents: read + +jobs: + test: + name: Test (Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }}) + runs-on: ubuntu-latest + strategy: + matrix: + elixir: ['1.14', '1.15', '1.16'] + otp: ['25', '26'] + exclude: + # Don't test unsupported combinations + - elixir: '1.14' + otp: '26' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: ${{ matrix.elixir }} + otp-version: ${{ matrix.otp }} + + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix-${{ matrix.otp }}-${{ matrix.elixir }}- + + - name: Install dependencies + run: mix deps.get + + - name: Compile code (warnings as errors) + run: mix compile --warnings-as-errors + + - name: Run tests + run: mix test + + - name: Check code formatting + run: mix format --check-formatted + + code_quality: + name: Code Quality + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.16' + otp-version: '26' + + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-26-1.16-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix-26-1.16- + + - name: Install dependencies + run: mix deps.get + + - name: Run Credo + run: mix credo --strict + + - name: Restore PLT cache + uses: actions/cache@v3 + id: plt-cache + with: + path: priv/plts + key: ${{ runner.os }}-26-1.16-plts-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-26-1.16-plts- + + - name: Create PLTs + if: steps.plt-cache.outputs.cache-hit != 'true' + run: mix dialyzer --plt + + - name: Run Dialyzer + run: mix dialyzer --format github + + coverage: + name: Test Coverage + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.16' + otp-version: '26' + + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-26-1.16-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix-26-1.16- + + - name: Install dependencies + run: mix deps.get + + - name: Run tests with coverage + run: mix coveralls.json + env: + MIX_ENV: test + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./cover/excoveralls.json + fail_ci_if_error: false + + build_escript: + name: Build Escript + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.16' + otp-version: '26' + + - name: Restore dependencies cache + uses: actions/cache@v3 + with: + path: | + deps + _build + key: ${{ runner.os }}-mix-26-1.16-${{ hashFiles('**/mix.lock') }} + restore-keys: ${{ runner.os }}-mix-26-1.16- + + - name: Install dependencies + run: mix deps.get + + - name: Build escript + run: mix escript.build + + - name: Upload escript artifact + uses: actions/upload-artifact@v3 + with: + name: blog_engine + path: blog_engine + retention-days: 7 diff --git a/.github/workflows/greetings.yml b/.github/workflows/greetings.yml new file mode 100644 index 0000000..560c5c2 --- /dev/null +++ b/.github/workflows/greetings.yml @@ -0,0 +1,35 @@ +name: Greetings + +on: [pull_request_target, issues] + +permissions: + issues: write + pull-requests: write + +jobs: + greeting: + runs-on: ubuntu-latest + steps: + - uses: actions/first-interaction@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + issue-message: | + 👋 Thanks for opening your first issue! We appreciate your contribution to BlogEngine. + + Please make sure you've provided all the necessary information and context. A maintainer will review your issue soon. + + While you wait, feel free to: + - ⭐ Star the repository + - 📖 Read our [Contributing Guide](https://github.com/codeforgood-org/elixir-blog-engine/blob/main/CONTRIBUTING.md) + - 💬 Join the discussion in other issues + pr-message: | + 🎉 Thanks for opening your first pull request! We're excited to have your contribution. + + Please make sure: + - ✅ All tests pass (`mix test`) + - ✅ Code is formatted (`mix format`) + - ✅ Credo checks pass (`mix credo`) + - ✅ You've updated relevant documentation + - ✅ You've added/updated tests for your changes + + A maintainer will review your PR soon. Thanks for contributing to BlogEngine! 🚀 diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..302e250 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,19 @@ +name: Auto Label PR + +on: + pull_request: + types: [opened, edited, synchronize] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Label based on changed files + uses: actions/labeler@v5 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + configuration-path: .github/labeler.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..796391f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build_and_release: + name: Build and Release + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Elixir + uses: erlef/setup-beam@v1 + with: + elixir-version: '1.16' + otp-version: '26' + + - name: Install dependencies + run: mix deps.get + + - name: Run tests + run: mix test + + - name: Build escript + run: mix escript.build + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: blog_engine + draft: false + prerelease: false + generate_release_notes: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index b263cd1..7b43bb6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +# Build artifacts /_build /cover /deps @@ -6,5 +7,35 @@ erl_crash.dump *.ez *.beam + +# Escript +/blog_engine + +# Configuration /config/*.secret.exs + +# Editor and IDE .elixir_ls/ +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Data storage +/priv/data/*.json +!/priv/data/.gitkeep + +# Test artifacts +/test/fixtures/data/ + +# Dialyzer +/priv/plts/ + +# Coverage +/cover/ +*.coverdata diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..546395a --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +elixir 1.16.0 +erlang 26.2.1 diff --git a/.tool-versions.example b/.tool-versions.example new file mode 100644 index 0000000..a1aedd1 --- /dev/null +++ b/.tool-versions.example @@ -0,0 +1,13 @@ +# asdf version management +# Install asdf: https://asdf-vm.com/guide/getting-started.html + +# Install required plugins +asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.git +asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git + +# Install versions from .tool-versions +asdf install + +# Set versions globally (optional) +asdf global elixir 1.16.0 +asdf global erlang 26.2.1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..570189d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Complete project restructure with proper Mix project layout +- Modular architecture with separate concerns (Post, Storage, CLI, Core) +- Persistent JSON storage for posts +- Edit functionality for existing posts +- Search functionality across title, body, and tags +- Tag-based organization and filtering +- Statistics command showing blog metrics +- Import/Export functionality for backup and migration +- Comprehensive documentation with examples +- Full test suite with ExUnit +- Code quality tools (Credo, Dialyzer) +- CI/CD pipeline with GitHub Actions +- Escript build support for standalone executable +- Multi-line input support for post bodies +- Automatic timestamps for creation and updates +- Enhanced CLI with better formatting and user feedback + +### Changed +- Converted from single-file script to full Mix project +- Improved error handling throughout +- Enhanced user interface with better visual separation +- Better date formatting for readability + +### Removed +- Single-file blog_engine.exs implementation (replaced by modular structure) + +## [1.0.0] - 2025-11-13 + +### Added +- Initial release with basic CRUD functionality +- Simple CLI interface +- In-memory post storage +- List, view, create, and delete commands + +--- + +[Unreleased]: https://github.com/codeforgood-org/elixir-blog-engine/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/codeforgood-org/elixir-blog-engine/releases/tag/v1.0.0 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6416b7b --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement through GitHub +issues or by contacting the project maintainers. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..e08aaf9 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,254 @@ +# Contributing to BlogEngine + +Thank you for your interest in contributing to BlogEngine! This document provides guidelines and instructions for contributing. + +## Code of Conduct + +By participating in this project, you agree to maintain a respectful and inclusive environment for everyone. + +## How Can I Contribute? + +### Reporting Bugs + +Before creating bug reports, please check existing issues to avoid duplicates. When creating a bug report, include: + +- A clear, descriptive title +- Exact steps to reproduce the problem +- Expected behavior vs actual behavior +- Your environment (Elixir version, OS, etc.) +- Any relevant logs or error messages + +### Suggesting Enhancements + +Enhancement suggestions are welcome! Please provide: + +- A clear, descriptive title +- Detailed description of the proposed functionality +- Why this enhancement would be useful +- Any implementation ideas you might have + +### Pull Requests + +1. Fork the repository +2. Create a new branch from `main`: + ```bash + git checkout -b feature/your-feature-name + ``` + +3. Make your changes following our coding standards (see below) + +4. Add or update tests as necessary + +5. Ensure all tests pass: + ```bash + mix test + ``` + +6. Format your code: + ```bash + mix format + ``` + +7. Run static analysis: + ```bash + mix credo + ``` + +8. Commit your changes with a clear commit message: + ```bash + git commit -m "Add feature: brief description" + ``` + +9. Push to your fork: + ```bash + git push origin feature/your-feature-name + ``` + +10. Open a Pull Request with a clear title and description + +## Development Setup + +### Prerequisites + +- Elixir 1.14+ +- Erlang/OTP 25+ +- Git + +### Setup Steps + +```bash +# Clone your fork +git clone https://github.com/YOUR_USERNAME/elixir-blog-engine.git +cd elixir-blog-engine + +# Add upstream remote +git remote add upstream https://github.com/codeforgood-org/elixir-blog-engine.git + +# Install dependencies +mix deps.get + +# Run tests +mix test + +# Build the executable +mix escript.build +``` + +## Coding Standards + +### Elixir Style Guide + +- Follow the [Elixir Style Guide](https://github.com/christopheradams/elixir_style_guide) +- Use `mix format` to automatically format code +- Run `mix credo` to check for code quality issues +- Maximum line length: 120 characters (enforced by formatter) + +### Documentation + +- Add `@moduledoc` to all modules +- Add `@doc` to all public functions +- Include `@spec` type specifications for public functions +- Provide usage examples in documentation when helpful + +Example: +```elixir +@doc """ +Creates a new post with the given attributes. + +## Examples + + iex> BlogEngine.Post.new(1, "Hello", "World", ["elixir"]) + %BlogEngine.Post{id: 1, title: "Hello", ...} +""" +@spec new(non_neg_integer(), String.t(), String.t(), list(String.t()) | nil) :: t() +def new(id, title, body, tags \\ nil) do + # implementation +end +``` + +### Testing + +- Write tests for all new functionality +- Maintain or improve code coverage +- Use descriptive test names +- Group related tests with `describe` blocks + +Example: +```elixir +defmodule BlogEngine.PostTest do + use ExUnit.Case + alias BlogEngine.Post + + describe "new/4" do + test "creates a post with all fields" do + post = Post.new(1, "Title", "Body", ["tag"]) + assert post.id == 1 + assert post.title == "Title" + end + end +end +``` + +### Commits + +- Use clear, descriptive commit messages +- Start with a verb in the imperative mood ("Add", "Fix", "Update") +- Keep the first line under 72 characters +- Add detailed description if needed in the commit body + +Good examples: +``` +Add search functionality for posts +Fix date formatting in post display +Update README with installation instructions +``` + +## Project Structure + +Understanding the project structure will help you contribute effectively: + +``` +lib/ +├── blog_engine.ex # Core business logic +└── blog_engine/ + ├── cli.ex # User interface + ├── post.ex # Data structure + └── storage.ex # Persistence + +test/ +├── blog_engine_test.exs +└── blog_engine/ + ├── cli_test.exs + ├── post_test.exs + └── storage_test.exs +``` + +## Testing + +### Running Tests + +```bash +# Run all tests +mix test + +# Run specific test file +mix test test/blog_engine/post_test.exs + +# Run tests matching a pattern +mix test --only tag_name + +# Run with coverage +mix coveralls +``` + +### Writing Tests + +- Test both happy paths and error cases +- Use factories or fixtures for test data +- Clean up any test artifacts (files, etc.) +- Mock external dependencies when appropriate + +## Pull Request Process + +1. **Update Documentation** - Ensure README and module docs reflect your changes + +2. **Update CHANGELOG.md** - Add an entry under "Unreleased" section + +3. **Pass All Checks** - Ensure tests, formatting, and Credo pass + +4. **Request Review** - Tag maintainers for review + +5. **Address Feedback** - Make requested changes promptly + +6. **Keep Updated** - Rebase on main if needed: + ```bash + git fetch upstream + git rebase upstream/main + ``` + +## Review Process + +Maintainers will review your PR for: + +- Code quality and style +- Test coverage +- Documentation completeness +- Backward compatibility +- Performance implications + +We aim to review PRs within 3-5 business days. + +## Recognition + +Contributors will be recognized in: +- CHANGELOG.md for their contributions +- GitHub contributors page + +## Questions? + +Feel free to: +- Open an issue for discussion +- Reach out to maintainers +- Ask questions in your PR + +Thank you for contributing to BlogEngine! diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1b319ad --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# Build stage +FROM hexpm/elixir:1.16.0-erlang-26.2.1-alpine-3.19.0 AS build + +# Install build dependencies +RUN apk add --no-cache build-base git + +# Set working directory +WORKDIR /app + +# Install hex and rebar +RUN mix local.hex --force && \ + mix local.rebar --force + +# Copy mix files +COPY mix.exs mix.lock ./ + +# Install dependencies +RUN mix deps.get --only prod && \ + mix deps.compile + +# Copy application code +COPY lib ./lib +COPY config ./config +COPY priv ./priv + +# Build escript +ENV MIX_ENV=prod +RUN mix escript.build + +# Runtime stage +FROM alpine:3.19 + +# Install runtime dependencies +RUN apk add --no-cache \ + ncurses-libs \ + libstdc++ \ + libgcc + +# Create non-root user +RUN addgroup -g 1000 blogengine && \ + adduser -D -u 1000 -G blogengine blogengine + +# Set working directory +WORKDIR /app + +# Copy escript from build stage +COPY --from=build --chown=blogengine:blogengine /app/blog_engine /app/blog_engine + +# Create data directory +RUN mkdir -p /app/priv/data && \ + chown -R blogengine:blogengine /app + +# Switch to non-root user +USER blogengine + +# Set entrypoint +ENTRYPOINT ["/app/blog_engine"] + +# Labels +LABEL maintainer="codeforgood-org" \ + description="BlogEngine - A powerful CLI blog manager built with Elixir" \ + version="1.0.0" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ff1f3f1 --- /dev/null +++ b/Makefile @@ -0,0 +1,135 @@ +.PHONY: help install build run test format lint dialyzer clean docs coverage quality ci release docker + +# Default target +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Available targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +install: ## Install dependencies + mix deps.get + mix deps.compile + +build: ## Build the escript executable + mix escript.build + @echo "✓ Executable built: ./blog_engine" + +run: build ## Build and run the application + ./blog_engine + +dev: ## Run in development mode with Mix + mix run -e "BlogEngine.CLI.start()" + +iex: ## Start IEx with the application loaded + iex -S mix + +test: ## Run all tests + mix test + +test-watch: ## Run tests in watch mode (requires mix test.watch) + mix test.watch + +format: ## Format code + mix format + +format-check: ## Check if code is formatted + mix format --check-formatted + +lint: ## Run Credo linter + mix credo + +lint-strict: ## Run Credo in strict mode + mix credo --strict + +dialyzer: ## Run Dialyzer type checker + mix dialyzer + +dialyzer-plt: ## Build Dialyzer PLT files (first time setup) + mix dialyzer --plt + +clean: ## Clean build artifacts + mix clean + rm -rf _build deps doc cover priv/plts blog_engine + @echo "✓ Cleaned build artifacts" + +clean-data: ## Clean data files (WARNING: deletes all posts!) + @echo "⚠️ This will delete all posts!" + @read -p "Are you sure? [y/N] " -n 1 -r; \ + echo; \ + if [[ $$REPLY =~ ^[Yy]$$ ]]; then \ + rm -rf priv/data/*.json; \ + echo "✓ Data files deleted"; \ + else \ + echo "Cancelled"; \ + fi + +docs: ## Generate documentation + mix docs + @echo "✓ Documentation generated in doc/index.html" + +coverage: ## Run tests with coverage report + mix coveralls + +coverage-html: ## Generate HTML coverage report + mix coveralls.html + @echo "✓ Coverage report generated in cover/excoveralls.html" + +quality: format lint ## Run all code quality checks + @echo "✓ All quality checks passed" + +ci: format-check lint test dialyzer ## Run all CI checks + @echo "✓ All CI checks passed" + +deps-update: ## Update all dependencies + mix deps.update --all + @echo "✓ Dependencies updated" + +deps-outdated: ## Check for outdated dependencies + mix hex.outdated + +release: clean install test quality build ## Prepare a release build + @echo "✓ Release build complete" + +# Docker targets +docker-build: ## Build Docker image + docker build -t blog-engine:latest . + @echo "✓ Docker image built: blog-engine:latest" + +docker-run: ## Run in Docker container + docker run -it --rm \ + -v $(PWD)/priv/data:/app/priv/data \ + blog-engine:latest + +docker-shell: ## Open shell in Docker container + docker run -it --rm \ + -v $(PWD)/priv/data:/app/priv/data \ + --entrypoint /bin/sh \ + blog-engine:latest + +# Utility targets +check: ## Quick check (format + lint + test) + @echo "Running quick checks..." + @make format-check + @make lint + @make test + @echo "✓ Quick checks passed" + +setup: install dialyzer-plt ## Initial project setup + @echo "✓ Project setup complete" + +backup: ## Backup all posts to timestamped file + @mkdir -p backups + @timestamp=$$(date +%Y%m%d_%H%M%S); \ + if [ -f priv/data/posts.json ]; then \ + cp priv/data/posts.json backups/posts_$$timestamp.json; \ + echo "✓ Backup created: backups/posts_$$timestamp.json"; \ + else \ + echo "⚠️ No posts file found"; \ + fi + +benchmark: ## Run performance benchmarks (if implemented) + mix run benchmark/run.exs + +all: clean install quality test build docs ## Run everything + @echo "✓ All tasks completed" diff --git a/README.md b/README.md new file mode 100644 index 0000000..cb91b32 --- /dev/null +++ b/README.md @@ -0,0 +1,337 @@ +# BlogEngine + +[![CI](https://github.com/codeforgood-org/elixir-blog-engine/workflows/CI/badge.svg)](https://github.com/codeforgood-org/elixir-blog-engine/actions) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Elixir](https://img.shields.io/badge/elixir-1.14%2B-purple.svg)](https://elixir-lang.org/) +[![Erlang/OTP](https://img.shields.io/badge/erlang-25%2B-red.svg)](https://www.erlang.org/) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) + +A powerful, feature-rich CLI blog engine built with Elixir. Manage your blog posts from the command line with an intuitive interface, persistent storage, and advanced features like tagging, search, and export/import capabilities. + +## 📚 Documentation + +- [Quick Start Guide](docs/QUICK_START.md) - Get started in 5 minutes +- [API Documentation](docs/API.md) - Complete API reference +- [Development Guide](docs/DEVELOPMENT.md) - Setup and workflow +- [Architecture](docs/ARCHITECTURE.md) - Design and patterns +- [Docker Guide](docs/DOCKER.md) - Docker usage and deployment +- [Contributing](CONTRIBUTING.md) - How to contribute +- [Security Policy](SECURITY.md) - Security guidelines +- [Changelog](CHANGELOG.md) - Version history + +## Features + +- **Create, Read, Update, Delete (CRUD)** - Full post management +- **Tagging System** - Organize posts with tags +- **Search Functionality** - Find posts by keywords in title, body, or tags +- **Persistent Storage** - Posts saved to JSON automatically +- **Import/Export** - Backup and share your posts +- **Statistics** - Track your blogging activity +- **Multi-line Input** - Write longer posts comfortably +- **Timestamps** - Automatic creation and update tracking +- **Interactive CLI** - User-friendly command-line interface + +## Installation + +### Prerequisites + +- Elixir 1.14 or higher +- Erlang/OTP 25 or higher + +### Quick Start + +1. Clone the repository: +```bash +git clone https://github.com/codeforgood-org/elixir-blog-engine.git +cd elixir-blog-engine +``` + +2. Install dependencies: +```bash +mix deps.get +``` + +3. Build the executable: +```bash +mix escript.build +``` + +4. Run the blog engine: +```bash +./blog_engine +``` + +Alternatively, you can run directly with Mix: +```bash +mix run -e "BlogEngine.CLI.start()" +``` + +### Using Docker + +Run with Docker (no Elixir installation needed): + +```bash +# Build the image +docker build -t blog-engine . + +# Run interactively +docker run -it --rm \ + -v $(pwd)/priv/data:/app/priv/data \ + blog-engine + +# Or use docker-compose +docker-compose up blog-engine +``` + +See the [Docker Guide](docs/DOCKER.md) for more details. + +### Using Makefile + +Common tasks with make: + +```bash +make install # Install dependencies +make build # Build escript +make run # Build and run +make test # Run tests +make quality # Run format + lint +make ci # Run all CI checks +make docker-build # Build Docker image +make help # Show all commands +``` + +### Try the Examples + +Load sample posts to explore features: + +```bash +# Import example posts +./scripts/import_examples.sh + +# Or run the API demo +mix run scripts/demo.exs +``` + +## Usage + +### Available Commands + +| Command | Description | +|---------|-------------| +| `new` | Create a new post | +| `list` | List all posts | +| `view ` | View a post by ID | +| `edit ` | Edit a post by ID | +| `delete ` | Delete a post by ID | +| `search ` | Search posts by keyword | +| `tag ` | List posts with a specific tag | +| `tags` | Show all tags and their counts | +| `export ` | Export all posts to a file | +| `import ` | Import posts from a file | +| `stats` | Show blog statistics | +| `help` | Show help message | +| `quit` | Exit the application | + +### Example Workflow + +``` +> new +Title: Getting Started with Elixir +Body (empty line to finish): +Elixir is a dynamic, functional language designed for building +scalable and maintainable applications. + +Tags (comma-separated, optional): elixir, tutorial, programming + +✓ Post created successfully with ID 1 + +> list + +=== All Posts === +-------------------------------------------------------------------------------- + +[1] Getting Started with Elixir + 2025-11-13 17:32:15 | Tags: elixir, tutorial, programming + +-------------------------------------------------------------------------------- +Total: 1 post(s) + +> search elixir + +=== Search Results for 'elixir' === +-------------------------------------------------------------------------------- + +[1] Getting Started with Elixir + 2025-11-13 17:32:15 | Tags: elixir, tutorial, programming + +-------------------------------------------------------------------------------- +Found: 1 post(s) + +> stats + +=== Blog Statistics === +Total posts: 1 +Total words: 12 +Average words per post: 12 +Total unique tags: 3 +Oldest post: Getting Started with Elixir (2025-11-13 17:32:15) +Newest post: Getting Started with Elixir (2025-11-13 17:32:15) +``` + +## Development + +### Running Tests + +```bash +mix test +``` + +Run tests with coverage: +```bash +mix coveralls +``` + +Generate HTML coverage report: +```bash +mix coveralls.html +``` + +### Code Quality + +Format code: +```bash +mix format +``` + +Run static analysis with Credo: +```bash +mix credo +``` + +Run type checking with Dialyzer: +```bash +mix dialyzer +``` + +### Generate Documentation + +```bash +mix docs +``` + +Documentation will be available in `doc/index.html`. + +## Project Structure + +``` +elixir-blog-engine/ +├── lib/ +│ ├── blog_engine.ex # Core business logic +│ └── blog_engine/ +│ ├── cli.ex # Command-line interface +│ ├── post.ex # Post struct and functions +│ └── storage.ex # JSON persistence layer +├── test/ +│ ├── blog_engine_test.exs +│ └── blog_engine/ +│ ├── cli_test.exs +│ ├── post_test.exs +│ └── storage_test.exs +├── priv/ +│ └── data/ # Persistent storage directory +│ └── posts.json # Saved posts (auto-generated) +├── config/ +│ └── config.exs # Application configuration +├── mix.exs # Project configuration +├── .formatter.exs # Code formatter config +├── .credo.exs # Credo linter config +└── README.md +``` + +## Storage + +Posts are automatically saved to `priv/data/posts.json` in JSON format. The file is created automatically when you create your first post. + +### Backup Your Posts + +Export to a backup file: +``` +> export ~/my-blog-backup.json +✓ Posts exported successfully to /home/user/my-blog-backup.json +``` + +Import from a backup: +``` +> import ~/my-blog-backup.json +✓ Posts imported successfully from /home/user/my-blog-backup.json +``` + +## Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. + +### Getting Started + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Make your changes +4. Run tests and ensure they pass +5. Format your code (`mix format`) +6. Commit your changes (`git commit -m 'Add amazing feature'`) +7. Push to the branch (`git push origin feature/amazing-feature`) +8. Open a Pull Request + +## Architecture + +BlogEngine follows a clean, modular architecture: + +- **BlogEngine** - Core module with business logic (CRUD operations, search, etc.) +- **BlogEngine.Post** - Post data structure and related functions +- **BlogEngine.Storage** - Handles JSON persistence +- **BlogEngine.CLI** - Interactive command-line interface + +This separation of concerns makes the codebase: +- Easy to test +- Simple to maintain +- Straightforward to extend with new features + +## Roadmap + +Future enhancements planned: + +- [ ] Markdown support for post bodies +- [ ] Post categories +- [ ] Draft/published status +- [ ] Post scheduling +- [ ] Multiple storage backends (Markdown files, SQL) +- [ ] Web interface +- [ ] RSS feed generation +- [ ] Full-text search with ranking +- [ ] Post templates +- [ ] Bulk operations + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +Built with: +- [Elixir](https://elixir-lang.org/) - The Elixir programming language +- [Jason](https://github.com/michalmuskala/jason) - JSON encoding/decoding +- [ExUnit](https://hexdocs.pm/ex_unit/) - Testing framework +- [ExDoc](https://github.com/elixir-lang/ex_doc) - Documentation generation +- [Credo](https://github.com/rrrene/credo) - Static code analysis +- [Dialyxir](https://github.com/jeremyjh/dialyxir) - Type checking + +## Support + +If you encounter any issues or have questions: + +1. Check the [documentation](https://hexdocs.pm/blog_engine/) +2. Search [existing issues](https://github.com/codeforgood-org/elixir-blog-engine/issues) +3. Open a [new issue](https://github.com/codeforgood-org/elixir-blog-engine/issues/new) + +--- + +Made with ❤️ by the CodeForGood community diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..196f504 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,193 @@ +# Security Policy + +## Supported Versions + +We release patches for security vulnerabilities. Which versions are eligible for receiving such patches depends on the CVSS v3.0 Rating: + +| Version | Supported | +| ------- | ------------------ | +| 1.x.x | :white_check_mark: | +| < 1.0 | :x: | + +## Reporting a Vulnerability + +**Please do not report security vulnerabilities through public GitHub issues.** + +If you discover a security vulnerability within BlogEngine, please send an email to the maintainers or report it through GitHub's Security Advisory feature. + +### Reporting Process + +1. **GitHub Security Advisory** (Preferred) + - Go to the repository's Security tab + - Click "Report a vulnerability" + - Fill out the advisory form with details + +2. **Email Report** + - Send details to the project maintainers + - Include the word "SECURITY" in the subject line + - Provide as much information as possible + +### What to Include + +Please include the following information in your report: + +- Type of vulnerability (e.g., SQL injection, XSS, command injection) +- Full paths of source file(s) related to the vulnerability +- Location of the affected source code (tag/branch/commit or direct URL) +- Step-by-step instructions to reproduce the issue +- Proof-of-concept or exploit code (if possible) +- Impact of the issue, including how an attacker might exploit it +- Your assessment of the severity + +### What to Expect + +- **Acknowledgment**: You should receive an acknowledgment within 48 hours +- **Communication**: We will keep you informed about the progress of fixing the vulnerability +- **Disclosure**: We will work with you to understand the issue and determine an appropriate disclosure timeline +- **Credit**: We will credit you for the discovery (unless you prefer to remain anonymous) + +## Security Best Practices for Users + +When using BlogEngine, follow these security best practices: + +### Data Security + +1. **Backup Regularly** + ```bash + > export ~/backups/blog-$(date +%Y%m%d).json + ``` + +2. **Protect Your Data** + - Keep `priv/data/posts.json` permissions restricted + - Don't commit sensitive post content to public repositories + - Use `.gitignore` to exclude data files from version control + +3. **File Permissions** + ```bash + chmod 600 priv/data/posts.json # Only owner can read/write + ``` + +### Input Validation + +BlogEngine sanitizes user input, but be aware: + +- All user input is treated as text +- No code execution from post content +- JSON files are validated before import + +### Export/Import Security + +When using export/import features: + +1. **Verify File Sources** + - Only import files from trusted sources + - Inspect JSON files before importing + - Watch for unusually large files + +2. **File Paths** + - Use absolute paths for export/import + - Avoid exporting to web-accessible directories + - Don't import from untrusted network locations + +### Dependency Security + +Keep dependencies updated: + +```bash +# Check for outdated dependencies +mix hex.outdated + +# Update dependencies +mix deps.update --all + +# Audit dependencies for known vulnerabilities +mix deps.audit +``` + +## Known Limitations + +### Current Security Considerations + +1. **Local Storage** + - Posts stored in plain text JSON + - No encryption at rest + - Suitable for local, single-user scenarios + +2. **Access Control** + - No user authentication + - No role-based access control + - Designed for single-user CLI usage + +3. **Network Security** + - No network communication (CLI only) + - No remote access vulnerabilities + - No web server exposure + +### Not Suitable For + +BlogEngine is **not designed** for: + +- Multi-user environments without proper OS-level access controls +- Storing sensitive or confidential information +- Production environments requiring encryption +- Scenarios requiring audit trails +- Network-accessible deployments without additional security layers + +## Secure Deployment + +If you want to use BlogEngine in a shared environment: + +1. **Use OS-Level Security** + ```bash + # Create dedicated user + sudo useradd -m blogengine + + # Restrict file access + chown -R blogengine:blogengine /path/to/blog_engine + chmod 700 /path/to/blog_engine + ``` + +2. **Separate Environments** + - Use different data directories for different users + - Don't share the escript binary between users + - Each user should have their own installation + +3. **Container Isolation** + - Use Docker for isolation (see Dockerfile) + - Run with non-root user + - Mount data directory as volume with restricted permissions + +## Security Updates + +We take security seriously and will: + +- Release patches for confirmed vulnerabilities promptly +- Communicate security issues through GitHub Security Advisories +- Maintain a security changelog in CHANGELOG.md +- Credit researchers who responsibly disclose vulnerabilities + +## Disclosure Policy + +When we receive a security report, we will: + +1. Confirm the vulnerability and determine its impact +2. Develop and test a fix +3. Release a patch update +4. Publish a security advisory +5. Credit the reporter (if desired) + +We aim to resolve critical vulnerabilities within 7 days and moderate vulnerabilities within 30 days. + +## Questions? + +If you have questions about this security policy, please open a GitHub issue with the "question" label. + +## Acknowledgments + +We appreciate the security research community and recognize the following researchers: + +- None reported yet + +--- + +**Last Updated**: 2025-11-13 diff --git a/benchmark/run.exs b/benchmark/run.exs new file mode 100755 index 0000000..fa41939 --- /dev/null +++ b/benchmark/run.exs @@ -0,0 +1,168 @@ +#!/usr/bin/env elixir + +# BlogEngine Performance Benchmarks +# Run with: mix run benchmark/run.exs + +Mix.install([ + {:benchee, "~> 1.3"} +]) + +defmodule BenchmarkHelper do + def create_sample_posts(count) do + Enum.map(1..count, fn i -> + %BlogEngine.Post{ + id: i, + title: "Benchmark Post #{i}", + body: String.duplicate("Lorem ipsum dolor sit amet. ", 100), + tags: ["benchmark", "test", "tag#{rem(i, 5)}"], + created_at: DateTime.utc_now(), + updated_at: nil + } + end) + end + + def create_state(post_count) do + posts = create_sample_posts(post_count) + %{posts: posts, next_id: post_count + 1} + end +end + +IO.puts("\n" <> String.duplicate("=", 80)) +IO.puts(" BlogEngine Performance Benchmarks") +IO.puts(String.duplicate("=", 80) <> "\n") + +# Benchmark: List Posts +IO.puts("Running list_posts benchmarks...") +Benchee.run( + %{ + "list_posts (100 posts)" => fn -> + state = BenchmarkHelper.create_state(100) + BlogEngine.list_posts(state) + end, + "list_posts (1000 posts)" => fn -> + state = BenchmarkHelper.create_state(1000) + BlogEngine.list_posts(state) + end, + "list_posts (10000 posts)" => fn -> + state = BenchmarkHelper.create_state(10000) + BlogEngine.list_posts(state) + end + }, + time: 5, + memory_time: 2, + warmup: 2 +) + +IO.puts("\n") + +# Benchmark: Search Posts +IO.puts("Running search_posts benchmarks...") +Benchee.run( + %{ + "search (100 posts)" => fn -> + state = BenchmarkHelper.create_state(100) + BlogEngine.search_posts(state, "benchmark") + end, + "search (1000 posts)" => fn -> + state = BenchmarkHelper.create_state(1000) + BlogEngine.search_posts(state, "benchmark") + end, + "search (10000 posts)" => fn -> + state = BenchmarkHelper.create_state(10000) + BlogEngine.search_posts(state, "benchmark") + end + }, + time: 5, + memory_time: 2, + warmup: 2 +) + +IO.puts("\n") + +# Benchmark: Filter by Tag +IO.puts("Running tag filtering benchmarks...") +Benchee.run( + %{ + "filter by tag (100 posts)" => fn -> + state = BenchmarkHelper.create_state(100) + BlogEngine.list_posts(state, "benchmark") + end, + "filter by tag (1000 posts)" => fn -> + state = BenchmarkHelper.create_state(1000) + BlogEngine.list_posts(state, "benchmark") + end, + "filter by tag (10000 posts)" => fn -> + state = BenchmarkHelper.create_state(10000) + BlogEngine.list_posts(state, "benchmark") + end + }, + time: 5, + memory_time: 2, + warmup: 2 +) + +IO.puts("\n") + +# Benchmark: Find Post +IO.puts("Running find_post benchmarks...") +Benchee.run( + %{ + "find (100 posts, first)" => fn -> + state = BenchmarkHelper.create_state(100) + BlogEngine.find_post(state, 1) + end, + "find (100 posts, last)" => fn -> + state = BenchmarkHelper.create_state(100) + BlogEngine.find_post(state, 100) + end, + "find (1000 posts, middle)" => fn -> + state = BenchmarkHelper.create_state(1000) + BlogEngine.find_post(state, 500) + end, + "find (10000 posts, middle)" => fn -> + state = BenchmarkHelper.create_state(10000) + BlogEngine.find_post(state, 5000) + end + }, + time: 5, + memory_time: 2, + warmup: 2 +) + +IO.puts("\n") + +# Benchmark: Get All Tags +IO.puts("Running get_all_tags benchmarks...") +Benchee.run( + %{ + "get_all_tags (100 posts)" => fn -> + state = BenchmarkHelper.create_state(100) + BlogEngine.get_all_tags(state) + end, + "get_all_tags (1000 posts)" => fn -> + state = BenchmarkHelper.create_state(1000) + BlogEngine.get_all_tags(state) + end, + "get_all_tags (10000 posts)" => fn -> + state = BenchmarkHelper.create_state(10000) + BlogEngine.get_all_tags(state) + end + }, + time: 5, + memory_time: 2, + warmup: 2 +) + +IO.puts("\n" <> String.duplicate("=", 80)) +IO.puts(" Benchmarks Complete!") +IO.puts(String.duplicate("=", 80)) +IO.puts("\nPerformance Summary:") +IO.puts(" - list_posts: O(n log n) due to sorting") +IO.puts(" - search_posts: O(n) linear search") +IO.puts(" - filter by tag: O(n) linear filter") +IO.puts(" - find_post: O(n) linear search") +IO.puts(" - get_all_tags: O(n) with frequency counting") +IO.puts("\nRecommendations:") +IO.puts(" - For <10k posts: Current implementation is sufficient") +IO.puts(" - For >10k posts: Consider indexing or database backend") +IO.puts("") diff --git a/blog_engine.exs b/blog_engine.exs deleted file mode 100644 index 2463de5..0000000 --- a/blog_engine.exs +++ /dev/null @@ -1,98 +0,0 @@ -defmodule BlogEngine do - defmodule Post do - defstruct [:id, :title, :body, :created_at] - end - - def start do - IO.puts("Welcome to the Elixir Blog Engine CLI") - loop([], 1) - end - - defp loop(posts, next_id) do - IO.puts("\nAvailable commands:") - IO.puts(" new - Create a new post") - IO.puts(" list - List all posts") - IO.puts(" view [id] - View a post by ID") - IO.puts(" delete [id] - Delete a post by ID") - IO.puts(" quit - Exit the app") - - case IO.gets("\nCommand> ") |> String.trim() do - "new" -> - {post, new_id} = create_post(next_id) - loop([post | posts], new_id) - - "list" -> - list_posts(posts) - loop(posts, next_id) - - "quit" -> - IO.puts("Goodbye!") - - cmd when String.starts_with?(cmd, "view ") -> - [_ | [id_str]] = String.split(cmd) - view_post(posts, String.to_integer(id_str)) - loop(posts, next_id) - - cmd when String.starts_with?(cmd, "delete ") -> - [_ | [id_str]] = String.split(cmd) - updated_posts = delete_post(posts, String.to_integer(id_str)) - loop(updated_posts, next_id) - - _ -> - IO.puts("Invalid command.") - loop(posts, next_id) - end - end - - defp create_post(id) do - title = IO.gets("Title: ") |> String.trim() - body = IO.gets("Body: ") |> String.trim() - created_at = DateTime.utc_now() |> DateTime.to_string() - - post = %Post{ - id: id, - title: title, - body: body, - created_at: created_at - } - - IO.puts("Post created with ID #{id}.") - {post, id + 1} - end - - defp list_posts([]) do - IO.puts("No posts available.") - end - - defp list_posts(posts) do - IO.puts("\nPosts:") - Enum.each(Enum.reverse(posts), fn post -> - IO.puts(" [#{post.id}] #{post.title} (#{post.created_at})") - end) - end - - defp view_post(posts, id) do - case Enum.find(posts, fn p -> p.id == id end) do - nil -> - IO.puts("Post not found.") - post -> - IO.puts("\n--- Post ##{post.id} ---") - IO.puts("Title: #{post.title}") - IO.puts("Created: #{post.created_at}") - IO.puts("Body:\n#{post.body}") - end - end - - defp delete_post(posts, id) do - case Enum.find(posts, fn p -> p.id == id end) do - nil -> - IO.puts("Post not found.") - posts - _ -> - IO.puts("Post #{id} deleted.") - Enum.reject(posts, fn p -> p.id == id end) - end - end -end - -BlogEngine.start() diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..a046509 --- /dev/null +++ b/config/config.exs @@ -0,0 +1,11 @@ +import Config + +# BlogEngine configuration + +# Configure the storage directory for posts +config :blog_engine, + storage_dir: "priv/data", + storage_file: "posts.json" + +# Import environment specific config if it exists +import_config "#{config_env()}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..ee3340c --- /dev/null +++ b/config/dev.exs @@ -0,0 +1,3 @@ +import Config + +# Development-specific configuration diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..a074dc4 --- /dev/null +++ b/config/prod.exs @@ -0,0 +1,3 @@ +import Config + +# Production-specific configuration diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..804a3f3 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,8 @@ +import Config + +# Test-specific configuration + +# Use a separate directory for test data to avoid interfering with dev data +config :blog_engine, + storage_dir: "test/fixtures/data", + storage_file: "test_posts.json" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..973a81a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + blog-engine: + build: + context: . + dockerfile: Dockerfile + image: blog-engine:latest + container_name: blog-engine + volumes: + # Mount data directory to persist posts + - ./priv/data:/app/priv/data + stdin_open: true # Enable interactive mode + tty: true # Allocate pseudo-TTY + restart: unless-stopped + + # Alternative service for development with hot reload + blog-engine-dev: + image: hexpm/elixir:1.16.0-erlang-26.2.1-alpine-3.19.0 + container_name: blog-engine-dev + working_dir: /app + volumes: + - .:/app + - deps:/app/deps + - build:/app/_build + command: sh -c "mix deps.get && mix run -e 'BlogEngine.CLI.start()'" + stdin_open: true + tty: true + environment: + - MIX_ENV=dev + +volumes: + deps: + build: diff --git a/docs/ADVANCED.md b/docs/ADVANCED.md new file mode 100644 index 0000000..212eb37 --- /dev/null +++ b/docs/ADVANCED.md @@ -0,0 +1,703 @@ +# Advanced Usage Guide + +This guide covers advanced features, customizations, and power-user tips for BlogEngine. + +## Table of Contents + +- [Using BlogEngine as a Library](#using-blogengine-as-a-library) +- [Custom Storage Backends](#custom-storage-backends) +- [Automation and Scripting](#automation-and-scripting) +- [Performance Optimization](#performance-optimization) +- [Integration Examples](#integration-examples) +- [Extending BlogEngine](#extending-blogengine) +- [Advanced Workflows](#advanced-workflows) + +## Using BlogEngine as a Library + +BlogEngine can be used programmatically in other Elixir projects. + +### Adding as Dependency + +```elixir +# mix.exs +defp deps do + [ + {:blog_engine, git: "https://github.com/codeforgood-org/elixir-blog-engine.git"} + ] +end +``` + +### Programmatic Usage + +```elixir +# Initialize +state = BlogEngine.init() + +# Create posts +{state, post1} = BlogEngine.create_post(state, "Title 1", "Body 1", ["tag1"]) +{state, post2} = BlogEngine.create_post(state, "Title 2", "Body 2", ["tag2"]) + +# Query +all_posts = BlogEngine.list_posts(state) +elixir_posts = BlogEngine.list_posts(state, "elixir") +search_results = BlogEngine.search_posts(state, "keyword") + +# Update +{:ok, state, updated} = BlogEngine.update_post(state, 1, title: "New Title") + +# Delete +{:ok, state} = BlogEngine.delete_post(state, 2) + +# Export/Import +:ok = BlogEngine.export_posts(state, "backup.json") +{:ok, state} = BlogEngine.import_posts(state, "backup.json") +``` + +### GenServer Wrapper + +For concurrent access, wrap in a GenServer: + +```elixir +defmodule BlogEngine.Server do + use GenServer + + # Client API + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + def create_post(title, body, tags) do + GenServer.call(__MODULE__, {:create_post, title, body, tags}) + end + + def list_posts(tag \\ nil) do + GenServer.call(__MODULE__, {:list_posts, tag}) + end + + def search_posts(query) do + GenServer.call(__MODULE__, {:search_posts, query}) + end + + # Server Callbacks + + @impl true + def init(_opts) do + state = BlogEngine.init() + {:ok, state} + end + + @impl true + def handle_call({:create_post, title, body, tags}, _from, state) do + {new_state, post} = BlogEngine.create_post(state, title, body, tags) + {:reply, {:ok, post}, new_state} + end + + @impl true + def handle_call({:list_posts, tag}, _from, state) do + posts = BlogEngine.list_posts(state, tag) + {:reply, posts, state} + end + + @impl true + def handle_call({:search_posts, query}, _from, state) do + posts = BlogEngine.search_posts(state, query) + {:reply, posts, state} + end +end + +# Usage +{:ok, _pid} = BlogEngine.Server.start_link() +{:ok, post} = BlogEngine.Server.create_post("Title", "Body", ["tag"]) +posts = BlogEngine.Server.list_posts() +``` + +## Custom Storage Backends + +Implement custom storage for different backends. + +### Interface + +```elixir +defmodule BlogEngine.Storage.Behaviour do + @callback load_posts() :: list(BlogEngine.Post.t()) + @callback save_posts(list(BlogEngine.Post.t())) :: :ok | {:error, term()} + @callback export_posts(list(BlogEngine.Post.t()), String.t()) :: :ok | {:error, term()} + @callback import_posts(String.t()) :: {:ok, list(BlogEngine.Post.t())} | {:error, term()} +end +``` + +### Example: Markdown Backend + +```elixir +defmodule BlogEngine.Storage.Markdown do + @behaviour BlogEngine.Storage.Behaviour + + @storage_dir "posts" + + @impl true + def load_posts do + File.mkdir_p(@storage_dir) + + Path.wildcard("#{@storage_dir}/*.md") + |> Enum.map(&parse_markdown_file/1) + end + + @impl true + def save_posts(posts) do + File.mkdir_p(@storage_dir) + + Enum.each(posts, fn post -> + filename = "#{@storage_dir}/#{post.id}-#{slugify(post.title)}.md" + content = format_post_as_markdown(post) + File.write!(filename, content) + end) + + :ok + end + + defp parse_markdown_file(path) do + content = File.read!(path) + # Parse frontmatter and content + # Return Post struct + end + + defp format_post_as_markdown(post) do + """ + --- + id: #{post.id} + title: #{post.title} + tags: #{Enum.join(post.tags || [], ", ")} + created_at: #{DateTime.to_iso8601(post.created_at)} + --- + + #{post.body} + """ + end + + defp slugify(string) do + string + |> String.downcase() + |> String.replace(~r/[^a-z0-9]+/, "-") + |> String.trim("-") + end + + @impl true + def export_posts(posts, path), do: save_posts(posts) + + @impl true + def import_posts(path), do: {:ok, load_posts()} +end +``` + +### Example: PostgreSQL Backend + +```elixir +defmodule BlogEngine.Storage.Postgres do + @behaviour BlogEngine.Storage.Behaviour + + # Requires Ecto and Postgrex + + @impl true + def load_posts do + Repo.all(BlogPost) + |> Enum.map(&to_post_struct/1) + end + + @impl true + def save_posts(posts) do + Repo.transaction(fn -> + Enum.each(posts, fn post -> + changeset = BlogPost.changeset(%BlogPost{}, post_to_map(post)) + Repo.insert_or_update!(changeset) + end) + end) + + :ok + end + + defp to_post_struct(blog_post) do + %BlogEngine.Post{ + id: blog_post.id, + title: blog_post.title, + body: blog_post.body, + tags: blog_post.tags, + created_at: blog_post.created_at, + updated_at: blog_post.updated_at + } + end + + defp post_to_map(post) do + %{ + id: post.id, + title: post.title, + body: post.body, + tags: post.tags, + created_at: post.created_at, + updated_at: post.updated_at + } + end + + # Implement export_posts and import_posts... +end +``` + +## Automation and Scripting + +### Automated Post Creation + +```elixir +#!/usr/bin/env elixir + +# create_daily_post.exs +Mix.install([{:blog_engine, git: "..."}]) + +state = BlogEngine.init() +date = Date.utc_today() |> Date.to_string() + +{state, post} = BlogEngine.create_post( + state, + "Daily Journal - #{date}", + "Today's entry...", + ["journal", "daily"] +) + +IO.puts("Created post ##{post.id}") +``` + +### Batch Operations + +```elixir +# Import multiple files +files = Path.wildcard("imports/*.json") + +state = Enum.reduce(files, BlogEngine.init(), fn file, acc_state -> + {:ok, new_state} = BlogEngine.import_posts(acc_state, file) + new_state +end) +``` + +### Scheduled Backups + +```bash +#!/bin/bash +# backup.sh + +DATE=$(date +%Y%m%d) +BACKUP_DIR="$HOME/blog-backups" + +mkdir -p "$BACKUP_DIR" + +cd /path/to/blog-engine +./blog_engine << EOF +export $BACKUP_DIR/posts_$DATE.json +quit +EOF + +echo "Backup created: $BACKUP_DIR/posts_$DATE.json" +``` + +Add to crontab: +``` +0 2 * * * /path/to/backup.sh +``` + +## Performance Optimization + +### Indexing Posts + +```elixir +defmodule BlogEngine.Index do + def build_indices(posts) do + %{ + by_id: build_id_index(posts), + by_tag: build_tag_index(posts), + search: build_search_index(posts) + } + end + + defp build_id_index(posts) do + Map.new(posts, fn post -> {post.id, post} end) + end + + defp build_tag_index(posts) do + posts + |> Enum.flat_map(fn post -> + (post.tags || []) + |> Enum.map(fn tag -> {String.downcase(tag), post} end) + end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + end + + defp build_search_index(posts) do + Map.new(posts, fn post -> + tokens = + [post.title, post.body] + |> Enum.join(" ") + |> String.downcase() + |> String.split() + |> MapSet.new() + + {post.id, tokens} + end) + end + + def search_indexed(indices, query) do + query_tokens = query |> String.downcase() |> String.split() |> MapSet.new() + + indices.search + |> Enum.filter(fn {_id, tokens} -> + MapSet.intersection(tokens, query_tokens) |> MapSet.size() > 0 + end) + |> Enum.map(fn {id, _} -> indices.by_id[id] end) + end +end +``` + +### Pagination + +```elixir +defmodule BlogEngine.Pagination do + def paginate(posts, page, per_page \\ 10) do + offset = (page - 1) * per_page + + %{ + items: Enum.slice(posts, offset, per_page), + page: page, + per_page: per_page, + total: length(posts), + total_pages: ceil(length(posts) / per_page) + } + end +end + +# Usage +posts = BlogEngine.list_posts(state) +page1 = BlogEngine.Pagination.paginate(posts, 1, 20) +``` + +### Lazy Loading + +```elixir +defmodule BlogEngine.Lazy do + def list_posts_lazy(state) do + state.posts + |> Stream.filter(&(&1.published)) + |> Stream.map(&transform_post/1) + end + + def search_posts_lazy(state, query) do + state.posts + |> Stream.filter(&Post.matches_query?(&1, query)) + |> Stream.take(100) # Limit results + |> Enum.to_list() + end +end +``` + +## Integration Examples + +### RSS Feed Generation + +```elixir +defmodule BlogEngine.RSS do + def generate_feed(posts, opts \\ []) do + title = opts[:title] || "My Blog" + link = opts[:link] || "https://example.com" + description = opts[:description] || "Blog posts" + + items = + posts + |> Enum.take(20) + |> Enum.map(&generate_item/1) + |> Enum.join("\n") + + """ + + + + #{title} + #{link} + #{description} + #{items} + + + """ + end + + defp generate_item(post) do + """ + + #{escape_xml(post.title)} + #{escape_xml(post.body)} + #{format_rfc822(post.created_at)} + + """ + end + + defp escape_xml(text) do + text + |> String.replace("&", "&") + |> String.replace("<", "<") + |> String.replace(">", ">") + end + + defp format_rfc822(datetime) do + # Format DateTime as RFC822 + Calendar.strftime(datetime, "%a, %d %b %Y %H:%M:%S +0000") + end +end + +# Usage +state = BlogEngine.init() +posts = BlogEngine.list_posts(state) +rss = BlogEngine.RSS.generate_feed(posts, title: "My Tech Blog") +File.write!("feed.xml", rss) +``` + +### Static Site Generation + +```elixir +defmodule BlogEngine.StaticSite do + def generate(posts, output_dir) do + File.mkdir_p!(output_dir) + + # Generate index + generate_index(posts, output_dir) + + # Generate individual posts + Enum.each(posts, &generate_post(&1, output_dir)) + + # Generate tag pages + generate_tag_pages(posts, output_dir) + end + + defp generate_index(posts, output_dir) do + html = """ + + + Blog + +

Blog Posts

+ #{render_post_list(posts)} + + + """ + + File.write!(Path.join(output_dir, "index.html"), html) + end + + defp generate_post(post, output_dir) do + html = """ + + + #{post.title} + +

#{post.title}

+

#{format_date(post.created_at)}

+ #{render_body(post.body)} + + + """ + + filename = "post-#{post.id}.html" + File.write!(Path.join(output_dir, filename), html) + end + + defp render_post_list(posts) do + posts + |> Enum.map(fn post -> + "
  • #{post.title}
  • " + end) + |> Enum.join("\n") + end + + defp render_body(body) do + body + |> String.split("\n") + |> Enum.map(&"

    #{&1}

    ") + |> Enum.join("\n") + end + + defp format_date(datetime) do + Calendar.strftime(datetime, "%B %d, %Y") + end +end +``` + +## Extending BlogEngine + +### Custom Commands + +Add custom CLI commands by modifying `lib/blog_engine/cli.ex`: + +```elixir +# Add to @commands list +{"archive ", "Archive posts older than N days"} + +# Add pattern match +defp parse_command(input) do + # ... existing patterns ... + ["archive", days] -> {:archive, [String.to_integer(days)]} +end + +# Add handler +defp handle_archive(state, days_old) do + cutoff = DateTime.add(DateTime.utc_now(), -days_old * 86400) + + {to_archive, to_keep} = + Enum.split_with(state.posts, fn post -> + DateTime.compare(post.created_at, cutoff) == :lt + end) + + archive_file = "archive_#{Date.utc_today()}.json" + :ok = BlogEngine.Storage.export_posts(to_archive, archive_file) + + new_state = %{state | posts: to_keep} + BlogEngine.Storage.save_posts(to_keep) + + IO.puts("✓ Archived #{length(to_archive)} posts to #{archive_file}") + new_state +end +``` + +### Plugin System + +Create a simple plugin system: + +```elixir +defmodule BlogEngine.Plugin do + @callback on_post_create(BlogEngine.Post.t()) :: :ok + @callback on_post_update(BlogEngine.Post.t(), BlogEngine.Post.t()) :: :ok + @callback on_post_delete(BlogEngine.Post.t()) :: :ok +end + +defmodule BlogEngine.Plugins do + def load_plugins do + # Load configured plugins + Application.get_env(:blog_engine, :plugins, []) + end + + def trigger_event(event, data) do + load_plugins() + |> Enum.each(fn plugin -> apply(plugin, event, data) end) + end +end + +# Example plugin +defmodule MyPlugin do + @behaviour BlogEngine.Plugin + + @impl true + def on_post_create(post) do + IO.puts("Plugin: New post created - #{post.title}") + :ok + end + + @impl true + def on_post_update(old_post, new_post) do + IO.puts("Plugin: Post updated - #{new_post.title}") + :ok + end + + @impl true + def on_post_delete(post) do + IO.puts("Plugin: Post deleted - #{post.title}") + :ok + end +end +``` + +## Advanced Workflows + +### Content Pipeline + +```elixir +defmodule BlogEngine.Pipeline do + def process_post(post) do + post + |> validate() + |> sanitize() + |> add_metadata() + |> generate_excerpt() + |> save() + end + + defp validate(post) do + # Validate required fields + post + end + + defp sanitize(post) do + # Remove unwanted characters + %{post | body: String.trim(post.body)} + end + + defp add_metadata(post) do + # Add reading time, word count, etc. + word_count = length(String.split(post.body)) + # Store in tags or custom field + post + end + + defp generate_excerpt(post) do + # Auto-generate excerpt + post + end + + defp save(post) do + # Save to storage + post + end +end +``` + +### Batch Imports + +```elixir +defmodule BlogEngine.BatchImport do + def import_from_wordpress(xml_file) do + # Parse WordPress XML + # Convert to BlogEngine posts + # Import in batches + end + + def import_from_medium(json_file) do + # Parse Medium export + # Convert format + # Import posts + end + + def import_from_markdown(directory) do + Path.wildcard("#{directory}/**/*.md") + |> Enum.map(&parse_markdown/1) + |> Enum.reduce(BlogEngine.init(), fn post, state -> + {new_state, _} = BlogEngine.create_post( + state, + post.title, + post.body, + post.tags + ) + new_state + end) + end +end +``` + +## Best Practices + +1. **Always backup before batch operations** +2. **Test custom code with small datasets first** +3. **Monitor memory usage with large datasets** +4. **Use streams for processing large collections** +5. **Index frequently queried data** +6. **Validate imported data** +7. **Log important operations** +8. **Handle errors gracefully** + +--- + +For more information: +- [API Documentation](API.md) +- [Development Guide](DEVELOPMENT.md) +- [Architecture](ARCHITECTURE.md) diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..856de82 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,627 @@ +# API Documentation + +This document describes the public API of BlogEngine modules. + +## BlogEngine Module + +The core module providing business logic for post management. + +### Types + +```elixir +@type post_id :: non_neg_integer() +@type posts :: list(Post.t()) +@type state :: %{posts: posts(), next_id: post_id()} +``` + +### Functions + +#### init/0 + +```elixir +@spec init() :: state() +``` + +Initializes the blog engine by loading existing posts from storage. + +**Returns:** Initial state with posts and next available ID + +**Example:** +```elixir +state = BlogEngine.init() +# => %{posts: [...], next_id: 5} +``` + +--- + +#### create_post/4 + +```elixir +@spec create_post(state(), String.t(), String.t(), list(String.t()) | nil) :: {state(), Post.t()} +``` + +Creates a new post with the given title, body, and optional tags. + +**Parameters:** +- `state` - Current application state +- `title` - Post title +- `body` - Post body content +- `tags` - Optional list of tags (default: nil) + +**Returns:** Tuple of {updated_state, created_post} + +**Example:** +```elixir +{new_state, post} = BlogEngine.create_post( + state, + "My Title", + "Post content here", + ["elixir", "tutorial"] +) +``` + +--- + +#### list_posts/2 + +```elixir +@spec list_posts(state(), String.t() | nil) :: posts() +``` + +Lists all posts, optionally filtered by tag. Posts are sorted by creation date (newest first). + +**Parameters:** +- `state` - Current application state +- `tag` - Optional tag to filter by (default: nil) + +**Returns:** List of posts + +**Example:** +```elixir +all_posts = BlogEngine.list_posts(state) +elixir_posts = BlogEngine.list_posts(state, "elixir") +``` + +--- + +#### find_post/2 + +```elixir +@spec find_post(state(), post_id()) :: {:ok, Post.t()} | {:error, :not_found} +``` + +Finds a post by its ID. + +**Parameters:** +- `state` - Current application state +- `id` - Post ID to find + +**Returns:** +- `{:ok, post}` if found +- `{:error, :not_found}` if not found + +**Example:** +```elixir +case BlogEngine.find_post(state, 1) do + {:ok, post} -> IO.puts(post.title) + {:error, :not_found} -> IO.puts("Not found") +end +``` + +--- + +#### update_post/3 + +```elixir +@spec update_post(state(), post_id(), keyword()) :: {:ok, state(), Post.t()} | {:error, :not_found} +``` + +Updates a post with new attributes. + +**Parameters:** +- `state` - Current application state +- `id` - Post ID to update +- `attrs` - Keyword list of attributes to update (`:title`, `:body`, `:tags`) + +**Returns:** +- `{:ok, updated_state, updated_post}` if successful +- `{:error, :not_found}` if post doesn't exist + +**Example:** +```elixir +{:ok, new_state, updated_post} = BlogEngine.update_post( + state, + 1, + title: "New Title", + tags: ["updated"] +) +``` + +--- + +#### delete_post/2 + +```elixir +@spec delete_post(state(), post_id()) :: {:ok, state()} | {:error, :not_found} +``` + +Deletes a post by its ID. + +**Parameters:** +- `state` - Current application state +- `id` - Post ID to delete + +**Returns:** +- `{:ok, updated_state}` if successful +- `{:error, :not_found}` if post doesn't exist + +**Example:** +```elixir +{:ok, new_state} = BlogEngine.delete_post(state, 1) +``` + +--- + +#### search_posts/2 + +```elixir +@spec search_posts(state(), String.t()) :: posts() +``` + +Searches posts by query string. Searches in title, body, and tags (case-insensitive). + +**Parameters:** +- `state` - Current application state +- `query` - Search query string + +**Returns:** List of matching posts (sorted by date, newest first) + +**Example:** +```elixir +results = BlogEngine.search_posts(state, "elixir") +``` + +--- + +#### get_all_tags/1 + +```elixir +@spec get_all_tags(state()) :: list({String.t(), non_neg_integer()}) +``` + +Gets all unique tags from all posts with their post counts. + +**Parameters:** +- `state` - Current application state + +**Returns:** List of {tag, count} tuples, sorted by count (descending) + +**Example:** +```elixir +tags = BlogEngine.get_all_tags(state) +# => [{"elixir", 5}, {"tutorial", 3}, ...] +``` + +--- + +#### export_posts/2 + +```elixir +@spec export_posts(state(), String.t()) :: :ok | {:error, term()} +``` + +Exports all posts to a JSON file. + +**Parameters:** +- `state` - Current application state +- `path` - File path to export to + +**Returns:** `:ok` or `{:error, reason}` + +**Example:** +```elixir +:ok = BlogEngine.export_posts(state, "/tmp/backup.json") +``` + +--- + +#### import_posts/2 + +```elixir +@spec import_posts(state(), String.t()) :: {:ok, state()} | {:error, term()} +``` + +Imports posts from a JSON file and merges with existing posts. Assigns new IDs to avoid conflicts. + +**Parameters:** +- `state` - Current application state +- `path` - File path to import from + +**Returns:** +- `{:ok, updated_state}` if successful +- `{:error, reason}` if file can't be read or parsed + +**Example:** +```elixir +{:ok, new_state} = BlogEngine.import_posts(state, "/tmp/backup.json") +``` + +--- + +## BlogEngine.Post Module + +Represents a blog post with all its metadata. + +### Types + +```elixir +@type t :: %__MODULE__{ + id: non_neg_integer(), + title: String.t(), + body: String.t(), + tags: list(String.t()) | nil, + created_at: DateTime.t(), + updated_at: DateTime.t() | nil +} +``` + +### Functions + +#### new/4 + +```elixir +@spec new(non_neg_integer(), String.t(), String.t(), list(String.t()) | nil) :: t() +``` + +Creates a new post with the given attributes. + +**Parameters:** +- `id` - Post ID +- `title` - Post title +- `body` - Post body content +- `tags` - Optional list of tags (default: nil) + +**Returns:** New Post struct + +**Example:** +```elixir +post = Post.new(1, "Title", "Body", ["tag1"]) +``` + +--- + +#### update/2 + +```elixir +@spec update(t(), keyword()) :: t() +``` + +Updates a post with new attributes. Sets the `updated_at` timestamp. + +**Parameters:** +- `post` - Post to update +- `attrs` - Keyword list of attributes to update + +**Returns:** Updated Post struct + +**Example:** +```elixir +updated = Post.update(post, title: "New Title", tags: ["new"]) +``` + +--- + +#### format_date/1 + +```elixir +@spec format_date(DateTime.t()) :: String.t() +``` + +Formats a DateTime for display. + +**Parameters:** +- `datetime` - DateTime to format + +**Returns:** Formatted string (YYYY-MM-DD HH:MM:SS) + +**Example:** +```elixir +Post.format_date(post.created_at) +# => "2025-11-13 17:32:15" +``` + +--- + +#### preview/1 + +```elixir +@spec preview(t()) :: String.t() +``` + +Returns a short preview of the post (title and first 100 chars of body). + +**Parameters:** +- `post` - Post to preview + +**Returns:** Preview string + +**Example:** +```elixir +preview = Post.preview(post) +``` + +--- + +#### matches_query?/2 + +```elixir +@spec matches_query?(t(), String.t()) :: boolean() +``` + +Checks if a post matches a search query (searches title, body, and tags, case-insensitive). + +**Parameters:** +- `post` - Post to check +- `query` - Search query + +**Returns:** true if matches, false otherwise + +**Example:** +```elixir +if Post.matches_query?(post, "elixir") do + IO.puts("Match found!") +end +``` + +--- + +#### has_tag?/2 + +```elixir +@spec has_tag?(t(), String.t()) :: boolean() +``` + +Checks if a post has a specific tag (case-insensitive). + +**Parameters:** +- `post` - Post to check +- `tag` - Tag to search for + +**Returns:** true if post has the tag, false otherwise + +**Example:** +```elixir +if Post.has_tag?(post, "tutorial") do + IO.puts("This is a tutorial!") +end +``` + +--- + +## BlogEngine.Storage Module + +Handles persistent storage of blog posts to JSON files. + +### Functions + +#### load_posts/0 + +```elixir +@spec load_posts() :: list(Post.t()) +``` + +Loads all posts from the storage file. + +**Returns:** List of posts (empty list if file doesn't exist) + +**Example:** +```elixir +posts = Storage.load_posts() +``` + +--- + +#### save_posts/1 + +```elixir +@spec save_posts(list(Post.t())) :: :ok | {:error, term()} +``` + +Saves all posts to the storage file. Creates the directory if needed. + +**Parameters:** +- `posts` - List of posts to save + +**Returns:** `:ok` or `{:error, reason}` + +**Example:** +```elixir +:ok = Storage.save_posts(posts) +``` + +--- + +#### export_posts/2 + +```elixir +@spec export_posts(list(Post.t()), String.t()) :: :ok | {:error, term()} +``` + +Exports posts to a specified file path. + +**Parameters:** +- `posts` - List of posts to export +- `path` - File path to export to + +**Returns:** `:ok` or `{:error, reason}` + +**Example:** +```elixir +:ok = Storage.export_posts(posts, "/tmp/export.json") +``` + +--- + +#### import_posts/1 + +```elixir +@spec import_posts(String.t()) :: {:ok, list(Post.t())} | {:error, term()} +``` + +Imports posts from a specified file path. + +**Parameters:** +- `path` - File path to import from + +**Returns:** +- `{:ok, posts}` if successful +- `{:error, reason}` if file can't be read or parsed + +**Example:** +```elixir +{:ok, imported_posts} = Storage.import_posts("/tmp/import.json") +``` + +--- + +## BlogEngine.CLI Module + +Command-line interface for the BlogEngine application. + +### Functions + +#### main/1 + +```elixir +def main(_args) +``` + +Main entry point for the escript. + +**Parameters:** +- `_args` - Command-line arguments (currently unused) + +--- + +#### start/0 + +```elixir +def start() +``` + +Starts the interactive blog engine CLI. + +**Example:** +```elixir +BlogEngine.CLI.start() +``` + +--- + +## Usage Examples + +### Basic CRUD Operations + +```elixir +# Initialize +state = BlogEngine.init() + +# Create +{state, post} = BlogEngine.create_post(state, "Title", "Body", ["tag"]) + +# Read +{:ok, post} = BlogEngine.find_post(state, post.id) + +# Update +{:ok, state, post} = BlogEngine.update_post(state, post.id, title: "New Title") + +# Delete +{:ok, state} = BlogEngine.delete_post(state, post.id) +``` + +### Search and Filter + +```elixir +# Search +results = BlogEngine.search_posts(state, "elixir") + +# Filter by tag +elixir_posts = BlogEngine.list_posts(state, "elixir") + +# Get all tags +tags = BlogEngine.get_all_tags(state) +``` + +### Import/Export + +```elixir +# Export +:ok = BlogEngine.export_posts(state, "backup.json") + +# Import +{:ok, state} = BlogEngine.import_posts(state, "backup.json") +``` + +### Working with Posts + +```elixir +# Create and modify +post = Post.new(1, "Title", "Body", ["tag"]) +updated = Post.update(post, title: "New Title") + +# Check properties +if Post.has_tag?(post, "tutorial") do + IO.puts("This is a tutorial") +end + +if Post.matches_query?(post, "elixir") do + IO.puts("Found elixir content") +end + +# Format for display +formatted_date = Post.format_date(post.created_at) +preview = Post.preview(post) +``` + +--- + +## Error Handling + +All functions that can fail return tagged tuples: + +```elixir +# Success +{:ok, result} +{:ok, state, result} +:ok + +# Failure +{:error, reason} +{:error, :not_found} +``` + +Always pattern match on results: + +```elixir +case BlogEngine.find_post(state, id) do + {:ok, post} -> + # Handle success + {:error, :not_found} -> + # Handle not found +end +``` + +--- + +## Thread Safety + +BlogEngine is designed for single-user CLI usage and is **not thread-safe**. The state is managed through functional updates rather than shared mutable state. + +If you need concurrent access, wrap operations in a GenServer or similar process. + +--- + +For more information, see: +- [Development Guide](DEVELOPMENT.md) +- [Architecture](ARCHITECTURE.md) +- [Quick Start](QUICK_START.md) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..db55c89 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,406 @@ +# Architecture + +This document describes the architecture and design decisions of BlogEngine. + +## Overview + +BlogEngine follows a clean, modular architecture with clear separation of concerns. The application is structured in layers: + +``` +┌─────────────────────────────────────┐ +│ CLI Layer │ +│ (User Interface) │ +│ BlogEngine.CLI │ +└─────────────┬───────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Business Logic Layer │ +│ (Core Functionality) │ +│ BlogEngine │ +└─────────────┬───────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Data Layer │ +│ BlogEngine.Post │ +│ BlogEngine.Storage │ +└─────────────────────────────────────┘ +``` + +## Module Responsibilities + +### BlogEngine.CLI + +**Purpose:** Command-line interface and user interaction + +**Responsibilities:** +- Parse user commands +- Display formatted output +- Handle user input (single-line, multi-line) +- Manage the REPL (Read-Eval-Print Loop) +- Format posts for display + +**Design Decisions:** +- Uses pattern matching for command parsing +- Stateful loop maintains application state +- Delegates all business logic to BlogEngine module +- No direct access to storage layer + +**Key Functions:** +- `main/1` - Escript entry point +- `start/0` - Initialize and start REPL +- `loop/1` - Main REPL loop +- Command handlers (`handle_new/1`, `handle_list/2`, etc.) + +### BlogEngine + +**Purpose:** Core business logic and orchestration + +**Responsibilities:** +- CRUD operations on posts +- Search functionality +- Tag management +- Import/Export coordination +- State management + +**Design Decisions:** +- Pure functions where possible +- Returns tuples `{:ok, result}` or `{:error, reason}` for operations that can fail +- Immutable state updates +- Delegates persistence to Storage module +- No knowledge of CLI layer + +**Key Functions:** +- `init/0` - Initialize application state +- `create_post/4`, `update_post/3`, `delete_post/2` +- `list_posts/2`, `find_post/2` +- `search_posts/2` +- `get_all_tags/1` +- `import_posts/2`, `export_posts/2` + +**State Structure:** +```elixir +%{ + posts: [%Post{}, ...], + next_id: integer() +} +``` + +### BlogEngine.Post + +**Purpose:** Post data structure and post-related operations + +**Responsibilities:** +- Define Post struct +- Post creation and updating +- Query matching (search, tags) +- Date formatting +- Post preview generation + +**Design Decisions:** +- Enforces required keys (`@enforce_keys`) +- Uses typespec for type safety +- Immutable updates via `struct/2` +- Pure functions for all operations + +**Post Structure:** +```elixir +%BlogEngine.Post{ + id: non_neg_integer(), + title: String.t(), + body: String.t(), + tags: list(String.t()) | nil, + created_at: DateTime.t(), + updated_at: DateTime.t() | nil +} +``` + +### BlogEngine.Storage + +**Purpose:** Persistence layer for posts + +**Responsibilities:** +- Save posts to JSON file +- Load posts from JSON file +- Import/Export functionality +- Serialization/Deserialization + +**Design Decisions:** +- JSON format for human-readable storage +- Pretty-printed JSON for easy inspection +- Automatic directory creation +- Graceful handling of missing files +- Preserves all post fields including timestamps + +**Storage Format:** +```json +[ + { + "id": 1, + "title": "Post Title", + "body": "Post body content", + "tags": ["tag1", "tag2"], + "created_at": "2025-11-13T17:32:15Z", + "updated_at": null + } +] +``` + +## Data Flow + +### Creating a Post + +``` +User Input + ↓ +CLI.handle_new/1 + ↓ +BlogEngine.create_post/4 + ↓ +Post.new/4 (creates struct) + ↓ +Storage.save_posts/1 (persists) + ↓ +Updated State + ↓ +CLI displays confirmation +``` + +### Loading Posts on Startup + +``` +BlogEngine.init/0 + ↓ +Storage.load_posts/0 + ↓ +JSON file read + ↓ +Deserialize posts + ↓ +Calculate next_id + ↓ +Return initial state +``` + +### Searching Posts + +``` +User search query + ↓ +CLI.handle_search/2 + ↓ +BlogEngine.search_posts/2 + ↓ +Filter posts via Post.matches_query?/2 + ↓ +Sort by date + ↓ +CLI displays results +``` + +## Design Patterns + +### Separation of Concerns + +Each module has a single, well-defined responsibility: +- **CLI** - User interface +- **BlogEngine** - Business logic +- **Post** - Data structure and behavior +- **Storage** - Persistence + +### Immutability + +All data structures are immutable. Updates create new versions: + +```elixir +# Instead of mutating: +post.title = "New Title" # Not possible in Elixir + +# We create new structs: +updated_post = Post.update(post, title: "New Title") +``` + +### Functional Core, Imperative Shell + +- **Functional Core:** Pure functions in Post and most of BlogEngine +- **Imperative Shell:** CLI handles I/O, Storage handles file operations + +### Error Handling + +Two approaches based on context: + +**Tagged Tuples (for expected errors):** +```elixir +{:ok, post} = BlogEngine.find_post(state, id) +{:error, :not_found} = BlogEngine.find_post(state, 999) +``` + +**Pattern Matching:** +```elixir +case BlogEngine.find_post(state, id) do + {:ok, post} -> display_post(post) + {:error, :not_found} -> IO.puts("Not found") +end +``` + +## Persistence Strategy + +### Why JSON? + +- **Human-readable:** Easy to inspect and edit manually +- **Simple:** No database setup required +- **Portable:** Works on any system +- **Git-friendly:** Text format works well with version control +- **Sufficient:** Adequate for typical CLI use cases + +### Storage Location + +``` +priv/data/posts.json +``` + +- `priv/` - Standard Elixir directory for runtime data +- Ignored by git (except .gitkeep) +- Automatically created if missing + +### Future Considerations + +The architecture is designed to easily swap storage backends: + +```elixir +# Current +BlogEngine.Storage (JSON) + +# Future possibilities +BlogEngine.Storage.JSON +BlogEngine.Storage.Markdown +BlogEngine.Storage.SQLite +BlogEngine.Storage.PostgreSQL +``` + +Simply implement the same interface: +- `load_posts/0` +- `save_posts/1` +- `import_posts/1` +- `export_posts/2` + +## Testing Strategy + +### Unit Tests + +Each module has comprehensive unit tests: +- **Post** - Test struct creation, updates, queries +- **Storage** - Test persistence, serialization +- **BlogEngine** - Test business logic + +### Integration Tests + +BlogEngine tests also serve as integration tests, testing the interaction between BlogEngine, Post, and Storage modules. + +### Test Isolation + +- Each test starts with clean state +- Storage is cleaned up before/after tests +- No shared state between tests + +## Performance Considerations + +### Current Implementation + +- All posts loaded into memory +- Linear search for queries +- O(n) operations for most searches + +### Appropriate For + +- Personal blogs (< 10,000 posts) +- CLI usage patterns +- Single-user scenarios + +### Scaling Considerations + +For larger datasets, consider: +- Lazy loading +- Indexing (by tag, date) +- Database backend +- Caching layer + +## Security Considerations + +### Input Validation + +- All user input is treated as strings +- No SQL injection risk (no SQL) +- No XSS risk (CLI output only) + +### File System + +- Writes only to designated directory +- Validates file paths for import/export +- No shell command execution from user input + +## Extension Points + +### Adding New Commands + +1. Add command to `@commands` list in CLI +2. Add pattern matching case in `parse_command/1` +3. Implement handler function +4. Add tests + +### Adding New Post Fields + +1. Update Post struct +2. Update Storage serialization/deserialization +3. Update CLI display functions +4. Add migration for existing data + +### Adding New Features + +The modular design makes it easy to add: +- New search algorithms +- Post categories +- Draft/published status +- Post scheduling +- RSS feed generation + +## Trade-offs + +### Simplicity vs. Features + +**Choice:** Favor simplicity +- No database complexity +- No web server overhead +- Pure Elixir, minimal dependencies + +### Performance vs. Maintainability + +**Choice:** Favor maintainability +- Simple linear searches +- All data in memory +- Clear, readable code + +### Flexibility vs. Constraints + +**Choice:** Balance both +- Modular architecture allows changes +- Simple interface keeps it focused +- Easy to extend without breaking + +## Future Architecture Considerations + +### Potential Improvements + +1. **Plugin System** - Allow custom commands and features +2. **Multiple Storage Backends** - Database, Markdown files, etc. +3. **Web Interface** - Optional HTTP server +4. **API Layer** - Separate business logic from CLI +5. **Event System** - Pub/sub for extensibility + +### Backwards Compatibility + +Design ensures: +- JSON format is stable +- API interfaces are versioned +- Migration paths for data diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..51c30fe --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,321 @@ +# Development Guide + +This guide will help you set up your development environment and understand the development workflow for BlogEngine. + +## Prerequisites + +- **Elixir 1.14 or higher** - [Installation Guide](https://elixir-lang.org/install.html) +- **Erlang/OTP 25 or higher** - Usually installed with Elixir +- **Git** - For version control +- A text editor or IDE (VS Code with ElixirLS is recommended) + +## Setting Up Your Development Environment + +### 1. Clone the Repository + +```bash +git clone https://github.com/codeforgood-org/elixir-blog-engine.git +cd elixir-blog-engine +``` + +### 2. Install Dependencies + +```bash +mix deps.get +``` + +### 3. Verify Setup + +Run tests to ensure everything is working: + +```bash +mix test +``` + +## Development Workflow + +### Running the Application + +There are several ways to run the application during development: + +**Method 1: Using Mix (Recommended for development)** +```bash +mix run -e "BlogEngine.CLI.start()" +``` + +**Method 2: Build and run escript** +```bash +mix escript.build +./blog_engine +``` + +**Method 3: Using IEx (Interactive Elixir)** +```bash +iex -S mix +``` + +Then in the IEx shell: +```elixir +BlogEngine.CLI.start() +``` + +### Running Tests + +**Run all tests:** +```bash +mix test +``` + +**Run specific test file:** +```bash +mix test test/blog_engine/post_test.exs +``` + +**Run tests matching a pattern:** +```bash +mix test --only focus +``` + +**Run tests with coverage:** +```bash +mix coveralls +``` + +**Generate HTML coverage report:** +```bash +mix coveralls.html +open cover/excoveralls.html +``` + +### Code Quality + +**Format code:** +```bash +mix format +``` + +**Check formatting without changing files:** +```bash +mix format --check-formatted +``` + +**Run Credo (static analysis):** +```bash +mix credo +``` + +**Run Credo in strict mode:** +```bash +mix credo --strict +``` + +**Run Dialyzer (type checking):** +```bash +# First time - create PLT files (this takes a while) +mix dialyzer --plt + +# Subsequent runs +mix dialyzer +``` + +### Documentation + +**Generate documentation:** +```bash +mix docs +``` + +Documentation will be available in `doc/index.html`. + +**View documentation:** +```bash +open doc/index.html +``` + +## Project Structure Explained + +``` +elixir-blog-engine/ +├── .github/ +│ └── workflows/ # GitHub Actions CI/CD +│ ├── ci.yml # Continuous Integration +│ └── release.yml # Release automation +├── config/ +│ ├── config.exs # Main configuration +│ ├── dev.exs # Development config +│ ├── test.exs # Test config +│ └── prod.exs # Production config +├── docs/ +│ └── DEVELOPMENT.md # This file +├── lib/ +│ ├── blog_engine.ex # Core business logic +│ └── blog_engine/ +│ ├── cli.ex # Command-line interface +│ ├── post.ex # Post data structure +│ └── storage.ex # JSON persistence +├── priv/ +│ └── data/ # Runtime data storage +│ └── .gitkeep +├── test/ +│ ├── test_helper.exs # Test setup +│ ├── blog_engine_test.exs +│ └── blog_engine/ +│ ├── post_test.exs +│ └── storage_test.exs +├── .credo.exs # Credo configuration +├── .formatter.exs # Code formatter config +├── .gitignore +├── CHANGELOG.md +├── CONTRIBUTING.md +├── LICENSE +├── mix.exs # Project configuration +└── README.md +``` + +### Key Modules + +- **BlogEngine** - Core module containing business logic for CRUD operations, search, tags, etc. +- **BlogEngine.Post** - Defines the Post struct and post-related functions (creation, updating, searching) +- **BlogEngine.Storage** - Handles persistence to/from JSON files +- **BlogEngine.CLI** - Interactive command-line interface with REPL + +## Debugging + +### Using IEx.pry + +Add `require IEx; IEx.pry()` in your code: + +```elixir +def some_function(data) do + require IEx; IEx.pry() + # Code execution will pause here +end +``` + +Run with: +```bash +iex -S mix +``` + +### Using IO.inspect + +For quick debugging: + +```elixir +data +|> IO.inspect(label: "Debug") +|> some_function() +``` + +### Logging + +Add debug output: + +```elixir +require Logger +Logger.debug("Value: #{inspect(value)}") +``` + +## Common Tasks + +### Adding a New Feature + +1. Create a new branch: + ```bash + git checkout -b feature/my-new-feature + ``` + +2. Write tests first (TDD approach): + ```bash + # Edit test file + mix test + ``` + +3. Implement the feature + +4. Ensure all quality checks pass: + ```bash + mix test + mix format + mix credo + ``` + +5. Commit and push: + ```bash + git add . + git commit -m "Add feature: description" + git push origin feature/my-new-feature + ``` + +6. Open a Pull Request + +### Updating Dependencies + +```bash +mix deps.update --all +mix test +``` + +### Creating a Release + +1. Update version in `mix.exs` +2. Update `CHANGELOG.md` +3. Commit changes +4. Create and push tag: + ```bash + git tag -a v1.1.0 -m "Release version 1.1.0" + git push origin v1.1.0 + ``` + +The GitHub Actions workflow will automatically create a release with the escript binary. + +## Troubleshooting + +### Dependencies won't compile + +```bash +mix deps.clean --all +mix deps.get +mix compile +``` + +### Tests are failing + +```bash +# Clean build and recompile +mix clean +mix compile +mix test +``` + +### Dialyzer is taking too long + +```bash +# Rebuild PLT from scratch +rm -rf priv/plts +mix dialyzer --plt +``` + +## Best Practices + +1. **Write tests first** - Follow TDD principles +2. **Format your code** - Run `mix format` before committing +3. **Add documentation** - All public functions should have `@doc` and `@spec` +4. **Run quality checks** - Ensure tests and Credo pass before pushing +5. **Keep commits atomic** - One logical change per commit +6. **Write clear commit messages** - Use imperative mood + +## Getting Help + +- Read the [Contributing Guide](../CONTRIBUTING.md) +- Check [existing issues](https://github.com/codeforgood-org/elixir-blog-engine/issues) +- Open a new issue for bugs or questions +- Join the discussion in Pull Requests + +## Resources + +- [Elixir Documentation](https://hexdocs.pm/elixir/) +- [Elixir School](https://elixirschool.com/) +- [Elixir Forum](https://elixirforum.com/) +- [ExUnit Documentation](https://hexdocs.pm/ex_unit/) +- [Mix Documentation](https://hexdocs.pm/mix/) diff --git a/docs/DOCKER.md b/docs/DOCKER.md new file mode 100644 index 0000000..53cb0eb --- /dev/null +++ b/docs/DOCKER.md @@ -0,0 +1,341 @@ +# Docker Guide + +This guide explains how to use BlogEngine with Docker. + +## Quick Start + +### Using Docker + +Build and run with Docker: + +```bash +# Build the image +docker build -t blog-engine:latest . + +# Run interactively +docker run -it --rm \ + -v $(pwd)/priv/data:/app/priv/data \ + blog-engine:latest +``` + +### Using Docker Compose + +Even easier with docker-compose: + +```bash +# Build and start +docker-compose up blog-engine + +# Or run in background +docker-compose up -d blog-engine + +# Attach to running container +docker attach blog-engine + +# Stop +docker-compose down +``` + +## Image Details + +### Production Image + +The Dockerfile uses multi-stage builds for a minimal production image: + +- **Build stage**: Uses full Elixir image to compile escript +- **Runtime stage**: Minimal Alpine image (only ~50MB) +- **Non-root user**: Runs as user `blogengine` for security +- **Single binary**: Contains entire application in escript + +### Image Layers + +``` +alpine:3.19 (base) +├── Runtime libraries (ncurses, libstdc++) +├── blogengine user +├── blog_engine escript (~2MB) +└── priv/data directory +``` + +## Volume Mounting + +### Persistent Data + +Mount the data directory to persist posts: + +```bash +docker run -it --rm \ + -v $(pwd)/priv/data:/app/priv/data \ + blog-engine:latest +``` + +Without volume mounting, posts will be lost when container stops. + +### Backup Strategy + +Your posts are automatically persisted in the mounted volume: + +```bash +# Backup +cp -r priv/data backups/data-$(date +%Y%m%d) + +# Restore +cp -r backups/data-20251113/* priv/data/ +``` + +## Development with Docker + +### Development Container + +Use the dev service for development with hot reload: + +```bash +# Start dev container +docker-compose up blog-engine-dev + +# Or with make +make docker-dev +``` + +The dev container: +- Mounts source code as volume +- Installs dependencies in named volumes +- Runs with Mix (not escript) +- Supports interactive development + +### Running Tests + +```bash +# Run tests in container +docker-compose run --rm blog-engine-dev mix test + +# With coverage +docker-compose run --rm blog-engine-dev mix coveralls + +# Run formatter +docker-compose run --rm blog-engine-dev mix format + +# Run Credo +docker-compose run --rm blog-engine-dev mix credo +``` + +## Advanced Usage + +### Custom Data Directory + +Mount a different directory for data: + +```bash +docker run -it --rm \ + -v /path/to/my/blog:/app/priv/data \ + blog-engine:latest +``` + +### Import/Export with Docker + +#### Export from Container + +```bash +# Inside container, use export command +> export /app/priv/data/backup.json + +# Copy from container to host +docker cp blog-engine:/app/priv/data/backup.json ./backup.json +``` + +#### Import to Container + +```bash +# Copy from host to container +docker cp ./backup.json blog-engine:/app/priv/data/import.json + +# Inside container, use import command +> import /app/priv/data/import.json +``` + +### Multiple Blog Instances + +Run multiple isolated blog instances: + +```bash +# Blog 1 +docker run -it --rm \ + --name blog1 \ + -v $(pwd)/blog1-data:/app/priv/data \ + blog-engine:latest + +# Blog 2 (in another terminal) +docker run -it --rm \ + --name blog2 \ + -v $(pwd)/blog2-data:/app/priv/data \ + blog-engine:latest +``` + +## Building Custom Images + +### Build Arguments + +Customize the build: + +```dockerfile +# Use different Elixir version +docker build --build-arg ELIXIR_VERSION=1.15.0 -t blog-engine:custom . +``` + +### Multi-platform Builds + +Build for multiple architectures: + +```bash +# Setup buildx +docker buildx create --use + +# Build for multiple platforms +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + -t blog-engine:latest \ + --push . +``` + +## Makefile Commands + +Use the Makefile for convenience: + +```bash +make docker-build # Build Docker image +make docker-run # Run in Docker +make docker-shell # Open shell in container +``` + +## Security Considerations + +### Non-root User + +The container runs as non-root user `blogengine` (UID 1000): + +```dockerfile +USER blogengine +``` + +### Read-only Root Filesystem + +For extra security, mount root as read-only: + +```bash +docker run -it --rm \ + --read-only \ + -v $(pwd)/priv/data:/app/priv/data \ + blog-engine:latest +``` + +### Network Isolation + +BlogEngine doesn't need network access: + +```bash +docker run -it --rm \ + --network none \ + -v $(pwd)/priv/data:/app/priv/data \ + blog-engine:latest +``` + +## Troubleshooting + +### Permission Issues + +If you get permission errors: + +```bash +# Fix ownership +sudo chown -R 1000:1000 priv/data + +# Or run with your UID +docker run -it --rm \ + --user $(id -u):$(id -g) \ + -v $(pwd)/priv/data:/app/priv/data \ + blog-engine:latest +``` + +### Container Won't Start + +Check logs: + +```bash +docker logs blog-engine +``` + +### Data Not Persisting + +Ensure volume is mounted correctly: + +```bash +# Check mounts +docker inspect blog-engine | grep -A 10 Mounts +``` + +### Rebuild After Code Changes + +```bash +# Force rebuild +docker-compose build --no-cache + +# Or with Docker +docker build --no-cache -t blog-engine:latest . +``` + +## Best Practices + +1. **Always mount volumes** for data persistence +2. **Use docker-compose** for easier management +3. **Tag your images** with versions +4. **Backup regularly** using export command +5. **Use dev container** for development +6. **Keep base image updated** for security patches + +## Examples + +### Daily Blog Routine + +```bash +# Start your blog +docker-compose up -d blog-engine +docker attach blog-engine + +# Work on posts... +> new +> list +> edit 1 + +# Exit (Ctrl+C) + +# Backup +docker exec blog-engine cat /app/priv/data/posts.json > backup.json + +# Stop +docker-compose down +``` + +### CI/CD Integration + +```yaml +# .github/workflows/docker.yml +- name: Build Docker image + run: docker build -t blog-engine:${{ github.sha }} . + +- name: Test in Docker + run: | + docker run --rm blog-engine:${{ github.sha }} --version +``` + +## Resources + +- [Dockerfile Best Practices](https://docs.docker.com/develop/develop-images/dockerfile_best-practices/) +- [Docker Compose Documentation](https://docs.docker.com/compose/) +- [Multi-stage Builds](https://docs.docker.com/build/building/multi-stage/) + +--- + +For more information, see: +- [Quick Start Guide](QUICK_START.md) +- [Development Guide](DEVELOPMENT.md) +- [Security Policy](../SECURITY.md) diff --git a/docs/FAQ.md b/docs/FAQ.md new file mode 100644 index 0000000..41836f8 --- /dev/null +++ b/docs/FAQ.md @@ -0,0 +1,397 @@ +# Frequently Asked Questions (FAQ) + +## General Questions + +### What is BlogEngine? + +BlogEngine is a command-line interface (CLI) application built with Elixir for managing blog posts. It provides a simple, powerful way to create, edit, organize, and search blog posts right from your terminal. + +### Who is BlogEngine for? + +- Developers who prefer CLI tools +- Technical writers organizing content +- Anyone wanting a simple, local blog management system +- People learning Elixir looking for a real-world example +- Users who want full control over their blog data + +### Do I need Elixir installed? + +**Option 1:** Yes, if running natively, you need Elixir 1.14+ and Erlang/OTP 25+ +**Option 2:** No, if using Docker - just install Docker and run the container + +### Is this a web application? + +No, BlogEngine is a CLI (command-line) application. It runs in your terminal and stores posts locally as JSON files. There's no web server or browser interface. + +### Can I use this for an actual blog? + +BlogEngine is designed for local post management and organization. To publish posts online, you would need to: +- Export posts and convert to your blog platform's format +- Use BlogEngine as a local drafting tool +- Build a custom integration with your publishing platform + +## Installation & Setup + +### How do I install BlogEngine? + +```bash +# Clone the repository +git clone https://github.com/codeforgood-org/elixir-blog-engine.git +cd elixir-blog-engine + +# Install dependencies +mix deps.get + +# Build and run +mix escript.build +./blog_engine +``` + +Or use Docker: +```bash +docker-compose up blog-engine +``` + +### What are the system requirements? + +**Native:** +- Elixir 1.14 or higher +- Erlang/OTP 25 or higher +- ~50MB disk space + +**Docker:** +- Docker and Docker Compose +- ~100MB disk space (for image) + +### The 'mix' command isn't found. What do I do? + +You need to install Elixir first. Visit https://elixir-lang.org/install.html for installation instructions for your operating system. + +### Can I use BlogEngine without installing Elixir? + +Yes! Use the Docker version: + +```bash +docker build -t blog-engine . +docker run -it --rm -v $(pwd)/priv/data:/app/priv/data blog-engine +``` + +## Usage Questions + +### How do I create my first post? + +1. Start BlogEngine: `./blog_engine` +2. Type `new` and press Enter +3. Enter title and body (press Enter twice to finish body) +4. Optionally add tags +5. Done! Your post is saved automatically + +### Where are my posts stored? + +Posts are stored in `priv/data/posts.json` as JSON. This file is: +- Human-readable +- Auto-created on first post +- Automatically saved after every change +- Can be backed up, edited, or moved + +### How do I edit an existing post? + +Use the `edit` command: +``` +> edit 1 +``` + +Then update the fields you want to change. Press Enter to keep current values. + +### Can I write multi-line posts? + +Yes! When entering the body: +1. Type your content across multiple lines +2. Press Enter twice (empty line) when done + +### How do I delete a post? + +``` +> delete 1 +``` + +You'll be asked to confirm before deletion. + +### How do I search for posts? + +``` +> search your-keyword +``` + +This searches in titles, bodies, and tags (case-insensitive). + +### How do tags work? + +Tags help organize posts: +- Add tags when creating/editing: `elixir, tutorial, programming` +- View all tags: `tags` +- Filter by tag: `tag elixir` +- Search includes tags: `search elixir` + +### Can I export my posts? + +Yes! Export to JSON: +``` +> export /path/to/backup.json +``` + +### Can I import posts from a file? + +Yes! Import from JSON: +``` +> import /path/to/backup.json +``` + +Note: Imported posts get new IDs to avoid conflicts. + +### How do I backup my posts? + +**Option 1:** Use export command +``` +> export ~/blog-backup-$(date +%Y%m%d).json +``` + +**Option 2:** Copy the data file +```bash +cp priv/data/posts.json ~/backups/ +``` + +**Option 3:** Use Makefile +```bash +make backup +``` + +## Technical Questions + +### What format is used for storage? + +JSON (JavaScript Object Notation). Posts are stored as a pretty-printed JSON array, making them: +- Human-readable +- Easy to edit manually +- Version control friendly +- Portable across systems + +### Can I edit the JSON file directly? + +Yes, but be careful: +- Ensure valid JSON syntax +- Maintain the required fields (id, title, body, created_at) +- Use proper ISO8601 format for dates +- Restart BlogEngine to see changes + +### How much data can BlogEngine handle? + +BlogEngine loads all posts into memory: +- **<1,000 posts:** Excellent performance +- **1,000-10,000 posts:** Good performance +- **>10,000 posts:** Consider a database backend + +See `mix run benchmark/run.exs` for performance metrics. + +### Is my data secure? + +BlogEngine stores data locally as plain-text JSON: +- **Pros:** Full control, portable, readable +- **Cons:** No encryption at rest +- **Recommendation:** Use OS-level encryption or file permissions for sensitive content + +### Can multiple users share a blog? + +Not simultaneously. BlogEngine is designed for single-user CLI usage. For multi-user: +- Use separate data directories per user +- Implement file locking (custom) +- Consider a database backend + +### Can I run BlogEngine on Windows? + +Yes! Elixir works on Windows. You can: +- Install Elixir for Windows +- Use WSL (Windows Subsystem for Linux) +- Use Docker for Windows + +### Does BlogEngine have a plugin system? + +Not currently, but the modular architecture makes it easy to extend: +- Fork and modify the code +- Add new modules in `lib/blog_engine/` +- Contribute features via pull requests + +### Can I use BlogEngine as a library? + +Yes! The core modules (`BlogEngine`, `Post`, `Storage`) can be used in other Elixir projects: + +```elixir +# In mix.exs +{:blog_engine, git: "https://github.com/codeforgood-org/elixir-blog-engine.git"} + +# In your code +state = BlogEngine.init() +{state, post} = BlogEngine.create_post(state, "Title", "Body", nil) +``` + +## Development Questions + +### How do I contribute? + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: `mix test` +5. Run quality checks: `make quality` +6. Open a pull request + +See [CONTRIBUTING.md](../CONTRIBUTING.md) for details. + +### How do I run tests? + +```bash +mix test # Run all tests +mix test --trace # Verbose output +mix coveralls # With coverage +``` + +### How do I add a new command? + +1. Add command to `@commands` in `lib/blog_engine/cli.ex` +2. Add pattern match in `parse_command/1` +3. Implement handler function `handle_your_command/1` +4. Add tests in `test/blog_engine/cli_test.exs` + +### Where do I report bugs? + +Open an issue on GitHub: https://github.com/codeforgood-org/elixir-blog-engine/issues + +Use the bug report template for best results. + +### How do I request features? + +Open an issue with the "feature request" template: https://github.com/codeforgood-org/elixir-blog-engine/issues + +## Troubleshooting + +### I get "command not found: blog_engine" + +Solutions: +1. Build first: `mix escript.build` +2. Use full path: `./blog_engine` +3. Or run with mix: `mix run -e "BlogEngine.CLI.start()"` + +### Posts aren't saving + +Check: +1. Write permissions in project directory +2. Disk space available +3. Valid JSON format (if editing manually) +4. Error messages in terminal + +### I can't find my posts + +Check: +- Posts file location: `priv/data/posts.json` +- Working directory when you ran BlogEngine +- Use `list` command to see all posts + +### Docker container won't start + +Solutions: +```bash +# Rebuild +docker-compose build --no-cache + +# Check logs +docker-compose logs + +# Verify permissions +sudo chown -R 1000:1000 priv/data +``` + +### Tests are failing + +```bash +# Clean and rebuild +mix clean +mix deps.get +mix compile +mix test +``` + +### How do I uninstall BlogEngine? + +Just delete the directory: +```bash +cd .. +rm -rf elixir-blog-engine +``` + +Your posts are in `elixir-blog-engine/priv/data/posts.json` - back them up first if needed! + +## Advanced Questions + +### Can I change the storage location? + +Yes, modify `config/config.exs`: + +```elixir +config :blog_engine, + storage_dir: "/custom/path", + storage_file: "my_posts.json" +``` + +### Can I use a database instead of JSON? + +You can implement a new storage backend: +1. Create a new module implementing the Storage interface +2. Implement `load_posts/0`, `save_posts/1`, etc. +3. Update `BlogEngine` to use your storage module + +### How do I automate backups? + +**Cron job (Linux/Mac):** +```bash +# Add to crontab (crontab -e) +0 2 * * * cd /path/to/blog && make backup +``` + +**Task Scheduler (Windows):** +Create a scheduled task to run `make backup` + +### Can I sync posts across machines? + +Options: +1. Use git to version control `priv/data/posts.json` +2. Use cloud storage (Dropbox, Google Drive) for the directory +3. Export/import between machines +4. Set up a shared network drive + +### How do I convert posts to Markdown? + +Write a custom export script: + +```elixir +posts = BlogEngine.Storage.load_posts() + +Enum.each(posts, fn post -> + File.write!( + "#{post.id}-#{post.title}.md", + """ + # #{post.title} + + #{post.body} + + Tags: #{Enum.join(post.tags || [], ", ")} + """ + ) +end) +``` + +## Still Have Questions? + +- 📖 Read the [documentation](QUICK_START.md) +- 💬 Open an [issue](https://github.com/codeforgood-org/elixir-blog-engine/issues) +- 🐛 Found a bug? [Report it](https://github.com/codeforgood-org/elixir-blog-engine/issues/new?template=bug_report.md) +- 💡 Have an idea? [Request a feature](https://github.com/codeforgood-org/elixir-blog-engine/issues/new?template=feature_request.md) diff --git a/docs/QUICK_START.md b/docs/QUICK_START.md new file mode 100644 index 0000000..04e3c14 --- /dev/null +++ b/docs/QUICK_START.md @@ -0,0 +1,294 @@ +# Quick Start Guide + +Get up and running with BlogEngine in under 5 minutes! + +## Installation + +### Option 1: From Source (Recommended) + +```bash +# Clone the repository +git clone https://github.com/codeforgood-org/elixir-blog-engine.git +cd elixir-blog-engine + +# Install dependencies +mix deps.get + +# Build the executable +mix escript.build + +# Run it! +./blog_engine +``` + +### Option 2: Direct Run with Mix + +```bash +# Clone and enter directory +git clone https://github.com/codeforgood-org/elixir-blog-engine.git +cd elixir-blog-engine + +# Install dependencies +mix deps.get + +# Run directly +mix run -e "BlogEngine.CLI.start()" +``` + +## Your First Blog Post + +Once the application starts, you'll see: + +``` +================================================================================ + Welcome to BlogEngine - A Powerful CLI Blog Manager +================================================================================ + +Type 'help' to see available commands or 'new' to create your first post. + +> +``` + +### Create a Post + +Type `new` and press Enter: + +``` +> new + +=== Create New Post === +Title: My First Blog Post +Body (empty line to finish): +This is my first post using BlogEngine! +It supports multiple lines. + +Tags (comma-separated, optional): elixir, blogging + +✓ Post created successfully with ID 1 +``` + +### List Your Posts + +``` +> list + +=== All Posts === +-------------------------------------------------------------------------------- + +[1] My First Blog Post + 2025-11-13 17:32:15 | Tags: elixir, blogging + +-------------------------------------------------------------------------------- +Total: 1 post(s) +``` + +### View a Post + +``` +> view 1 + +================================================================================ +Post #1 +================================================================================ +Title: My First Blog Post +Created: 2025-11-13 17:32:15 +Tags: elixir, blogging + +This is my first post using BlogEngine! +It supports multiple lines. +================================================================================ +``` + +## Essential Commands + +| Command | Example | Description | +|---------|---------|-------------| +| `new` | `new` | Create a new post | +| `list` | `list` | Show all posts | +| `view` | `view 1` | View post #1 | +| `edit` | `edit 1` | Edit post #1 | +| `delete` | `delete 1` | Delete post #1 | +| `search` | `search elixir` | Find posts containing "elixir" | +| `tag` | `tag tutorial` | Show posts with tag "tutorial" | +| `tags` | `tags` | List all tags | +| `stats` | `stats` | Show blog statistics | +| `help` | `help` | Show all commands | +| `quit` | `quit` | Exit the app | + +## Common Tasks + +### Edit a Post + +``` +> edit 1 + +=== Edit Post #1 === +(Leave blank to keep current value) + +Title [My First Blog Post]: My Updated Title +Body (empty line to finish, 'keep' to keep current): +keep +Tags [elixir, blogging] (comma-separated): elixir, blogging, updated + +✓ Post updated successfully +``` + +### Search Posts + +``` +> search elixir + +=== Search Results for 'elixir' === +-------------------------------------------------------------------------------- + +[1] My Updated Title + 2025-11-13 17:32:15 [edited] | Tags: elixir, blogging, updated + +-------------------------------------------------------------------------------- +Found: 1 post(s) +``` + +### Filter by Tag + +``` +> tag blogging + +=== Posts tagged with 'blogging' === +-------------------------------------------------------------------------------- + +[1] My Updated Title + 2025-11-13 17:32:15 [edited] | Tags: elixir, blogging, updated + +-------------------------------------------------------------------------------- +Total: 1 post(s) +``` + +### View Statistics + +``` +> stats + +=== Blog Statistics === +Total posts: 1 +Total words: 12 +Average words per post: 12 +Total unique tags: 3 +Oldest post: My Updated Title (2025-11-13 17:32:15) +Newest post: My Updated Title (2025-11-13 17:32:15) +``` + +### Backup Your Posts + +``` +> export ~/my-blog-backup.json +✓ Posts exported successfully to /home/user/my-blog-backup.json +``` + +### Restore from Backup + +``` +> import ~/my-blog-backup.json +✓ Posts imported successfully from /home/user/my-blog-backup.json +``` + +## Tips & Tricks + +### Multi-line Posts + +When entering the post body, you can write multiple lines. Press Enter twice (empty line) to finish: + +``` +Body (empty line to finish): +This is line 1 +This is line 2 +This is line 3 +← (press Enter on empty line to finish) +``` + +### Keeping Current Values When Editing + +When editing, press Enter without typing to keep the current value: + +``` +Title [Current Title]: ← (just press Enter to keep) +``` + +For body, type 'keep': + +``` +Body (empty line to finish, 'keep' to keep current): +keep +``` + +### Case-Insensitive Search + +Search is case-insensitive and searches in title, body, and tags: + +``` +> search ELIXIR # Same as "elixir" +> search Tutorial # Same as "tutorial" +``` + +### Viewing Tags + +See all your tags and how many posts use each: + +``` +> tags + +=== All Tags === +---------------------------------------- + elixir (5) + tutorial (3) + advanced (2) + blogging (1) +---------------------------------------- +Total: 4 tag(s) +``` + +## Data Storage + +Your posts are automatically saved to `priv/data/posts.json`. This file is: + +- **Automatically created** on first post +- **Human-readable** JSON format +- **Auto-saved** after every change +- **Persistent** across sessions + +You can view or edit this file directly if needed! + +## Next Steps + +1. **Create more posts** - Build your blog content +2. **Organize with tags** - Use tags to categorize posts +3. **Regular backups** - Use `export` to backup your posts +4. **Explore features** - Try all commands with `help` + +## Getting Help + +- Type `help` in the app for command list +- Read the [full README](../README.md) for detailed information +- Check the [Development Guide](DEVELOPMENT.md) if contributing +- Open an [issue](https://github.com/codeforgood-org/elixir-blog-engine/issues) for bugs + +## Troubleshooting + +### "Command not found: mix" + +Install Elixir first: https://elixir-lang.org/install.html + +### "Dependencies not fetched" + +Run: `mix deps.get` + +### "Posts not saving" + +Check that you have write permissions in the project directory. + +### "Can't find a post" + +Use `list` to see all post IDs, then `view ` with the correct ID. + +--- + +**You're all set!** Start blogging with `./blog_engine` or `mix run -e "BlogEngine.CLI.start()"` diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..6664fc2 --- /dev/null +++ b/docs/RELEASE_CHECKLIST.md @@ -0,0 +1,320 @@ +# Release Checklist + +This checklist ensures a smooth and consistent release process for BlogEngine. + +## Pre-Release + +### 1. Version Bump + +- [ ] Update version in `mix.exs`: + ```elixir + version: "X.Y.Z" + ``` + +- [ ] Follow [Semantic Versioning](https://semver.org/): + - MAJOR: Breaking changes + - MINOR: New features, backwards-compatible + - PATCH: Bug fixes, backwards-compatible + +### 2. Update CHANGELOG.md + +- [ ] Move items from "Unreleased" to new version section +- [ ] Add version number and release date +- [ ] Organize changes into categories: + - Added + - Changed + - Deprecated + - Removed + - Fixed + - Security + +Example: +```markdown +## [1.1.0] - 2025-11-13 + +### Added +- New feature X +- New feature Y + +### Fixed +- Bug fix A +- Bug fix B +``` + +### 3. Update Documentation + +- [ ] Review and update README.md +- [ ] Update API documentation if changed +- [ ] Update Docker guides if changed +- [ ] Update examples if needed +- [ ] Check all documentation links work + +### 4. Code Quality + +- [ ] Run full test suite: + ```bash + mix test + ``` + +- [ ] Run code formatter: + ```bash + mix format --check-formatted + ``` + +- [ ] Run static analysis: + ```bash + mix credo --strict + ``` + +- [ ] Run type checker: + ```bash + mix dialyzer + ``` + +- [ ] Check test coverage: + ```bash + mix coveralls + ``` + +### 5. Security Check + +- [ ] Audit dependencies: + ```bash + mix deps.audit + ``` + +- [ ] Review SECURITY.md is up to date +- [ ] Check for hardcoded secrets or credentials +- [ ] Review Docker image security + +### 6. Build Verification + +- [ ] Build escript successfully: + ```bash + mix escript.build + ``` + +- [ ] Test escript execution: + ```bash + ./blog_engine + ``` + +- [ ] Build Docker image: + ```bash + docker build -t blog-engine:test . + ``` + +- [ ] Test Docker image: + ```bash + docker run -it --rm blog-engine:test + ``` + +### 7. Integration Testing + +- [ ] Test all CLI commands manually +- [ ] Test import/export functionality +- [ ] Test with example data +- [ ] Test error scenarios +- [ ] Test on different platforms (if possible): + - [ ] Linux + - [ ] macOS + - [ ] Windows (WSL) + +### 8. Documentation Review + +- [ ] Verify installation instructions work +- [ ] Test Quick Start guide +- [ ] Verify Docker instructions +- [ ] Check all code examples run +- [ ] Review API documentation accuracy + +## Release Process + +### 1. Commit Changes + +```bash +git add mix.exs CHANGELOG.md +git commit -m "Bump version to X.Y.Z" +``` + +### 2. Create Git Tag + +```bash +git tag -a vX.Y.Z -m "Release version X.Y.Z" +``` + +### 3. Push to GitHub + +```bash +git push origin main +git push origin vX.Y.Z +``` + +### 4. Verify CI Passes + +- [ ] Check GitHub Actions workflows pass: + - [ ] CI workflow (tests, linting, etc.) + - [ ] Release workflow (if triggered) + +### 5. Create GitHub Release + +Go to https://github.com/codeforgood-org/elixir-blog-engine/releases/new + +- [ ] Select the tag (vX.Y.Z) +- [ ] Title: "BlogEngine vX.Y.Z" +- [ ] Description: Copy from CHANGELOG.md +- [ ] Attach artifacts (if not automated): + - [ ] blog_engine escript binary + - [ ] Source code (automatic) +- [ ] Check "Set as the latest release" +- [ ] Publish release + +### 6. Verify Release Artifacts + +- [ ] Download and test escript from release +- [ ] Verify Docker image builds from tag +- [ ] Check release notes display correctly + +## Post-Release + +### 1. Update Documentation Sites + +- [ ] Update any external documentation +- [ ] Update package registries (if applicable) +- [ ] Update Docker Hub (if publishing there) + +### 2. Announce Release + +Consider announcing on: +- [ ] GitHub Discussions +- [ ] Project README +- [ ] Social media (if applicable) +- [ ] Elixir Forum (for major releases) + +### 3. Monitor Issues + +- [ ] Watch for new issues related to release +- [ ] Respond to user feedback +- [ ] Prepare hotfix if critical issues found + +### 4. Prepare Next Release + +- [ ] Create new "Unreleased" section in CHANGELOG.md +- [ ] Update project board/issues +- [ ] Close milestone (if used) +- [ ] Create next milestone + +## Hotfix Process + +For critical bugs requiring immediate release: + +### 1. Create Hotfix Branch + +```bash +git checkout -b hotfix/vX.Y.Z+1 vX.Y.Z +``` + +### 2. Fix Bug + +- [ ] Make minimal changes to fix issue +- [ ] Add test for the bug +- [ ] Update CHANGELOG.md + +### 3. Test Thoroughly + +- [ ] Run all tests +- [ ] Manually verify fix + +### 4. Release Hotfix + +- [ ] Bump patch version +- [ ] Create tag +- [ ] Push to GitHub +- [ ] Create release + +### 5. Merge Back + +```bash +git checkout main +git merge hotfix/vX.Y.Z+1 +git push origin main +``` + +## Release Types + +### Major Release (X.0.0) + +Breaking changes require: +- [ ] Migration guide in CHANGELOG +- [ ] Updated documentation +- [ ] Deprecation warnings in previous version (if possible) +- [ ] Communication plan for users + +### Minor Release (X.Y.0) + +New features require: +- [ ] Documentation for new features +- [ ] Examples of new features +- [ ] Tests for new features + +### Patch Release (X.Y.Z) + +Bug fixes require: +- [ ] Test demonstrating the bug +- [ ] Minimal changes +- [ ] Quick turnaround + +## Rollback Procedure + +If a release has critical issues: + +### 1. Assess Severity + +- [ ] Determine if rollback needed +- [ ] Document the issue + +### 2. Create Hotfix or Rollback + +Option A (Hotfix): +- [ ] Follow hotfix process above + +Option B (Rollback): +- [ ] Delete problematic release +- [ ] Delete tag: `git tag -d vX.Y.Z` +- [ ] Force push: `git push origin :refs/tags/vX.Y.Z` +- [ ] Communicate rollback + +### 3. Communicate + +- [ ] Update release notes +- [ ] Notify users +- [ ] Provide workaround or timeline + +## Automation Opportunities + +Consider automating: +- [ ] Version bumping +- [ ] CHANGELOG generation +- [ ] Release note creation +- [ ] Artifact uploads +- [ ] Docker image publishing +- [ ] Announcement posting + +## Release Metrics + +Track for each release: +- [ ] Time from tag to release +- [ ] Number of downloads +- [ ] Issues reported +- [ ] Hotfixes needed + +## Notes + +- Always do a dry run for major releases +- Test the release process in a fork first +- Keep releases small and frequent +- Document any process improvements + +--- + +**Remember:** A good release is boring - everything works as expected! diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md new file mode 100644 index 0000000..ce94d9a --- /dev/null +++ b/docs/TROUBLESHOOTING.md @@ -0,0 +1,651 @@ +# Troubleshooting Guide + +This guide helps you diagnose and fix common issues with BlogEngine. + +## Quick Diagnostics + +Run these commands to check your setup: + +```bash +# Check Elixir installation +elixir --version + +# Check if dependencies are installed +mix deps.get + +# Verify compilation +mix compile + +# Run tests to verify functionality +mix test +``` + +## Installation Issues + +### Problem: "mix: command not found" + +**Cause:** Elixir is not installed or not in PATH + +**Solutions:** + +1. Install Elixir: https://elixir-lang.org/install.html + +2. Verify installation: + ```bash + elixir --version + ``` + +3. Add to PATH (if installed but not found): + ```bash + # Add to ~/.bashrc or ~/.zshrc + export PATH="$PATH:/path/to/elixir/bin" + ``` + +4. Alternative: Use Docker instead + ```bash + docker-compose up blog-engine + ``` + +--- + +### Problem: Dependencies won't install + +**Error:** +``` +** (Mix) Could not fetch dependencies +``` + +**Solutions:** + +1. Check internet connection + +2. Clean and retry: + ```bash + mix deps.clean --all + mix deps.get + ``` + +3. Update Hex: + ```bash + mix local.hex --force + mix local.rebar --force + ``` + +4. Check Elixir version: + ```bash + elixir --version # Should be 1.14+ + ``` + +--- + +### Problem: Compilation fails + +**Error:** +``` +** (CompileError) lib/blog_engine.ex:X: undefined function +``` + +**Solutions:** + +1. Clean build: + ```bash + mix clean + mix compile + ``` + +2. Update dependencies: + ```bash + mix deps.update --all + ``` + +3. Check for syntax errors: + ```bash + mix format --check-formatted + ``` + +## Runtime Issues + +### Problem: "blog_engine: command not found" + +**Cause:** Escript not built or not in current directory + +**Solutions:** + +1. Build the escript: + ```bash + mix escript.build + ``` + +2. Run from current directory: + ```bash + ./blog_engine + ``` + +3. Or use Mix: + ```bash + mix run -e "BlogEngine.CLI.start()" + ``` + +4. Or add to PATH: + ```bash + export PATH="$PATH:$(pwd)" + ``` + +--- + +### Problem: Posts not saving + +**Symptoms:** +- No error message +- Posts disappear after restart +- Can't find posts.json file + +**Solutions:** + +1. Check directory permissions: + ```bash + ls -la priv/data/ + # Should show write permissions + ``` + +2. Create directory manually: + ```bash + mkdir -p priv/data + chmod 755 priv/data + ``` + +3. Check disk space: + ```bash + df -h . + ``` + +4. Verify file location: + ```bash + find . -name "posts.json" + ``` + +5. Check working directory: + ```bash + pwd # Should be in blog-engine root + ``` + +--- + +### Problem: Can't find posts + +**Symptoms:** +- "Post not found" error +- Empty list when running `list` command +- Know posts exist but can't access them + +**Solutions:** + +1. Verify posts file exists: + ```bash + cat priv/data/posts.json + ``` + +2. Check file permissions: + ```bash + ls -la priv/data/posts.json + chmod 644 priv/data/posts.json + ``` + +3. Validate JSON: + ```bash + cat priv/data/posts.json | python -m json.tool + ``` + +4. Check for empty state: + ```bash + # List all posts + echo "list" | ./blog_engine + ``` + +--- + +### Problem: Corrupted JSON file + +**Error:** +``` +** (Jason.DecodeError) unexpected byte at position X +``` + +**Solutions:** + +1. Backup current file: + ```bash + cp priv/data/posts.json priv/data/posts.json.backup + ``` + +2. Validate JSON: + ```bash + cat priv/data/posts.json | jq . + ``` + +3. Fix common issues: + - Missing commas between objects + - Unclosed brackets or braces + - Invalid escape sequences in strings + +4. If unfixable, start fresh: + ```bash + echo "[]" > priv/data/posts.json + ``` + +5. Restore from backup: + ```bash + cp backups/posts_YYYYMMDD.json priv/data/posts.json + ``` + +## Search & Filter Issues + +### Problem: Search returns no results + +**Symptoms:** +- Know content exists but search finds nothing +- Other commands work fine + +**Solutions:** + +1. Check search term: + - Search is case-insensitive + - Partial matches work: "eli" finds "elixir" + +2. Verify data: + ```elixir + # In IEx + iex -S mix + posts = BlogEngine.Storage.load_posts() + IO.inspect(posts) + ``` + +3. Try different terms: + - Search in title: `search title-word` + - Search in body: `search body-content` + - Search in tags: `search tag-name` + +--- + +### Problem: Tag filtering not working + +**Symptoms:** +- `tag ` returns no posts +- Know posts with that tag exist + +**Solutions:** + +1. Check tag name: + - Tags are case-insensitive + - Use exact tag name (no partial matches) + +2. List all tags: + ``` + > tags + ``` + +3. Verify post tags: + ``` + > view 1 + # Check Tags: field + ``` + +## Performance Issues + +### Problem: Slow performance + +**Symptoms:** +- Commands take a long time +- List/search is slow +- High memory usage + +**Solutions:** + +1. Check number of posts: + ```bash + jq '. | length' priv/data/posts.json + ``` + +2. Run benchmarks: + ```bash + mix run benchmark/run.exs + ``` + +3. Optimize for large datasets: + - Archive old posts + - Split into multiple files + - Use database backend (custom) + +4. Check system resources: + ```bash + # Memory + free -h + + # Disk I/O + iostat + ``` + +--- + +### Problem: Out of memory + +**Error:** +``` +eheap_alloc: Cannot allocate X bytes of memory +``` + +**Solutions:** + +1. Reduce post count: + - Export old posts + - Archive to separate files + +2. Increase VM memory: + ```bash + elixir --erl "+hms 512M" -S mix run -e "BlogEngine.CLI.start()" + ``` + +3. Check post sizes: + ```bash + jq '[.[] | .body | length] | max' priv/data/posts.json + ``` + +## Docker Issues + +### Problem: Docker container won't start + +**Error:** +``` +Error response from daemon: No such image +``` + +**Solutions:** + +1. Build the image: + ```bash + docker-compose build + ``` + +2. Or with Docker: + ```bash + docker build -t blog-engine . + ``` + +3. Check Docker is running: + ```bash + docker ps + ``` + +--- + +### Problem: Permission denied in Docker + +**Error:** +``` +Permission denied: /app/priv/data/posts.json +``` + +**Solutions:** + +1. Fix ownership: + ```bash + sudo chown -R 1000:1000 priv/data + ``` + +2. Run with your UID: + ```bash + docker run -it --rm \ + --user $(id -u):$(id -g) \ + -v $(pwd)/priv/data:/app/priv/data \ + blog-engine + ``` + +--- + +### Problem: Data not persisting in Docker + +**Symptoms:** +- Posts disappear when container stops +- Changes not saved + +**Solutions:** + +1. Mount volume correctly: + ```bash + docker run -it --rm \ + -v $(pwd)/priv/data:/app/priv/data \ + blog-engine + ``` + +2. Check volume mount: + ```bash + docker inspect blog-engine | grep Mounts -A 10 + ``` + +3. Use absolute paths: + ```bash + docker run -it --rm \ + -v /full/path/to/priv/data:/app/priv/data \ + blog-engine + ``` + +## Test Issues + +### Problem: Tests failing + +**Error:** +``` +1) test ... + ** (MatchError) no match of right hand side value +``` + +**Solutions:** + +1. Clean test data: + ```bash + rm -rf priv/data/*.json + mix test + ``` + +2. Run tests in isolation: + ```bash + mix test test/blog_engine/post_test.exs + ``` + +3. Check for interference: + ```bash + # Tests should clean up after themselves + ls priv/data/ + ``` + +4. Rebuild: + ```bash + mix clean + mix compile + mix test + ``` + +--- + +### Problem: Dialyzer errors + +**Error:** +``` +Function ... has no local return +``` + +**Solutions:** + +1. Rebuild PLT: + ```bash + rm -rf priv/plts/ + mix dialyzer --plt + ``` + +2. Update dependencies: + ```bash + mix deps.update --all + mix dialyzer + ``` + +3. Check for type mismatches in code + +## Development Issues + +### Problem: Format check fails + +**Error:** +``` +** (Mix) mix format failed due to --check-formatted +``` + +**Solutions:** + +1. Run formatter: + ```bash + mix format + ``` + +2. Check specific files: + ```bash + mix format lib/blog_engine.ex + ``` + +--- + +### Problem: Credo warnings + +**Warnings:** +``` +Warnings - potential issues +``` + +**Solutions:** + +1. Review warnings: + ```bash + mix credo --strict + ``` + +2. Fix automatically where possible: + ```bash + mix credo --strict --format=oneline + ``` + +3. Add inline config if intentional: + ```elixir + # credo:disable-for-next-line + ``` + +## Platform-Specific Issues + +### macOS + +**Problem:** Permission denied when running escript + +**Solution:** +```bash +chmod +x blog_engine +./blog_engine +``` + +--- + +### Windows + +**Problem:** Escript won't run + +**Solution:** +```powershell +# Use elixir directly +elixir blog_engine +``` + +Or use WSL: +```bash +wsl +./blog_engine +``` + +--- + +### Linux + +**Problem:** Missing dependencies + +**Solution:** +```bash +# Ubuntu/Debian +sudo apt-get install erlang elixir + +# Fedora +sudo dnf install erlang elixir + +# Arch +sudo pacman -S elixir +``` + +## Getting Help + +If you're still stuck: + +1. **Check logs:** + ```bash + # Look for error messages in terminal + ./blog_engine 2>&1 | tee blog-engine.log + ``` + +2. **Gather information:** + ```bash + # System info + uname -a + elixir --version + mix --version + + # Project info + git rev-parse HEAD + ls -la priv/data/ + ``` + +3. **Open an issue:** + - Go to: https://github.com/codeforgood-org/elixir-blog-engine/issues + - Use bug report template + - Include error messages and system info + +4. **Check existing issues:** + - Search: https://github.com/codeforgood-org/elixir-blog-engine/issues?q=is%3Aissue + +## Preventive Measures + +### Regular Backups + +```bash +# Automated backup script +#!/bin/bash +DATE=$(date +%Y%m%d_%H%M%S) +cp priv/data/posts.json backups/posts_$DATE.json +``` + +### Health Checks + +```bash +# Check project health +make ci # Runs all quality checks +``` + +### Keep Updated + +```bash +# Update dependencies regularly +mix deps.update --all + +# Check for security issues +mix deps.audit +``` + +--- + +For more help: +- [FAQ](FAQ.md) +- [Documentation](QUICK_START.md) +- [GitHub Issues](https://github.com/codeforgood-org/elixir-blog-engine/issues) diff --git a/examples/sample_posts.json b/examples/sample_posts.json new file mode 100644 index 0000000..a1430c9 --- /dev/null +++ b/examples/sample_posts.json @@ -0,0 +1,42 @@ +[ + { + "id": 1, + "title": "Welcome to BlogEngine", + "body": "This is your first post in BlogEngine!\n\nBlogEngine is a powerful CLI tool for managing blog posts. You can create, edit, search, and organize your posts right from your terminal.\n\nTry these commands:\n- 'list' to see all posts\n- 'new' to create a post\n- 'search' to find posts\n- 'help' for more commands", + "tags": ["welcome", "tutorial"], + "created_at": "2025-11-13T17:00:00Z", + "updated_at": null + }, + { + "id": 2, + "title": "Getting Started with Elixir", + "body": "Elixir is a dynamic, functional language designed for building scalable and maintainable applications.\n\nKey features:\n- Functional programming paradigm\n- Pattern matching\n- Concurrent and distributed\n- Runs on the Erlang VM (BEAM)\n- Great for web development with Phoenix\n\nIf you're new to Elixir, check out the official guides at elixir-lang.org!", + "tags": ["elixir", "programming", "tutorial"], + "created_at": "2025-11-13T17:15:00Z", + "updated_at": null + }, + { + "id": 3, + "title": "My Favorite Elixir Features", + "body": "After using Elixir for a while, here are my favorite features:\n\n1. Pattern Matching - Makes code incredibly clear\n2. The Pipe Operator |> - Transforms nested function calls into readable pipelines\n3. Immutability - No more debugging weird side effects\n4. OTP - Battle-tested concurrency primitives\n5. Documentation - First-class docs with ExDoc\n\nWhat are your favorite Elixir features?", + "tags": ["elixir", "opinion", "features"], + "created_at": "2025-11-13T18:30:00Z", + "updated_at": "2025-11-13T19:00:00Z" + }, + { + "id": 4, + "title": "Building CLI Applications", + "body": "Command-line applications are a great way to learn a new language.\n\nElixir makes it easy with:\n- escript for building executables\n- IO module for user input\n- Pattern matching for command parsing\n- Mix for project management\n\nBlogEngine itself is a great example of what you can build!", + "tags": ["elixir", "cli", "development"], + "created_at": "2025-11-13T19:45:00Z", + "updated_at": null + }, + { + "id": 5, + "title": "Organizing Your Blog with Tags", + "body": "Tags are a powerful way to organize your posts.\n\nTips for effective tagging:\n- Use consistent tag names\n- Don't over-tag (3-5 tags per post is good)\n- Think about categories (topics, types, series)\n- Use the 'tags' command to see all your tags\n- Use 'tag ' to filter posts\n\nHappy blogging!", + "tags": ["blogging", "tips", "organization"], + "created_at": "2025-11-13T20:00:00Z", + "updated_at": null + } +] diff --git a/lib/blog_engine.ex b/lib/blog_engine.ex new file mode 100644 index 0000000..6f95ba5 --- /dev/null +++ b/lib/blog_engine.ex @@ -0,0 +1,169 @@ +defmodule BlogEngine do + @moduledoc """ + BlogEngine - A powerful CLI blog engine built with Elixir. + + This module provides core functionality for managing blog posts including + creating, reading, updating, deleting, searching, and organizing posts with tags. + """ + + alias BlogEngine.{Post, Storage} + + @type post_id :: non_neg_integer() + @type posts :: list(Post.t()) + @type state :: %{posts: posts(), next_id: post_id()} + + @doc """ + Initializes the blog engine by loading existing posts from storage. + Returns the initial state with posts and next available ID. + """ + @spec init() :: state() + def init do + posts = Storage.load_posts() + + next_id = + case posts do + [] -> 1 + _ -> (posts |> Enum.map(& &1.id) |> Enum.max()) + 1 + end + + %{posts: posts, next_id: next_id} + end + + @doc """ + Creates a new post with the given title, body, and optional tags. + Returns the updated state and the created post. + """ + @spec create_post(state(), String.t(), String.t(), list(String.t()) | nil) :: + {state(), Post.t()} + def create_post(%{posts: posts, next_id: id} = _state, title, body, tags \\ nil) do + post = Post.new(id, title, body, tags) + new_posts = [post | posts] + Storage.save_posts(new_posts) + + {%{posts: new_posts, next_id: id + 1}, post} + end + + @doc """ + Lists all posts, optionally filtered by tag. + Returns posts sorted by creation date (newest first). + """ + @spec list_posts(state(), String.t() | nil) :: posts() + def list_posts(%{posts: posts}, tag \\ nil) do + posts + |> maybe_filter_by_tag(tag) + |> Enum.sort_by(& &1.created_at, {:desc, DateTime}) + end + + @doc """ + Finds a post by its ID. + Returns {:ok, post} or {:error, :not_found}. + """ + @spec find_post(state(), post_id()) :: {:ok, Post.t()} | {:error, :not_found} + def find_post(%{posts: posts}, id) do + case Enum.find(posts, fn p -> p.id == id end) do + nil -> {:error, :not_found} + post -> {:ok, post} + end + end + + @doc """ + Updates a post with new attributes. + Returns {:ok, updated_state, updated_post} or {:error, :not_found}. + """ + @spec update_post(state(), post_id(), keyword()) :: + {:ok, state(), Post.t()} | {:error, :not_found} + def update_post(%{posts: posts} = state, id, attrs) do + case Enum.find_index(posts, fn p -> p.id == id end) do + nil -> + {:error, :not_found} + + index -> + old_post = Enum.at(posts, index) + updated_post = Post.update(old_post, attrs) + new_posts = List.replace_at(posts, index, updated_post) + Storage.save_posts(new_posts) + + new_state = %{state | posts: new_posts} + {:ok, new_state, updated_post} + end + end + + @doc """ + Deletes a post by its ID. + Returns {:ok, updated_state} or {:error, :not_found}. + """ + @spec delete_post(state(), post_id()) :: {:ok, state()} | {:error, :not_found} + def delete_post(%{posts: posts} = state, id) do + case Enum.find(posts, fn p -> p.id == id end) do + nil -> + {:error, :not_found} + + _post -> + new_posts = Enum.reject(posts, fn p -> p.id == id end) + Storage.save_posts(new_posts) + {:ok, %{state | posts: new_posts}} + end + end + + @doc """ + Searches posts by query string (searches title, body, and tags). + Returns matching posts sorted by relevance (newest first). + """ + @spec search_posts(state(), String.t()) :: posts() + def search_posts(%{posts: posts}, query) do + posts + |> Enum.filter(&Post.matches_query?(&1, query)) + |> Enum.sort_by(& &1.created_at, {:desc, DateTime}) + end + + @doc """ + Gets all unique tags from all posts. + Returns a sorted list of tags with their post counts. + """ + @spec get_all_tags(state()) :: list({String.t(), non_neg_integer()}) + def get_all_tags(%{posts: posts}) do + posts + |> Enum.flat_map(fn post -> post.tags || [] end) + |> Enum.frequencies() + |> Enum.sort_by(fn {_tag, count} -> count end, :desc) + end + + @doc """ + Exports all posts to a file. + """ + @spec export_posts(state(), String.t()) :: :ok | {:error, term()} + def export_posts(%{posts: posts}, path) do + Storage.export_posts(posts, path) + end + + @doc """ + Imports posts from a file and merges with existing posts. + Assigns new IDs to imported posts to avoid conflicts. + """ + @spec import_posts(state(), String.t()) :: {:ok, state()} | {:error, term()} + def import_posts(%{posts: existing_posts, next_id: next_id} = state, path) do + case Storage.import_posts(path) do + {:ok, imported_posts} -> + # Reassign IDs to avoid conflicts + {new_posts, new_next_id} = + Enum.reduce(imported_posts, {existing_posts, next_id}, fn post, {acc_posts, acc_id} -> + new_post = %{post | id: acc_id} + {[new_post | acc_posts], acc_id + 1} + end) + + Storage.save_posts(new_posts) + {:ok, %{state | posts: new_posts, next_id: new_next_id}} + + {:error, reason} -> + {:error, reason} + end + end + + # Private helper functions + + defp maybe_filter_by_tag(posts, nil), do: posts + + defp maybe_filter_by_tag(posts, tag) do + Enum.filter(posts, &Post.has_tag?(&1, tag)) + end +end diff --git a/lib/blog_engine/cli.ex b/lib/blog_engine/cli.ex new file mode 100644 index 0000000..b77895f --- /dev/null +++ b/lib/blog_engine/cli.ex @@ -0,0 +1,477 @@ +defmodule BlogEngine.CLI do + @moduledoc """ + Command-line interface for the BlogEngine application. + + Provides an interactive REPL for managing blog posts with commands + for creating, listing, viewing, editing, deleting, and searching posts. + """ + + alias BlogEngine.Post + + @commands [ + {"new", "Create a new post"}, + {"list", "List all posts"}, + {"view ", "View a post by ID"}, + {"edit ", "Edit a post by ID"}, + {"delete ", "Delete a post by ID"}, + {"search ", "Search posts by keyword"}, + {"tag ", "List posts with a specific tag"}, + {"tags", "Show all tags and their counts"}, + {"export ", "Export all posts to a file"}, + {"import ", "Import posts from a file"}, + {"stats", "Show blog statistics"}, + {"help", "Show this help message"}, + {"quit", "Exit the application"} + ] + + @doc """ + Main entry point for the escript. + """ + def main(_args) do + start() + end + + @doc """ + Starts the interactive blog engine CLI. + """ + def start do + print_welcome() + state = BlogEngine.init() + loop(state) + end + + # Main REPL loop + defp loop(state) do + command = get_input("\n> ") + + case parse_command(command) do + {:new, []} -> + {new_state, _post} = handle_new(state) + loop(new_state) + + {:list, []} -> + handle_list(state, nil) + loop(state) + + {:view, [id]} -> + handle_view(state, id) + loop(state) + + {:edit, [id]} -> + new_state = handle_edit(state, id) + loop(new_state) + + {:delete, [id]} -> + new_state = handle_delete(state, id) + loop(new_state) + + {:search, query_parts} -> + query = Enum.join(query_parts, " ") + handle_search(state, query) + loop(state) + + {:tag, [tag]} -> + handle_list_by_tag(state, tag) + loop(state) + + {:tags, []} -> + handle_tags(state) + loop(state) + + {:export, [path]} -> + handle_export(state, path) + loop(state) + + {:import, [path]} -> + new_state = handle_import(state, path) + loop(new_state) + + {:stats, []} -> + handle_stats(state) + loop(state) + + {:help, []} -> + print_help() + loop(state) + + {:quit, []} -> + print_goodbye() + :ok + + {:error, :invalid_command} -> + IO.puts("❌ Invalid command. Type 'help' for available commands.") + loop(state) + + {:error, :missing_args} -> + IO.puts("❌ Missing required arguments. Type 'help' for usage.") + loop(state) + end + end + + # Command handlers + + defp handle_new(state) do + IO.puts("\n=== Create New Post ===") + title = get_input("Title: ") + body = get_multiline_input("Body (empty line to finish):\n") + tags = get_tags_input() + + {new_state, post} = BlogEngine.create_post(state, title, body, tags) + IO.puts("\n✓ Post created successfully with ID #{post.id}") + + new_state + end + + defp handle_list(state, tag) do + posts = BlogEngine.list_posts(state, tag) + + if Enum.empty?(posts) do + IO.puts("\nNo posts found.") + else + header = if tag, do: "\n=== Posts tagged with '#{tag}' ===", else: "\n=== All Posts ===" + IO.puts(header) + IO.puts(String.duplicate("-", 80)) + + Enum.each(posts, fn post -> + display_post_summary(post) + end) + + IO.puts(String.duplicate("-", 80)) + IO.puts("Total: #{length(posts)} post(s)") + end + end + + defp handle_view(state, id_str) do + case parse_id(id_str) do + {:ok, id} -> + case BlogEngine.find_post(state, id) do + {:ok, post} -> + display_post_full(post) + + {:error, :not_found} -> + IO.puts("❌ Post not found.") + end + + :error -> + IO.puts("❌ Invalid post ID.") + end + end + + defp handle_edit(state, id_str) do + case parse_id(id_str) do + {:ok, id} -> + case BlogEngine.find_post(state, id) do + {:ok, post} -> + IO.puts("\n=== Edit Post ##{id} ===") + IO.puts("(Leave blank to keep current value)\n") + + title = get_optional_input("Title [#{post.title}]: ", post.title) + body = get_optional_multiline_input("Body (empty line to finish, 'keep' to keep current):\n", post.body) + tags = get_optional_tags_input(post.tags) + + case BlogEngine.update_post(state, id, title: title, body: body, tags: tags) do + {:ok, new_state, _updated_post} -> + IO.puts("\n✓ Post updated successfully") + new_state + + {:error, :not_found} -> + IO.puts("❌ Post not found.") + state + end + + {:error, :not_found} -> + IO.puts("❌ Post not found.") + state + end + + :error -> + IO.puts("❌ Invalid post ID.") + state + end + end + + defp handle_delete(state, id_str) do + case parse_id(id_str) do + {:ok, id} -> + case BlogEngine.find_post(state, id) do + {:ok, post} -> + IO.puts("\nAre you sure you want to delete '#{post.title}'? (yes/no)") + confirmation = get_input("> ") + + if String.downcase(confirmation) == "yes" do + case BlogEngine.delete_post(state, id) do + {:ok, new_state} -> + IO.puts("✓ Post deleted successfully") + new_state + + {:error, :not_found} -> + IO.puts("❌ Post not found.") + state + end + else + IO.puts("Deletion cancelled.") + state + end + + {:error, :not_found} -> + IO.puts("❌ Post not found.") + state + end + + :error -> + IO.puts("❌ Invalid post ID.") + state + end + end + + defp handle_search(state, query) do + posts = BlogEngine.search_posts(state, query) + + if Enum.empty?(posts) do + IO.puts("\nNo posts found matching '#{query}'") + else + IO.puts("\n=== Search Results for '#{query}' ===") + IO.puts(String.duplicate("-", 80)) + + Enum.each(posts, fn post -> + display_post_summary(post) + end) + + IO.puts(String.duplicate("-", 80)) + IO.puts("Found: #{length(posts)} post(s)") + end + end + + defp handle_list_by_tag(state, tag) do + handle_list(state, tag) + end + + defp handle_tags(state) do + tags = BlogEngine.get_all_tags(state) + + if Enum.empty?(tags) do + IO.puts("\nNo tags found.") + else + IO.puts("\n=== All Tags ===") + IO.puts(String.duplicate("-", 40)) + + Enum.each(tags, fn {tag, count} -> + IO.puts(" #{tag} (#{count})") + end) + + IO.puts(String.duplicate("-", 40)) + IO.puts("Total: #{length(tags)} tag(s)") + end + end + + defp handle_export(state, path) do + case BlogEngine.export_posts(state, path) do + :ok -> + IO.puts("✓ Posts exported successfully to #{path}") + + {:error, reason} -> + IO.puts("❌ Failed to export posts: #{inspect(reason)}") + end + end + + defp handle_import(state, path) do + case BlogEngine.import_posts(state, path) do + {:ok, new_state} -> + IO.puts("✓ Posts imported successfully from #{path}") + new_state + + {:error, reason} -> + IO.puts("❌ Failed to import posts: #{inspect(reason)}") + state + end + end + + defp handle_stats(state) do + posts = state.posts + total_posts = length(posts) + + if total_posts == 0 do + IO.puts("\nNo posts yet. Create your first post with 'new'!") + else + total_words = Enum.reduce(posts, 0, fn post, acc -> + acc + length(String.split(post.body)) + end) + + avg_words = div(total_words, total_posts) + tags_count = BlogEngine.get_all_tags(state) |> length() + + oldest_post = Enum.min_by(posts, & &1.created_at, DateTime) + newest_post = Enum.max_by(posts, & &1.created_at, DateTime) + + IO.puts("\n=== Blog Statistics ===") + IO.puts("Total posts: #{total_posts}") + IO.puts("Total words: #{total_words}") + IO.puts("Average words per post: #{avg_words}") + IO.puts("Total unique tags: #{tags_count}") + IO.puts("Oldest post: #{oldest_post.title} (#{Post.format_date(oldest_post.created_at)})") + IO.puts("Newest post: #{newest_post.title} (#{Post.format_date(newest_post.created_at)})") + end + end + + # Display helpers + + defp display_post_summary(post) do + tags_str = format_tags(post.tags) + date_str = Post.format_date(post.created_at) + updated_str = if post.updated_at, do: " [edited]", else: "" + + IO.puts("\n[#{post.id}] #{post.title}#{updated_str}") + IO.puts(" #{date_str}#{tags_str}") + end + + defp display_post_full(post) do + IO.puts("\n" <> String.duplicate("=", 80)) + IO.puts("Post ##{post.id}") + IO.puts(String.duplicate("=", 80)) + IO.puts("Title: #{post.title}") + IO.puts("Created: #{Post.format_date(post.created_at)}") + + if post.updated_at do + IO.puts("Updated: #{Post.format_date(post.updated_at)}") + end + + if post.tags && !Enum.empty?(post.tags) do + IO.puts("Tags: #{Enum.join(post.tags, ", ")}") + end + + IO.puts("\n#{post.body}") + IO.puts(String.duplicate("=", 80)) + end + + defp format_tags(nil), do: "" + defp format_tags([]), do: "" + defp format_tags(tags), do: " | Tags: #{Enum.join(tags, ", ")}" + + # Input helpers + + defp get_input(prompt) do + IO.gets(prompt) |> String.trim() + end + + defp get_multiline_input(prompt) do + IO.puts(prompt) + read_multiline([]) + end + + defp read_multiline(lines) do + case IO.gets("") |> String.trim_trailing("\n") do + "" -> + lines |> Enum.reverse() |> Enum.join("\n") + + line -> + read_multiline([line | lines]) + end + end + + defp get_optional_input(prompt, default) do + case get_input(prompt) do + "" -> default + value -> value + end + end + + defp get_optional_multiline_input(prompt, default) do + IO.puts(prompt) + + case IO.gets("") |> String.trim() do + "" -> default + "keep" -> default + first_line -> read_multiline([first_line]) + end + end + + defp get_tags_input do + tags_str = get_input("Tags (comma-separated, optional): ") + + case String.trim(tags_str) do + "" -> + nil + + tags -> + tags + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + end + end + + defp get_optional_tags_input(current_tags) do + current_str = if current_tags, do: Enum.join(current_tags, ", "), else: "none" + tags_str = get_input("Tags [#{current_str}] (comma-separated): ") + + case String.trim(tags_str) do + "" -> + current_tags + + tags -> + tags + |> String.split(",") + |> Enum.map(&String.trim/1) + |> Enum.reject(&(&1 == "")) + |> case do + [] -> nil + list -> list + end + end + end + + # Command parsing + + defp parse_command(input) do + parts = String.split(input, " ", trim: true) + + case parts do + [] -> {:error, :invalid_command} + ["new"] -> {:new, []} + ["list"] -> {:list, []} + ["view", id] -> {:view, [id]} + ["edit", id] -> {:edit, [id]} + ["delete", id] -> {:delete, [id]} + ["search" | query] when length(query) > 0 -> {:search, query} + ["tag", tag] -> {:tag, [tag]} + ["tags"] -> {:tags, []} + ["export", path] -> {:export, [path]} + ["import", path] -> {:import, [path]} + ["stats"] -> {:stats, []} + ["help"] -> {:help, []} + ["quit"] -> {:quit, []} + ["exit"] -> {:quit, []} + _ -> {:error, :invalid_command} + end + end + + defp parse_id(id_str) do + case Integer.parse(id_str) do + {id, ""} -> {:ok, id} + _ -> :error + end + end + + # UI helpers + + defp print_welcome do + IO.puts("\n" <> String.duplicate("=", 80)) + IO.puts(" Welcome to BlogEngine - A Powerful CLI Blog Manager") + IO.puts(String.duplicate("=", 80)) + IO.puts("\nType 'help' to see available commands or 'new' to create your first post.") + end + + defp print_help do + IO.puts("\n=== Available Commands ===\n") + + Enum.each(@commands, fn {cmd, desc} -> + IO.puts(" #{String.pad_trailing(cmd, 20)} - #{desc}") + end) + + IO.puts("") + end + + defp print_goodbye do + IO.puts("\nThank you for using BlogEngine. Goodbye!") + end +end diff --git a/lib/blog_engine/post.ex b/lib/blog_engine/post.ex new file mode 100644 index 0000000..5417fd6 --- /dev/null +++ b/lib/blog_engine/post.ex @@ -0,0 +1,112 @@ +defmodule BlogEngine.Post do + @moduledoc """ + Represents a blog post with all its metadata. + + A post contains an ID, title, body content, optional tags, + creation timestamp, and last updated timestamp. + """ + + @enforce_keys [:id, :title, :body, :created_at] + defstruct [:id, :title, :body, :tags, :created_at, :updated_at] + + @type t :: %__MODULE__{ + id: non_neg_integer(), + title: String.t(), + body: String.t(), + tags: list(String.t()) | nil, + created_at: DateTime.t(), + updated_at: DateTime.t() | nil + } + + @doc """ + Creates a new post with the given attributes. + + ## Examples + + iex> BlogEngine.Post.new(1, "Hello", "World", ["elixir"]) + %BlogEngine.Post{ + id: 1, + title: "Hello", + body: "World", + tags: ["elixir"], + created_at: ~U[2025-01-01 00:00:00Z] + } + """ + @spec new(non_neg_integer(), String.t(), String.t(), list(String.t()) | nil) :: t() + def new(id, title, body, tags \\ nil) do + %__MODULE__{ + id: id, + title: title, + body: body, + tags: tags, + created_at: DateTime.utc_now(), + updated_at: nil + } + end + + @doc """ + Updates a post with new title and/or body and/or tags. + Sets the updated_at timestamp to now. + + ## Examples + + iex> post = BlogEngine.Post.new(1, "Old", "Content", []) + iex> BlogEngine.Post.update(post, title: "New") + %BlogEngine.Post{title: "New", updated_at: ~U[2025-01-01 00:00:01Z]} + """ + @spec update(t(), keyword()) :: t() + def update(post, attrs) do + post + |> struct(attrs) + |> Map.put(:updated_at, DateTime.utc_now()) + end + + @doc """ + Formats a post's created_at or updated_at timestamp for display. + """ + @spec format_date(DateTime.t()) :: String.t() + def format_date(datetime) do + datetime + |> DateTime.to_string() + |> String.slice(0..18) + end + + @doc """ + Returns a short preview of the post (title and first 100 chars of body). + """ + @spec preview(t()) :: String.t() + def preview(%__MODULE__{title: title, body: body}) do + body_preview = + body + |> String.slice(0..99) + |> then(fn preview -> + if String.length(body) > 100, do: preview <> "...", else: preview + end) + + "#{title}\n#{body_preview}" + end + + @doc """ + Checks if a post matches a search query. + Searches in title, body, and tags (case-insensitive). + """ + @spec matches_query?(t(), String.t()) :: boolean() + def matches_query?(%__MODULE__{title: title, body: body, tags: tags}, query) do + query_lower = String.downcase(query) + + String.contains?(String.downcase(title), query_lower) or + String.contains?(String.downcase(body), query_lower) or + (tags && Enum.any?(tags, fn tag -> String.contains?(String.downcase(tag), query_lower) end)) + end + + @doc """ + Checks if a post has a specific tag (case-insensitive). + """ + @spec has_tag?(t(), String.t()) :: boolean() + def has_tag?(%__MODULE__{tags: nil}, _tag), do: false + + def has_tag?(%__MODULE__{tags: tags}, tag) do + tag_lower = String.downcase(tag) + Enum.any?(tags, fn t -> String.downcase(t) == tag_lower end) + end +end diff --git a/lib/blog_engine/storage.ex b/lib/blog_engine/storage.ex new file mode 100644 index 0000000..a8e64ca --- /dev/null +++ b/lib/blog_engine/storage.ex @@ -0,0 +1,114 @@ +defmodule BlogEngine.Storage do + @moduledoc """ + Handles persistent storage of blog posts to JSON files. + + Posts are stored in the priv/data directory and automatically + loaded on application start. + """ + + alias BlogEngine.Post + + @storage_dir "priv/data" + @storage_file "#{@storage_dir}/posts.json" + + @doc """ + Loads all posts from the storage file. + Returns an empty list if the file doesn't exist. + """ + @spec load_posts() :: list(Post.t()) + def load_posts do + case File.read(@storage_file) do + {:ok, content} -> + content + |> Jason.decode!() + |> Enum.map(&deserialize_post/1) + + {:error, :enoent} -> + [] + + {:error, reason} -> + IO.puts("Warning: Failed to load posts: #{inspect(reason)}") + [] + end + end + + @doc """ + Saves all posts to the storage file. + Creates the storage directory if it doesn't exist. + """ + @spec save_posts(list(Post.t())) :: :ok | {:error, term()} + def save_posts(posts) do + File.mkdir_p(@storage_dir) + + posts_json = + posts + |> Enum.map(&serialize_post/1) + |> Jason.encode!(pretty: true) + + File.write(@storage_file, posts_json) + end + + @doc """ + Exports posts to a specified file path. + """ + @spec export_posts(list(Post.t()), String.t()) :: :ok | {:error, term()} + def export_posts(posts, path) do + posts_json = + posts + |> Enum.map(&serialize_post/1) + |> Jason.encode!(pretty: true) + + File.write(path, posts_json) + end + + @doc """ + Imports posts from a specified file path. + """ + @spec import_posts(String.t()) :: {:ok, list(Post.t())} | {:error, term()} + def import_posts(path) do + case File.read(path) do + {:ok, content} -> + posts = + content + |> Jason.decode!() + |> Enum.map(&deserialize_post/1) + + {:ok, posts} + + {:error, reason} -> + {:error, reason} + end + end + + # Private functions + + defp serialize_post(%Post{} = post) do + %{ + "id" => post.id, + "title" => post.title, + "body" => post.body, + "tags" => post.tags, + "created_at" => DateTime.to_iso8601(post.created_at), + "updated_at" => post.updated_at && DateTime.to_iso8601(post.updated_at) + } + end + + defp deserialize_post(data) do + {:ok, created_at, 0} = DateTime.from_iso8601(data["created_at"]) + + updated_at = + case data["updated_at"] do + nil -> nil + timestamp -> DateTime.from_iso8601(timestamp) |> elem(1) + end + + %Post{ + id: data["id"], + title: data["title"], + body: data["body"], + tags: data["tags"], + created_at: created_at, + updated_at: updated_at + } + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..d21ef7f --- /dev/null +++ b/mix.exs @@ -0,0 +1,65 @@ +defmodule BlogEngine.MixProject do + use Mix.Project + + def project do + [ + app: :blog_engine, + version: "1.0.0", + elixir: "~> 1.14", + start_permanent: Mix.env() == :prod, + deps: deps(), + description: "A powerful CLI blog engine built with Elixir", + package: package(), + docs: docs(), + test_coverage: [tool: ExCoveralls], + preferred_cli_env: [ + coveralls: :test, + "coveralls.detail": :test, + "coveralls.post": :test, + "coveralls.html": :test + ], + dialyzer: [ + plt_file: {:no_warn, "priv/plts/dialyzer.plt"} + ], + escript: escript() + ] + end + + def application do + [ + extra_applications: [:logger, :crypto] + ] + end + + defp deps do + [ + {:jason, "~> 1.4"}, + {:ex_doc, "~> 0.31", only: :dev, runtime: false}, + {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, + {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}, + {:excoveralls, "~> 0.18", only: :test} + ] + end + + defp package do + [ + name: "blog_engine", + licenses: ["MIT"], + links: %{"GitHub" => "https://github.com/codeforgood-org/elixir-blog-engine"} + ] + end + + defp docs do + [ + main: "readme", + extras: ["README.md", "CONTRIBUTING.md", "CHANGELOG.md"] + ] + end + + defp escript do + [ + main_module: BlogEngine.CLI, + name: "blog_engine" + ] + end +end diff --git a/priv/data/.gitkeep b/priv/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/demo.exs b/scripts/demo.exs new file mode 100755 index 0000000..410253f --- /dev/null +++ b/scripts/demo.exs @@ -0,0 +1,129 @@ +#!/usr/bin/env elixir + +# Demo script showing BlogEngine API usage +# Run with: mix run scripts/demo.exs + +IO.puts("\n" <> String.duplicate("=", 80)) +IO.puts(" BlogEngine API Demo") +IO.puts(String.duplicate("=", 80) <> "\n") + +# Initialize the blog engine +IO.puts("Initializing BlogEngine...") +state = BlogEngine.init() +IO.puts("✓ Initialized with #{length(state.posts)} existing posts\n") + +# Create some posts +IO.puts("Creating sample posts...") + +{state, post1} = BlogEngine.create_post( + state, + "Understanding Pattern Matching", + "Pattern matching is one of Elixir's most powerful features...", + ["elixir", "tutorial"] +) +IO.puts("✓ Created post ##{post1.id}: #{post1.title}") + +{state, post2} = BlogEngine.create_post( + state, + "Building Concurrent Systems", + "Learn how to build concurrent and fault-tolerant systems with OTP...", + ["elixir", "concurrency", "advanced"] +) +IO.puts("✓ Created post ##{post2.id}: #{post2.title}") + +{state, post3} = BlogEngine.create_post( + state, + "My Journey with Functional Programming", + "Switching from OOP to FP was a game-changer for me...", + ["programming", "opinion"] +) +IO.puts("✓ Created post ##{post3.id}: #{post3.title}\n") + +# List all posts +IO.puts("Listing all posts:") +IO.puts(String.duplicate("-", 80)) +posts = BlogEngine.list_posts(state) +Enum.each(posts, fn post -> + tags_str = if post.tags, do: " [#{Enum.join(post.tags, ", ")}]", else: "" + IO.puts("[#{post.id}] #{post.title}#{tags_str}") +end) +IO.puts(String.duplicate("-", 80)) +IO.puts("Total: #{length(posts)} posts\n") + +# Search posts +query = "elixir" +IO.puts("Searching for '#{query}':") +results = BlogEngine.search_posts(state, query) +IO.puts("Found #{length(results)} posts matching '#{query}'") +Enum.each(results, fn post -> + IO.puts(" - #{post.title}") +end) +IO.puts("") + +# Filter by tag +tag = "tutorial" +IO.puts("Posts tagged with '#{tag}':") +tagged_posts = BlogEngine.list_posts(state, tag) +Enum.each(tagged_posts, fn post -> + IO.puts(" - #{post.title}") +end) +IO.puts("") + +# Get all tags +IO.puts("All tags with counts:") +tags = BlogEngine.get_all_tags(state) +Enum.each(tags, fn {tag, count} -> + IO.puts(" #{tag}: #{count}") +end) +IO.puts("") + +# Update a post +IO.puts("Updating post ##{post1.id}...") +{:ok, state, updated_post} = BlogEngine.update_post( + state, + post1.id, + title: "Mastering Pattern Matching in Elixir" +) +IO.puts("✓ Updated: #{updated_post.title}") +IO.puts(" Created: #{BlogEngine.Post.format_date(updated_post.created_at)}") +IO.puts(" Updated: #{BlogEngine.Post.format_date(updated_post.updated_at)}\n") + +# View a post +IO.puts("Viewing post ##{post2.id}:") +{:ok, post} = BlogEngine.find_post(state, post2.id) +IO.puts(String.duplicate("=", 80)) +IO.puts("Title: #{post.title}") +IO.puts("Created: #{BlogEngine.Post.format_date(post.created_at)}") +IO.puts("Tags: #{Enum.join(post.tags || [], ", ")}") +IO.puts("\n#{post.body}") +IO.puts(String.duplicate("=", 80) <> "\n") + +# Export posts +export_path = "/tmp/blog_demo_export.json" +IO.puts("Exporting posts to #{export_path}...") +:ok = BlogEngine.export_posts(state, export_path) +IO.puts("✓ Exported #{length(state.posts)} posts\n") + +# Stats +IO.puts("Blog Statistics:") +IO.puts(" Total posts: #{length(state.posts)}") +IO.puts(" Next ID: #{state.next_id}") +IO.puts(" Unique tags: #{length(BlogEngine.get_all_tags(state))}") + +total_words = Enum.reduce(state.posts, 0, fn post, acc -> + acc + length(String.split(post.body)) +end) +IO.puts(" Total words: #{total_words}") +IO.puts(" Avg words/post: #{div(total_words, length(state.posts))}\n") + +# Delete a post +IO.puts("Deleting post ##{post3.id}...") +{:ok, state} = BlogEngine.delete_post(state, post3.id) +IO.puts("✓ Deleted post ##{post3.id}") +IO.puts(" Remaining posts: #{length(state.posts)}\n") + +IO.puts(String.duplicate("=", 80)) +IO.puts(" Demo Complete!") +IO.puts(String.duplicate("=", 80)) +IO.puts("\nCheck #{export_path} to see the exported posts.") +IO.puts("Run './blog_engine' to try the interactive CLI.\n") diff --git a/scripts/import_examples.sh b/scripts/import_examples.sh new file mode 100755 index 0000000..f3ea52c --- /dev/null +++ b/scripts/import_examples.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# Script to import example posts +# Usage: ./scripts/import_examples.sh + +set -e + +echo "BlogEngine - Import Example Posts" +echo "==================================" +echo "" + +EXAMPLE_FILE="examples/sample_posts.json" +DATA_DIR="priv/data" +DATA_FILE="$DATA_DIR/posts.json" + +# Check if example file exists +if [ ! -f "$EXAMPLE_FILE" ]; then + echo "❌ Error: Example file not found: $EXAMPLE_FILE" + exit 1 +fi + +# Check if data directory exists, create if not +if [ ! -d "$DATA_DIR" ]; then + echo "Creating data directory: $DATA_DIR" + mkdir -p "$DATA_DIR" +fi + +# Backup existing posts if any +if [ -f "$DATA_FILE" ]; then + BACKUP_FILE="${DATA_FILE}.backup.$(date +%Y%m%d_%H%M%S)" + echo "⚠️ Existing posts found. Creating backup: $BACKUP_FILE" + cp "$DATA_FILE" "$BACKUP_FILE" +fi + +# Copy example posts +echo "Copying example posts..." +cp "$EXAMPLE_FILE" "$DATA_FILE" + +echo "" +echo "✓ Example posts imported successfully!" +echo "" +echo "You can now run './blog_engine' and use 'list' to see the posts." +echo "" +echo "Example commands to try:" +echo " list - View all posts" +echo " view 1 - View the welcome post" +echo " search elixir - Search for Elixir posts" +echo " tag tutorial - View tutorial posts" +echo " tags - See all tags" +echo "" diff --git a/scripts/setup-hooks.sh b/scripts/setup-hooks.sh new file mode 100755 index 0000000..2437e31 --- /dev/null +++ b/scripts/setup-hooks.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +# Setup script for git hooks +# Run this after cloning the repository: ./scripts/setup-hooks.sh + +echo "Setting up git hooks..." + +# Create symlinks for hooks +ln -sf ../../.git-hooks/pre-commit .git/hooks/pre-commit +ln -sf ../../.git-hooks/commit-msg .git/hooks/commit-msg + +# Make hooks executable +chmod +x .git-hooks/pre-commit +chmod +x .git-hooks/commit-msg +chmod +x .git/hooks/pre-commit +chmod +x .git/hooks/commit-msg + +echo "✓ Git hooks installed successfully!" +echo "" +echo "The following hooks are now active:" +echo " - pre-commit: Runs format, credo, and tests" +echo " - commit-msg: Validates commit message format" +echo "" +echo "To skip hooks temporarily, use: git commit --no-verify" diff --git a/test/blog_engine/post_test.exs b/test/blog_engine/post_test.exs new file mode 100644 index 0000000..50f5599 --- /dev/null +++ b/test/blog_engine/post_test.exs @@ -0,0 +1,182 @@ +defmodule BlogEngine.PostTest do + use ExUnit.Case + doctest BlogEngine.Post + + alias BlogEngine.Post + + describe "new/4" do + test "creates a post with all required fields" do + post = Post.new(1, "Title", "Body", ["tag1", "tag2"]) + + assert post.id == 1 + assert post.title == "Title" + assert post.body == "Body" + assert post.tags == ["tag1", "tag2"] + assert %DateTime{} = post.created_at + assert post.updated_at == nil + end + + test "creates a post without tags" do + post = Post.new(1, "Title", "Body") + + assert post.tags == nil + end + + test "sets created_at to current time" do + before = DateTime.utc_now() + post = Post.new(1, "Title", "Body") + after_time = DateTime.utc_now() + + assert DateTime.compare(post.created_at, before) in [:gt, :eq] + assert DateTime.compare(post.created_at, after_time) in [:lt, :eq] + end + end + + describe "update/2" do + test "updates title" do + post = Post.new(1, "Old Title", "Body") + updated = Post.update(post, title: "New Title") + + assert updated.title == "New Title" + assert updated.body == "Body" + assert updated.updated_at != nil + end + + test "updates body" do + post = Post.new(1, "Title", "Old Body") + updated = Post.update(post, body: "New Body") + + assert updated.body == "New Body" + assert updated.title == "Title" + end + + test "updates tags" do + post = Post.new(1, "Title", "Body", ["old"]) + updated = Post.update(post, tags: ["new1", "new2"]) + + assert updated.tags == ["new1", "new2"] + end + + test "updates multiple fields at once" do + post = Post.new(1, "Old", "Old", ["old"]) + updated = Post.update(post, title: "New", body: "New", tags: ["new"]) + + assert updated.title == "New" + assert updated.body == "New" + assert updated.tags == ["new"] + end + + test "sets updated_at timestamp" do + post = Post.new(1, "Title", "Body") + updated = Post.update(post, title: "New") + + assert %DateTime{} = updated.updated_at + assert DateTime.compare(updated.updated_at, post.created_at) == :gt + end + end + + describe "format_date/1" do + test "formats datetime to readable string" do + {:ok, datetime, 0} = DateTime.from_iso8601("2025-11-13T17:32:15Z") + formatted = Post.format_date(datetime) + + assert formatted == "2025-11-13 17:32:15" + end + end + + describe "preview/1" do + test "returns title and first 100 chars of short body" do + post = Post.new(1, "Title", "Short body") + preview = Post.preview(post) + + assert preview == "Title\nShort body" + end + + test "truncates long body with ellipsis" do + long_body = String.duplicate("a", 150) + post = Post.new(1, "Title", long_body) + preview = Post.preview(post) + + assert String.contains?(preview, "Title") + assert String.contains?(preview, "...") + assert String.length(preview) < String.length(long_body) + 10 + end + + test "preview is exactly 100 chars + ellipsis for bodies over 100 chars" do + long_body = String.duplicate("a", 150) + post = Post.new(1, "Title", long_body) + preview = Post.preview(post) + + body_part = preview |> String.split("\n") |> Enum.at(1) + assert String.length(body_part) == 103 # 100 chars + "..." + end + end + + describe "matches_query?/2" do + test "matches query in title (case-insensitive)" do + post = Post.new(1, "Elixir Tutorial", "Content", nil) + + assert Post.matches_query?(post, "elixir") + assert Post.matches_query?(post, "ELIXIR") + assert Post.matches_query?(post, "Tutorial") + end + + test "matches query in body (case-insensitive)" do + post = Post.new(1, "Title", "Learn Phoenix Framework", nil) + + assert Post.matches_query?(post, "phoenix") + assert Post.matches_query?(post, "FRAMEWORK") + assert Post.matches_query?(post, "learn") + end + + test "matches query in tags (case-insensitive)" do + post = Post.new(1, "Title", "Body", ["elixir", "tutorial"]) + + assert Post.matches_query?(post, "elixir") + assert Post.matches_query?(post, "TUTORIAL") + end + + test "returns false when no match" do + post = Post.new(1, "Title", "Body", ["tag"]) + + refute Post.matches_query?(post, "nonexistent") + refute Post.matches_query?(post, "xyz") + end + + test "handles posts without tags" do + post = Post.new(1, "Title", "Body", nil) + + assert Post.matches_query?(post, "title") + refute Post.matches_query?(post, "nonexistent") + end + end + + describe "has_tag?/2" do + test "returns true when post has the tag (case-insensitive)" do + post = Post.new(1, "Title", "Body", ["elixir", "tutorial"]) + + assert Post.has_tag?(post, "elixir") + assert Post.has_tag?(post, "ELIXIR") + assert Post.has_tag?(post, "Tutorial") + end + + test "returns false when post doesn't have the tag" do + post = Post.new(1, "Title", "Body", ["elixir"]) + + refute Post.has_tag?(post, "ruby") + refute Post.has_tag?(post, "python") + end + + test "returns false when post has no tags" do + post = Post.new(1, "Title", "Body", nil) + + refute Post.has_tag?(post, "any") + end + + test "returns false when post has empty tags list" do + post = Post.new(1, "Title", "Body", []) + + refute Post.has_tag?(post, "any") + end + end +end diff --git a/test/blog_engine/storage_test.exs b/test/blog_engine/storage_test.exs new file mode 100644 index 0000000..ffdf838 --- /dev/null +++ b/test/blog_engine/storage_test.exs @@ -0,0 +1,221 @@ +defmodule BlogEngine.StorageTest do + use ExUnit.Case + + alias BlogEngine.{Post, Storage} + + setup do + # Clean up storage before and after each test + File.rm_rf("priv/data") + on_exit(fn -> File.rm_rf("priv/data") end) + :ok + end + + describe "load_posts/0" do + test "returns empty list when no storage file exists" do + posts = Storage.load_posts() + assert posts == [] + end + + test "loads posts from storage file" do + post1 = Post.new(1, "Title 1", "Body 1", ["tag1"]) + post2 = Post.new(2, "Title 2", "Body 2", nil) + + Storage.save_posts([post1, post2]) + loaded_posts = Storage.load_posts() + + assert length(loaded_posts) == 2 + assert Enum.any?(loaded_posts, fn p -> p.title == "Title 1" end) + assert Enum.any?(loaded_posts, fn p -> p.title == "Title 2" end) + end + + test "preserves all post fields" do + original_post = Post.new(1, "Title", "Body", ["tag1", "tag2"]) + updated_post = Post.update(original_post, title: "Updated") + + Storage.save_posts([updated_post]) + [loaded_post] = Storage.load_posts() + + assert loaded_post.id == updated_post.id + assert loaded_post.title == updated_post.title + assert loaded_post.body == updated_post.body + assert loaded_post.tags == updated_post.tags + assert DateTime.compare(loaded_post.created_at, updated_post.created_at) == :eq + assert DateTime.compare(loaded_post.updated_at, updated_post.updated_at) == :eq + end + + test "handles posts without tags" do + post = Post.new(1, "Title", "Body", nil) + + Storage.save_posts([post]) + [loaded_post] = Storage.load_posts() + + assert loaded_post.tags == nil + end + + test "handles posts without updated_at" do + post = Post.new(1, "Title", "Body", nil) + + Storage.save_posts([post]) + [loaded_post] = Storage.load_posts() + + assert loaded_post.updated_at == nil + end + end + + describe "save_posts/1" do + test "creates storage directory if it doesn't exist" do + refute File.exists?("priv/data") + + post = Post.new(1, "Title", "Body", nil) + Storage.save_posts([post]) + + assert File.exists?("priv/data") + assert File.exists?("priv/data/posts.json") + end + + test "saves posts to JSON file" do + post = Post.new(1, "Title", "Body", ["tag"]) + Storage.save_posts([post]) + + assert File.exists?("priv/data/posts.json") + {:ok, content} = File.read("priv/data/posts.json") + data = Jason.decode!(content) + + assert is_list(data) + assert length(data) == 1 + end + + test "overwrites existing file" do + post1 = Post.new(1, "Title 1", "Body 1", nil) + post2 = Post.new(2, "Title 2", "Body 2", nil) + + Storage.save_posts([post1]) + Storage.save_posts([post2]) + + loaded_posts = Storage.load_posts() + assert length(loaded_posts) == 1 + assert Enum.at(loaded_posts, 0).title == "Title 2" + end + + test "saves empty list" do + Storage.save_posts([]) + + loaded_posts = Storage.load_posts() + assert loaded_posts == [] + end + end + + describe "export_posts/2" do + test "exports posts to specified file" do + post1 = Post.new(1, "Title 1", "Body 1", ["tag1"]) + post2 = Post.new(2, "Title 2", "Body 2", nil) + + export_path = "test_export.json" + assert :ok = Storage.export_posts([post1, post2], export_path) + + assert File.exists?(export_path) + {:ok, content} = File.read(export_path) + data = Jason.decode!(content) + + assert length(data) == 2 + + # Clean up + File.rm(export_path) + end + + test "creates pretty-printed JSON" do + post = Post.new(1, "Title", "Body", nil) + export_path = "test_export.json" + + Storage.export_posts([post], export_path) + + {:ok, content} = File.read(export_path) + # Pretty-printed JSON should have newlines + assert String.contains?(content, "\n") + + # Clean up + File.rm(export_path) + end + end + + describe "import_posts/1" do + test "imports posts from specified file" do + post1 = Post.new(1, "Title 1", "Body 1", ["tag1"]) + post2 = Post.new(2, "Title 2", "Body 2", nil) + + export_path = "test_import.json" + Storage.export_posts([post1, post2], export_path) + + assert {:ok, imported_posts} = Storage.import_posts(export_path) + + assert length(imported_posts) == 2 + assert Enum.any?(imported_posts, fn p -> p.title == "Title 1" end) + assert Enum.any?(imported_posts, fn p -> p.title == "Title 2" end) + + # Clean up + File.rm(export_path) + end + + test "returns error for non-existent file" do + assert {:error, :enoent} = Storage.import_posts("nonexistent.json") + end + + test "preserves all post fields during import" do + original_post = Post.new(1, "Title", "Body", ["tag1", "tag2"]) + updated_post = Post.update(original_post, title: "Updated") + + export_path = "test_import.json" + Storage.export_posts([updated_post], export_path) + + {:ok, [imported_post]} = Storage.import_posts(export_path) + + assert imported_post.id == updated_post.id + assert imported_post.title == updated_post.title + assert imported_post.body == updated_post.body + assert imported_post.tags == updated_post.tags + # Timestamps should be preserved + assert DateTime.compare(imported_post.created_at, updated_post.created_at) == :eq + assert DateTime.compare(imported_post.updated_at, updated_post.updated_at) == :eq + + # Clean up + File.rm(export_path) + end + end + + describe "round-trip save and load" do + test "data integrity is maintained through save/load cycle" do + original_posts = [ + Post.new(1, "Post 1", "Body 1", ["tag1", "tag2"]), + Post.new(2, "Post 2", "Body 2", nil), + Post.new(3, "Post 3", "Body 3", ["tag3"]) + ] + + # Update one post to test updated_at preservation + original_posts = List.update_at(original_posts, 1, fn post -> + Post.update(post, title: "Updated Post 2") + end) + + Storage.save_posts(original_posts) + loaded_posts = Storage.load_posts() + + assert length(loaded_posts) == length(original_posts) + + # Check each post + Enum.each(original_posts, fn original -> + loaded = Enum.find(loaded_posts, fn p -> p.id == original.id end) + + assert loaded != nil + assert loaded.title == original.title + assert loaded.body == original.body + assert loaded.tags == original.tags + assert DateTime.compare(loaded.created_at, original.created_at) == :eq + + if original.updated_at do + assert DateTime.compare(loaded.updated_at, original.updated_at) == :eq + else + assert loaded.updated_at == nil + end + end) + end + end +end diff --git a/test/blog_engine_test.exs b/test/blog_engine_test.exs new file mode 100644 index 0000000..c5304e8 --- /dev/null +++ b/test/blog_engine_test.exs @@ -0,0 +1,273 @@ +defmodule BlogEngineTest do + use ExUnit.Case + doctest BlogEngine + + alias BlogEngine.Post + + setup do + # Clean up storage before each test + File.rm_rf("priv/data") + state = BlogEngine.init() + {:ok, state: state} + end + + describe "init/0" do + test "initializes with empty state when no posts exist" do + state = BlogEngine.init() + assert state.posts == [] + assert state.next_id == 1 + end + + test "loads existing posts and sets correct next_id", %{state: initial_state} do + {state, _post} = BlogEngine.create_post(initial_state, "Test", "Body", nil) + + # Simulate restart by reinitializing + new_state = BlogEngine.init() + assert length(new_state.posts) == 1 + assert new_state.next_id == 2 + end + end + + describe "create_post/4" do + test "creates a post with all fields", %{state: state} do + {new_state, post} = BlogEngine.create_post(state, "Title", "Body", ["tag1", "tag2"]) + + assert post.id == 1 + assert post.title == "Title" + assert post.body == "Body" + assert post.tags == ["tag1", "tag2"] + assert post.created_at != nil + assert new_state.next_id == 2 + assert length(new_state.posts) == 1 + end + + test "creates a post without tags", %{state: state} do + {_new_state, post} = BlogEngine.create_post(state, "Title", "Body", nil) + assert post.tags == nil + end + + test "increments next_id correctly", %{state: state} do + {state, _} = BlogEngine.create_post(state, "Post 1", "Body 1", nil) + {state, _} = BlogEngine.create_post(state, "Post 2", "Body 2", nil) + {state, post3} = BlogEngine.create_post(state, "Post 3", "Body 3", nil) + + assert post3.id == 3 + assert state.next_id == 4 + end + end + + describe "list_posts/2" do + test "returns empty list when no posts exist", %{state: state} do + posts = BlogEngine.list_posts(state) + assert posts == [] + end + + test "returns all posts sorted by date (newest first)", %{state: state} do + {state, post1} = BlogEngine.create_post(state, "First", "Body", nil) + :timer.sleep(10) + {state, post2} = BlogEngine.create_post(state, "Second", "Body", nil) + :timer.sleep(10) + {state, post3} = BlogEngine.create_post(state, "Third", "Body", nil) + + posts = BlogEngine.list_posts(state) + + assert length(posts) == 3 + assert Enum.at(posts, 0).id == post3.id + assert Enum.at(posts, 1).id == post2.id + assert Enum.at(posts, 2).id == post1.id + end + + test "filters posts by tag", %{state: state} do + {state, _} = BlogEngine.create_post(state, "Post 1", "Body", ["elixir"]) + {state, _} = BlogEngine.create_post(state, "Post 2", "Body", ["ruby"]) + {state, _} = BlogEngine.create_post(state, "Post 3", "Body", ["elixir", "phoenix"]) + + elixir_posts = BlogEngine.list_posts(state, "elixir") + ruby_posts = BlogEngine.list_posts(state, "ruby") + + assert length(elixir_posts) == 2 + assert length(ruby_posts) == 1 + end + end + + describe "find_post/2" do + test "finds an existing post", %{state: state} do + {state, created_post} = BlogEngine.create_post(state, "Title", "Body", nil) + + assert {:ok, found_post} = BlogEngine.find_post(state, created_post.id) + assert found_post.id == created_post.id + assert found_post.title == created_post.title + end + + test "returns error for non-existent post", %{state: state} do + assert {:error, :not_found} = BlogEngine.find_post(state, 999) + end + end + + describe "update_post/3" do + test "updates post title", %{state: state} do + {state, post} = BlogEngine.create_post(state, "Original", "Body", nil) + + assert {:ok, new_state, updated_post} = + BlogEngine.update_post(state, post.id, title: "Updated") + + assert updated_post.title == "Updated" + assert updated_post.body == "Body" + assert updated_post.updated_at != nil + end + + test "updates post body", %{state: state} do + {state, post} = BlogEngine.create_post(state, "Title", "Original", nil) + + assert {:ok, _new_state, updated_post} = + BlogEngine.update_post(state, post.id, body: "Updated") + + assert updated_post.body == "Updated" + end + + test "updates post tags", %{state: state} do + {state, post} = BlogEngine.create_post(state, "Title", "Body", ["old"]) + + assert {:ok, _new_state, updated_post} = + BlogEngine.update_post(state, post.id, tags: ["new1", "new2"]) + + assert updated_post.tags == ["new1", "new2"] + end + + test "returns error for non-existent post", %{state: state} do + assert {:error, :not_found} = BlogEngine.update_post(state, 999, title: "Test") + end + + test "persists changes to storage", %{state: state} do + {state, post} = BlogEngine.create_post(state, "Original", "Body", nil) + {:ok, _state, _} = BlogEngine.update_post(state, post.id, title: "Updated") + + # Reload from storage + reloaded_state = BlogEngine.init() + {:ok, reloaded_post} = BlogEngine.find_post(reloaded_state, post.id) + + assert reloaded_post.title == "Updated" + end + end + + describe "delete_post/2" do + test "deletes an existing post", %{state: state} do + {state, post} = BlogEngine.create_post(state, "Title", "Body", nil) + + assert {:ok, new_state} = BlogEngine.delete_post(state, post.id) + assert length(new_state.posts) == 0 + assert {:error, :not_found} = BlogEngine.find_post(new_state, post.id) + end + + test "returns error for non-existent post", %{state: state} do + assert {:error, :not_found} = BlogEngine.delete_post(state, 999) + end + + test "persists deletion to storage", %{state: state} do + {state, post} = BlogEngine.create_post(state, "Title", "Body", nil) + {:ok, _state} = BlogEngine.delete_post(state, post.id) + + # Reload from storage + reloaded_state = BlogEngine.init() + assert reloaded_state.posts == [] + end + end + + describe "search_posts/2" do + setup %{state: state} do + {state, _} = BlogEngine.create_post(state, "Elixir Tutorial", "Learn Elixir basics", ["elixir"]) + {state, _} = BlogEngine.create_post(state, "Ruby Guide", "Ruby programming guide", ["ruby"]) + {state, _} = BlogEngine.create_post(state, "Phoenix Framework", "Build apps with Phoenix", ["elixir", "phoenix"]) + + {:ok, state: state} + end + + test "finds posts by title", %{state: state} do + results = BlogEngine.search_posts(state, "Elixir") + assert length(results) == 1 + assert Enum.at(results, 0).title == "Elixir Tutorial" + end + + test "finds posts by body content", %{state: state} do + results = BlogEngine.search_posts(state, "programming") + assert length(results) == 1 + assert Enum.at(results, 0).title == "Ruby Guide" + end + + test "finds posts by tag", %{state: state} do + results = BlogEngine.search_posts(state, "phoenix") + assert length(results) == 1 + assert Enum.at(results, 0).title == "Phoenix Framework" + end + + test "search is case-insensitive", %{state: state} do + results = BlogEngine.search_posts(state, "ELIXIR") + assert length(results) == 1 + end + + test "returns empty list when no matches", %{state: state} do + results = BlogEngine.search_posts(state, "javascript") + assert results == [] + end + end + + describe "get_all_tags/1" do + test "returns empty list when no posts have tags", %{state: state} do + {state, _} = BlogEngine.create_post(state, "Post", "Body", nil) + tags = BlogEngine.get_all_tags(state) + assert tags == [] + end + + test "returns all unique tags with counts", %{state: state} do + {state, _} = BlogEngine.create_post(state, "Post 1", "Body", ["elixir", "tutorial"]) + {state, _} = BlogEngine.create_post(state, "Post 2", "Body", ["elixir", "advanced"]) + {state, _} = BlogEngine.create_post(state, "Post 3", "Body", ["ruby"]) + + tags = BlogEngine.get_all_tags(state) + + assert length(tags) == 3 + assert {"elixir", 2} in tags + assert {"tutorial", 1} in tags + assert {"advanced", 1} in tags + assert {"ruby", 1} in tags + end + + test "tags are sorted by count descending", %{state: state} do + {state, _} = BlogEngine.create_post(state, "Post 1", "Body", ["popular"]) + {state, _} = BlogEngine.create_post(state, "Post 2", "Body", ["popular"]) + {state, _} = BlogEngine.create_post(state, "Post 3", "Body", ["rare"]) + + tags = BlogEngine.get_all_tags(state) + + assert Enum.at(tags, 0) == {"popular", 2} + assert Enum.at(tags, 1) == {"rare", 1} + end + end + + describe "export_posts/2 and import_posts/2" do + test "exports and imports posts successfully", %{state: state} do + {state, _} = BlogEngine.create_post(state, "Post 1", "Body 1", ["tag1"]) + {state, _} = BlogEngine.create_post(state, "Post 2", "Body 2", ["tag2"]) + + export_path = "test_export.json" + + # Export + assert :ok = BlogEngine.export_posts(state, export_path) + assert File.exists?(export_path) + + # Import to new state + new_state = BlogEngine.init() + assert {:ok, imported_state} = BlogEngine.import_posts(new_state, export_path) + + # Verify imported posts (IDs will be reassigned) + assert length(imported_state.posts) == 4 # 2 original + 2 imported + + # Clean up + File.rm(export_path) + end + + test "import returns error for non-existent file", %{state: state} do + assert {:error, :enoent} = BlogEngine.import_posts(state, "nonexistent.json") + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()